Commit c368d2720cf for woocommerce
commit c368d2720cf853e735ac78ad3ed49e899580e3fe
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Mon May 4 10:14:00 2026 +0300
Schedule review-request email via Action Scheduler (#64395)
* 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.
* Schedule review-request email via Action Scheduler
* Add changefile(s) from automation for the following project(s): woocommerce
* Always unschedule review-request action on cancellation events
The early return that gated the unschedule call on the presence of
`_wc_review_request_scheduled_at` meant a stray scheduled action could
survive cancellation/refund/trash/delete whenever the meta drifted out
of sync with the underlying Action Scheduler row (manual edits, partial
failure, race condition, etc.). Cancellation events are a safety
boundary; they should always reach `as_unschedule_action()`.
`as_unschedule_action()` is a no-op when no matching action exists, so
removing the gate has no functional cost. The tracking-meta cleanup is
still gated on the meta actually being set so we don't write an empty
meta row for orders that never had one.
Adds `test_cancellation_unschedules_when_meta_missing` to lock in the
new behaviour.
* Move ReviewRequestScheduler to Internal/OrderReviews/Scheduler
`Internal/Email/` is for email-engine infrastructure (DeferredEmailQueue,
EmailColors, EmailFont, EmailStyleSync, OrderPriceFormatter); the
review-request scheduler is feature code, not engine code. Pull it out
into a dedicated `Internal/OrderReviews/` namespace where the M3
endpoint and the M4 form/submission classes will join it.
Class is renamed `ReviewRequestScheduler` → `Scheduler` since the
namespace already provides the qualifier. Public contract is unchanged:
extensions interact through the documented filters
(`woocommerce_should_send_review_request`,
`woocommerce_review_request_delay_seconds`) and the email's enabled
toggle, never the class directly.
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64395-wooplug-6589-schedule-review-request-email b/plugins/woocommerce/changelog/64395-wooplug-6589-schedule-review-request-email
new file mode 100644
index 00000000000..6bd237664d9
--- /dev/null
+++ b/plugins/woocommerce/changelog/64395-wooplug-6589-schedule-review-request-email
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Schedule the Review request email via Action Scheduler so it fires automatically a configurable number of days after an order is completed, and cancel the pending send when the order is cancelled, refunded, trashed or deleted.
\ 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 dcc88bd8571..858c7a575f6 100644
--- a/plugins/woocommerce/includes/class-wc-emails.php
+++ b/plugins/woocommerce/includes/class-wc-emails.php
@@ -119,6 +119,7 @@ class WC_Emails {
'woocommerce_order_status_failed',
'woocommerce_order_fully_refunded',
'woocommerce_order_partially_refunded',
+ 'woocommerce_send_review_request',
'woocommerce_new_customer_note',
'woocommerce_created_customer',
'woocommerce_payment_gateway_enabled',
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 1b95445ae07..862d5621e0b 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -399,6 +399,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
$container->get( Automattic\WooCommerce\Internal\PushNotifications\PushNotifications::class )->register();
$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
+ $container->get( Automattic\WooCommerce\Internal\OrderReviews\Scheduler::class )->register();
// Classes inheriting from RestApiControllerBase.
$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
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
index 17249b3b7b4..f0ff16d1f36 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
@@ -56,8 +56,8 @@ if ( ! class_exists( 'WC_Email_Customer_Review_Request', false ) ) :
'{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 );
+ // Trigger fires via WC_Emails' transactional pipeline after Action Scheduler fires `woocommerce_send_review_request`.
+ add_action( 'woocommerce_send_review_request_notification', array( $this, 'trigger' ), 10, 1 );
// Call parent constructor.
parent::__construct();
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
new file mode 100644
index 00000000000..0452c675508
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
@@ -0,0 +1,164 @@
+<?php
+/**
+ * Scheduler class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Email_Customer_Review_Request;
+use WC_Order;
+
+/**
+ * Schedules and cancels the delayed "Review request" customer email via Action Scheduler.
+ *
+ * Listens for order-completed transitions to enqueue a single
+ * `woocommerce_send_review_request` action that fires after the delay
+ * configured in the email's settings. Cancels the pending action when the
+ * order is later refunded, cancelled, trashed or deleted.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 10.8.0
+ */
+class Scheduler implements RegisterHooksInterface {
+
+ /**
+ * Action Scheduler hook fired when the configured delay elapses. The
+ * `WC_Email_Customer_Review_Request` class listens on the same hook.
+ */
+ public const ACTION_HOOK = 'woocommerce_send_review_request';
+
+ /**
+ * Order meta key storing the unix timestamp the email was scheduled for.
+ * Used both for idempotency and so merchants can see (via CRUD) when the
+ * review-request email is due.
+ */
+ public const SCHEDULED_META_KEY = '_wc_review_request_scheduled_at';
+
+ /**
+ * Source slug for WC logger entries produced by this class.
+ */
+ private const LOG_SOURCE = 'review-request';
+
+ /**
+ * Register hooks and filters.
+ */
+ public function register(): void {
+ add_action( 'woocommerce_order_status_completed', array( $this, 'handle_woocommerce_order_status_completed' ), 10, 1 );
+ add_action( 'woocommerce_order_status_cancelled', array( $this, 'handle_cancellation' ), 10, 1 );
+ add_action( 'woocommerce_order_status_refunded', array( $this, 'handle_cancellation' ), 10, 1 );
+ add_action( 'woocommerce_trash_order', array( $this, 'handle_cancellation' ), 10, 1 );
+ add_action( 'woocommerce_before_delete_order', array( $this, 'handle_cancellation' ), 10, 1 );
+ }
+
+ /**
+ * Schedule the review-request email when an order becomes complete.
+ *
+ * @internal
+ *
+ * @param int $order_id The completed order ID.
+ */
+ public function handle_woocommerce_order_status_completed( int $order_id ): void {
+ $order = wc_get_order( $order_id );
+
+ if ( ! $order instanceof WC_Order ) {
+ return;
+ }
+
+ $email = $this->get_email();
+ if ( null === $email || ! $email->is_enabled() ) {
+ $this->log_skip( $order_id, 'email is disabled' );
+ return;
+ }
+
+ if ( $order->get_meta( self::SCHEDULED_META_KEY ) ) {
+ $this->log_skip( $order_id, 'already scheduled' );
+ return;
+ }
+
+ /**
+ * Filter whether to schedule the review-request email for a given order.
+ *
+ * Return false to opt a specific order out of the automated email while
+ * leaving the email enabled store-wide.
+ *
+ * @param bool $should_send Whether to schedule the email. Default true.
+ * @param WC_Order $order The order being processed.
+ *
+ * @since 10.8.0
+ */
+ $should_send = (bool) apply_filters( 'woocommerce_should_send_review_request', true, $order );
+ if ( ! $should_send ) {
+ $this->log_skip( $order_id, 'opt-out filter returned false' );
+ return;
+ }
+
+ $when = time() + $email->get_delay_seconds();
+ as_schedule_single_action( $when, self::ACTION_HOOK, array( $order_id ) );
+
+ $order->update_meta_data( self::SCHEDULED_META_KEY, (string) $when );
+ $order->save();
+ }
+
+ /**
+ * Cancel any pending review-request action when the order leaves the
+ * eligible state.
+ *
+ * Hooked into `woocommerce_order_status_cancelled`,
+ * `woocommerce_order_status_refunded`, `woocommerce_trash_order` and
+ * `woocommerce_before_delete_order` so full refunds, cancellations, trashes
+ * and deletions all clean up the pending job.
+ *
+ * @internal
+ *
+ * @param int $order_id The affected order ID.
+ */
+ public function handle_cancellation( int $order_id ): void {
+ // Always attempt to unschedule, even when the order or meta is missing,
+ // so an out-of-sync meta value cannot leave a stray scheduled send.
+ // `as_unschedule_action()` is a no-op when no matching action exists.
+ as_unschedule_action( self::ACTION_HOOK, array( $order_id ) );
+
+ $order = wc_get_order( $order_id );
+ if ( $order instanceof WC_Order && $order->get_meta( self::SCHEDULED_META_KEY ) ) {
+ $order->delete_meta_data( self::SCHEDULED_META_KEY );
+ $order->save();
+ }
+ }
+
+ /**
+ * Retrieve the review-request email class instance from the mailer.
+ */
+ private function get_email(): ?WC_Email_Customer_Review_Request {
+ $mailer = WC()->mailer();
+ if ( ! $mailer ) {
+ return null;
+ }
+
+ $emails = $mailer->get_emails();
+ $email = $emails['WC_Email_Customer_Review_Request'] ?? null;
+
+ return $email instanceof WC_Email_Customer_Review_Request ? $email : null;
+ }
+
+ /**
+ * Log a skipped scheduling attempt with the reason.
+ *
+ * @param int $order_id The order ID the attempt was for.
+ * @param string $reason Human-readable reason the attempt was skipped.
+ */
+ private function log_skip( int $order_id, string $reason ): void {
+ wc_get_logger()->info(
+ sprintf(
+ /* translators: 1: order ID, 2: skip reason */
+ __( 'Skipped scheduling review-request email for order %1$d: %2$s.', 'woocommerce' ),
+ $order_id,
+ $reason
+ ),
+ array( 'source' => self::LOG_SOURCE )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
new file mode 100644
index 00000000000..2f06f63adb5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
@@ -0,0 +1,220 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Internal\OrderReviews\Scheduler;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use WC_Email_Customer_Review_Request;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Scheduler test.
+ *
+ * @covers \Automattic\WooCommerce\Internal\OrderReviews\Scheduler
+ */
+class SchedulerTest extends WC_Unit_Test_Case {
+
+ /**
+ * Prepare the mailer and enable the review-request email.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ // Make sure the email class is available for WC()->mailer().
+ WC()->mailer();
+
+ $this->set_review_email_enabled( true );
+ }
+
+ /**
+ * Reset between tests.
+ */
+ public function tearDown(): void {
+ $this->set_review_email_enabled( false );
+ remove_all_filters( 'woocommerce_should_send_review_request' );
+ remove_all_filters( 'woocommerce_review_request_delay_seconds' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Completing an order schedules the review-request action and records the scheduled-at meta.
+ */
+ public function test_schedules_on_order_completed(): void {
+ $order = $this->create_pending_order();
+
+ $order->update_status( 'completed' );
+
+ $this->assertTrue( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ $this->assertNotEmpty( wc_get_order( $order->get_id() )->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+ }
+
+ /**
+ * @testdox The delay comes from the email class's get_delay_seconds() helper.
+ */
+ public function test_schedules_using_email_delay(): void {
+ $email = $this->get_email();
+ $email->update_option( 'delay_days', '3' );
+
+ $order = $this->create_pending_order();
+ $before = time();
+ $order->update_status( 'completed' );
+
+ $when = (int) wc_get_order( $order->get_id() )->get_meta( Scheduler::SCHEDULED_META_KEY );
+
+ // Allow a few seconds of wall-clock drift during the test.
+ $expected = $before + ( 3 * DAY_IN_SECONDS );
+ $this->assertGreaterThanOrEqual( $expected - 5, $when );
+ $this->assertLessThanOrEqual( $expected + 5, $when );
+ }
+
+ /**
+ * @testdox Scheduling is skipped when the email is disabled.
+ */
+ public function test_skips_when_email_disabled(): void {
+ $this->set_review_email_enabled( false );
+
+ $order = $this->create_pending_order();
+ $order->update_status( 'completed' );
+
+ $this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ $this->assertEmpty( wc_get_order( $order->get_id() )->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+ }
+
+ /**
+ * @testdox A second completion for the same order does not reschedule.
+ */
+ public function test_is_idempotent(): void {
+ $order = $this->create_pending_order();
+ $order->update_status( 'completed' );
+ $first = (int) wc_get_order( $order->get_id() )->get_meta( Scheduler::SCHEDULED_META_KEY );
+
+ // Simulate a second completed-notification firing (e.g. status toggled back and forth).
+ sleep( 1 );
+ do_action( 'woocommerce_order_status_completed', $order->get_id() ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- existing core hook, fired here only to simulate a duplicate transition in the test.
+ $second = (int) wc_get_order( $order->get_id() )->get_meta( Scheduler::SCHEDULED_META_KEY );
+
+ $this->assertSame( $first, $second, 'Scheduled-at meta should not change on re-completion.' );
+ }
+
+ /**
+ * @testdox woocommerce_should_send_review_request=false skips scheduling.
+ */
+ public function test_opt_out_filter_skips_scheduling(): void {
+ add_filter( 'woocommerce_should_send_review_request', '__return_false' );
+
+ $order = $this->create_pending_order();
+ $order->update_status( 'completed' );
+
+ $this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Cancelling or refunding the order unschedules the pending action and clears the meta.
+ *
+ * @dataProvider cancellation_status_provider
+ *
+ * @param string $new_status The order status to transition into.
+ */
+ public function test_status_transition_cancels_pending_action( string $new_status ): void {
+ $order = $this->create_pending_order();
+ $order->update_status( 'completed' );
+ $this->assertTrue( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+
+ $order->update_status( $new_status );
+
+ $this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ $this->assertEmpty( wc_get_order( $order->get_id() )->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+ }
+
+ /**
+ * Provides order statuses whose transition should cancel the pending email.
+ *
+ * @return array<string, array{string}>
+ */
+ public function cancellation_status_provider(): array {
+ return array(
+ 'cancelled' => array( 'cancelled' ),
+ 'refunded' => array( 'refunded' ),
+ );
+ }
+
+ /**
+ * @testdox Trashing the order unschedules the pending action.
+ */
+ public function test_trashing_order_cancels_pending_action(): void {
+ $order = $this->create_pending_order();
+ $order->update_status( 'completed' );
+ $this->assertTrue( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+
+ // A non-forced delete routes through the order data store's trash path.
+ $order->delete( false );
+
+ $this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
+ /**
+ * @testdox Deleting the order unschedules the pending action.
+ */
+ public function test_deleting_order_cancels_pending_action(): void {
+ $order = $this->create_pending_order();
+ $order->update_status( 'completed' );
+ $order_id = $order->get_id();
+ $this->assertTrue( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order_id ) ) );
+
+ $order->delete( true );
+
+ $this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order_id ) ) );
+ }
+
+ /**
+ * @testdox Cancellation unschedules the action even when the tracking meta is missing.
+ *
+ * Guards against an out-of-sync meta value leaving a stray scheduled send.
+ */
+ public function test_cancellation_unschedules_when_meta_missing(): void {
+ $order = $this->create_pending_order();
+ $order->update_status( 'completed' );
+ $order_id = $order->get_id();
+ $this->assertTrue( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order_id ) ) );
+
+ // Simulate an out-of-sync state: meta cleared while the action is still pending.
+ $order->delete_meta_data( Scheduler::SCHEDULED_META_KEY );
+ $order->save();
+
+ $order->update_status( 'cancelled' );
+
+ $this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order_id ) ) );
+ }
+
+ /**
+ * Create an order in a non-completed status so transitioning to completed fires the hook cleanly.
+ */
+ private function create_pending_order(): WC_Order {
+ $order = OrderHelper::create_order();
+ $order->set_status( 'pending' );
+ $order->save();
+ return $order;
+ }
+
+ /**
+ * Get the review-request email instance from the mailer.
+ */
+ private function get_email(): WC_Email_Customer_Review_Request {
+ $emails = WC()->mailer()->get_emails();
+ return $emails['WC_Email_Customer_Review_Request'];
+ }
+
+ /**
+ * Toggle the review-request email's enabled state both in the DB and on the live instance.
+ *
+ * @param bool $enabled Whether the email should be enabled.
+ */
+ private function set_review_email_enabled( bool $enabled ): void {
+ $email = $this->get_email();
+ $email->update_option( 'enabled', $enabled ? 'yes' : 'no' );
+ $email->enabled = $enabled ? 'yes' : 'no';
+ }
+}