Index: src/wp-admin/includes/admin-filters.php
===================================================================
--- src/wp-admin/includes/admin-filters.php	(revision 43000)
+++ src/wp-admin/includes/admin-filters.php	(working copy)
@@ -133,6 +133,10 @@
 add_action( 'upgrader_process_complete', 'wp_update_plugins', 10, 0 );
 add_action( 'upgrader_process_complete', 'wp_update_themes', 10, 0 );
 
+// Privacy hooks
+add_filter( 'wp_privacy_personal_data_export_page', 'wp_privacy_process_personal_data_export_page', 10, 5 );
+add_action( 'wp_privacy_personal_data_export_file', 'wp_privacy_generate_personal_data_export_file', 10 );
+
 // Privacy policy text changes check.
 add_action( 'admin_init', array( 'WP_Privacy_Policy_Content', 'text_change_check' ), 20 );
 
@@ -144,4 +148,3 @@
 
 // Stop checking for text changes after the policy page is updated.
 add_action( 'post_updated', array( 'WP_Privacy_Policy_Content', '_policy_page_updated' ) );
-
Index: src/wp-admin/includes/ajax-actions.php
===================================================================
--- src/wp-admin/includes/ajax-actions.php	(revision 43000)
+++ src/wp-admin/includes/ajax-actions.php	(working copy)
@@ -4328,13 +4328,30 @@
 }
 
 function wp_ajax_wp_privacy_export_personal_data() {
-	check_ajax_referer( 'wp-privacy-export-personal-data', 'security' );
+	$request_id  = (int) $_POST['id'];
 
+	if ( empty( $request_id ) ) {
+		wp_send_json_error( __( 'Error: Invalid request ID.' ) );
+	}
+
 	if ( ! current_user_can( 'manage_options' ) ) {
 		wp_send_json_error( __( 'Error: Invalid request.' ) );
 	}
 
-	$email_address  = sanitize_text_field( $_POST['email'] );
+	check_ajax_referer( 'wp-privacy-export-personal-data-' . $request_id, 'security' );
+
+	// Find the request CPT
+	$request = get_post( $request_id );
+	if ( 'user_export_request' !== $request->post_type ) {
+		wp_send_json_error( 'internal error: invalid request id' );
+	}
+
+	$email_address = get_post_meta( $request_id, '_user_email', true );
+
+	if ( ! is_email( $email_address ) ) {
+		wp_send_json_error( 'A valid email address must be given.' );
+	}
+
 	$exporter_index = (int) $_POST['exporter'];
 	$page           = (int) $_POST['page'];
 
@@ -4375,13 +4392,6 @@
 			wp_send_json_error( 'Page index cannot be less than one.' );
 		}
 
-		// Surprisingly, email addresses can contain mutli-byte characters now
-		$email_address = trim( mb_strtolower( $email_address ) );
-
-		if ( ! is_email( $email_address ) ) {
-			wp_send_json_error( 'A valid email address must be given.' );
-		}
-
 		$exporter = $exporters[ $index ];
 		if ( ! is_array( $exporter ) ) {
 			wp_send_json_error( "Expected an array describing the exporter at index {$exporter_index}." );
@@ -4435,8 +4445,9 @@
 	 * @param int    $exporter_index  The index of the exporter that provided this data.
 	 * @param string $email_address   The email address associated with this personal data.
 	 * @param int    $page            The zero-based page for this response.
+	 * @param int    $request_id      The privacy request post ID associated with this request.
 	 */
-	$response = apply_filters( 'wp_privacy_personal_data_export_page', $response, $exporter_index, $email_address, $page );
+	$response = apply_filters( 'wp_privacy_personal_data_export_page', $response, $exporter_index, $email_address, $page, $request_id );
 	if ( is_wp_error( $response ) ) {
 		wp_send_json_error( $response );
 	}
Index: src/wp-admin/includes/file.php
===================================================================
--- src/wp-admin/includes/file.php	(revision 43000)
+++ src/wp-admin/includes/file.php	(working copy)
@@ -1934,3 +1934,325 @@
 	</div>
 	<?php
 }
