Commit ef178525e0c for woocommerce
commit ef178525e0c98b6fbb40c7f48edf65841da13708
Author: Ján Mikláš <neosinner@gmail.com>
Date: Wed Jun 3 12:39:58 2026 +0200
Avoid resending emails when restoring HPOS orders (#65121)
* Avoid resending emails when restoring orders
* Add changelog for order restore email fix
* Revert comment position
diff --git a/plugins/woocommerce/changelog/wooplug-6670-untrash-customer-emails b/plugins/woocommerce/changelog/wooplug-6670-untrash-customer-emails
new file mode 100644
index 00000000000..89bbef2ab49
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6670-untrash-customer-emails
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop re-sending customer order email notifications when restoring orders from the trash.
diff --git a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
index 97f4a30e574..3aa743bb319 100644
--- a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
@@ -1174,4 +1174,115 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
wp_cache_set( $tax_keys[ $order_id ], $tax_by_order[ $order_id ] ?? 0.0, 'orders' );
}
}
+
+ /**
+ * Temporarily neutralizes the WC_Emails transactional dispatch listeners
+ * (send_transactional_email and queue_transactional_email) so that restoring an order
+ * from the trash does not re-notify the customer about an order they were already
+ * emailed about.
+ *
+ * The listeners are left in their original WP_Hook slots: the action, priority,
+ * accepted-args count and position are all kept, and only the callback function is
+ * wrapped to suppress dispatches for the restored order. This preserves the relative
+ * ordering of every other listener on those actions, so third-party integrations are
+ * unaffected. Pass the returned snapshot to {@see restore_transactional_email_dispatch()}
+ * to undo this.
+ *
+ * @since 10.9.0
+ *
+ * @param int $restored_order_id The ID of the order being restored.
+ * @return list<array{0: string, 1: int, 2: string, 3: callable}> Snapshot of neutralized listeners.
+ */
+ protected function suspend_transactional_email_dispatch( int $restored_order_id ): array {
+ global $wp_filter;
+
+ $suspended = array();
+ $dispatch_method_set = array( 'send_transactional_email', 'queue_transactional_email' );
+
+ foreach ( $wp_filter as $action => $hook ) {
+ if ( ! is_string( $action ) || ! $hook instanceof WP_Hook ) {
+ continue;
+ }
+ foreach ( $hook->callbacks as $priority => $callbacks ) {
+ foreach ( $callbacks as $key => $cb ) {
+ $function = $cb['function'] ?? null;
+ if ( ! is_array( $function ) || ! isset( $function[0], $function[1] ) ) {
+ continue;
+ }
+ if ( ! is_callable( $function ) ) {
+ continue;
+ }
+ $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
+ $method = $function[1];
+ if ( ! is_string( $class ) || ! is_string( $method ) ) {
+ continue;
+ }
+ if ( 'WC_Emails' !== $class || ! in_array( $method, $dispatch_method_set, true ) ) {
+ continue;
+ }
+ $suspended[] = array( $action, (int) $priority, (string) $key, $function );
+ $hook->callbacks[ $priority ][ $key ]['function'] = function ( ...$args ) use ( $action, $function, $restored_order_id ) {
+ if ( $this->should_suppress_transactional_email_dispatch( $action, $restored_order_id, $args ) ) {
+ return null;
+ }
+
+ return call_user_func_array( $function, $args );
+ };
+ }
+ }
+ }
+
+ return $suspended;
+ }
+
+ /**
+ * Checks whether a transactional email dispatch belongs to the restored order.
+ *
+ * @since 10.9.0
+ *
+ * @param string $action The action being dispatched.
+ * @param int $restored_order_id The ID of the order being restored.
+ * @param array $args The runtime action arguments.
+ * @return bool
+ */
+ private function should_suppress_transactional_email_dispatch( string $action, int $restored_order_id, array $args ): bool {
+ if ( 0 !== strpos( $action, 'woocommerce_order_status_' ) ) {
+ return false;
+ }
+
+ $order = $args[1] ?? null;
+ if ( $order instanceof WC_Order ) {
+ return $restored_order_id === $order->get_id();
+ }
+
+ $order_id = $args[0] ?? null;
+ return is_numeric( $order_id ) && $restored_order_id === (int) $order_id;
+ }
+
+ /**
+ * Restores the transactional email dispatch listeners previously neutralized by
+ * {@see suspend_transactional_email_dispatch()} to their original callbacks.
+ *
+ * @since 10.9.0
+ *
+ * @param list<array{0: string, 1: int, 2: string, 3: callable}> $suspended Snapshot returned by suspend_transactional_email_dispatch().
+ *
+ * @return void
+ */
+ protected function restore_transactional_email_dispatch( array $suspended ): void {
+ global $wp_filter;
+
+ foreach ( $suspended as $entry ) {
+ list( $action, $priority, $key, $function ) = $entry;
+ if ( ! isset( $wp_filter[ $action ] ) || ! $wp_filter[ $action ] instanceof WP_Hook ) {
+ continue;
+ }
+ // Only restore the slot if it still exists; if it was removed during the
+ // suspended window we must not resurrect it.
+ if ( ! isset( $wp_filter[ $action ]->callbacks[ $priority ][ $key ] ) ) {
+ continue;
+ }
+ $wp_filter[ $action ]->callbacks[ $priority ][ $key ]['function'] = $function;
+ }
+ }
}
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php
index 975d40184f7..3d4c00a04aa 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php
@@ -1244,8 +1244,21 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement
return false;
}
- $order->set_status( get_post_field( 'post_status', $order->get_id() ) );
- return (bool) $order->save();
+ // Restoring the order triggers a status transition (from 'trash' back to its previous
+ // status). WC_Emails dispatches customer notifications on those transitions, so suppress
+ // email dispatch for this order while we restore to avoid re-notifying the customer
+ // about an order they were already emailed about. The status transition actions
+ // themselves still fire, so any 3rd-party code hooked into them keeps working.
+ $suspended_email_dispatch = $this->suspend_transactional_email_dispatch( $order->get_id() );
+
+ try {
+ $order->set_status( get_post_field( 'post_status', $order->get_id() ) );
+ $saved = (bool) $order->save();
+ } finally {
+ $this->restore_transactional_email_dispatch( $suspended_email_dispatch );
+ }
+
+ return $saved;
}
/**
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index f32af8c65ac..a55f564b2a0 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -2771,8 +2771,19 @@ FROM $order_meta_table
*/
do_action( 'woocommerce_untrash_order', $order->get_id(), $previous_status );
- $order->set_status( $previous_status );
- $order->save();
+ // Customer order emails (and other transactional emails) are dispatched via WC_Emails
+ // listeners on the woocommerce_order_status_* actions. Suppress dispatch for this order
+ // while we restore it so customers aren't re-notified about an order they
+ // already received emails for. The status transition actions themselves still fire,
+ // so any 3rd-party code hooked into them keeps working.
+ $suspended_email_dispatch = $this->suspend_transactional_email_dispatch( $order->get_id() );
+
+ try {
+ $order->set_status( $previous_status );
+ $order->save();
+ } finally {
+ $this->restore_transactional_email_dispatch( $suspended_email_dispatch );
+ }
// Was the status successfully restored? Let's clean up the meta and indicate success...
if ( 'wc-' . $order->get_status() === $previous_status ) {
@@ -2796,7 +2807,6 @@ FROM $order_meta_table
return false;
}
-
/**
* Deletes order data from custom order tables.
*
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
index fb874650bf0..8c8f6a3d3f0 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
@@ -303,6 +303,161 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$this->assertEquals( $original_status, $order->get_status(), 'The original order status is restored following untrash.' );
}
+ /**
+ * @testdox Restoring a CPT order from trash does not re-fire transactional email dispatch, but still fires the status transition actions.
+ */
+ public function test_untrash_suspends_email_dispatch_but_keeps_status_actions(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $order_id = $order->get_id();
+
+ $order->delete();
+ $this->assertEquals( OrderStatus::TRASH, $order->get_status(), 'The order was successfully trashed.' );
+
+ $status_action_count = 0;
+ $status_notification_count = 0;
+ $status_action = function ( $id ) use ( $order_id, &$status_action_count ) {
+ if ( $order_id === $id ) {
+ ++$status_action_count;
+ }
+ };
+ $notification_action = function ( $id ) use ( $order_id, &$status_notification_count ) {
+ if ( $order_id === $id ) {
+ ++$status_notification_count;
+ }
+ };
+
+ add_action( 'woocommerce_order_status_completed', $status_action );
+ add_action( 'woocommerce_order_status_completed_notification', $notification_action );
+
+ try {
+ $order = wc_get_order( $order_id );
+ $this->assertTrue( $order->untrash(), 'The order was restored from the trash.' );
+ } finally {
+ remove_action( 'woocommerce_order_status_completed', $status_action );
+ remove_action( 'woocommerce_order_status_completed_notification', $notification_action );
+ }
+
+ $this->assertEquals( OrderStatus::COMPLETED, $order->get_status() );
+ // The status transition action still fires so 3rd-party integrations keep working.
+ $this->assertSame( 1, $status_action_count );
+ // The _notification action (which transactional emails listen to) must NOT fire on restore.
+ $this->assertSame( 0, $status_notification_count );
+ }
+
+ /**
+ * @testdox Restoring an order only suppresses transactional email dispatch for the restored order.
+ */
+ public function test_untrash_does_not_suppress_email_dispatch_for_other_orders(): void {
+ $restored_order = WC_Helper_Order::create_order();
+ $restored_order->set_status( OrderStatus::COMPLETED );
+ $restored_order->save();
+ $restored_order_id = $restored_order->get_id();
+
+ $other_order = WC_Helper_Order::create_order();
+ $other_order->set_status( OrderStatus::PENDING );
+ $other_order->save();
+ $other_order_id = $other_order->get_id();
+
+ $restored_order->delete();
+ $this->assertEquals( OrderStatus::TRASH, $restored_order->get_status(), 'The restored order was successfully trashed.' );
+
+ $restored_order_notification_count = 0;
+ $other_order_notification_count = 0;
+ $other_order_status_change_count = 0;
+ $status_action = function ( $order_id ) use ( $restored_order_id, $other_order, &$other_order_status_change_count ) {
+ if ( $restored_order_id !== $order_id ) {
+ return;
+ }
+
+ $other_order->set_status( OrderStatus::COMPLETED );
+ $other_order->save();
+ ++$other_order_status_change_count;
+ };
+ $notification_action = function ( $order_id ) use ( $restored_order_id, $other_order_id, &$restored_order_notification_count, &$other_order_notification_count ) {
+ if ( $restored_order_id === $order_id ) {
+ ++$restored_order_notification_count;
+ }
+ if ( $other_order_id === $order_id ) {
+ ++$other_order_notification_count;
+ }
+ };
+
+ add_action( 'woocommerce_order_status_completed', $status_action );
+ add_action( 'woocommerce_order_status_completed_notification', $notification_action );
+
+ try {
+ $restored_order = wc_get_order( $restored_order_id );
+ $this->assertTrue( $restored_order->untrash(), 'The order was restored from the trash.' );
+ } finally {
+ remove_action( 'woocommerce_order_status_completed', $status_action );
+ remove_action( 'woocommerce_order_status_completed_notification', $notification_action );
+ }
+
+ $this->assertSame( 1, $other_order_status_change_count );
+ $this->assertSame( 0, $restored_order_notification_count );
+ $this->assertSame( 1, $other_order_notification_count );
+ }
+
+ /**
+ * No-op listener used as a stable, uniquely-identified probe by
+ * {@see test_untrash_preserves_email_hook_callback_order()}.
+ *
+ * @return void
+ */
+ public static function untrash_email_hook_order_probe(): void {}
+
+ /**
+ * @testdox Restoring an order keeps the WC_Emails dispatch listener in its original hook slot rather than removing and re-adding it.
+ */
+ public function test_untrash_preserves_email_hook_callback_order(): void {
+ global $wp_filter;
+
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $order_id = $order->get_id();
+ $order->delete();
+
+ // Register a 3rd-party listener after WC_Emails dispatch, at the same priority. A
+ // uniquely-named static method is used so its hook ID is a stable string that nothing
+ // else collides with.
+ $probe = array( self::class, 'untrash_email_hook_order_probe' );
+ add_action( 'woocommerce_order_status_completed', $probe, 10 );
+
+ // Capture the priority 10 listener order at the moment the restore transition fires.
+ // A remove/re-add suspension would leave WC_Emails dispatch absent here (it would be
+ // removed before save() and only re-added afterwards); the in-place wrapper keeps
+ // it registered in its original slot, ahead of the 3rd-party listener.
+ $keys_during = array();
+ $recorder = function () use ( &$wp_filter, &$keys_during ) {
+ $keys_during = array_keys( $wp_filter['woocommerce_order_status_completed']->callbacks[10] );
+ };
+ add_action( 'woocommerce_order_status_completed', $recorder, 99 );
+
+ try {
+ $order = wc_get_order( $order_id );
+ $this->assertTrue( $order->untrash(), 'The order was restored from the trash.' );
+ } finally {
+ remove_action( 'woocommerce_order_status_completed', $probe, 10 );
+ remove_action( 'woocommerce_order_status_completed', $recorder, 99 );
+ }
+
+ $pos_dispatch = false;
+ foreach ( array( 'WC_Emails::send_transactional_email', 'WC_Emails::queue_transactional_email' ) as $dispatch_key ) {
+ $pos_dispatch = array_search( $dispatch_key, $keys_during, true );
+ if ( false !== $pos_dispatch ) {
+ break;
+ }
+ }
+ $pos_listener = array_search( self::class . '::untrash_email_hook_order_probe', $keys_during, true );
+
+ $this->assertNotFalse( $pos_dispatch, 'WC_Emails dispatch callback stays registered during the restore transition.' );
+ $this->assertNotFalse( $pos_listener, 'The 3rd-party listener stays registered during the restore transition.' );
+ $this->assertLessThan( $pos_listener, $pos_dispatch, 'WC_Emails dispatch keeps its original position ahead of a later listener.' );
+ }
+
/**
* @testDox A 'suppress_filters' argument can be passed to 'delete', if true no 'woocommerce_(before_)trash/delete_order' actions will be fired.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
index 8a033bb6006..31dc525f432 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -595,6 +595,77 @@ class OrdersTableDataStoreTests extends \HposTestCase {
//phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders
}
+ /**
+ * @testdox Restoring an HPOS order from trash does not re-fire transactional email dispatch, but still fires the status transition actions.
+ */
+ public function test_cot_datastore_untrash_suspends_email_dispatch_but_keeps_status_actions() {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $order = $this->create_complex_cot_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $this->sut->trash_order( $order );
+ $this->sut->read( $order );
+
+ $status_action_count = 0;
+ $status_notification_count = 0;
+ $status_action = function ( $order_id ) use ( $order, &$status_action_count ) {
+ if ( $order->get_id() === $order_id ) {
+ ++$status_action_count;
+ }
+ };
+ $notification_action = function ( $order_id ) use ( $order, &$status_notification_count ) {
+ if ( $order->get_id() === $order_id ) {
+ ++$status_notification_count;
+ }
+ };
+
+ add_action( 'woocommerce_order_status_completed', $status_action );
+ add_action( 'woocommerce_order_status_completed_notification', $notification_action );
+
+ try {
+ $this->assertTrue( $this->sut->untrash_order( $order ) );
+ } finally {
+ remove_action( 'woocommerce_order_status_completed', $status_action );
+ remove_action( 'woocommerce_order_status_completed_notification', $notification_action );
+ }
+
+ $this->assertSame( OrderStatus::COMPLETED, $order->get_status() );
+ // The status transition action still fires so 3rd-party integrations keep working.
+ $this->assertSame( 1, $status_action_count );
+ // The _notification action (which transactional emails listen to) must NOT fire on restore.
+ $this->assertSame( 0, $status_notification_count );
+ }
+
+ /**
+ * @testdox After untrash, the WC_Emails transactional dispatch listeners are reinstated.
+ */
+ public function test_cot_datastore_untrash_restores_email_dispatch_after_save() {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $order = $this->create_complex_cot_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $this->sut->trash_order( $order );
+ $this->sut->read( $order );
+
+ $dispatch_callbacks_before = array(
+ has_action( 'woocommerce_order_status_completed', array( 'WC_Emails', 'send_transactional_email' ) ),
+ has_action( 'woocommerce_order_status_completed', array( 'WC_Emails', 'queue_transactional_email' ) ),
+ );
+
+ $this->assertTrue( $this->sut->untrash_order( $order ) );
+
+ $dispatch_callbacks_after = array(
+ has_action( 'woocommerce_order_status_completed', array( 'WC_Emails', 'send_transactional_email' ) ),
+ has_action( 'woocommerce_order_status_completed', array( 'WC_Emails', 'queue_transactional_email' ) ),
+ );
+
+ $this->assertSame( $dispatch_callbacks_before, $dispatch_callbacks_after );
+ }
+
/**
* @testDox Tests the `delete()` method on the COT datastore -- full deletes.
*