Commit 6389d2dad1d for woocommerce

commit 6389d2dad1d3d1ac3fb99fb8c0230f60940b7aa7
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date:   Fri Sep 11 11:39:41 2026 +0300

    Include the date range in emailed Analytics report exports (#68469)

    * Include the date range in emailed Analytics report exports

diff --git a/plugins/woocommerce/changelog/49059-include-dates-in-emailed-analytics-exports b/plugins/woocommerce/changelog/49059-include-dates-in-emailed-analytics-exports
new file mode 100644
index 00000000000..b1b3296ea70
--- /dev/null
+++ b/plugins/woocommerce/changelog/49059-include-dates-in-emailed-analytics-exports
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Include the report's date range in emailed Analytics exports, in the email and in the downloaded file's name.
diff --git a/plugins/woocommerce/includes/react-admin/emails/html-admin-report-export-download.php b/plugins/woocommerce/includes/react-admin/emails/html-admin-report-export-download.php
index da2c109b7ad..12526f45fb7 100644
--- a/plugins/woocommerce/includes/react-admin/emails/html-admin-report-export-download.php
+++ b/plugins/woocommerce/includes/react-admin/emails/html-admin-report-export-download.php
@@ -19,6 +19,21 @@ do_action( 'woocommerce_email_header', $email_heading, $email );
 		echo esc_html( sprintf( __( 'Download your %s Report', 'woocommerce' ), $report_name ) );
 	?>
 </a>
+<?php
+/**
+ * Date range the report covers, passed in by ReportCSVEmail. Empty for reports without one.
+ *
+ * @var string $date_range
+ */
+if ( ! empty( $date_range ) ) :
+	?>
+<p>
+	<?php
+		/* translators: %s: the date range the report covers, e.g. "June 1, 2025 - June 30, 2025" */
+		echo esc_html( sprintf( __( 'Date range: %s', 'woocommerce' ), $date_range ) );
+	?>
+</p>
+<?php endif; ?>
 <p>
 	<?php
 		/**
diff --git a/plugins/woocommerce/includes/react-admin/emails/plain-admin-report-export-download.php b/plugins/woocommerce/includes/react-admin/emails/plain-admin-report-export-download.php
index 365bf01bf51..461169b04c4 100644
--- a/plugins/woocommerce/includes/react-admin/emails/plain-admin-report-export-download.php
+++ b/plugins/woocommerce/includes/react-admin/emails/plain-admin-report-export-download.php
@@ -16,6 +16,18 @@ echo wp_kses_post( sprintf( __( 'Download your %1$s Report: %2$s', 'woocommerce'

 echo "\n\n";

+/**
+ * Date range the report covers, passed in by ReportCSVEmail. Empty for reports without one.
+ *
+ * @var string $date_range
+ */
+if ( ! empty( $date_range ) ) {
+	/* translators: %s: the date range the report covers, e.g. "June 1, 2025 - June 30, 2025" */
+	echo esc_html( sprintf( __( 'Date range: %s', 'woocommerce' ), $date_range ) );
+
+	echo "\n\n";
+}
+
 /**
  * Length of time the download link stays valid, passed in by ReportCSVEmail.
  *
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php
index dd4977b1ad6..ebf02a7a06e 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php
@@ -255,12 +255,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {

 		// @todo - add thing in the links below instead?
 		if ( 100 === $percentage ) {
-			$query_args = array(
-				'action'   => ReportExporter::DOWNLOAD_EXPORT_ACTION,
-				'filename' => "wc-{$report_type}-report-export-{$export_id}",
-			);
-
-			$result['download_url'] = add_query_arg( $query_args, admin_url() );
+			$result['download_url'] = ReportExporter::get_download_url( $report_type, $export_id );
 		}

 		// Wrap the data in a response object.
diff --git a/plugins/woocommerce/src/Admin/ReportCSVEmail.php b/plugins/woocommerce/src/Admin/ReportCSVEmail.php
index 3deebe9301a..68c80692708 100644
--- a/plugins/woocommerce/src/Admin/ReportCSVEmail.php
+++ b/plugins/woocommerce/src/Admin/ReportCSVEmail.php
@@ -42,10 +42,19 @@ class ReportCSVEmail extends \WC_Email {
 	 */
 	protected $download_url;

+	/**
+	 * Date range the report covers, formatted for display. Empty when the report has no range.
+	 *
+	 * @var string
+	 */
+	protected $report_date_range = '';
+
 	/**
 	 * Constructor.
 	 */
 	public function __construct() {
+		$this->placeholders['{report_date_range}'] = '';
+
 		$this->id             = 'admin_report_export_download';
 		$this->template_base  = WC()->plugin_path() . '/includes/react-admin/emails/';
 		$this->template_html  = 'html-admin-report-export-download.php';
@@ -111,9 +120,16 @@ class ReportCSVEmail extends \WC_Email {
 	/**
 	 * Get email subject.
 	 *
+	 * Says which period the report covers when it has one, so a merchant running the same report
+	 * over several date ranges can tell the emails apart without opening them.
+	 *
 	 * @return string
 	 */
 	public function get_default_subject() {
+		if ( '' !== $this->report_date_range ) {
+			return __( '[{site_title}]: Your {report_name} Report for {report_date_range} is ready', 'woocommerce' );
+		}
+
 		return __( '[{site_title}]: Your {report_name} Report download is ready', 'woocommerce' );
 	}

@@ -127,6 +143,7 @@ class ReportCSVEmail extends \WC_Email {
 			$this->template_html,
 			array(
 				'report_name'   => $this->report_type,
+				'date_range'    => $this->report_date_range,
 				'download_url'  => $this->download_url,
 				'email_heading' => $this->get_heading(),
 				'sent_to_admin' => true,
@@ -149,6 +166,7 @@ class ReportCSVEmail extends \WC_Email {
 			$this->template_plain,
 			array(
 				'report_name'   => $this->report_type,
+				'date_range'    => $this->report_date_range,
 				'download_url'  => $this->download_url,
 				'email_heading' => $this->get_heading(),
 				'sent_to_admin' => true,
@@ -171,6 +189,21 @@ class ReportCSVEmail extends \WC_Email {
 		return human_time_diff( 0, ReportExporter::EXPORT_RETENTION_PERIOD );
 	}

+	/**
+	 * Set the date range the report covers, so the email can say which period it is for.
+	 *
+	 * Call before trigger(). Reports that are not limited to a period, such as Stock, leave it unset.
+	 *
+	 * @since 11.2.0
+	 * @param string $date_range The date range the report covers, formatted for display.
+	 * @return void
+	 */
+	public function set_report_date_range( $date_range ) {
+		$this->report_date_range = is_string( $date_range ) ? $date_range : '';
+
+		$this->placeholders['{report_date_range}'] = $this->report_date_range;
+	}
+
 	/**
 	 * Trigger the sending of this email.
 	 *
diff --git a/plugins/woocommerce/src/Admin/ReportCSVExporter.php b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
index a85d26c7a63..68b16fcfa5b 100644
--- a/plugins/woocommerce/src/Admin/ReportCSVExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
@@ -43,6 +43,13 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
 	 */
 	protected $controller;

+	/**
+	 * Detail appended to the name the export is downloaded as.
+	 *
+	 * @var string
+	 */
+	protected $download_suffix = '';
+
 	/**
 	 * Constructor.
 	 *
@@ -116,6 +123,56 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
 		return self::get_reports_directory() . $this->get_filename();
 	}

+	/**
+	 * Get the name the export is downloaded as.
+	 *
+	 * The stored file keeps get_filename(), which only has to identify the export.
+	 *
+	 * @since 11.2.0
+	 * @return string
+	 */
+	public function get_download_filename() {
+		$filename = $this->get_filename();
+
+		if ( '' === $this->download_suffix ) {
+			return $filename;
+		}
+
+		// Stripped and restored the way set_filename() does it, so the name keeps a single .csv.
+		return sanitize_file_name( str_replace( '.csv', '', $filename ) . '-' . $this->download_suffix . '.csv' );
+	}
+
+	/**
+	 * Set the export headers.
+	 *
+	 * Re-sends the parent's Content-Disposition so the download is named after what the report
+	 * covers, leaving the stored file's name alone.
+	 *
+	 * @since 11.2.0
+	 * @return void
+	 */
+	public function send_headers() {
+		parent::send_headers();
+
+		if ( '' !== $this->download_suffix ) {
+			header( 'Content-Disposition: attachment; filename=' . $this->get_download_filename() );
+		}
+	}
+
+	/**
+	 * Add detail to the name the export is downloaded as, leaving the stored file's name alone.
+	 *
+	 * Lets a download say what the report covers, such as the date range, when the stored name
+	 * only identifies the export.
+	 *
+	 * @since 11.2.0
+	 * @param string $suffix Detail to append to the download's name, e.g. a date range.
+	 * @return void
+	 */
+	public function set_download_suffix( $suffix ) {
+		$this->download_suffix = sanitize_file_name( $suffix );
+	}
+
 	/**
 	 * Check whether a complete generated export is still on disk.
 	 *
diff --git a/plugins/woocommerce/src/Admin/ReportExporter.php b/plugins/woocommerce/src/Admin/ReportExporter.php
index 2323aeaf1bf..ffd9b087cdb 100644
--- a/plugins/woocommerce/src/Admin/ReportExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportExporter.php
@@ -10,6 +10,7 @@ if ( ! defined( 'ABSPATH' ) ) {
 }

 use Automattic\WooCommerce\Admin\Schedulers\SchedulerTraits;
+use Automattic\WooCommerce\Utilities\TimeUtil;

 /**
  * ReportExporter Class.
@@ -146,7 +147,7 @@ class ReportExporter {
 			self::queue_batches( 1, $num_batches, 'export_report', $report_batch_args );

 			if ( $send_email ) {
-				$email_action_args = array( get_current_user_id(), $export_id, $report_type );
+				$email_action_args = array( get_current_user_id(), $export_id, $report_type, $report_args );
 				self::schedule_action( 'email_report_download_link', $email_action_args );
 			}
 		}
@@ -167,7 +168,7 @@ class ReportExporter {
 		$report_args['page'] = $page_number;

 		$exporter = new ReportCSVExporter( $report_type, $report_args );
-		$exporter->set_filename( "wc-{$report_type}-report-export-{$export_id}" );
+		$exporter->set_filename( self::get_export_filename( $report_type, $export_id ) );
 		$exporter->generate_file();

 		self::update_export_percentage_complete( $report_type, $export_id, $exporter->get_percent_complete() );
@@ -220,30 +221,168 @@ class ReportExporter {
 	}

 	/**
-	 * Serve the export file.
+	 * Get the name a report export is stored under.
+	 *
+	 * @param string $report_type Report type. E.g. 'customers'.
+	 * @param string $export_id Unique ID for report (timestamp expected).
+	 * @return string
 	 */
-	public static function download_export_file() {
-		/*
-		 * A read-only download of a report the requesting user is already allowed to view, gated on
-		 * the view_woocommerce_reports capability, so a nonce would only prevent nuisance CSRF. The
-		 * action is compared verbatim against a fixed name, and set_filename() applies
-		 * sanitize_file_name(), which keeps the path inside the reports directory. A nonce is not an
-		 * option here either: nonces last 24 hours, and this link is emailed and kept for a week.
-		 */
-		// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+	private static function get_export_filename( $report_type, $export_id ) {
+		return "wc-{$report_type}-report-export-{$export_id}";
+	}
+
+	/**
+	 * Get the URL a finished report export is downloaded from.
+	 *
+	 * @since 11.2.0
+	 * @param string $report_type Report type. E.g. 'customers'.
+	 * @param string $export_id Unique ID for report (timestamp expected).
+	 * @param array  $report_args Optional. Report parameters the export was queued with. When they name
+	 *                            a date range, the link carries it so the download is named after it.
+	 * @return string
+	 */
+	public static function get_download_url( $report_type, $export_id, $report_args = array() ) {
+		$query_args = array(
+			'action'   => self::DOWNLOAD_EXPORT_ACTION,
+			'filename' => self::get_export_filename( $report_type, $export_id ),
+		);
+
+		$date_range = self::get_export_date_range( $report_args );
+		if ( $date_range ) {
+			$query_args['date_range'] = $date_range['after'] . '-to-' . $date_range['before'];
+		}
+
+		return add_query_arg( $query_args, admin_url() );
+	}
+
+	/**
+	 * Get the date range a report export covers.
+	 *
+	 * Reports are not all limited to a period. Stock, for one, has no date range at all.
+	 *
+	 * @since 11.2.0
+	 * @param array $report_args Report parameters, passed to data query.
+	 * @return string[] The range's `after` and `before` dates as `Y-m-d`, or an empty array when the report has no range.
+	 */
+	public static function get_export_date_range( $report_args ) {
+		if ( ! is_array( $report_args ) ) {
+			return array();
+		}
+
+		$date_range = array();
+
+		foreach ( array( 'after', 'before' ) as $bound ) {
+			// Report args arrive from a REST request, so they hold whatever the caller sent. Take the
+			// date as written rather than converting it: the report reads these as store local time.
+			// The shape alone is not enough, since a date like 2025-06-31 would roll over to July 1.
+			if (
+				empty( $report_args[ $bound ] ) ||
+				! is_string( $report_args[ $bound ] ) ||
+				! preg_match( '/^(\d{4}-\d{2}-\d{2})/', $report_args[ $bound ], $matches ) ||
+				! TimeUtil::is_valid_date( $matches[1], 'Y-m-d' )
+			) {
+				return array();
+			}
+
+			$date_range[ $bound ] = $matches[1];
+		}
+
+		return $date_range;
+	}
+
+	/**
+	 * Get the date range a report export covers, formatted for display.
+	 *
+	 * @since 11.2.0
+	 * @param array $report_args Report parameters, passed to data query.
+	 * @return string Date range in the site's date format, or an empty string when the report has no range.
+	 */
+	public static function get_export_date_range_label( $report_args ) {
+		$date_range = self::get_export_date_range( $report_args );
+
+		if ( ! $date_range ) {
+			return '';
+		}
+
+		$after  = self::format_date_range_bound( $date_range['after'] );
+		$before = self::format_date_range_bound( $date_range['before'] );
+
+		if ( '' === $after || '' === $before ) {
+			return '';
+		}
+
+		if ( $after === $before ) {
+			return $after;
+		}
+
+		/* translators: 1: first day of the period a report covers, 2: last day of that period. */
+		return sprintf( _x( '%1$s - %2$s', 'Report date range: from-to', 'woocommerce' ), $after, $before );
+	}
+
+	/**
+	 * Format one end of a report's date range for display.
+	 *
+	 * @param string $date Date as `Y-m-d`.
+	 * @return string The date in the store's date format, or an empty string when it cannot be read.
+	 */
+	private static function format_date_range_bound( $date ) {
+		// Read in the store's own timezone, so the date reads back as the merchant picked it and a
+		// date format that names the timezone names theirs rather than UTC. Midday is a safe anchor.
+		$parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $date . ' 12:00:00', wp_timezone() );
+
+		if ( false === $parsed ) {
+			return '';
+		}
+
+		return (string) wp_date( wc_date_format(), $parsed->getTimestamp() );
+	}
+
+	/**
+	 * Build the exporter a download request is asking for.
+	 *
+	 * A read-only download of a report the requesting user is already allowed to view, gated on
+	 * the view_woocommerce_reports capability, so a nonce would only prevent nuisance CSRF. The
+	 * action is compared verbatim against a fixed name, and set_filename() applies
+	 * sanitize_file_name(), which keeps the path inside the reports directory. A nonce is not an
+	 * option here either: nonces last 24 hours, and this link is emailed and kept for a week.
+	 *
+	 * @param array $request Unslashed request parameters, expected to be `$_GET`.
+	 * @return ReportCSVExporter|null The exporter for the requested export, or null when the request asks for no export.
+	 */
+	private static function get_requested_export( $request ) {
 		if (
-			! isset( $_GET['action'] ) ||
-			self::DOWNLOAD_EXPORT_ACTION !== wp_unslash( $_GET['action'] ) ||
-			empty( $_GET['filename'] ) ||
-			! is_string( $_GET['filename'] ) ||
+			! is_array( $request ) ||
+			! isset( $request['action'] ) ||
+			self::DOWNLOAD_EXPORT_ACTION !== $request['action'] ||
+			empty( $request['filename'] ) ||
+			! is_string( $request['filename'] ) ||
 			! current_user_can( 'view_woocommerce_reports' )
 		) {
-			return;
+			return null;
 		}

 		$exporter = new ReportCSVExporter();
-		$exporter->set_filename( wp_unslash( $_GET['filename'] ) );
-		// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		$exporter->set_filename( $request['filename'] );
+
+		// The stored name only identifies the export, so the emailed link carries the report's date
+		// range to name the download after the period it covers. It never reaches the file path.
+		if ( ! empty( $request['date_range'] ) && is_string( $request['date_range'] ) ) {
+			$exporter->set_download_suffix( $request['date_range'] );
+		}
+
+		return $exporter;
+	}
+
+	/**
+	 * Serve the export file.
+	 */
+	public static function download_export_file() {
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read in get_requested_export(), which documents why there is no nonce and validates every value it reads.
+		$exporter = self::get_requested_export( wp_unslash( $_GET ) );
+
+		if ( ! $exporter ) {
+			return;
+		}

 		// Say so rather than serving an empty CSV: the exporter creates a blank file for a path
 		// that no longer exists, which reads as a report with no results.
@@ -272,20 +411,19 @@ class ReportExporter {
 	 * @param int    $user_id User ID that requested the email.
 	 * @param string $export_id Unique ID for report (timestamp expected).
 	 * @param string $report_type Report type. E.g. 'customers'.
+	 * @param array  $report_args Optional. Report parameters the export was queued with. Exports queued
+	 *                            before WooCommerce 11.2.0 run without them.
 	 * @return void
 	 */
-	public static function email_report_download_link( $user_id, $export_id, $report_type ) {
+	public static function email_report_download_link( $user_id, $export_id, $report_type, $report_args = array() ) {
 		$percent_complete = self::get_export_percentage_complete( $report_type, $export_id );

 		if ( 100 === $percent_complete ) {
-			$query_args   = array(
-				'action'   => self::DOWNLOAD_EXPORT_ACTION,
-				'filename' => "wc-{$report_type}-report-export-{$export_id}",
-			);
-			$download_url = add_query_arg( $query_args, admin_url() );
+			$download_url = self::get_download_url( $report_type, $export_id, $report_args );

 			\WC_Emails::instance();
 			$email = new ReportCSVEmail();
+			$email->set_report_date_range( self::get_export_date_range_label( $report_args ) );
 			$email->trigger( $user_id, $report_type, $download_url );
 		}
 	}
diff --git a/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php b/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php
index acb24f295de..99f7f784ad9 100644
--- a/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/ReportCSVEmailTest.php
@@ -21,10 +21,12 @@ class ReportCSVEmailTest extends WC_Unit_Test_Case {
 	/**
 	 * Build an email addressed at a generated export.
 	 *
+	 * @param string $date_range Optional. Date range the report covers, formatted for display.
 	 * @return ReportCSVEmail
 	 */
-	private function create_email(): ReportCSVEmail {
+	private function create_email( string $date_range = '' ): ReportCSVEmail {
 		$email = new ReportCSVEmail();
+		$email->set_report_date_range( $date_range );

 		foreach ( array(
 			'report_type'  => 'Orders',
@@ -63,4 +65,102 @@ class ReportCSVEmailTest extends WC_Unit_Test_Case {
 			'The plain text email should tell the merchant how long the link lasts.'
 		);
 	}
+
+	/**
+	 * @testdox The email says which period the report covers.
+	 *
+	 * @testWith ["get_content_html"]
+	 *           ["get_content_plain"]
+	 *
+	 * @param string $method Method that renders the email body.
+	 */
+	public function test_email_states_the_date_range( string $method ): void {
+		$content = $this->create_email( 'June 1, 2025 - June 30, 2025' )->$method();
+
+		$this->assertStringContainsString(
+			'Date range: June 1, 2025 - June 30, 2025',
+			$content,
+			'The email should say which period the report covers.'
+		);
+	}
+
+	/**
+	 * @testdox A report without a date range says nothing about one.
+	 *
+	 * @testWith ["get_content_html"]
+	 *           ["get_content_plain"]
+	 *
+	 * @param string $method Method that renders the email body.
+	 */
+	public function test_email_omits_an_empty_date_range( string $method ): void {
+		$content = $this->create_email()->$method();
+
+		$this->assertStringNotContainsString(
+			'Date range:',
+			$content,
+			'Reports that are not limited to a period should not mention a date range.'
+		);
+	}
+
+	/**
+	 * @testdox The subject says which period the report covers, so two exports of the same report can be told apart unopened.
+	 */
+	public function test_subject_states_the_date_range(): void {
+		$subject = $this->trigger_and_get_subject( 'June 1, 2025 - June 30, 2025' );
+
+		$this->assertStringContainsString(
+			'Your Orders Report for June 1, 2025 - June 30, 2025 is ready',
+			$subject,
+			'The subject should name the period the report covers.'
+		);
+	}
+
+	/**
+	 * @testdox A report without a date range keeps the plain subject.
+	 */
+	public function test_subject_without_a_date_range(): void {
+		$subject = $this->trigger_and_get_subject();
+
+		$this->assertStringContainsString(
+			'Your Orders Report download is ready',
+			$subject,
+			'Reports that are not limited to a period should keep the original subject.'
+		);
+	}
+
+	/**
+	 * @testdox trigger() keeps the parameters it shipped with, so a subclass overriding it still loads.
+	 */
+	public function test_trigger_keeps_its_original_parameters(): void {
+		$parameters = ( new \ReflectionMethod( ReportCSVEmail::class, 'trigger' ) )->getParameters();
+
+		$this->assertCount(
+			3,
+			$parameters,
+			'trigger() is public and overridable. Adding a parameter, even an optional one, fatals every subclass that overrides the original three. Pass anything new through a setter instead.'
+		);
+	}
+
+	/**
+	 * Send the email to a user and return the subject it went out with.
+	 *
+	 * @param string $date_range Optional. Date range the report covers, formatted for display.
+	 * @return string
+	 */
+	private function trigger_and_get_subject( string $date_range = '' ): string {
+		$user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$mailer  = tests_retrieve_phpmailer_instance();
+		$email   = new ReportCSVEmail();
+
+		$email->set_report_date_range( $date_range );
+		$email->trigger(
+			$user_id,
+			'orders',
+			'https://example.org/?action=woocommerce_admin_download_report_csv&filename=wc-orders-report-export'
+		);
+
+		$sent = end( $mailer->mock_sent );
+
+		return $sent ? $sent['subject'] : '';
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
index 171f20ba492..d5660b6524f 100644
--- a/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/ReportExporterTest.php
@@ -148,6 +148,381 @@ class ReportExporterTest extends WC_Unit_Test_Case {
 		$this->assertFileExists( $reports_dir . 'index.html', 'Cleanup should not touch the directory guards.' );
 	}

+	/**
+	 * @testdox A report's date range is read from the arguments it was exported with.
+	 *
+	 * @testWith ["2025-06-01T00:00:00", "2025-06-30T23:59:59", "2025-06-01", "2025-06-30"]
+	 *           ["2025-06-01", "2025-06-01", "2025-06-01", "2025-06-01"]
+	 *           ["2024-02-01T00:00:00", "2024-02-29T23:59:59", "2024-02-01", "2024-02-29"]
+	 *
+	 * @param string $after           The export's `after` argument.
+	 * @param string $before          The export's `before` argument.
+	 * @param string $expected_after  Expected first day of the range.
+	 * @param string $expected_before Expected last day of the range.
+	 */
+	public function test_date_range_is_read_from_report_args( string $after, string $before, string $expected_after, string $expected_before ): void {
+		$this->assertSame(
+			array(
+				'after'  => $expected_after,
+				'before' => $expected_before,
+			),
+			ReportExporter::get_export_date_range(
+				array(
+					'after'  => $after,
+					'before' => $before,
+				)
+			),
+			'The range should be the dates the report was run for, as written.'
+		);
+	}
+
+	/**
+	 * @testdox Arguments without a usable date range produce no range.
+	 *
+	 * A date that does not exist counts as unusable. Left alone it would roll over, so an export
+	 * run for June 31 would be labelled and named July 1.
+	 *
+	 * @testWith [{}]
+	 *           [{"after": "2025-06-01T00:00:00"}]
+	 *           [{"after": "2025-06-01T00:00:00", "before": ""}]
+	 *           [{"after": "2025-06-01T00:00:00", "before": "last month"}]
+	 *           [{"after": "2025-06-01T00:00:00", "before": ["2025-06-30"]}]
+	 *           [{"after": "2025-06-31T00:00:00", "before": "2025-06-30T23:59:59"}]
+	 *           [{"after": "2025-06-01T00:00:00", "before": "2025-13-45T00:00:00"}]
+	 *           [{"after": "2025-02-29T00:00:00", "before": "2025-03-01T00:00:00"}]
+	 *
+	 * @param array $report_args Report parameters the export was queued with.
+	 */
+	public function test_report_args_without_a_date_range( array $report_args ): void {
+		$this->assertSame(
+			array(),
+			ReportExporter::get_export_date_range( $report_args ),
+			'A report that is not limited to a period should report no date range.'
+		);
+	}
+
+	/**
+	 * @testdox The date range is labelled in the store's date format.
+	 */
+	public function test_date_range_label_uses_the_store_date_format(): void {
+		update_option( 'date_format', 'F j, Y' );
+
+		$this->assertSame(
+			'June 1, 2025 - June 30, 2025',
+			ReportExporter::get_export_date_range_label(
+				array(
+					'after'  => '2025-06-01T00:00:00',
+					'before' => '2025-06-30T23:59:59',
+				)
+			),
+			'The label should read as the merchant picked the range.'
+		);
+	}
+
+	/**
+	 * @testdox The date range is labelled in the store's timezone, not in UTC.
+	 *
+	 * A date format that names the timezone should name the merchant's own, and the date itself
+	 * should read the same whichever timezone the store keeps.
+	 *
+	 * @testWith ["Europe/Sofia", "Y-m-d T", "2025-06-01 EEST"]
+	 *           ["America/Los_Angeles", "Y-m-d T", "2025-06-01 PDT"]
+	 *           ["Pacific/Kiritimati", "F j, Y", "June 1, 2025"]
+	 *           ["Pacific/Midway", "F j, Y", "June 1, 2025"]
+	 *
+	 * @param string $timezone Store timezone.
+	 * @param string $format   Store date format.
+	 * @param string $expected Expected label for a one day report.
+	 */
+	public function test_date_range_label_uses_the_store_timezone( string $timezone, string $format, string $expected ): void {
+		update_option( 'timezone_string', $timezone );
+		update_option( 'date_format', $format );
+
+		$this->assertSame(
+			$expected,
+			ReportExporter::get_export_date_range_label(
+				array(
+					'after'  => '2025-06-01T00:00:00',
+					'before' => '2025-06-01T23:59:59',
+				)
+			),
+			'The label should read in the store timezone rather than UTC.'
+		);
+	}
+
+	/**
+	 * @testdox The date range label goes through the WooCommerce date format, so a store can filter it.
+	 */
+	public function test_date_range_label_uses_the_woocommerce_date_format(): void {
+		update_option( 'date_format', 'F j, Y' );
+		add_filter( 'woocommerce_date_format', fn() => 'd/m/Y' );
+
+		$this->assertSame(
+			'01/06/2025 - 30/06/2025',
+			ReportExporter::get_export_date_range_label(
+				array(
+					'after'  => '2025-06-01T00:00:00',
+					'before' => '2025-06-30T23:59:59',
+				)
+			),
+			'The label should honour woocommerce_date_format like the rest of WooCommerce date output.'
+		);
+	}
+
+	/**
+	 * @testdox A single day range is labelled as one date rather than a range.
+	 */
+	public function test_single_day_date_range_label(): void {
+		update_option( 'date_format', 'F j, Y' );
+
+		$this->assertSame(
+			'June 1, 2025',
+			ReportExporter::get_export_date_range_label(
+				array(
+					'after'  => '2025-06-01T00:00:00',
+					'before' => '2025-06-01T23:59:59',
+				)
+			),
+			'A one day report should not repeat the same date twice.'
+		);
+	}
+
+	/**
+	 * @testdox An export is downloaded under a name that says which period it covers.
+	 */
+	public function test_download_is_named_after_the_period_it_covers(): void {
+		$filename = $this->create_export( 'wc-products-report-export-1234567890' );
+
+		$exporter = new ReportCSVExporter();
+		$exporter->set_filename( $filename );
+		$exporter->set_download_suffix( '2025-06-01-to-2025-06-30' );
+
+		$this->assertSame(
+			'wc-products-report-export-1234567890-2025-06-01-to-2025-06-30.csv',
+			$exporter->get_download_filename(),
+			'The download should be named after the period the report covers.'
+		);
+		$this->assertSame(
+			$filename,
+			$exporter->get_filename(),
+			'Naming the download should leave the stored export name alone.'
+		);
+		$this->assertTrue(
+			$exporter->export_file_exists(),
+			'The stored export should still be found under the name it was written with.'
+		);
+	}
+
+	/**
+	 * @testdox A download link that names a date range is served under a name that says so.
+	 */
+	public function test_download_request_names_the_download_after_the_date_range(): void {
+		$this->act_as_reports_user();
+
+		$exporter = $this->request_export( $this->download_request( array( 'date_range' => '2025-06-01-to-2025-06-30' ) ) );
+
+		$this->assertNotNull( $exporter, 'A valid download request should be served.' );
+		$this->assertSame(
+			'wc-products-report-export-1234567890-2025-06-01-to-2025-06-30.csv',
+			$exporter->get_download_filename(),
+			'The date range on the link should reach the name the export is downloaded as.'
+		);
+		$this->assertSame(
+			'wc-products-report-export-1234567890.csv',
+			$exporter->get_filename(),
+			'The date range on the link should never change the name the export is stored under.'
+		);
+	}
+
+	/**
+	 * @testdox A download link without a date range keeps the stored export name.
+	 */
+	public function test_download_request_without_a_date_range(): void {
+		$this->act_as_reports_user();
+
+		$exporter = $this->request_export( $this->download_request() );
+
+		$this->assertNotNull( $exporter, 'A valid download request should be served.' );
+		$this->assertSame(
+			'wc-products-report-export-1234567890.csv',
+			$exporter->get_download_filename(),
+			'A link that names no period should download under the stored name, as it did before.'
+		);
+	}
+
+	/**
+	 * @testdox A hostile date range cannot escape the download name or the reports directory.
+	 *
+	 * @testWith ["../../../../etc/passwd", "wc-products-report-export-1234567890-etcpasswd.csv"]
+	 *           ["a\r\nX-Injected: 1", "wc-products-report-export-1234567890-a-X-Injected-1.csv"]
+	 *           ["setup.bat", "wc-products-report-export-1234567890-setup.bat_.csv"]
+	 *
+	 * @param string $date_range Date range as it arrives on the link.
+	 * @param string $expected   Expected download name.
+	 */
+	public function test_download_request_sanitises_the_date_range( string $date_range, string $expected ): void {
+		$this->act_as_reports_user();
+
+		$exporter = $this->request_export( $this->download_request( array( 'date_range' => $date_range ) ) );
+
+		$this->assertNotNull( $exporter, 'A valid download request should be served.' );
+		$this->assertSame(
+			$expected,
+			$exporter->get_download_filename(),
+			'The date range only names the download, so it must not carry separators or a second extension.'
+		);
+		$this->assertSame(
+			'wc-products-report-export-1234567890.csv',
+			$exporter->get_filename(),
+			'The date range must never reach the path the export is read from.'
+		);
+	}
+
+	/**
+	 * @testdox A download request is refused without the reports capability.
+	 */
+	public function test_download_request_requires_the_reports_capability(): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) );
+
+		$this->assertNull(
+			$this->request_export( $this->download_request() ),
+			'A user who cannot view reports should not be served an export.'
+		);
+	}
+
+	/**
+	 * @testdox Requests that do not ask for an export are left alone.
+	 *
+	 * @testWith [{}]
+	 *           [{"action": "edit", "filename": "wc-products-report-export-1234567890"}]
+	 *           [{"action": "woocommerce_admin_download_report_csv"}]
+	 *           [{"action": "woocommerce_admin_download_report_csv", "filename": ""}]
+	 *           [{"action": "woocommerce_admin_download_report_csv", "filename": ["x"]}]
+	 *
+	 * @param array $request Request parameters, as the download handler reads them.
+	 */
+	public function test_requests_that_do_not_ask_for_an_export( array $request ): void {
+		$this->act_as_reports_user();
+
+		$this->assertNull(
+			$this->request_export( $request ),
+			'The download handler should leave requests that are not report downloads alone.'
+		);
+	}
+
+	/**
+	 * Sign in as a user allowed to view reports.
+	 *
+	 * @return void
+	 */
+	private function act_as_reports_user(): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+	}
+
+	/**
+	 * Build the parameters of a valid download link.
+	 *
+	 * @param array $extra Parameters to add to the request.
+	 * @return array
+	 */
+	private function download_request( array $extra = array() ): array {
+		return array_merge(
+			array(
+				'action'   => ReportExporter::DOWNLOAD_EXPORT_ACTION,
+				'filename' => 'wc-products-report-export-1234567890',
+			),
+			$extra
+		);
+	}
+
+	/**
+	 * Build the exporter that a download request would be served by.
+	 *
+	 * Reaches the handler's own reading of the request, so that dropping the date range on the way
+	 * from the link to the download's name fails a test.
+	 *
+	 * @param array $request Request parameters, as the download handler reads them from `$_GET`.
+	 * @return ReportCSVExporter|null
+	 */
+	private function request_export( array $request ) {
+		$method = new \ReflectionMethod( ReportExporter::class, 'get_requested_export' );
+		$method->setAccessible( true );
+
+		return $method->invoke( null, $request );
+	}
+
+	/**
+	 * @testdox The emailed download link names the period the export covers.
+	 */
+	public function test_emailed_link_carries_the_date_range(): void {
+		$sent = $this->email_completed_export(
+			array(
+				array(
+					'after'  => '2025-06-01T00:00:00',
+					'before' => '2025-06-30T23:59:59',
+				),
+			)
+		);
+
+		$this->assertStringContainsString(
+			'date_range=2025-06-01-to-2025-06-30',
+			$sent['body'],
+			'The emailed link should name the period the export covers.'
+		);
+	}
+
+	/**
+	 * @testdox An export queued before the date range was added still emails a working link.
+	 */
+	public function test_emailed_link_for_an_export_queued_without_report_args(): void {
+		// Exports queued by an earlier release carry three arguments, not four.
+		$sent = $this->email_completed_export( array() );
+
+		$this->assertStringContainsString(
+			'Your Products Report download is ready',
+			$sent['subject'],
+			'An export queued without report arguments should keep the original subject.'
+		);
+		$this->assertStringContainsString(
+			'action=woocommerce_admin_download_report_csv',
+			$sent['body'],
+			'An export queued without report arguments should still be emailed a download link.'
+		);
+		$this->assertStringNotContainsString(
+			'date_range=',
+			$sent['body'],
+			'An export with no known date range should not claim one.'
+		);
+	}
+
+	/**
+	 * Email the download link for a finished export and return the message that went out.
+	 *
+	 * Dispatched through the hook Action Scheduler fires, so the number of arguments a queued
+	 * action carries is what decides how the callback is reached.
+	 *
+	 * @param array $queued_args Arguments the queued action carries after the report type.
+	 * @return array The sent message.
+	 */
+	private function email_completed_export( array $queued_args ): array {
+		$user_id   = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$export_id = (string) microtime( true );
+		$hook      = ReportExporter::get_action( 'email_report_download_link' );
+		$mailer    = tests_retrieve_phpmailer_instance();
+
+		ReportExporter::update_export_percentage_complete( 'products', $export_id, 100 );
+
+		$this->assertNotFalse( has_action( $hook ), 'The export email action should be registered.' );
+
+		do_action_ref_array( $hook, array_merge( array( $user_id, $export_id, 'products' ), $queued_args ) );
+
+		$sent = end( $mailer->mock_sent );
+
+		$this->assertIsArray( $sent, 'A finished export should be emailed to the user who asked for it.' );
+
+		return $sent;
+	}
+
 	/**
 	 * @testdox Cleanup runs from the daily WooCommerce Admin event.
 	 */