+
+/**
+ * Generate a single group for the personal data export report.
+ *
+ * @since 4.9.6
+ *
+ * @param array  $group_data The group data to render.
+ *     'group_label'          string             The user-facing heading for the group, e.g. 'Comments'.
+ *     'items'                associative-array  An array of group items.
+ *       $group_item_id       string             A group item ID (e.g. 'comment-123' ).
+ *       => $group_item_data  array              An array of name-value pairs for the item.
+ *         'name'             string             The user-facing name of an item name-value pair, e.g. 'IP Address'.
+ *         'value'            string             The user-facing value of an item data pair, e.g. '50.60.70.0'.
+ * @return string The HTML for this group and its items
+ */
+function wp_privacy_generate_personal_data_export_group_html( $group_data ) {
+	$allowed_tags      = array( 'a' => array( 'href', 'target'), 'br' => array() );
+	$allowed_protocols = array( 'http', 'https' );
+	$group_html        = '';
+
+	$group_html .= '<h2>' . esc_html( $group_data['group_label'] ) . '</h2>';
+	$group_html .= '<div>';
+
+	foreach ( (array) $group_data['items'] as $group_item_id => $group_item_data ) {
+		$group_html .= '<table>';
+		$group_html .= '<tbody>';
+
+		foreach ( (array) $group_item_data as $group_item_datum ) {
+			$group_html .= '<tr>';
+			$group_html .= '<th>' . esc_html( $group_item_datum['name'] ) . '</th>';
+			$group_html .= '<td>' . wp_kses( $group_item_datum['value'], $allowed_tags, $allowed_protocols ) . '</td>';
+			$group_html .= '</tr>';
+		}
+
+		$group_html .= '</tbody>';
+		$group_html .= '</table>';
+	}
+
+	$group_html .= '</div>';
+
+	return $group_html;
+}
+
+/**
+ * Generate the personal data export file.
+ *
+ * @since 4.9.6
+ *
+ * @param int  $request_id  The export request ID.
+ */
+function wp_privacy_generate_personal_data_export_file( $request_id ) {
+	// Maybe make this a cron job instead
+	wp_privacy_delete_old_export_files();
+
+	if ( ! class_exists( 'ZipArchive' ) ) {
+		wp_send_json_error( __( 'Unable to generate export file. ZipArchive not available.' ) );
+	}
+
+	// Get the request
+	$request = get_post( $request_id );
+	if ( 'user_export_request' !== $request->post_type ) {
+		wp_send_json_error( __( 'Invalid request ID when generating export file' ) );
+	}
+
+	// Create the exports folder if needed
+	$upload_dir  = wp_upload_dir();
+	$exports_dir = trailingslashit( $upload_dir['basedir'] . '/exports' );
+	$exports_url = trailingslashit( $upload_dir['baseurl'] . '/exports' );
+
+	$result = wp_mkdir_p( $exports_dir );
+	if ( is_wp_error( $result ) ) {
+		wp_send_json_error( $result->get_error_message() );
+	}
+
+	// Protect export folder from browsing
+	$index_pathname = $exports_dir . 'index.html';
+	if ( ! file_exists( $index_pathname ) ) {
+		$file = fopen( $index_pathname, 'w' );
+		if ( false === $file ) {
+			wp_send_json_error( __( 'Unable to protect export folder from browsing' ) );
+		}
+		fwrite( $file, 'Silence is golden.' );
+		fclose( $file );
+	}
+
+	// Generate a difficult to guess filename
+	$email_address = get_post_meta( $request_id, '_user_email', true );
+	if ( ! is_email( $email_address ) ) {
+		wp_send_json_error( __( 'Invalid email address when generating export file' ) );
+	}
+
+	$stripped_email       = str_replace( '@', '-at-', $email_address );
+	$stripped_email       = sanitize_title( $stripped_email ); // slugify the email address
+	$obscura              = md5( rand() );
+	$file_basename        = 'wp-personal-data-file-' . $stripped_email . '-' . $obscura;
+	$html_report_filename = $file_basename . '.html';
+	$html_report_pathname = $exports_dir . $html_report_filename;
+	$file = fopen( $html_report_pathname, 'w' );
+	if ( false === $file ) {
+		wp_send_json_error( __( 'Unable to open export file (HTML report) for writing' ) );
+	}
+
+	$title = sprintf(
+		__( 'Personal Data Export for %s' ),
+		$email_address
+	);
+
+	// Open HTML
+	fwrite( $file, "<!DOCTYPE html>\n" );
+	fwrite( $file, "<html>\n" );
+
+	// Head
+	fwrite( $file, "<head>\n" );
+	fwrite( $file, "<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />\n" );
+	fwrite( $file, "<style type='text/css'>" );
+	fwrite( $file, "body { color: black; font-family: Arial, sans-serif; font-size: 11pt; margin: 15px auto; width: 860px; }" );
+	fwrite( $file, "table { background: #f0f0f0; border: 1px solid #ddd; margin-bottom: 20px; width: 100%; }" );
+	fwrite( $file, "th { padding: 5px; text-align: left; width: 20%; }" );
+	fwrite( $file, "td { padding: 5px; }" );
+	fwrite( $file, "tr:nth-child(odd) { background-color: #fafafa; }" );
+	fwrite( $file, "</style>" );
+	fwrite( $file, "<title>" );
+	fwrite( $file, esc_html( $title ) );
+	fwrite( $file, "</title>" );
+	fwrite( $file, "</head>\n" );
+
+	// Body
+	fwrite( $file, "<body>\n" );
+
+	// Heading
+	fwrite( $file, "<h1>" . esc_html__( 'Personal Data Export' ) . "</h1>" );
+
+	// And now, all the Groups
+	$groups = get_post_meta( $request_id, '_export_data_grouped', true );
+
+	// First, build a "About" group on the fly for this report
+	$about_group = array(
+		'group_label' => __( 'About' ),
+		'items'       => array(
+			'about-1' => array(
+				array(
+					'name'  => __( 'Report generated for' ),
+					'value' => $email_address,
+				),
+				array(
+					'name'  => __( 'For site' ),
+					'value' => get_bloginfo( 'name' ),
+				),
+				array(
+					'name'  => __( 'At URL' ),
+					'value' => get_bloginfo( 'url' ),
+				),
+				array(
+					'name'  => __( 'On' ),
+					'value' => current_time( 'mysql' ),
+				),
+			),
+		)
+	);
+
+	// Merge in the special about group
+	$groups = array_merge( array( 'about' => $about_group ), $groups );
+
+	// Now, iterate over every group in $groups and have the formatter render it in HTML
+	foreach ( (array) $groups as $group_id => $group_data ) {
+		fwrite( $file, wp_privacy_generate_personal_data_export_group_html( $group_data ) );
+	}
+
+	fwrite( $file, "</body>\n" );
+
+	// Close HTML
+	fwrite( $file, "</html>\n" );
+	fclose( $file );
+
+	// Now, generate the ZIP
+	$archive_filename = $file_basename . '.zip';
+	$archive_pathname = $exports_dir . $archive_filename;
+	$archive_url      = $exports_url . $archive_filename;
+
+	$zip = new ZipArchive;
+
+	if ( TRUE === $zip->open( $archive_pathname, ZipArchive::CREATE ) ) {
+		$zip->addFile( $html_report_pathname, 'index.html' );
+		$zip->close();
+	} else {
+		wp_send_json_error( __( 'Unable to open export file (archive) for writing' ) );
+	}
+
+	// And remove the HTML file
+	unlink( $html_report_pathname );
+
+	// Save the export file in the request
+	update_post_meta( $request_id, '_export_file_url', $archive_url );
+	update_post_meta( $request_id, '_export_file_path', $archive_pathname );
+}
+
+/**
+ * Intercept personal data exporter page ajax responses in order to assemble the personal data export file.
+ * @see wp_privacy_personal_data_export_page
+ * @since 4.9.6
+ *
+ * @param array  $response        The response from the personal data exporter for the given page.
+ * @param int    $exporter_index  The index of the personal data exporter. Begins at 1.
+ * @param string $email_address   The email address of the user whose personal data this is.
+ * @param int    $page            The page of personal data for this exporter. Begins at 1.
+ * @param int    $request_id      The request ID for this personal data export.
+ * @return array The filtered response.
+ */
+function wp_privacy_process_personal_data_export_page( $response, $exporter_index, $email_address, $page, $request_id ) {
+	// Do some simple checks on the shape of the response from the exporter
+	// If the exporter response is malformed, don't attempt to consume it - let it
+	// pass through to generate a warning to the user by default ajax processing
+	if ( ! is_array( $response ) ) {
+		return $response;
+	}
+
+	if ( ! array_key_exists( 'done', $response ) ) {
+		return $response;
+	}
+
+	if ( ! array_key_exists( 'data', $response ) ) {
+		return $response;
+	}
+
+	if ( ! is_array( $response['data'] ) ) {
+		return $response;
+	}
+
+	// Get the request
+	$request = get_post( $request_id );
+	if ( 'user_export_request' !== $request->post_type ) {
+		wp_send_json_error( __( 'Invalid request ID when merging exporter data' ) );
+	}
+
+	$export_data = array();
+
+	// First exporter, first page? Reset the report data accumulation array
+	if ( 1 === $exporter_index && 1 === $page ) {
+		update_post_meta( $request_id, '_export_data_raw', $export_data );
+	} else {
+		$export_data = get_post_meta( $request_id, '_export_data_raw', true );
+	}
+
+	// Now, merge the data from the exporter response into the data we have accumulated already
+	$export_data = array_merge( $export_data, $response['data'] );
+	update_post_meta( $request_id, '_export_data_raw', $export_data );
+
+	// If we are not yet on the last page of the last exporter, return now
+	$exporters = apply_filters( 'wp_privacy_personal_data_exporters', array() );
+	$is_last_exporter = $exporter_index === count( $exporters );
+	$exporter_done = $response['done'];
+	if ( ! $is_last_exporter || ! $exporter_done ) {
+		return $response;
+	}
+
+	// Now we need to re-organize the raw data hierarchically in groups and items.
+	$groups = array();
+	foreach ( (array) $export_data as $export_datum ) {
+		$group_id    = $export_datum['group_id'];
+		$group_label = $export_datum['group_label'];
+		if ( ! array_key_exists( $group_id, $groups ) ) {
+			$groups[ $group_id ] = array(
+				'group_label' => $group_label,
+				'items'       => array(),
+			);
+		}
+
+		$item_id = $export_datum['item_id'];
+		if ( ! array_key_exists( $item_id, $groups[ $group_id ]['items'] ) ) {
+			$groups[ $group_id ]['items'][ $item_id ] = array();
+		}
+
+		$old_item_data = $groups[ $group_id ]['items'][ $item_id ];
+		$merged_item_data = array_merge( $export_datum['data'], $old_item_data );
+		$groups[ $group_id ]['items'][ $item_id ] = $merged_item_data;
+	}
+
+	// Save the grouped data into the request
+	delete_post_meta( $request_id, '_export_data_raw' );
+	update_post_meta( $request_id, '_export_data_grouped', $groups );
+
+	// And now, generate the export file
+	$report_path = get_post_meta( $request_id, '_export_file_path', true );
+	if ( ! empty( $request_path ) ) {
+		delete_post_meta( $request_id, '_export_file_path' );
+		@unlink( $request_path );
+	}
+	delete_post_meta( $request_id, '_export_file_url' );
+
+	// Act on the collected, grouped personal data
+	do_action( 'wp_privacy_personal_data_export_file', $request_id );
+
+	// Clear the grouped data now that it is no longer needed
+	delete_post_meta( $request_id, '_export_data_grouped' );
+
+	// Modify the response to include the URL of the export file
+	$export_file_url = get_post_meta( $request_id, '_export_file_url', true );
+	if ( ! empty( $export_file_url ) ) {
+		$response['url'] = $export_file_url;
+	}
+
+	return $response;
+}
+
+/**
+ * Cleans up export files older than three days old
+ *
+ * @since 4.9.6
+ */
+function wp_privacy_delete_old_export_files() {
+	$upload_dir  = wp_upload_dir();
+	$exports_dir = trailingslashit( $upload_dir['basedir'] . '/exports' );
+	$export_files = list_files( $exports_dir );
+
+	foreach( (array) $export_files as $export_file ) {
+		$file_age_in_seconds = time() - filemtime( $export_file );
+
+		if ( 3 * DAY_IN_SECONDS < $file_age_in_seconds ) {
+			@unlink( $export_file );
+		}
+	}
+}
Index: src/wp-admin/includes/user.php
===================================================================
--- src/wp-admin/includes/user.php	(revision 43000)
+++ src/wp-admin/includes/user.php	(working copy)
@@ -847,6 +847,9 @@
 
 	_wp_personal_data_handle_actions();
 
