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.' );
+ }
+}