Commit 58b110fd433 for woocommerce
commit 58b110fd433b96c15ca622ac9b62917d09e0e20b
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Fri May 15 13:54:37 2026 +0300
Skip review-request email when the order has nothing reviewable (#64960)
* Skip review-request email when nothing is reviewable
Gate scheduling and sending on ItemEligibility::has_actionable_items()
so the customer never receives an email that would land on the
empty-state page (all products have reviews disabled per-product or
site-wide, or every reviewable item is already reviewed on this order).
The send-time check covers eligibility loss between scheduling and the
delayed send.
* Document empty-state fallback for direct-URL visits
* Add changefile(s) from automation for the following project(s): woocommerce
* Document woocommerce_review_order_eligible_items at has_actionable_items call site
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64960-wooplug-6704-skip-scheduling-review-request-email-when-nothing-is b/plugins/woocommerce/changelog/64960-wooplug-6704-skip-scheduling-review-request-email-when-nothing-is
new file mode 100644
index 00000000000..965dd1ed542
--- /dev/null
+++ b/plugins/woocommerce/changelog/64960-wooplug-6704-skip-scheduling-review-request-email-when-nothing-is
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Skip scheduling and sending the customer review-request email when the order has no reviewable items (every product has reviews disabled per-product or site-wide, or every reviewable item is already reviewed for that order).
\ No newline at end of file
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 a7e3192e637..06a6bb2cb43 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
@@ -6,6 +6,7 @@
*/
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
defined( 'ABSPATH' ) || exit;
@@ -141,7 +142,16 @@ if ( ! class_exists( 'WC_Email_Customer_Review_Request', false ) ) :
$this->object
);
- return in_array( $this->object->get_status(), $eligible_statuses, true );
+ if ( ! in_array( $this->object->get_status(), $eligible_statuses, true ) ) {
+ return false;
+ }
+
+ // Eligibility can change between scheduling and sending (e.g. the
+ // admin disables site-wide reviews during the delay window, or the
+ // customer reviews everything via another entry point). Re-check at
+ // send time so the email is silently dropped instead of pointing
+ // the customer at the empty-state page.
+ return ItemEligibility::has_actionable_items( $this->object );
}
/**
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
index 220fe10aba9..c84acd07cf2 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
@@ -241,6 +241,56 @@ class ItemEligibility {
);
}
+ /**
+ * Whether an order has at least one item the customer can still review.
+ *
+ * Walks the same eligible-items list and per-item decisions the page
+ * renders, so the answer matches what `customer-review-order.php` would
+ * show: items with `STATUS_SKIP` (reviews disabled on the product, or
+ * site-wide via `woocommerce_enable_reviews`) and items already reviewed
+ * on this order are excluded. Any remaining `STATUS_FORM` row without a
+ * matching review counts as actionable.
+ *
+ * Callers in the email pipeline use this to short-circuit scheduling and
+ * sending when the customer would otherwise land on the empty-state page.
+ *
+ * @since 10.9.0
+ *
+ * @param WC_Order $order Order being inspected.
+ * @return bool True when at least one item is still reviewable.
+ */
+ public static function has_actionable_items( WC_Order $order ): bool {
+ /**
+ * Filter the eligible items considered when deciding whether the
+ * Customer Review Request email should fire for an order.
+ *
+ * Same hook the page template, submission handler, and endpoint use,
+ * so all four entry points agree on the eligible-items set.
+ *
+ * @since 10.9.0
+ *
+ * @param WC_Order_Item[] $items Order line items.
+ * @param WC_Order $order The order being inspected.
+ */
+ $items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
+ self::preload_for_items( $items, $order );
+
+ foreach ( $items as $item ) {
+ if ( ! $item instanceof WC_Order_Item_Product ) {
+ continue;
+ }
+ $decision = self::decide( $item, $order );
+ if ( self::STATUS_SKIP === $decision['status'] ) {
+ continue;
+ }
+ if ( ! ( $decision['comment'] instanceof WP_Comment ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Drop fully-refunded line items from the eligible-items list.
*
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
index bcd262b59b2..aacaaf51175 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
@@ -154,6 +154,15 @@ class Scheduler {
return;
}
+ // Don't queue an email whose link would land on the empty-state page:
+ // every product on the order has reviews disabled (per-product or
+ // site-wide via `woocommerce_enable_reviews`), or every reviewable
+ // item already has a review tied to this order.
+ if ( ! ItemEligibility::has_actionable_items( $order ) ) {
+ $this->log_skip( $order_id, 'no reviewable items' );
+ return;
+ }
+
$when = time() + $email->get_delay_seconds();
as_schedule_single_action( $when, self::ACTION_HOOK, array( $order_id ) );
diff --git a/plugins/woocommerce/templates/order/customer-review-order-empty.php b/plugins/woocommerce/templates/order/customer-review-order-empty.php
index 3ffe6c7cada..54cb128806f 100644
--- a/plugins/woocommerce/templates/order/customer-review-order-empty.php
+++ b/plugins/woocommerce/templates/order/customer-review-order-empty.php
@@ -30,6 +30,9 @@ $meta_parts = \Automattic\WooCommerce\Internal\OrderReviews\Meta::parts_for_orde
if ( $reviewed_count > 0 ) {
esc_html_e( 'Thank you for your reviews', 'woocommerce' );
} else {
+ // Defensive fallback for direct-URL visits (bookmark, admin-shared link).
+ // The email pipeline never schedules or sends when an order has no
+ // reviewable items, so customers don't reach this branch via the email.
esc_html_e( 'Nothing to review here', 'woocommerce' );
}
?>
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
index 6bb0ed461d9..44b91f9896a 100644
--- 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
@@ -256,6 +256,33 @@ class WC_Email_Customer_Review_Request_Test extends \WC_Unit_Test_Case {
$this->assertSame( $before, $after, 'Review-request email must not dispatch for non-eligible status.' );
}
+ /**
+ * @testdox trigger() skips orders whose items all have reviews disabled.
+ *
+ * Eligibility can change between scheduling and sending — e.g. the admin
+ * disables product reviews site-wide during the delay window — so the
+ * email gates on `ItemEligibility::has_actionable_items()` at send time.
+ */
+ public function test_trigger_skips_when_no_actionable_items(): void {
+ $this->sut->update_option( 'enabled', 'yes' );
+ $this->sut->enabled = 'yes';
+
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_reviews_allowed( false );
+ $product->save();
+
+ $order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order( 1, $product );
+ $order->set_status( 'completed' );
+ $order->save();
+
+ $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, 'Review-request email must not dispatch when nothing on the order is reviewable.' );
+ }
+
/**
* @testdox The woocommerce_review_order_eligible_statuses filter widens the eligible set for trigger().
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
index 7b4cf23c4f8..d1c1895fc16 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
@@ -278,4 +278,54 @@ class ItemEligibilityTest extends WC_Unit_Test_Case {
$this->assertNotNull( $decision['comment'] );
$this->assertSame( 0, $call_count, 'decide() should not query when preload_for_items() has cached the result.' );
}
+
+ /**
+ * @testdox has_actionable_items() returns true when at least one item is reviewable.
+ */
+ public function test_has_actionable_items_true_for_default_order(): void {
+ $built = $this->make_order();
+
+ $this->assertTrue( ItemEligibility::has_actionable_items( $built['order'] ) );
+ }
+
+ /**
+ * @testdox has_actionable_items() returns false when every product has reviews disabled.
+ */
+ public function test_has_actionable_items_false_when_all_items_disabled(): void {
+ $built = $this->make_order();
+ $product = wc_get_product( $built['product_id'] );
+ $product->set_reviews_allowed( false );
+ $product->save();
+
+ $this->assertFalse( ItemEligibility::has_actionable_items( $built['order'] ) );
+ }
+
+ /**
+ * @testdox has_actionable_items() returns false when reviews are disabled site-wide.
+ */
+ public function test_has_actionable_items_false_when_site_wide_reviews_disabled(): void {
+ $built = $this->make_order();
+ $previous = get_option( 'woocommerce_enable_reviews', 'yes' );
+ update_option( 'woocommerce_enable_reviews', 'no' );
+ remove_post_type_support( 'product', 'comments' );
+
+ try {
+ $this->assertFalse( ItemEligibility::has_actionable_items( $built['order'] ) );
+ } finally {
+ update_option( 'woocommerce_enable_reviews', $previous );
+ if ( 'yes' === $previous ) {
+ add_post_type_support( 'product', 'comments' );
+ }
+ }
+ }
+
+ /**
+ * @testdox has_actionable_items() returns false once every reviewable item is reviewed.
+ */
+ public function test_has_actionable_items_false_when_all_items_reviewed(): void {
+ $built = $this->make_order( 'all-done@example.test' );
+ $this->insert_review( $built['product_id'], 'all-done@example.test', 'Done.', 5, (int) $built['order']->get_id() );
+
+ $this->assertFalse( ItemEligibility::has_actionable_items( $built['order'] ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
index dbd9f09448b..d88bab23924 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
@@ -6,7 +6,9 @@ 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_Helper_Product;
use WC_Order;
+use WC_Order_Item_Product;
use WC_Unit_Test_Case;
/**
@@ -105,6 +107,79 @@ class SchedulerTest extends WC_Unit_Test_Case {
$this->assertSame( $first, $second, 'Scheduled-at meta should not change on re-completion.' );
}
+ /**
+ * @testdox Scheduling is skipped when every product on the order has reviews disabled per-product.
+ */
+ public function test_skips_when_all_items_have_reviews_disabled(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_reviews_allowed( false );
+ $product->save();
+
+ $order = $this->create_pending_order_with_product( $product );
+ $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 Scheduling is skipped when site-wide reviews are disabled.
+ *
+ * The `woocommerce_enable_reviews=no` setting removes `comments` support
+ * from the product post type so `comments_open()` returns false for every
+ * product, which `ItemEligibility::has_actionable_items()` reads.
+ */
+ public function test_skips_when_site_wide_reviews_disabled(): void {
+ $previous = get_option( 'woocommerce_enable_reviews', 'yes' );
+ update_option( 'woocommerce_enable_reviews', 'no' );
+ // `comments` post-type support is registered at init based on the
+ // option, so reflect the option change for the rest of this test.
+ remove_post_type_support( 'product', 'comments' );
+
+ try {
+ $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 ) );
+ } finally {
+ update_option( 'woocommerce_enable_reviews', $previous );
+ if ( 'yes' === $previous ) {
+ add_post_type_support( 'product', 'comments' );
+ }
+ }
+ }
+
+ /**
+ * @testdox A mixed order with at least one reviewable item still schedules.
+ */
+ public function test_schedules_when_at_least_one_item_is_reviewable(): void {
+ $reviewable = WC_Helper_Product::create_simple_product();
+ $disabled = WC_Helper_Product::create_simple_product();
+ $disabled->set_reviews_allowed( false );
+ $disabled->save();
+
+ $order = OrderHelper::create_order( 1, $reviewable );
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $disabled,
+ 'quantity' => 1,
+ 'subtotal' => wc_get_price_excluding_tax( $disabled ),
+ 'total' => wc_get_price_excluding_tax( $disabled ),
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_status( 'pending' );
+ $order->calculate_totals();
+ $order->save();
+
+ $order->update_status( 'completed' );
+
+ $this->assertTrue( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
/**
* @testdox woocommerce_should_send_review_request=false skips scheduling.
*/
@@ -236,6 +311,18 @@ class SchedulerTest extends WC_Unit_Test_Case {
return $order;
}
+ /**
+ * Create a pending order whose single line item is the provided product.
+ *
+ * @param \WC_Product $product Product to add to the order.
+ */
+ private function create_pending_order_with_product( \WC_Product $product ): WC_Order {
+ $order = OrderHelper::create_order( 1, $product );
+ $order->set_status( 'pending' );
+ $order->save();
+ return $order;
+ }
+
/**
* Get the review-request email instance from the mailer.
*/