Commit 2b1a31b9eab for woocommerce

commit 2b1a31b9eab6625f4f9ffffe381835a302a0c16c
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Thu Apr 30 11:01:52 2026 +0300

    Add Customer Review Request email (#64393)

    * Add WC_Email_Customer_Review_Request class and templates

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Address review feedback on review-request email

    - Reset trigger() state at entry so a call with an invalid order id cannot fall through with the previous recipient/placeholders.
    - Switch delay_days read from absint() to (int) so a negative stored value clamps to MIN_DELAY_DAYS rather than flipping positive.

    Both paths gain regression tests.

    * Align review-request email template with Figma design

    Applies the copy and layout from the Figma design (node 1:1161):

    - Default heading changes from 'Review your order' to 'Rate your recent purchases'.
    - Body copy replaced with the designer-specified wording about helping other shoppers.
    - CTA button relabelled 'Leave a review' and resized to match the Figma button (padding, font size, line height).
    - Order meta (Order number and date) now renders as a small footer line instead of a full order details table. The woocommerce_email_order_details hook call is dropped; it wasn't in the Figma.
    - Plain-text template mirrors the HTML changes.

    * Guard email templates against missing order object

    trigger() resets $this->object to false when called with an invalid
    order id, so both customer-review-request.php and its plain counterpart
    now guard every $order method call behind `$order instanceof WC_Order`.
    The greeting falls back to 'Hi,' and the order-meta line is skipped
    entirely when no order is present — fixes the 'critical error' that was
    raised when the template was rendered without a bound order (e.g. from
    the Send test email flow after trigger(0)).

    * Address second round of CodeRabbit review feedback

    - get_review_order_url now uses wc_get_endpoint_url so plain-permalink stores produce a valid query-arg URL (?review-order=ID) rather than an invalid path concatenation. The review-request test relaxes its assertion to match either permalink shape.
    - init_form_fields escapes each placeholder key individually before imploding, so the placeholder hint renders as two separate <code> spans in admin settings instead of one block with escaped </code> literals.

    * Render review-request body in the block email editor path

    On sites with the block_email_editor feature enabled, WC routes rendering through BlockEmailRenderer which uses templates/emails/block/general-block-email.php. That shared template has per-email-id branches for customer_invoice, customer_new_account, POS emails, etc., but no branch for customer_review_request, so our body silently dropped and JN previews rendered empty.

    - Added a new block template at templates/emails/block/customer-review-request.php with the Figma-aligned body (greeting, copy, CTA button, order meta line) — same content as the classic template minus the email header/footer chrome (the block editor provides those).
    - Hook the email class into woocommerce_email_general_block_content to render that template when this email is the one being rendered.
    - Opt out of woocommerce_emails_general_block_content_emails_without_order_details so the default template does not inject the full order-details table below our body — the Figma design shows only the compact meta line.

    * Rebuild review-request email templates to match existing WC patterns

    HTML preview was rendering near-empty on the JN site. Aligning the template structure with customer-invoice.php and customer-completed-order.php resolves it:

    - classic HTML template now mirrors customer-invoice.php: FeaturesUtil::feature_is_enabled('email_improvements') wrapper, standard `woocommerce_email_order_details` / `_order_meta` / `_customer_details` hook calls, standard additional_content wrapping, no inline-styled custom button. The "Leave a review" CTA is now an inline link inside the intro paragraph (built via wp_kses + printf, same shape as the "Pay for this order" link in customer-invoice).
    - plain-text template mirrors customer-invoice plain equivalent.
    - new block-editor initial content template (templates/emails/block/customer-review-request.php) using the same wp-block markup and personalization tags as other WC block templates.
    - customer_review_request added to WCTransactionalEmails::$core_transactional_emails so the email editor seeds a post for it.
    - Removed the ad-hoc block_content() / exclude_from_order_details bridge on the email class — no longer needed now that the block-editor path resolves the per-id block template automatically.

    * Restore Figma layout on review-request email templates

    Previous commit over-corrected — it matched customer-invoice's full order-details pipeline instead of the Figma design. Bringing the layout back:

    - HTML template: heading, greeting, body paragraph, styled CTA button, compact "Order #N (date)" meta line, additional content. email_improvements wrapper preserved; order_details/order_meta/customer_details hooks stay omitted per the Figma.
    - Plain-text template matches the same structure.
    - Block-editor template matches the Figma content (heading, greeting, body, order meta) using personalization tags.

    * Fix translators comment location in block template

    phpcs requires the translators comment on the line immediately above the esc_html__() call; with the printf() wrapper and multi-line formatting it wasn't resolving that way. Assigning the escaped format string to a variable first puts the comment exactly where the sniff expects it.

    * Address review feedback on review-request email

    - Drop the duplicate $this->title reassignment inside the
      block_email_editor_enabled branch in __construct(); the value
      matches the init above.
    - Replace the hardcoded blue inline-styled CTA in the HTML template
      with a plain anchor so colors come from WC_Email::style_inline()
      like every other order-update template.

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64393-wooplug-6590-add-wc-email-customer-review-request b/plugins/woocommerce/changelog/64393-wooplug-6590-add-wc-email-customer-review-request
new file mode 100644
index 00000000000..6fe59924219
--- /dev/null
+++ b/plugins/woocommerce/changelog/64393-wooplug-6590-add-wc-email-customer-review-request
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a new "Review request" transactional email that invites customers to review the products they purchased. Disabled by default and configurable under WooCommerce → Settings → Emails.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-wc-emails.php b/plugins/woocommerce/includes/class-wc-emails.php
index a41c109b0bf..dcc88bd8571 100644
--- a/plugins/woocommerce/includes/class-wc-emails.php
+++ b/plugins/woocommerce/includes/class-wc-emails.php
@@ -291,6 +291,7 @@ class WC_Emails {
 			'WC_Email_Customer_Processing_Order'     => __DIR__ . '/emails/class-wc-email-customer-processing-order.php',
 			'WC_Email_Customer_Completed_Order'      => __DIR__ . '/emails/class-wc-email-customer-completed-order.php',
 			'WC_Email_Customer_Refunded_Order'       => __DIR__ . '/emails/class-wc-email-customer-refunded-order.php',
+			'WC_Email_Customer_Review_Request'       => __DIR__ . '/emails/class-wc-email-customer-review-request.php',
 			'WC_Email_Customer_Invoice'              => __DIR__ . '/emails/class-wc-email-customer-invoice.php',
 			'WC_Email_Customer_Note'                 => __DIR__ . '/emails/class-wc-email-customer-note.php',
 			'WC_Email_Customer_Reset_Password'       => __DIR__ . '/emails/class-wc-email-customer-reset-password.php',
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
new file mode 100644
index 00000000000..17249b3b7b4
--- /dev/null
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
@@ -0,0 +1,313 @@
+<?php
+/**
+ * Class WC_Email_Customer_Review_Request file.
+ *
+ * @package WooCommerce\Emails
+ */
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+if ( ! class_exists( 'WC_Email_Customer_Review_Request', false ) ) :
+
+	/**
+	 * Customer Review Request email.
+	 *
+	 * A delayed transactional email that invites the customer to review the products
+	 * they purchased. The send is scheduled via Action Scheduler (see
+	 * `woocommerce_send_review_request`) a configurable number of days after the
+	 * order is marked complete. The email links to a per-order Review Order page
+	 * protected by the order key.
+	 *
+	 * @class    WC_Email_Customer_Review_Request
+	 * @version  10.8.0
+	 * @package  WooCommerce\Classes\Emails
+	 */
+	class WC_Email_Customer_Review_Request extends WC_Email {
+
+		/**
+		 * Minimum allowed delay, in days.
+		 */
+		private const MIN_DELAY_DAYS = 1;
+
+		/**
+		 * Maximum allowed delay, in days.
+		 */
+		private const MAX_DELAY_DAYS = 60;
+
+		/**
+		 * Default delay, in days.
+		 */
+		private const DEFAULT_DELAY_DAYS = 7;
+
+		/**
+		 * Constructor.
+		 */
+		public function __construct() {
+			$this->id             = 'customer_review_request';
+			$this->customer_email = true;
+			$this->title          = __( 'Review request', 'woocommerce' );
+			$this->email_group    = 'order-updates';
+			$this->template_html  = 'emails/customer-review-request.php';
+			$this->template_plain = 'emails/plain/customer-review-request.php';
+			$this->placeholders   = array(
+				'{order_date}'   => '',
+				'{order_number}' => '',
+			);
+
+			// Trigger fires from Action Scheduler. Scheduling itself lives in the review-request scheduler class.
+			add_action( 'woocommerce_send_review_request', array( $this, 'trigger' ), 10, 1 );
+
+			// Call parent constructor.
+			parent::__construct();
+
+			// Must be after parent's constructor which sets `email_improvements_enabled` property.
+			$this->description = __( 'Review request emails are sent to customers a few days after their order is complete, inviting them to leave reviews for the products they purchased.', 'woocommerce' );
+
+			if ( $this->block_email_editor_enabled ) {
+				$this->description = __( 'Invites customers to review the products from their completed order.', 'woocommerce' );
+			}
+		}
+
+		/**
+		 * Trigger the sending of this email.
+		 *
+		 * Wired to `woocommerce_send_review_request`, which Action Scheduler fires
+		 * with the order id as its single argument.
+		 *
+		 * @param int $order_id The order ID.
+		 */
+		public function trigger( $order_id ): void {
+			$this->setup_locale();
+
+			// Reset state from any previous invocation so a call with an invalid
+			// order id cannot re-use the previous recipient / placeholders.
+			$this->object                         = false;
+			$this->recipient                      = '';
+			$this->placeholders['{order_date}']   = '';
+			$this->placeholders['{order_number}'] = '';
+
+			$order = $order_id ? wc_get_order( $order_id ) : false;
+
+			if ( $order instanceof WC_Order ) {
+				$this->object                         = $order;
+				$this->recipient                      = $order->get_billing_email();
+				$date_created                         = $order->get_date_created();
+				$this->placeholders['{order_date}']   = $date_created ? wc_format_datetime( $date_created ) : '';
+				$this->placeholders['{order_number}'] = $order->get_order_number();
+			}
+
+			if ( $this->is_enabled() && $this->get_recipient() ) {
+				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
+			}
+
+			$this->restore_locale();
+		}
+
+		/**
+		 * Get default email subject.
+		 *
+		 * @since  10.8.0
+		 * @return string
+		 */
+		public function get_default_subject() {
+			return __( 'How was your order from {site_title}?', 'woocommerce' );
+		}
+
+		/**
+		 * Get default email heading.
+		 *
+		 * @since  10.8.0
+		 * @return string
+		 */
+		public function get_default_heading() {
+			return __( 'Rate your recent purchases', 'woocommerce' );
+		}
+
+		/**
+		 * Default content to show below main email content.
+		 *
+		 * @since  10.8.0
+		 * @return string
+		 */
+		public function get_default_additional_content() {
+			return __( 'Thanks again for shopping with us. If you have any questions, reply to this email and we\'ll help out.', 'woocommerce' );
+		}
+
+		/**
+		 * Get the URL of the per-order Review Order page for this email's order.
+		 *
+		 * Mirrors the pay-for-order URL shape. `wc_get_endpoint_url()` is used so
+		 * plain-permalink stores get a valid query-arg URL rather than an invalid
+		 * concatenation. The endpoint itself is registered in a later milestone.
+		 *
+		 * @since  10.8.0
+		 * @return string
+		 */
+		public function get_review_order_url() {
+			if ( ! ( $this->object instanceof WC_Order ) ) {
+				return '';
+			}
+
+			$endpoint_url = wc_get_endpoint_url( 'review-order', (string) $this->object->get_id(), wc_get_checkout_url() );
+			$url          = add_query_arg( 'key', $this->object->get_order_key(), $endpoint_url );
+
+			/**
+			 * Filter the Review Order URL that the review-request email links to.
+			 *
+			 * @param string   $url   The review-order URL.
+			 * @param WC_Order $order The order object.
+			 *
+			 * @since 10.8.0
+			 */
+			return (string) apply_filters( 'woocommerce_review_order_url', $url, $this->object );
+		}
+
+		/**
+		 * Return the configured send delay in seconds, filterable.
+		 *
+		 * The stored `delay_days` option is clamped to the supported range before
+		 * being converted to seconds. The final value passes through the
+		 * `woocommerce_review_request_delay_seconds` filter so integrations can
+		 * override it without needing to touch the admin setting.
+		 *
+		 * @since  10.8.0
+		 * @return int Delay in seconds.
+		 */
+		public function get_delay_seconds() {
+			// Use (int) rather than absint() so a negative stored value clamps
+			// to MIN_DELAY_DAYS rather than flipping positive.
+			$delay_days = (int) $this->get_option( 'delay_days', self::DEFAULT_DELAY_DAYS );
+			$delay_days = max( self::MIN_DELAY_DAYS, min( self::MAX_DELAY_DAYS, $delay_days ) );
+
+			/**
+			 * Filter the review-request email delay, in seconds.
+			 *
+			 * @param int $delay_seconds Delay in seconds. Defaults to the admin-configured `delay_days` * DAY_IN_SECONDS.
+			 *
+			 * @since 10.8.0
+			 */
+			return (int) apply_filters( 'woocommerce_review_request_delay_seconds', $delay_days * DAY_IN_SECONDS );
+		}
+
+		/**
+		 * Get content html.
+		 *
+		 * @return string
+		 */
+		public function get_content_html() {
+			return wc_get_template_html(
+				$this->template_html,
+				array(
+					'order'              => $this->object,
+					'email_heading'      => $this->get_heading(),
+					'review_order_url'   => $this->get_review_order_url(),
+					'additional_content' => $this->get_additional_content(),
+					'sent_to_admin'      => false,
+					'plain_text'         => false,
+					'email'              => $this,
+				)
+			);
+		}
+
+		/**
+		 * Get content plain.
+		 *
+		 * @return string
+		 */
+		public function get_content_plain() {
+			return wc_get_template_html(
+				$this->template_plain,
+				array(
+					'order'              => $this->object,
+					'email_heading'      => $this->get_heading(),
+					'review_order_url'   => $this->get_review_order_url(),
+					'additional_content' => $this->get_additional_content(),
+					'sent_to_admin'      => false,
+					'plain_text'         => true,
+					'email'              => $this,
+				)
+			);
+		}
+
+		/**
+		 * Initialise settings form fields.
+		 *
+		 * Adds a `delay_days` field on top of the standard WC_Email fields so
+		 * merchants can change how long to wait before asking for a review.
+		 */
+		public function init_form_fields(): void {
+			$placeholder_text = sprintf(
+				/* translators: %s: list of placeholders */
+				__( 'Available placeholders: %s', 'woocommerce' ),
+				'<code>' . implode( '</code>, <code>', array_map( 'esc_html', array_keys( $this->placeholders ) ) ) . '</code>'
+			);
+			$this->form_fields = array(
+				'enabled'            => array(
+					'title'   => __( 'Enable/Disable', 'woocommerce' ),
+					'type'    => 'checkbox',
+					'label'   => __( 'Enable this email notification', 'woocommerce' ),
+					'default' => 'no',
+				),
+				'delay_days'         => array(
+					'title'             => __( 'Delay (days)', 'woocommerce' ),
+					'type'              => 'number',
+					'description'       => __( 'How many days after the order is marked complete before the review request email is sent.', 'woocommerce' ),
+					'default'           => (string) self::DEFAULT_DELAY_DAYS,
+					'desc_tip'          => true,
+					'custom_attributes' => array(
+						'min'  => (string) self::MIN_DELAY_DAYS,
+						'max'  => (string) self::MAX_DELAY_DAYS,
+						'step' => '1',
+					),
+				),
+				'subject'            => array(
+					'title'       => __( 'Subject', 'woocommerce' ),
+					'type'        => 'text',
+					'desc_tip'    => true,
+					'description' => $placeholder_text,
+					'placeholder' => $this->get_default_subject(),
+					'default'     => '',
+				),
+				'heading'            => array(
+					'title'       => __( 'Email heading', 'woocommerce' ),
+					'type'        => 'text',
+					'desc_tip'    => true,
+					'description' => $placeholder_text,
+					'placeholder' => $this->get_default_heading(),
+					'default'     => '',
+				),
+				'additional_content' => array(
+					'title'       => __( 'Additional content', 'woocommerce' ),
+					'description' => __( 'Text to appear below the main email content.', 'woocommerce' ) . ' ' . $placeholder_text,
+					'css'         => 'width:400px; height: 75px;',
+					'placeholder' => __( 'N/A', 'woocommerce' ),
+					'type'        => 'textarea',
+					'default'     => $this->get_default_additional_content(),
+					'desc_tip'    => true,
+				),
+				'email_type'         => array(
+					'title'       => __( 'Email type', 'woocommerce' ),
+					'type'        => 'select',
+					'description' => __( 'Choose which format of email to send.', 'woocommerce' ),
+					'default'     => 'html',
+					'class'       => 'email_type wc-enhanced-select',
+					'options'     => $this->get_email_type_options(),
+					'desc_tip'    => true,
+				),
+			);
+
+			if ( FeaturesUtil::feature_is_enabled( 'email_improvements' ) ) {
+				$this->form_fields['cc']  = $this->get_cc_field();
+				$this->form_fields['bcc'] = $this->get_bcc_field();
+			}
+			if ( $this->block_email_editor_enabled ) {
+				$this->form_fields['preheader'] = $this->get_preheader_field();
+			}
+		}
+	}
+
+endif;
+
+return new WC_Email_Customer_Review_Request();
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
index 0c58cd3b9bb..0d8f10b7278 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
@@ -33,6 +33,7 @@ class WCTransactionalEmails {
 		'customer_refunded_order',
 		'customer_partially_refunded_order',
 		'customer_reset_password',
+		'customer_review_request',
 		'failed_order',
 		'new_order',
 	);
diff --git a/plugins/woocommerce/templates/emails/block/customer-review-request.php b/plugins/woocommerce/templates/emails/block/customer-review-request.php
new file mode 100644
index 00000000000..af3bb245df8
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/block/customer-review-request.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * Customer review request email (initial block content)
+ *
+ * This template can be overridden by editing it in the WooCommerce email editor.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails\Block
+ * @version 10.8.0
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+// phpcs:disable Squiz.PHP.EmbeddedPhp.ContentBeforeOpen -- removed to prevent empty new lines.
+// phpcs:disable Squiz.PHP.EmbeddedPhp.ContentAfterEnd -- removed to prevent empty new lines.
+?>
+
+<!-- wp:heading -->
+<h2 class="wp-block-heading"> <?php echo esc_html__( 'Rate your recent purchases', 'woocommerce' ); ?> </h2>
+<!-- /wp:heading -->
+
+<!-- wp:paragraph -->
+<p><?php
+	/* translators: %s: Customer first name */
+	printf( esc_html__( 'Hi %s,', 'woocommerce' ), '<!--[woocommerce/customer-first-name]-->' );
+?></p>
+<!-- /wp:paragraph -->
+
+<!-- wp:paragraph -->
+<p> <?php echo esc_html__( 'We’d love to know what you thought of the products you ordered. Your review helps other shoppers make better decisions and helps us improve.', 'woocommerce' ); ?> </p>
+<!-- /wp:paragraph -->
+
+<!-- wp:paragraph -->
+<p><?php
+/* translators: 1: order number, 2: order date */
+$order_meta_format = esc_html__( 'Order #%1$s (%2$s)', 'woocommerce' );
+printf( $order_meta_format, '<!--[woocommerce/order-number]-->', '<!--[woocommerce/order-date]-->' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $order_meta_format is escaped above; personalization tokens are literal HTML comments.
+?></p>
+<!-- /wp:paragraph -->
diff --git a/plugins/woocommerce/templates/emails/customer-review-request.php b/plugins/woocommerce/templates/emails/customer-review-request.php
new file mode 100644
index 00000000000..e1fa3416a13
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/customer-review-request.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Customer review request email
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/customer-review-request.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails
+ * @version 10.8.0
+ */
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+$email_improvements_enabled = FeaturesUtil::feature_is_enabled( 'email_improvements' );
+
+/**
+ * Hook for the woocommerce_email_header.
+ *
+ * @param string   $email_heading The email heading.
+ * @param WC_Email $email         The email object.
+ * @since 2.5.0
+ *
+ * @hooked WC_Emails::email_header() Output the email header
+ */
+do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
+
+<?php echo $email_improvements_enabled ? '<div class="email-introduction">' : ''; ?>
+<p>
+<?php
+if ( ! empty( $order->get_billing_first_name() ) ) {
+	/* translators: %s: Customer first name */
+	printf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $order->get_billing_first_name() ) );
+} else {
+	printf( esc_html__( 'Hi,', 'woocommerce' ) );
+}
+?>
+</p>
+
+<p><?php esc_html_e( 'We’d love to know what you thought of the products you ordered. Your review helps other shoppers make better decisions and helps us improve.', 'woocommerce' ); ?></p>
+
+<?php if ( ! empty( $review_order_url ) ) : ?>
+<p>
+	<a href="<?php echo esc_url( $review_order_url ); ?>"><?php esc_html_e( 'Leave a review', 'woocommerce' ); ?></a>
+</p>
+<?php endif; ?>
+<?php echo $email_improvements_enabled ? '</div>' : ''; ?>
+
+<?php if ( $order instanceof WC_Order ) : ?>
+<p style="font-size: 12px; line-height: 16px; color: #4d4d4d; margin-top: 16px;">
+	<?php
+	$date_created = $order->get_date_created();
+	printf(
+	/* translators: 1: order number, 2: order date */
+		esc_html__( 'Order #%1$s (%2$s)', 'woocommerce' ),
+		esc_html( $order->get_order_number() ),
+		esc_html( $date_created ? wc_format_datetime( $date_created ) : '' )
+	);
+	?>
+</p>
+<?php endif; ?>
+
+<?php
+/**
+ * Show user-defined additional content - this is set in each email's settings.
+ */
+if ( $additional_content ) {
+	echo $email_improvements_enabled ? '<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation"><tr><td class="email-additional-content">' : '';
+	echo wp_kses_post( wpautop( wptexturize( $additional_content ) ) );
+	echo $email_improvements_enabled ? '</td></tr></table>' : '';
+}
+
+/**
+ * Hook for the woocommerce_email_footer.
+ *
+ * @param WC_Email $email The email object.
+ * @since 2.5.0
+ *
+ * @hooked WC_Emails::email_footer() Output the email footer
+ */
+do_action( 'woocommerce_email_footer', $email );
diff --git a/plugins/woocommerce/templates/emails/plain/customer-review-request.php b/plugins/woocommerce/templates/emails/plain/customer-review-request.php
new file mode 100644
index 00000000000..612b97bbd5f
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/plain/customer-review-request.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Customer review request email (plain text)
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/plain/customer-review-request.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails\Plain
+ * @version 10.8.0
+ */
+
+// phpcs:disable Universal.WhiteSpace.PrecisionAlignment.Found, Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed -- Plain text output needs specific spacing without tabs
+
+defined( 'ABSPATH' ) || exit;
+
+echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
+echo esc_html( wp_strip_all_tags( $email_heading ) );
+echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
+
+if ( ! empty( $order->get_billing_first_name() ) ) {
+	/* translators: %s: Customer first name */
+	echo sprintf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $order->get_billing_first_name() ) ) . "\n\n";
+} else {
+	echo esc_html__( 'Hi,', 'woocommerce' ) . "\n\n";
+}
+
+echo esc_html__( 'We’d love to know what you thought of the products you ordered. Your review helps other shoppers make better decisions and helps us improve.', 'woocommerce' ) . "\n\n";
+
+if ( ! empty( $review_order_url ) ) {
+	echo esc_html__( 'Leave a review:', 'woocommerce' ) . "\n";
+	echo esc_url( $review_order_url ) . "\n\n";
+}
+
+if ( $order instanceof WC_Order ) {
+	$date_created = $order->get_date_created();
+	printf(
+		/* translators: 1: order number, 2: order date */
+		esc_html__( 'Order #%1$s (%2$s)', 'woocommerce' ),
+		esc_html( $order->get_order_number() ),
+		esc_html( $date_created ? wc_format_datetime( $date_created ) : '' )
+	);
+	echo "\n\n";
+}
+
+echo "----------------------------------------\n\n";
+
+/**
+ * Show user-defined additional content - this is set in each email's settings.
+ */
+if ( $additional_content ) {
+	echo esc_html( wp_strip_all_tags( wptexturize( $additional_content ) ) );
+	echo "\n\n----------------------------------------\n\n";
+}
+
+/**
+ * Filter the email footer text.
+ *
+ * @param string $footer_text The footer text.
+ * @since 2.3.0
+ */
+echo wp_kses_post( apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ) );
+
+// phpcs:enable Universal.WhiteSpace.PrecisionAlignment.Found, Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-review-request-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-review-request-test.php
new file mode 100644
index 00000000000..fc229759cd5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-review-request-test.php
@@ -0,0 +1,184 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * WC_Email_Customer_Review_Request test.
+ *
+ * @covers WC_Email_Customer_Review_Request
+ */
+class WC_Email_Customer_Review_Request_Test extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var WC_Email_Customer_Review_Request
+	 */
+	private $sut;
+
+	/**
+	 * Load up the email classes since they aren't loaded by default.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$bootstrap = \WC_Unit_Tests_Bootstrap::instance();
+		require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email.php';
+		require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-customer-review-request.php';
+
+		$this->sut = new WC_Email_Customer_Review_Request();
+	}
+
+	/**
+	 * @testdox Email is disabled by default so it has no effect on sites that don't opt in.
+	 */
+	public function test_disabled_by_default(): void {
+		// Option is intentionally unset, mirroring a fresh install.
+		$this->assertFalse( $this->sut->is_enabled() );
+	}
+
+	/**
+	 * @testdox Default delay is seven days and feeds the delay_seconds helper.
+	 */
+	public function test_default_delay_seconds(): void {
+		$this->assertSame( 7 * DAY_IN_SECONDS, $this->sut->get_delay_seconds() );
+	}
+
+	/**
+	 * @testdox Admin-entered delay_days is clamped to 1..60 before conversion.
+	 */
+	public function test_delay_days_is_clamped(): void {
+		$this->sut->update_option( 'delay_days', '0' );
+		$this->assertSame( 1 * DAY_IN_SECONDS, $this->sut->get_delay_seconds() );
+
+		$this->sut->update_option( 'delay_days', '200' );
+		$this->assertSame( 60 * DAY_IN_SECONDS, $this->sut->get_delay_seconds() );
+
+		$this->sut->update_option( 'delay_days', '14' );
+		$this->assertSame( 14 * DAY_IN_SECONDS, $this->sut->get_delay_seconds() );
+	}
+
+	/**
+	 * @testdox A negative stored delay_days clamps to MIN_DELAY_DAYS rather than flipping positive.
+	 */
+	public function test_delay_days_clamps_negative_to_minimum(): void {
+		$this->sut->update_option( 'delay_days', '-5' );
+		$this->assertSame( 1 * DAY_IN_SECONDS, $this->sut->get_delay_seconds() );
+	}
+
+	/**
+	 * @testdox The woocommerce_review_request_delay_seconds filter wins over the admin setting.
+	 */
+	public function test_delay_seconds_filter_overrides_setting(): void {
+		$this->sut->update_option( 'delay_days', '7' );
+
+		$override = static function () {
+			return 90;
+		};
+		add_filter( 'woocommerce_review_request_delay_seconds', $override );
+
+		$this->assertSame( 90, $this->sut->get_delay_seconds() );
+
+		remove_filter( 'woocommerce_review_request_delay_seconds', $override );
+	}
+
+	/**
+	 * @testdox Review Order URL references the review-order endpoint and carries the order key.
+	 */
+	public function test_review_order_url_shape(): void {
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+		$this->sut->trigger( $order->get_id() );
+
+		$url = $this->sut->get_review_order_url();
+
+		// wc_get_endpoint_url renders as "review-order/{id}/" on pretty permalinks
+		// and "review-order={id}" on plain permalinks — accept either.
+		$this->assertMatchesRegularExpression(
+			'#review-order[/=]' . $order->get_id() . '#',
+			$url
+		);
+		$this->assertStringContainsString( 'key=' . $order->get_order_key(), $url );
+	}
+
+	/**
+	 * @testdox get_review_order_url returns empty string when no order is bound.
+	 */
+	public function test_review_order_url_empty_without_order(): void {
+		$this->assertSame( '', $this->sut->get_review_order_url() );
+	}
+
+	/**
+	 * @testdox woocommerce_review_order_url filter can replace the generated URL.
+	 */
+	public function test_review_order_url_filterable(): void {
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+		$this->sut->trigger( $order->get_id() );
+
+		$override = static function () {
+			return 'https://example.test/custom';
+		};
+		add_filter( 'woocommerce_review_order_url', $override );
+
+		$this->assertSame( 'https://example.test/custom', $this->sut->get_review_order_url() );
+
+		remove_filter( 'woocommerce_review_order_url', $override );
+	}
+
+	/**
+	 * @testdox Settings form exposes the delay_days field alongside the standard WC_Email fields.
+	 */
+	public function test_form_fields_expose_delay(): void {
+		$this->sut->init_form_fields();
+
+		$this->assertArrayHasKey( 'enabled', $this->sut->form_fields );
+		$this->assertArrayHasKey( 'delay_days', $this->sut->form_fields );
+		$this->assertSame( 'number', $this->sut->form_fields['delay_days']['type'] );
+		$this->assertSame( '1', $this->sut->form_fields['delay_days']['custom_attributes']['min'] );
+		$this->assertSame( '60', $this->sut->form_fields['delay_days']['custom_attributes']['max'] );
+	}
+
+	/**
+	 * @testdox Class registers as a known WC_Email so the WC Settings > Emails page renders it.
+	 */
+	public function test_is_registered_with_wc_emails(): void {
+		$emails = WC()->mailer()->get_emails();
+
+		$this->assertArrayHasKey( 'WC_Email_Customer_Review_Request', $emails );
+	}
+
+	/**
+	 * @testdox Calling trigger() with an invalid order id after a valid call does not dispatch to the previous recipient.
+	 */
+	public function test_trigger_clears_state_on_invalid_order(): void {
+		$this->sut->update_option( 'enabled', 'yes' );
+		$this->sut->enabled = 'yes';
+
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+
+		// First call populates recipient + placeholders from a valid order.
+		$this->sut->trigger( $order->get_id() );
+
+		// Second call with an invalid id should not fall through with the previous state.
+		$mailer = tests_retrieve_phpmailer_instance();
+		$before = count( $mailer->mock_sent );
+		$this->sut->trigger( 0 );
+		$after = count( $mailer->mock_sent );
+
+		$this->assertSame( $before, $after, 'trigger() must not send to the previous order\'s recipient when called with an invalid id.' );
+		$this->assertSame( '', $this->sut->recipient );
+		$this->assertFalse( $this->sut->object );
+	}
+
+	/**
+	 * @testdox trigger() is a no-op when the email is disabled (default state).
+	 */
+	public function test_trigger_is_noop_when_disabled(): void {
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+
+		$mailer = tests_retrieve_phpmailer_instance();
+		$before = count( $mailer->mock_sent );
+		$this->sut->trigger( $order->get_id() );
+		$after = count( $mailer->mock_sent );
+
+		$this->assertSame( $before, $after, 'Disabled review-request email must not dispatch any mail.' );
+	}
+}