Index: src/wp-admin/includes/admin-filters.php
===================================================================
--- src/wp-admin/includes/admin-filters.php	(revision 42995)
+++ src/wp-admin/includes/admin-filters.php	(working copy)
@@ -133,6 +133,9 @@
 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, 4 );
+
 // Privacy policy text changes check.
 add_action( 'admin_init', array( 'WP_Privacy_Policy_Content', 'text_change_check' ), 20 );
 
Index: src/wp-admin/includes/ajax-actions.php
===================================================================
--- src/wp-admin/includes/ajax-actions.php	(revision 42995)
+++ 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' );
+	// check_ajax_referer( 'wp-privacy-export-personal-data', 'security' );
+	// TODO nonce with request ID
 
 	if ( ! current_user_can( 'manage_options' ) ) {
 		wp_send_json_error( __( 'Error: Invalid request.' ) );
 	}
 
-	$email_address  = sanitize_text_field( $_POST['email'] );
+	$request_id  = sanitize_text_field( $_POST['id'] );
+
+	// 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 );
+
+	// 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_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 42995)
+++ src/wp-admin/includes/file.php	(working copy)
@@ -1934,3 +1934,261 @@
 	</div>
 	<?php
 }
+
+// TODO phpDocs
+function wp_privacy_generate_personal_data_export_group_html( $group_id, $group_data ) {
+	$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>';
+			// TODO entities!
+			$group_html .= '<td>' . esc_html( $group_item_datum['value'] ) . '</td>';
+			// TODO support attachment links
+			$group_html .= '</tr>';
+		}
+
+		$group_html .= '</tbody>';
+		$group_html .= '</table>';
+	}
+
+	$group_html .= '</div>';
+
+	return $group_html;
+}
+
+// TODO phpDocs
+function wp_privacy_generate_personal_data_export( $exports_dir, $email_address, $personal_data ) {
+
+	// TODO Instead of HTML, return a ZIP containing the HTML and the attachments
+	$timestamp = current_time( 'timestamp' );
+	$index_filename = 'wp-personal-data-export-' . md5( $email_address ) . '-' . md5( $timestamp ) . '.html';
+
+	$index_path = trailingslashit( $exports_dir ) . $index_filename;
+	$file = fopen( $index_path, 'w' );
+	// TODO catch fopen error
+
+	$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 = array();
+
+	// First, build a "About" group on the fly for this report
+	$groups['about'] = 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' ),
+				),
+			),
+		)
+	);
+
+	// Next, iterate over every item in $personal_data
+	// Extract all the unique group_ids, group_labels and item_ids
+	// Initialize/append the data for each item under its item_id
+	foreach ( (array) $personal_data as $personal_datum ) {
+		$group_id    = $personal_datum['group_id'];
+		$group_label = $personal_datum['group_label'];
+		if ( ! array_key_exists( $group_id, $groups ) ) {
+			$groups[ $group_id ] = array(
+				'group_label' => $group_label,
+				'items'       => array(),
+			);
+		}
+
+		$item_id = $personal_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( $personal_datum['data'], $old_item_data );
+		$groups[ $group_id ]['items'][ $item_id ] = $merged_item_data;
+	}
+
+	// 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_id, $group_data ) );
+	}
+
+	fwrite( $file, "</body>\n" );
+
+	// Close HTML
+	fwrite( $file, "</html>\n" );
+	fclose( $file );
+
+	// Now, generate the ZIP
+	$archive_filename = 'wp-personal-data-export-' . md5( $email_address ) . '-' . md5( $timestamp ) . '.zip';
+	$archive_path = trailingslashit( $exports_dir ) . $archive_filename;
+
+	$zip = new ZipArchive;
+	// TODO test for no ZipArchive to work with
+
+	if ( TRUE === $zip->open( $archive_path, ZipArchive::CREATE ) ) {
+		$zip->addFile( $index_path, 'index.html' );
+		// TODO - add things referenced in wp-content/uploads
+		$zip->close();
+	} else {
+		error_log( "unable to open zip for creation" );
+		// TODO handle error here
+	}
+
+	// And remove the HTML file
+	unlink( $index_path );
+
+	return(
+		array(
+			'timestamp' => $timestamp,
+			'filename' => $archive_filename,
+		)
+	);
+}
+
+// TODO phpDocs
+function wp_privacy_process_personal_data_export_page( $response, $exporter_index, $email_address, $page ) {
+
+	// Housekeeping
+	$upload_dir  = wp_upload_dir();
+	$exports_dir = $upload_dir['basedir'] . '/exports';
+	$exports_url = $upload_dir['baseurl'] . '/exports';
+
+	// Create the exports folder if needed
+	$result = wp_mkdir_p( $exports_dir );
+	if ( is_wp_error( $result ) ) {
+		return $result;
+	}
+
+	// TODO Make sure the exports folder is protected (htaccess, index)
+
+	// Generate a export file option key from the email address
+	$export_file_option = '_wp_privacy_export_file_' . md5( $email_address );
+
+	// See if we have an export URL cached for this user already
+	$export_file_details = get_site_option( $export_file_option, '', false );
+
+	// And if we do, short circuit the export and send the path back instead
+	if ( ! empty( $export_file_details ) ) {
+		$pieces = explode( ':', $export_file_details ); // path:timestamp
+		$export_file_path = trailingslashit( $exports_dir ) . $pieces[0];
+
+		if ( file_exists( $export_file_path ) ) {
+			return(
+				array(
+					'data' => array(),
+					'done' => true,
+					'url'  => $pieces[0],
+				)
+			);
+		}
+
+		// No file? Delete the option and continue building the export
+		delete_site_option( $export_file_option );
+	}
+
+	// Generate a (temporary) data storage option key from the email address
+	$export_data_option = '_wp_privacy_export_data_' . md5( $email_address );
+
+	// First page of first exporter? Prepare/clear the option where we will assemble all the responses
+	if ( 1 === $exporter_index && 1 === $page ) {
+		update_site_option( $export_data_option, array() );
+	}
+
+	// Grab whatever data has already been collected on previous exporters and/or pages
+	$export_data = get_site_option( $export_data_option, array() );
+
+	// Does the response include data? If so, add this response's data to all the data we have received so far
+	// TODO - enforce the shape?
+	// TODO - enforce data as a numeric (not associative) array
+	$exporter_page_data = $response['data'];
+	if ( ! empty( $exporter_page_data ) && is_array( $exporter_page_data ) ) {
+		$export_data = array_merge( $export_data, $exporter_page_data );
+		update_site_option( $export_data_option, $export_data );
+	}
+
+	// Are we on the last exporter, and did that export say it was done? If not, return now
+	// so we can continue to collect data for export
+	$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;
+	}
+
+	// TODO de-repetitive-ize the data
+
+	// Generate the export
+	$result = wp_privacy_generate_personal_data_export( $exports_dir, $email_address, $export_data );
+	if ( is_wp_error( $result ) ) {
+		return $result;
+	}
+
+	// Get the timestamp and filename from the export
+	$timestamp = $result['timestamp'];
+	$filename = $result['filename'];
+
+	// Build the URL for the file
+	$export_url = trailingslashit( $exports_url ) . $filename;
+
+	// Modify the response to include the URL of the export file
+	$response['url'] = $export_url;
+
+	// TODO Save the export file in an option for safekeeping
+
+	return $response;
+}
+
+function wp_privacy_delete_old_export_files() {
+
+	// TODO delete old export files and their options
+
+}
Index: src/wp-admin/includes/user.php
===================================================================
--- src/wp-admin/includes/user.php	(revision 42995)
+++ 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 42995)
+++ src/wp-admin/js/xfn.js	(working copy)
@@ -50,6 +50,72 @@
 		} );
 	}
 