+	// "Borrow" xfn.js for now so we don't have to create new files.
+	wp_enqueue_script( 'xfn' );
+
 	$requests_table = new WP_Privacy_Data_Export_Requests_Table( array(
 		'plural'   => 'privacy_requests',
 		'singular' => 'privacy_request',
Index: src/wp-admin/js/xfn.js
===================================================================
--- src/wp-admin/js/xfn.js	(revision 43000)
+++ src/wp-admin/js/xfn.js	(working copy)
@@ -39,17 +39,98 @@
 
 	function appendResultsAfterRow( $requestRow, classes, summaryMessage, additionalMessages ) {
 		clearResultsAfterRow( $requestRow );
+
+		var itemList = '';
 		if ( additionalMessages.length ) {
-			// TODO - render additionalMessages after the summaryMessage
+			$.each( additionalMessages, function( index, value ) {
+				itemList = itemList + '<li>' + value + '</li>';
+			} );
+			itemList = '<ul>' + itemList + '</ul>';
 		}
 
 		$requestRow.after( function() {
-			return '<tr class="request-results"><td colspan="5"><div class="notice inline notice-alt ' + classes + '"><p>' +
+			return '<tr class="request-results"><td colspan="5">' +
+				'<div class="notice inline notice-alt ' + classes + '">' +
+				'<p>' +
 				summaryMessage +
-				'</p></div></td></tr>';
+				'</p>' +
+				itemList +
+				'</div>' +
+				'</td>' +
+				'</tr>';
 		} );
 	}
 
+	$( '.download_personal_data a' ).click( function( event ) {
+		event.preventDefault();
+		event.stopPropagation();
+
+		var $this          = $( this );
+		var $action        = $this.parents( '.download_personal_data' );
+		var $requestRow    = $this.parents( 'tr' );
+		var requestID      = $action.data( 'request-id' );
+		var nonce          = $action.data( 'nonce' );
+		var exportersCount = $action.data( 'exporters-count' );
+
+		$action.blur();
+		clearResultsAfterRow( $requestRow );
+
+		function on_export_done_success( zipUrl ) {
+			set_action_state( $action, 'download_personal_data_idle' );
+			if ( 'undefined' !== typeof zipUrl ) {
+				window.location = zipUrl;
+			} else {
+				on_export_failure( strings.noExportFile );
+			}
+		}
+
+		function on_export_failure( errorMessage ) {
+			set_action_state( $action, 'download_personal_data_failed' );
+			if ( errorMessage ) {
+				appendResultsAfterRow( $requestRow, 'notice-error', strings.exportError, [ errorMessage ] );
+			}
+		}
+
+		function do_next_export( exporterIndex, pageIndex ) {
+			$.ajax(
+				{
+					url: ajaxurl,
+					data: {
+						action: 'wp-privacy-export-personal-data',
+						exporter: exporterIndex,
+						id: requestID,
+						page: pageIndex,
+						security: nonce,
+					},
+					method: 'post'
+				}
+			).done( function( response ) {
+				if ( ! response.success ) {
+					// e.g. invalid request ID
+					on_export_failure( response.data );
+					return;
+				}
+				var responseData = response.data;
+				if ( ! responseData.done ) {
+					setTimeout( do_next_export( exporterIndex, pageIndex + 1 ) );
+				} else {
+					if ( exporterIndex < exportersCount ) {
+						setTimeout( do_next_export( exporterIndex + 1, 1 ) );
+					} else {
+						on_export_done_success( responseData.url );
+					}
+				}
+			} ).fail( function( jqxhr, textStatus, error ) {
+				// e.g. Nonce failure
+				on_export_failure( error );
+			} );
+		}
+
+		// And now, let's begin
+		set_action_state( $action, 'download_personal_data_processing' );
+		do_next_export( 1, 1 );
+	} )
+
 	$( '.remove_personal_data a' ).click( function( event ) {
 		event.preventDefault();
 		event.stopPropagation();
@@ -92,7 +173,7 @@
 
 		function on_erasure_failure() {
 			set_action_state( $action, 'remove_personal_data_failed' );
-			appendResultsAfterRow( $requestRow, 'notice-error', strings.anErrorOccurred, [] );
+			appendResultsAfterRow( $requestRow, 'notice-error', strings.removalError, [] );
 		}
 
 		function do_next_erasure( eraserIndex, pageIndex ) {
Index: src/wp-includes/script-loader.php
===================================================================
--- src/wp-includes/script-loader.php	(revision 43000)
+++ src/wp-includes/script-loader.php	(working copy)
@@ -715,7 +715,9 @@
 				'foundAndRemoved' => __( 'All of the personal data found for this user was removed.' ),
 				'noneRemoved'     => __( 'Personal data was found for this user but was not removed.' ),
 				'someNotRemoved'  => __( 'Personal data was found for this user but some of the personal data found was not removed.' ),
-				'anErrorOccurred' => __( 'An error occurred while attempting to find and remove personal data.' ),
+				'removalError'    => __( 'An error occurred while attempting to find and remove personal data.' ),
+				'noExportFile'    => __( 'No personal data export file was generated.' ),
+				'exportError'     => __( 'An error occurred while attempting to export personal data.' ),
 			)
 		);
 
