Commit 6a0189c3ba2 for woocommerce

commit 6a0189c3ba229ac034abd05cbc3598d4a19f8296
Author: Mayisha <33387139+Mayisha@users.noreply.github.com>
Date:   Wed Jul 8 13:53:46 2026 +0600

    [RSM] Auto-send abandoned cart recovery email (#65238)

    * Schedule the abandoned cart recovery email automatically

    * add changelog

    * Address PR review on checkout-recovery unsubscribe

    * Address nitpick comments

    * Update order note

    * fix test

    * Dispatch the auto-send AS action from the Scheduler

    * Add an order note when the auto-send dispatches

    * Address feedback

    * Address feedback

    * fix lint

    * Address feedback

    * fix changelog

    * Address feedback

    * Narrow abandoned cart recovery auto-send to pending-only orders

    * Retarget abandoned cart recovery @since tags from 10.9.0 to 11.0.0
    ---------

    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/abandoned-cart-recovery-auto-send b/plugins/woocommerce/changelog/abandoned-cart-recovery-auto-send
new file mode 100644
index 00000000000..f4827614bdd
--- /dev/null
+++ b/plugins/woocommerce/changelog/abandoned-cart-recovery-auto-send
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Schedule the abandoned cart recovery email to send 2 hours after a pending order is created, and cancel the pending send when the order leaves the pending status.
diff --git a/plugins/woocommerce/changelog/abandoned-cart-recovery-email b/plugins/woocommerce/changelog/abandoned-cart-recovery-email
index cad033f0e9a..e42b071dff1 100644
--- a/plugins/woocommerce/changelog/abandoned-cart-recovery-email
+++ b/plugins/woocommerce/changelog/abandoned-cart-recovery-email
@@ -1,4 +1,4 @@
 Significance: minor
 Type: add

-Add a new transactional abandoned cart recovery email that prompts customers to complete a pending checkout, behind the new `checkout_recovery` feature flag.
+Add a new transactional abandoned cart recovery email that prompts customers to complete a pending checkout, behind the new `abandoned_cart_recovery` feature flag.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index c53fd3b6376..1ac3e2a4e83 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -1001,7 +1001,7 @@ final class WooCommerce {
 	 * so the order-edit action listener is registered before
 	 * `WC_Meta_Box_Order_Actions::save()` dispatches its hook on POST.
 	 *
-	 * @since 10.9.0
+	 * @since 11.0.0
 	 * @internal
 	 */
 	public function maybe_init_abandoned_cart_recovery(): void {
@@ -1009,6 +1009,7 @@ final class WooCommerce {
 			return;
 		}
 		wc_get_container()->get( \Automattic\WooCommerce\Internal\AbandonedCartRecovery\ManualSendHandler::class );
+		wc_get_container()->get( \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler::class );
 	}

 	/**
@@ -1023,7 +1024,7 @@ final class WooCommerce {
 	 * stop working. Both consequences are wrong, so this method runs even
 	 * when no specific email kind that uses it is currently active.
 	 *
-	 * @since 10.9.0
+	 * @since 11.0.0
 	 * @internal
 	 */
 	public function init_email_unsubscribes(): void {
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
index 9780c73616a..d7b67585388 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
@@ -24,7 +24,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 	 * Merchants can also trigger the email manually from the order edit page.
 	 *
 	 * @class    WC_Email_Customer_Abandoned_Cart_Recovery
-	 * @version  10.9.0
+	 * @version  11.0.0
 	 * @package  WooCommerce\Classes\Emails
 	 */
 	class WC_Email_Customer_Abandoned_Cart_Recovery extends WC_Email {
@@ -87,6 +87,18 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 */
 		public const ABANDONMENT_THRESHOLD_SECONDS = HOUR_IN_SECONDS;

+		/**
+		 * Delay between order creation and the automated recovery send.
+		 *
+		 * Deliberately longer than `ABANDONMENT_THRESHOLD_SECONDS` so the auto-send
+		 * never races the customer's own checkout retry. The shorter eligibility
+		 * threshold still gates manual sends from the order edit page so merchants
+		 * have an earlier window to intervene.
+		 *
+		 * @since 11.0.0
+		 */
+		public const AUTO_SEND_DELAY_SECONDS = 2 * HOUR_IN_SECONDS;
+
 		/**
 		 * Constructor.
 		 */
@@ -105,13 +117,6 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 				'{order_number}' => '',
 			);

-			// Trigger fires after Action Scheduler dispatches `woocommerce_send_abandoned_cart_recovery_notification`,
-			// or when the merchant invokes the manual-send action from the order edit page.
-			// The order-edit action hooks live in `Internal\AbandonedCartRecovery\ManualSendHandler`
-			// so the listener is in place before the admin POST runs the order-meta save flow
-			// (which happens before the mailer would otherwise be instantiated).
-			add_action( 'woocommerce_send_abandoned_cart_recovery_notification', array( $this, 'trigger' ), 10, 1 );
-
 			parent::__construct();

 			// Must be after parent's constructor which sets `email_improvements_enabled` property.
@@ -121,17 +126,20 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		/**
 		 * Trigger the sending of this email.
 		 *
-		 * Wired to `woocommerce_send_abandoned_cart_recovery_notification`, which Action
-		 * Scheduler fires with the order id as its single argument. Also called
-		 * directly by the manual-send action on the order edit page.
+		 * Called by `Scheduler::handle_scheduled_send()` after Action Scheduler dispatches
+		 * `woocommerce_send_abandoned_cart_recovery_notification`, and directly by the
+		 * manual-send action on the order edit page.
 		 *
-		 * @since 10.9.0
+		 * @since 11.0.0
 		 *
 		 * @param int $order_id The order ID.
+		 * @return bool True when an email was dispatched in this call, false when any
+		 *              gate (suppression, disabled, ineligible, unsubscribed, dedup) or
+		 *              the underlying `send()` rejected the request.
 		 */
-		public function trigger( $order_id ): void {
+		public function trigger( $order_id ): bool {
 			if ( self::is_suppressed() ) {
-				return;
+				return false;
 			}

 			$this->setup_locale();
@@ -153,23 +161,30 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 				$this->placeholders['{order_number}'] = $order->get_order_number();
 			}

+			$dispatched = false;
+
 			if (
 				$this->is_enabled()
 				&& $this->get_recipient()
 				&& $this->object instanceof WC_Order
 				&& $this->is_order_eligible_for_recovery( $this->object )
 				&& ! self::is_recipient_unsubscribed( $this->get_recipient() )
+				&& '' === (string) $this->object->get_meta( self::META_KEY_SENT_AT )
 			) {
-				$sent = $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
+				$dispatched = (bool) $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );

 				// Only record the send timestamp when the dispatch actually succeeded.
-				if ( $sent ) {
+				// Subsequent invocations (duplicate AS firings, post-manual auto fires)
+				// short-circuit on this meta before reaching `send()` again.
+				if ( $dispatched ) {
 					$this->object->update_meta_data( self::META_KEY_SENT_AT, (string) time() );
 					$this->object->save_meta_data();
 				}
 			}

 			$this->restore_locale();
+
+			return $dispatched;
 		}

 		/**
@@ -181,7 +196,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 * abandonment. A capability check guards against role configurations that
 		 * grant the order edit page to users without `edit_shop_orders`.
 		 *
-		 * @since 10.9.0
+		 * @since 11.0.0
 		 *
 		 * @param array         $actions Existing order actions keyed by action id.
 		 * @param WC_Order|null $order   Order being rendered, or null in contexts without one.
@@ -212,6 +227,12 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 				return $actions;
 			}

+			// trigger() refuses to dispatch a second time once META_KEY_SENT_AT is
+			// set, so don't surface a control that would silently no-op.
+			if ( '' !== (string) $order->get_meta( self::META_KEY_SENT_AT ) ) {
+				return $actions;
+			}
+
 			$actions[ self::MANUAL_RECOVERY_EMAIL_SEND_ACTION ] = __( 'Send abandoned cart recovery email', 'woocommerce' );

 			return $actions;
@@ -231,7 +252,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 * eligible-status set via `woocommerce_abandoned_cart_recovery_eligible_statuses`
 		 * and both paths will agree.
 		 *
-		 * @since 10.9.0
+		 * @since 11.0.0
 		 *
 		 * @param WC_Order $order Order to evaluate.
 		 * @return bool
@@ -244,7 +265,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 			 * integrations or merchants who want recovery to fire for other states (e.g. `failed`)
 			 * can widen the list here.
 			 *
-			 * @since 10.9.0
+			 * @since 11.0.0
 			 *
 			 * @param string[] $eligible_statuses Default: ABANDONED_STATUSES.
 			 * @param WC_Order $order             Order being inspected.
@@ -278,7 +299,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 * capability and order status as defense in depth in case the hook is
 		 * invoked from a non-metabox path.
 		 *
-		 * @since 10.9.0
+		 * @since 11.0.0
 		 *
 		 * @param WC_Order $order The order whose customer should receive the email.
 		 * @return void
@@ -308,17 +329,28 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 				return;
 			}

+			// Mirror trigger()'s dedup gate before firing the before/after
+			// resend hooks so extensions don't see a resend event for a send
+			// that trigger() would short-circuit.
+			if ( '' !== (string) $order->get_meta( self::META_KEY_SENT_AT ) ) {
+				return;
+			}
+
 			/**
 			 * Fires before the abandoned cart recovery email is manually resent.
 			 *
-			 * @since 10.9.0
+			 * @since 11.0.0
 			 *
 			 * @param WC_Order $order      Order being recovered.
 			 * @param string   $email_type Email identifier ('customer_abandoned_cart_recovery').
 			 */
 			do_action( 'woocommerce_before_resend_order_emails', $order, $this->id );

-			$this->trigger( $order->get_id() );
+			// Gate the note + admin notice on trigger()'s return so the audit
+			// trail only records sends that actually went out.
+			if ( ! $this->trigger( $order->get_id() ) ) {
+				return;
+			}

 			$order->add_order_note(
 				__( 'Abandoned cart recovery email sent from the order actions menu.', 'woocommerce' ),
@@ -330,7 +362,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 			/**
 			 * Fires after the abandoned cart recovery email has been manually resent.
 			 *
-			 * @since 10.9.0
+			 * @since 11.0.0
 			 *
 			 * @param WC_Order $order      Order being recovered.
 			 * @param string   $email_type Email identifier ('customer_abandoned_cart_recovery').
@@ -351,7 +383,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 * order edit page. The Action Scheduler integration consults this before
 		 * scheduling a send.
 		 *
-		 * @since 10.9.0
+		 * @since 11.0.0
 		 * @return bool
 		 */
 		public function is_automated(): bool {
@@ -361,7 +393,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		/**
 		 * Currently-active known recovery handlers, keyed by plugin file path with the display name as value.
 		 *
-		 * @since 10.9.0
+		 * @since 11.0.0
 		 * @return array<string, string> Map of plugin file path → display name for plugins that are active.
 		 */
 		public static function get_active_recovery_handlers(): array {
@@ -386,7 +418,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 * manual-send handler and the scheduler can call it without instantiating
 		 * the email class.
 		 *
-		 * @since 10.9.0
+		 * @since 11.0.0
 		 *
 		 * @return bool
 		 */
@@ -397,7 +429,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 			 * Partner plugins that handle abandoned cart recovery themselves can
 			 * return true here to prevent core from sending a duplicate email.
 			 *
-			 * @since 10.9.0
+			 * @since 11.0.0
 			 *
 			 * @param bool $suppress Default false.
 			 */
@@ -411,7 +443,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 * pending order. A future iteration may swap this for a single-use signed
 		 * URL with explicit expiry (see `woocommerce_abandoned_cart_recovery_url` filter).
 		 *
-		 * @since  10.9.0
+		 * @since  11.0.0
 		 * @return string
 		 */
 		public function get_recovery_url() {
@@ -422,7 +454,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 			/**
 			 * Filter the URL included in the abandoned cart recovery email.
 			 *
-			 * @since 10.9.0
+			 * @since 11.0.0
 			 *
 			 * @param string   $url   Default: the pending order's pay endpoint.
 			 * @param WC_Order $order Order being recovered.
@@ -439,7 +471,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 * both states mean there's no recipient to unsubscribe and the
 		 * template should suppress the footer link.
 		 *
-		 * @since  10.9.0
+		 * @since  11.0.0
 		 * @return string
 		 */
 		public function get_unsubscribe_url() {
@@ -460,7 +492,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 * dropdown gate, and any future auto-send scheduler — without each
 		 * caller needing to thread the repository through.
 		 *
-		 * @since  10.9.0
+		 * @since  11.0.0
 		 *
 		 * @param string $email Raw recipient email.
 		 * @return bool
@@ -475,7 +507,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		/**
 		 * Get default email subject.
 		 *
-		 * @since  10.9.0
+		 * @since  11.0.0
 		 * @return string
 		 */
 		public function get_default_subject() {
@@ -485,7 +517,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		/**
 		 * Get default email heading.
 		 *
-		 * @since  10.9.0
+		 * @since  11.0.0
 		 * @return string
 		 */
 		public function get_default_heading() {
@@ -495,7 +527,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		/**
 		 * Default content to show below main email content.
 		 *
-		 * @since  10.9.0
+		 * @since  11.0.0
 		 * @return string
 		 */
 		public function get_default_additional_content() {
diff --git a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php
index 1883aebb330..3e11df33ab1 100644
--- a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php
+++ b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php
@@ -27,7 +27,7 @@ use WC_Order;
  *
  * @internal Just for internal use.
  *
- * @since 10.9.0
+ * @since 11.0.0
  */
 class ManualSendHandler {

diff --git a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
new file mode 100644
index 00000000000..82c104e250f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
@@ -0,0 +1,221 @@
+<?php
+/**
+ * Scheduler class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AbandonedCartRecovery;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+use WC_Email_Customer_Abandoned_Cart_Recovery;
+use WC_Order;
+
+/**
+ * Schedules and cancels the automated abandoned-cart recovery email via Action Scheduler.
+ *
+ * Listens for new orders in the `pending` status to enqueue a single
+ * `woocommerce_send_abandoned_cart_recovery_notification` action that fires
+ * after `WC_Email_Customer_Abandoned_Cart_Recovery::AUTO_SEND_DELAY_SECONDS`.
+ * The pending action is cancelled when the order transitions out of `pending`
+ * or is trashed/deleted, so a customer who completes checkout before the delay
+ * elapses never receives the nudge.
+ *
+ * Per-order idempotency is enforced two ways: a scheduled-at meta key blocks
+ * re-scheduling for the same order, and the trigger-time send gate refuses to
+ * dispatch when `META_KEY_SENT_AT` is already populated. Together these handle
+ * duplicate `woocommerce_new_order` fires, duplicate AS action firings, and
+ * the race between a manual send and the still-pending automated send.
+ *
+ * The container auto-calls `init()` after instantiation; resolution is driven
+ * by `WooCommerce::maybe_init_abandoned_cart_recovery()`, hooked on `init`
+ * priority 1.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 11.0.0
+ */
+class Scheduler {
+
+	/**
+	 * Action Scheduler hook fired when the configured delay elapses.
+	 *
+	 * Registered in `Scheduler::init()` against `handle_scheduled_send()`, which
+	 * resolves the email class through the mailer and performs the actual send
+	 * when the hook fires.
+	 */
+	public const ACTION_HOOK = 'woocommerce_send_abandoned_cart_recovery_notification';
+
+	/**
+	 * Order meta key storing the unix timestamp the email is scheduled for.
+	 *
+	 * Used for idempotency (block duplicate schedules) and so merchants can
+	 * tell from order meta when an automated send is due. Distinct from
+	 * `META_KEY_SENT_AT`, which records that a send already happened.
+	 */
+	public const SCHEDULED_META_KEY = '_abandoned_cart_recovery_scheduled_at';
+
+	/**
+	 * Register hooks and filters.
+	 *
+	 * Auto-called by the WC dependency container after instantiation.
+	 *
+	 * @internal
+	 */
+	final public function init(): void {
+		add_action( 'woocommerce_new_order', array( $this, 'handle_new_order' ), 10, 2 );
+		// Catch every transition out of `pending` (processing, completed,
+		// cancelled, failed, refunded, custom statuses…) so the pending send is
+		// unscheduled regardless of which status the order moves to.
+		add_action( 'woocommerce_order_status_changed', array( $this, 'handle_status_changed' ), 10, 3 );
+		add_action( 'woocommerce_trash_order', array( $this, 'handle_cancellation' ), 10, 1 );
+		add_action( 'woocommerce_before_delete_order', array( $this, 'handle_cancellation' ), 10, 1 );
+		add_action( self::ACTION_HOOK, array( $this, 'handle_scheduled_send' ), 10, 1 );
+	}
+
+	/**
+	 * Schedule the automated send when a `pending` order is created.
+	 *
+	 * No-op when the order is not `pending`, when the email is disabled or
+	 * suppressed, when the merchant has opted out of automated sends, or when
+	 * this order already has a pending or completed send.
+	 *
+	 * @internal
+	 *
+	 * @param int           $order_id The new order ID.
+	 * @param WC_Order|null $order    The order object passed by `woocommerce_new_order`;
+	 *                                falls back to a lookup only when absent.
+	 */
+	public function handle_new_order( int $order_id, $order = null ): void {
+		if ( ! $order instanceof WC_Order ) {
+			$order = wc_get_order( $order_id );
+		}
+		if ( ! $order instanceof WC_Order ) {
+			return;
+		}
+
+		if ( OrderStatus::PENDING !== $order->get_status() ) {
+			return;
+		}
+
+		$email = $this->get_email();
+		if ( null === $email || ! $email->is_enabled() || ! $email->is_automated() ) {
+			return;
+		}
+
+		if ( WC_Email_Customer_Abandoned_Cart_Recovery::is_suppressed() ) {
+			return;
+		}
+
+		if ( '' !== (string) $order->get_meta( self::SCHEDULED_META_KEY ) ) {
+			return;
+		}
+
+		if ( '' !== (string) $order->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ) ) {
+			return;
+		}
+
+		$when = time() + WC_Email_Customer_Abandoned_Cart_Recovery::AUTO_SEND_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_meta_data();
+	}
+
+	/**
+	 * Unschedule the pending recovery send whenever the order leaves the
+	 * `pending` status. `woocommerce_order_status_changed` fires for every
+	 * transition, so a single listener covers processing / completed /
+	 * cancelled / failed / refunded / custom statuses in one place.
+	 *
+	 * @internal
+	 *
+	 * @param int    $order_id   Order ID.
+	 * @param string $old_status Previous status (sans `wc-` prefix).
+	 * @param string $new_status New status (sans `wc-` prefix).
+	 */
+	public function handle_status_changed( int $order_id, string $old_status, string $new_status ): void {
+		if ( OrderStatus::PENDING !== $old_status || OrderStatus::PENDING === $new_status ) {
+			return;
+		}
+
+		$this->handle_cancellation( $order_id );
+	}
+
+	/**
+	 * Cancel any pending recovery-send action and clear the scheduled-at meta.
+	 *
+	 * Hooked directly into `woocommerce_trash_order` and
+	 * `woocommerce_before_delete_order` for the trash/delete lifecycle events,
+	 * and called from `handle_status_changed()` for every transition out of
+	 * `pending`.
+	 *
+	 * @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 && '' !== (string) $order->get_meta( self::SCHEDULED_META_KEY ) ) {
+			$order->delete_meta_data( self::SCHEDULED_META_KEY );
+			$order->save_meta_data();
+		}
+	}
+
+	/**
+	 * Dispatch the recovery email when the scheduled AS action fires.
+	 *
+	 * Resolve the email lazily through the mailer (which loads the class) and delegate the
+	 * actual send to `trigger()`, which keeps every send-time gate
+	 * (enabled, recipient, eligibility, unsubscribed, dedup-meta) in one
+	 * place.
+	 *
+	 * @internal
+	 *
+	 * @param int $order_id Order id the AS action was scheduled with.
+	 */
+	public function handle_scheduled_send( int $order_id ): void {
+		$email = $this->get_email();
+		if ( null === $email ) {
+			return;
+		}
+
+		$dispatched = $email->trigger( $order_id );
+		if ( ! $dispatched ) {
+			return;
+		}
+
+		$order = wc_get_order( $order_id );
+		if ( ! $order instanceof WC_Order ) {
+			return;
+		}
+
+		$order->add_order_note(
+			__( 'Abandoned cart recovery email sent automatically.', 'woocommerce' ),
+			0,
+			false,
+			array( 'note_group' => OrderNoteGroup::EMAIL_NOTIFICATION )
+		);
+	}
+
+	/**
+	 * Retrieve the recovery email class instance from the mailer.
+	 */
+	private function get_email(): ?WC_Email_Customer_Abandoned_Cart_Recovery {
+		$mailer = WC()->mailer();
+		if ( ! $mailer ) {
+			return null;
+		}
+
+		$emails = $mailer->get_emails();
+		$email  = $emails['WC_Email_Customer_Abandoned_Cart_Recovery'] ?? null;
+
+		return $email instanceof WC_Email_Customer_Abandoned_Cart_Recovery ? $email : null;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php
index faaf2f96d7b..6a1a4ca72a9 100644
--- a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php
+++ b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php
@@ -21,7 +21,7 @@ namespace Automattic\WooCommerce\Internal\Email\Unsubscribes;
  *
  * @internal Just for internal use.
  *
- * @since 10.9.0
+ * @since 11.0.0
  */
 class Endpoint {

diff --git a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php
index 6b7ae670f8b..34892622fa2 100644
--- a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php
+++ b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php
@@ -26,7 +26,7 @@ namespace Automattic\WooCommerce\Internal\Email\Unsubscribes;
  *
  * @internal Just for internal use.
  *
- * @since 10.9.0
+ * @since 11.0.0
  */
 class Storage {

diff --git a/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php b/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php
index b730479f9ac..dbc7964cf5c 100644
--- a/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php
+++ b/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php
@@ -12,7 +12,7 @@
  *
  * @see https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates\Emails\Block
- * @version 10.9.0
+ * @version 11.0.0
  */

 defined( 'ABSPATH' ) || exit;
diff --git a/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php b/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php
index dfffe09b065..d7831312af2 100644
--- a/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php
+++ b/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php
@@ -12,7 +12,7 @@
  *
  * @see https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates\Emails
- * @version 10.9.0
+ * @version 11.0.0
  */

 use Automattic\WooCommerce\Utilities\FeaturesUtil;
diff --git a/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php b/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php
index dcf4ea84d9d..9059466f001 100644
--- a/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php
+++ b/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php
@@ -12,7 +12,7 @@
  *
  * @see https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates\Emails\Plain
- * @version 10.9.0
+ * @version 11.0.0
  */

 // phpcs:disable Universal.WhiteSpace.PrecisionAlignment.Found, Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed -- Plain text output needs specific spacing without tabs
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php
index 7e380d146ef..9f4829bcaf2 100644
--- a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php
@@ -290,6 +290,26 @@ class WC_Email_Customer_Abandoned_Cart_Recovery_Test extends \WC_Unit_Test_Case
 		$this->assertLessThanOrEqual( time() + 1, (int) $saved );
 	}

+	/**
+	 * @testdox trigger() does not dispatch when META_KEY_SENT_AT is already populated, so a duplicate AS firing or a re-trigger after a successful send cannot double-email the customer.
+	 */
+	public function test_trigger_skips_when_already_sent(): void {
+		$this->sut->update_option( 'enabled', 'yes' );
+		$this->sut->enabled = 'yes';
+
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+		$order = $this->age_order_past_threshold( $order );
+		$order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) time() );
+		$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, 'Order already marked as sent must not receive a second dispatch.' );
+	}
+
 	/**
 	 * @testdox trigger() does not write the sent_at meta when the email is disabled (no send happened).
 	 */
@@ -684,6 +704,55 @@ class WC_Email_Customer_Abandoned_Cart_Recovery_Test extends \WC_Unit_Test_Case
 		$this->assertArrayNotHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
 	}

+	/**
+	 * @testdox register_order_action() hides the action once the order is marked as sent so merchants don't click a dropdown item that would silently no-op on the trigger-side dedup gate.
+	 */
+	public function test_register_order_action_skips_when_already_sent(): void {
+		$this->become_admin();
+		$this->sut->update_option( 'enabled', 'yes' );
+		$this->sut->enabled = 'yes';
+
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+		$order = $this->age_order_past_threshold( $order );
+		$order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) time() );
+		$order->save();
+
+		$actions = $this->sut->register_order_action( array(), $order );
+
+		$this->assertArrayNotHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
+	}
+
+	/**
+	 * @testdox handle_recovery_email_send() does not record a "sent from the order actions menu" order note when trigger() bails on the dedup gate — keeps the audit trail honest about whether an email actually went out.
+	 */
+	public function test_handle_recovery_email_send_skips_note_when_already_sent(): void {
+		$this->become_admin();
+		$this->sut->update_option( 'enabled', 'yes' );
+		$this->sut->enabled = 'yes';
+
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+		$order = $this->age_order_past_threshold( $order );
+		$order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) ( time() - HOUR_IN_SECONDS ) );
+		$order->save();
+
+		$mailer = tests_retrieve_phpmailer_instance();
+		$before = count( $mailer->mock_sent );
+
+		$this->sut->handle_recovery_email_send( $order );
+
+		$this->assertSame( $before, count( $mailer->mock_sent ), 'Already-sent order must not dispatch a second message from the manual handler.' );
+
+		$notes        = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+		$note_strings = wp_list_pluck( $notes, 'content' );
+		$this->assertEmpty(
+			array_filter(
+				$note_strings,
+				static fn ( $note ) => false !== strpos( $note, 'sent from the order actions menu' )
+			),
+			'Dedup-gated trigger() must not leave a "sent from the order actions menu" order note behind.'
+		);
+	}
+
 	/**
 	 * @testdox handle_recovery_email_send() is a no-op when the email is disabled — avoids writing an order note for a send that never happened.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
new file mode 100644
index 00000000000..5c9e01846fb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
@@ -0,0 +1,392 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbandonedCartRecovery;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use WC_Email_Customer_Abandoned_Cart_Recovery;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Scheduler test.
+ *
+ * @covers \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler
+ */
+class SchedulerTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Scheduler
+	 */
+	private $sut;
+
+	/**
+	 * The email class instance — needed so the scheduler's `get_email()` lookup
+	 * finds something in the mailer registry.
+	 *
+	 * @var WC_Email_Customer_Abandoned_Cart_Recovery
+	 */
+	private $email;
+
+	/**
+	 * Snapshot of `active_plugins` taken in setUp so tests that mock a known
+	 * recovery handler can restore the original list in tearDown.
+	 *
+	 * @var array
+	 */
+	private $original_active_plugins = array();
+
+	/**
+	 * Enable the feature flag, force-include the email class, re-init the
+	 * mailer so it picks up the registration, then resolve the SUT.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		update_option( 'woocommerce_feature_abandoned_cart_recovery_enabled', 'yes' );
+		$this->original_active_plugins = (array) get_option( 'active_plugins', array() );
+
+		$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-abandoned-cart-recovery.php';
+
+		WC()->mailer()->init();
+
+		// Grab the mailer's registered instance — the Scheduler's `get_email()`
+		// returns this same instance, so option updates from the test propagate
+		// to the SUT instead of being applied to a parallel object.
+		$emails      = WC()->mailer()->get_emails();
+		$this->email = $emails['WC_Email_Customer_Abandoned_Cart_Recovery'];
+		$this->email->update_option( 'enabled', 'yes' );
+		$this->email->enabled = 'yes';
+		$this->email->update_option( 'automated', 'yes' );
+
+		$this->sut = wc_get_container()->get( Scheduler::class );
+
+		add_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ), 10, 1 );
+	}
+
+	/**
+	 * Reset settings + cancel any leftover scheduled actions between tests.
+	 */
+	public function tearDown(): void {
+		remove_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ), 10 );
+
+		delete_option( 'woocommerce_feature_abandoned_cart_recovery_enabled' );
+		delete_option( 'woocommerce_customer_abandoned_cart_recovery_settings' );
+		update_option( 'active_plugins', $this->original_active_plugins );
+
+		as_unschedule_all_actions( Scheduler::ACTION_HOOK );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox init() registers the new-order, status-changed, trash, delete, and AS-callback hooks so a fresh container resolve wires the schedule + cancel + dispatch listeners in one place.
+	 */
+	public function test_init_registers_hooks(): void {
+		// setUp() pre-registers ACTION_HOOK so the dispatch test works without
+		// init() (which would also wire woocommerce_new_order and auto-fire on
+		// every OrderHelper::create_order). Tear that fixture shortcut down
+		// here so this test asserts the production wiring rather than passing
+		// on the setUp registration.
+		remove_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ), 10 );
+		$this->assertFalse(
+			has_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ) ),
+			'Fixture cleanup precondition: ACTION_HOOK must be unregistered before init() is asserted.'
+		);
+
+		// init() ran when the container first resolved Scheduler, but WP's
+		// test framework has since restored `$wp_filter` past that point.
+		// Re-invoke here so the assertions exercise the production wiring.
+		$this->sut->init();
+
+		$this->assertNotFalse( has_action( 'woocommerce_new_order', array( $this->sut, 'handle_new_order' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_order_status_changed', array( $this->sut, 'handle_status_changed' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_trash_order', array( $this->sut, 'handle_cancellation' ) ) );
+		$this->assertNotFalse( has_action( 'woocommerce_before_delete_order', array( $this->sut, 'handle_cancellation' ) ) );
+		$this->assertNotFalse( has_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ) ) );
+	}
+
+	/**
+	 * @testdox handle_scheduled_send() resolves the email lazily and dispatches the send — the path AS uses on its WP-Cron firing context where the email class isn't already loaded.
+	 */
+	public function test_handle_scheduled_send_dispatches_to_email(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+		$order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
+		$order->save();
+		$order = wc_get_order( $order->get_id() );
+
+		$mailer = tests_retrieve_phpmailer_instance();
+		$before = count( $mailer->mock_sent );
+
+		$this->sut->handle_scheduled_send( $order->get_id() );
+
+		$this->assertSame( $before + 1, count( $mailer->mock_sent ), 'AS-fired callback must dispatch one email.' );
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertNotEmpty(
+			$fresh->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ),
+			'Successful send must record the sent_at meta so the dedup gate works on subsequent fires.'
+		);
+	}
+
+	/**
+	 * @testdox handle_scheduled_send() records a "sent automatically" order note when the send actually goes out, so the audit trail mirrors the manual-send path.
+	 */
+	public function test_handle_scheduled_send_records_order_note_on_success(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+		$order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
+		$order->save();
+
+		$this->sut->handle_scheduled_send( $order->get_id() );
+
+		$notes        = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+		$note_strings = wp_list_pluck( $notes, 'content' );
+		$this->assertNotEmpty(
+			array_filter(
+				$note_strings,
+				static fn ( $note ) => false !== strpos( $note, 'sent automatically' )
+			),
+			'Successful auto-send must add a "sent automatically" order note.'
+		);
+	}
+
+	/**
+	 * @testdox handle_scheduled_send() does NOT add a note when trigger() bails (already sent, disabled, suppressed, unsubscribed) — no audit row for a non-event.
+	 */
+	public function test_handle_scheduled_send_skips_note_when_trigger_bails(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		// Mark the order as already sent so trigger() bails on the dedup gate.
+		$order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) ( time() - HOUR_IN_SECONDS ) );
+		$order->save();
+		$order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
+		$order->save();
+
+		$this->sut->handle_scheduled_send( $order->get_id() );
+
+		$notes        = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+		$note_strings = wp_list_pluck( $notes, 'content' );
+		$this->assertEmpty(
+			array_filter(
+				$note_strings,
+				static fn ( $note ) => false !== strpos( $note, 'sent automatically' )
+			),
+			'Dedup-gated trigger() must not record a "sent automatically" order note.'
+		);
+	}
+
+	/**
+	 * @testdox do_action( Scheduler::ACTION_HOOK, $order_id ) reaches handle_scheduled_send so the production WP-Cron dispatch path is wired without the email class being instantiated up-front.
+	 */
+	public function test_action_dispatch_reaches_handle_scheduled_send(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+		$order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
+		$order->save();
+
+		$mailer = tests_retrieve_phpmailer_instance();
+		$before = count( $mailer->mock_sent );
+
+		/**
+		 * Fires the Action Scheduler callback that dispatches the abandoned
+		 * cart recovery email — simulated here so the test exercises the
+		 * registered handler end-to-end.
+		 *
+		 * @since 11.0.0
+		 *
+		 * @param int $order_id The order to dispatch the recovery email for.
+		 */
+		do_action( Scheduler::ACTION_HOOK, $order->get_id() );
+
+		$this->assertSame( $before + 1, count( $mailer->mock_sent ) );
+	}
+
+	/**
+	 * @testdox handle_new_order() schedules the AS action and records the scheduled-at meta for a pending order when automated + enabled.
+	 */
+	public function test_handle_new_order_schedules_for_pending_order(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+
+		$this->sut->handle_new_order( $order->get_id() );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertNotEmpty(
+			$fresh->get_meta( Scheduler::SCHEDULED_META_KEY ),
+			'Scheduled-at meta must be populated after handle_new_order() schedules the send.'
+		);
+		$this->assertNotFalse(
+			as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ),
+			'An AS action must be queued for the new pending order.'
+		);
+	}
+
+	/**
+	 * @testdox handle_new_order() is a no-op when the order is created in a non-abandoned status (e.g. processing).
+	 */
+	public function test_handle_new_order_skips_non_abandoned_status(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$this->sut->handle_new_order( $order->get_id() );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+	}
+
+	/**
+	 * @testdox handle_new_order() is a no-op when the merchant has turned off automated scheduling — the email stays manual-send-only.
+	 */
+	public function test_handle_new_order_skips_when_not_automated(): void {
+		$this->email->update_option( 'automated', 'no' );
+
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+
+		$this->sut->handle_new_order( $order->get_id() );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox handle_new_order() is a no-op when the email itself is disabled, so the dropdown gate and the scheduler agree on what "off" means.
+	 */
+	public function test_handle_new_order_skips_when_email_disabled(): void {
+		$this->email->update_option( 'enabled', 'no' );
+		$this->email->enabled = 'no';
+
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+
+		$this->sut->handle_new_order( $order->get_id() );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox handle_new_order() is a no-op when the suppress filter returns true, so partner plugins that handle recovery themselves don't see a duplicate send queued.
+	 */
+	public function test_handle_new_order_skips_when_suppressed(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+
+		add_filter( 'woocommerce_abandoned_cart_recovery_suppress', '__return_true' );
+		try {
+			$this->sut->handle_new_order( $order->get_id() );
+		} finally {
+			remove_filter( 'woocommerce_abandoned_cart_recovery_suppress', '__return_true' );
+		}
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox handle_new_order() does not stack schedules: a second call for the same order id is a no-op once SCHEDULED_META_KEY is set.
+	 */
+	public function test_handle_new_order_is_idempotent(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+
+		$this->sut->handle_new_order( $order->get_id() );
+		$fresh      = wc_get_order( $order->get_id() );
+		$first_when = (string) $fresh->get_meta( Scheduler::SCHEDULED_META_KEY );
+
+		$this->sut->handle_new_order( $order->get_id() );
+		$fresh       = wc_get_order( $order->get_id() );
+		$second_when = (string) $fresh->get_meta( Scheduler::SCHEDULED_META_KEY );
+
+		$this->assertSame( $first_when, $second_when, 'Repeat new-order events must not reschedule the send.' );
+	}
+
+	/**
+	 * @testdox handle_new_order() refuses to schedule when the order is already marked as sent — defense against re-creating a schedule for an order that already received the email.
+	 */
+	public function test_handle_new_order_skips_when_already_sent(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) time() );
+		$order->save();
+
+		$this->sut->handle_new_order( $order->get_id() );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox handle_status_changed() cancels the pending send when the order transitions out of the abandoned set (e.g. pending → processing).
+	 */
+	public function test_handle_status_changed_cancels_on_exit_from_abandoned_set(): void {
+		$order = $this->schedule_for_pending_order();
+
+		$this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::PROCESSING );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared once the order leaves the abandoned set.' );
+		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+	}
+
+	/**
+	 * @testdox handle_status_changed() does nothing when the previous status was already outside `pending` — nothing to cancel.
+	 */
+	public function test_handle_status_changed_noop_when_old_status_already_outside_set(): void {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		// No prior schedule → just assert this path doesn't blow up and the
+		// meta stays empty.
+		$this->sut->handle_status_changed( $order->get_id(), OrderStatus::PROCESSING, OrderStatus::COMPLETED );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox handle_cancellation() unschedules and clears the meta for a trashed order so a deleted-then-restored order doesn't fire a stale send.
+	 */
+	public function test_handle_cancellation_clears_state(): void {
+		$order = $this->schedule_for_pending_order();
+
+		$this->sut->handle_cancellation( $order->get_id() );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+	}
+
+	/**
+	 * Create a pending order and run it through handle_new_order() so the
+	 * tests for the cancel/status-change paths start from a known scheduled
+	 * state.
+	 */
+	private function schedule_for_pending_order(): WC_Order {
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+
+		$this->sut->handle_new_order( $order->get_id() );
+
+		return wc_get_order( $order->get_id() );
+	}
+}