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.
 	 *