+	$( '.download_personal_data a' ).click( function( event ) {
+		event.stopPropagation();
+
+		var $this          = $( this );
+		var $action        = $this.parents( '.download_personal_data' );
+		var requestID      = $action.data( 'request-id' );
+		var nonce          = $action.data( 'nonce' );
+		var exportersCount = $action.data( 'exporters-count' );
+
+		$action.blur();
+
+		function on_export_done_success( url ) {
+			set_action_state( $action, 'download_personal_data_idle' );
+			// TODO - simplify once 43551 has landed - we won't need to test for a url
+			// nor show the successMessage then - we can just kick off the ZIP download
+			if ( url ) {
+				window.location = url; // kick off ZIP download
+			} else {
+				alert( 'Success!' ); // TODO DO NOT COMMIT
+			}
+		}
+
+		function on_export_failure( textStatus, error ) {
+			set_action_state( $action, 'download_personal_data_failed' );
+			// TODO show a div with the error message
+			alert( 'Failure!' ); // TODO DO NOT COMMIT
+		}
+
+		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 ) {
+				on_export_failure( 'error', responseData.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 {
+					console.log( responseData );
+					on_export_done_success( responseData.url );
+				}
+			}
+		} ).fail( function( jqxhr, textStatus, error ) {
+			on_export_failure( textStatus, 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();
