Commit 1685d8dee17 for woocommerce

commit 1685d8dee1706188d83cb3201ecf9a2ea62069e9
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Thu Aug 27 14:16:51 2026 +0200

    Remove experimental abandoned cart recovery feature (#67726)

    * Remove the abandoned cart recovery auto-send

    Reverts the automated side of the abandoned cart recovery email (#65238 and
    its follow-ups #66808 and #66921). The email stays, but it is now dispatched
    only when a merchant triggers it from the order edit page.

    - Delete `Internal\AbandonedCartRecovery\Scheduler` and its test, and stop
      resolving it from `WooCommerce::maybe_init_abandoned_cart_recovery()`.
      Nothing registers a callback for
      `woocommerce_send_abandoned_cart_recovery_notification` any more, so a
      queued action that fires before the cleanup migration runs is an inert
      no-op rather than a stray customer email.
    - Drop the now-dangling `automated` ("Send automatically") setting,
      `is_automated()` and `AUTO_SEND_DELAY_SECONDS`, so merchants aren't left
      with a toggle that has no scheduler behind it.
    - Add `wc_update_11102_cleanup_abandoned_cart_recovery_auto_send`, which
      cancels queued sends and deletes `_abandoned_cart_recovery_scheduled_at`
      order meta (both post meta and HPOS) left over from 11.0.x.

    The `META_KEY_SENT_AT` dedup gate and `trigger()`'s bool return are kept:
    they guard the manual path against double sends, and narrowing the public
    return type back to void would break external callers.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Y3NWoi8PUKQJWXLb8zgFUp

    * Remove the abandoned cart recovery feature

    Removes the experimental abandoned cart recovery feature in full, reverting
    #65136, #65140, #65164, #65165 and #65521 on top of the auto-send removal in
    the previous commit. The feature shipped in 11.0.x behind the default-off,
    experimental `abandoned_cart_recovery` flag.

    Deleted:
    - `WC_Email_Customer_Abandoned_Cart_Recovery` and its HTML, plain-text and
      block templates, plus its registration in `WC_Emails::init()`.
    - `Internal\AbandonedCartRecovery\ManualSendHandler`, which registered the
      order-edit "Send abandoned cart recovery email" action.
    - `Internal\Email\Unsubscribes\{Endpoint,Storage}`, the `wc-email-unsubscribe`
      endpoint and the `wc_email_unsubscribes` table. The table was introduced for
      this email and had no other consumer, so it is no longer installed, is
      dropped from `WC_Install::get_tables()` and is dropped on update.
    - The `abandoned_cart_recovery` feature flag, its post-enable admin notice,
      and the settings-page AutomateWoo/MailPoet recommendations panel (including
      its entry in the embedded body layout and its options allowlist entry).

    `wc_update_11102_remove_abandoned_cart_recovery` now cleans up everything an
    opted-in site could be left holding: queued Action Scheduler sends, both order
    meta keys, the feature/settings/recommendation options, the enable-notice
    transient, the email editor's `woo_email` post and its mapping option, and the
    unsubscribes table. Dropping that table also avoids retaining hashed recipient
    addresses after the GDPR eraser that covered them is gone.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Y3NWoi8PUKQJWXLb8zgFUp

    * Apply suggestion from @senadir

    * Address review feedback on abandoned cart recovery removal

    - Retarget the cleanup migration at 11.2.0 (key, callback name and @since),
      since 11.1.0 is already at beta.
    - Drop the order meta cleanup. Scanning every order to delete
      _abandoned_cart_recovery_* is expensive on large stores and the stale keys
      are inert once nothing reads them.
    - Drop the email template post cleanup. The recovery email was never in
      WCTransactionalEmails::$core_transactional_emails, so no woo_email post
      could have been created for it.
    - Keep wc_email_unsubscribes in WC_Install::get_tables() so uninstall and
      multisite site deletion still drop it if the migration has not run yet.
      get_tables() is a drop list only, so this does not recreate the table.
    - Delete UnsubscribeTagsProvider and unwire it from PersonalizationTagManager.
      It existed solely to expose an unsubscribe URL for the recovery email.
    - Remove the abandoned cart paths from the community PR assigner.

    ---------

    Co-authored-by: Claude <noreply@anthropic.com>

diff --git a/.github/project-community-pr-assigner.yml b/.github/project-community-pr-assigner.yml
index c38d65d68b8..8b43784e468 100644
--- a/.github/project-community-pr-assigner.yml
+++ b/.github/project-community-pr-assigner.yml
@@ -172,13 +172,13 @@
 'packages/php/email-editor/**/*':
     - team: ballade

-'plugins/woocommerce/client/admin/client/{abandoned-cart-recovery,marketing,settings-email}/**/*':
+'plugins/woocommerce/client/admin/client/{marketing,settings-email}/**/*':
     - team: ballade

 'plugins/woocommerce/src/Admin/Marketing/**/*':
     - team: ballade

-'plugins/woocommerce/src/Internal/{AbandonedCartRecovery,Email,EmailEditor}/**/*':
+'plugins/woocommerce/src/Internal/{Email,EmailEditor}/**/*':
     - team: ballade

 'plugins/woocommerce/src/Internal/Admin/{EmailImprovements,EmailPreview,Emails,Marketing}/**/*':
@@ -196,7 +196,7 @@
 'plugins/woocommerce/tests/**/*{coupon,email,mailer,marketing}*':
     - team: ballade

-'plugins/woocommerce/tests/php/src/Internal/{AbandonedCartRecovery,Email,EmailEditor}/**/*':
+'plugins/woocommerce/tests/php/src/Internal/{Email,EmailEditor}/**/*':
     - team: ballade

 'plugins/woocommerce/tests/php/src/Internal/Admin/{EmailImprovements,EmailPreview,Emails,Marketing}/**/*':
diff --git a/plugins/woocommerce/changelog/remove-abandoned-cart-recovery-feature b/plugins/woocommerce/changelog/remove-abandoned-cart-recovery-feature
new file mode 100644
index 00000000000..a18ea31141b
--- /dev/null
+++ b/plugins/woocommerce/changelog/remove-abandoned-cart-recovery-feature
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Remove the experimental abandoned cart recovery feature.
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations-wrapper.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations-wrapper.tsx
deleted file mode 100644
index 1cfa1f50160..00000000000
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations-wrapper.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * External dependencies
- */
-import { lazy, Suspense } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import { EmbeddedBodyProps } from '../embedded-body-layout/embedded-body-props';
-import RecommendationsEligibilityWrapper from '../settings-recommendations/recommendations-eligibility-wrapper';
-
-const AbandonedCartRecoveryRecommendationsLoader = lazy(
-	() =>
-		import(
-			/* webpackChunkName: "abandoned-cart-recovery-recommendations" */ './abandoned-cart-recovery-recommendations'
-		)
-);
-
-const ABANDONED_CART_RECOVERY_EMAIL_SECTION =
-	'wc_email_customer_abandoned_cart_recovery';
-
-export const AbandonedCartRecoveryRecommendations = ( {
-	page,
-	tab,
-	section,
-}: EmbeddedBodyProps ) => {
-	if ( page !== 'wc-settings' ) {
-		return null;
-	}
-
-	if ( tab !== 'email' ) {
-		return null;
-	}
-
-	if ( section !== ABANDONED_CART_RECOVERY_EMAIL_SECTION ) {
-		return null;
-	}
-
-	return (
-		<RecommendationsEligibilityWrapper>
-			<Suspense fallback={ null }>
-				<AbandonedCartRecoveryRecommendationsLoader />
-			</Suspense>
-		</RecommendationsEligibilityWrapper>
-	);
-};
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.scss b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.scss
deleted file mode 100644
index 2e4acf5d31b..00000000000
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.scss
+++ /dev/null
@@ -1,57 +0,0 @@
-.woocommerce-recommended-abandoned-cart-recovery-extensions {
-	overflow: hidden;
-
-	.woocommerce-list__item {
-		> .woocommerce-list__item-inner {
-			align-items: center;
-			padding: $gap $gap-large;
-		}
-
-		&:hover {
-			background-color: $white;
-
-			.woocommerce-list__item-title {
-				color: $gray-900;
-			}
-		}
-	}
-
-	.woocommerce-list__item-title {
-		font-size: 14px;
-		color: $gray-900;
-		font-weight: 600;
-	}
-
-	.woocommerce-pill {
-		margin-left: $gap-smallest;
-		margin-top: $gap-smallest;
-		margin-bottom: $gap-smallest;
-		padding: 2px 8px;
-
-		@media (min-width: #{ ($break-mobile) }) {
-			margin-top: 0;
-			margin-bottom: 0;
-		}
-	}
-
-	.woocommerce-list__item-after {
-		align-self: center;
-
-		.components-button {
-			margin-left: $gap-small;
-		}
-	}
-
-	.woocommerce-recommended-abandoned-cart-recovery__header-heading {
-		color: $gray-700;
-	}
-}
-
-// Scope the embedded-layout padding override to pages that actually render
-// this recommendation — without `:has()`, the selector would target every
-// wc-settings page's primary container as soon as this chunk loaded.
-body.woocommerce_page_wc-settings .woocommerce-embedded-layout__primary:has(
-.woocommerce-recommended-abandoned-cart-recovery-extensions
-) {
-	padding: 0 20px 80px;
-}
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx
deleted file mode 100644
index 38c9197c900..00000000000
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-/**
- * External dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { useSelect, useDispatch } from '@wordpress/data';
-import { Children, useState } from '@wordpress/element';
-import { CardFooter } from '@wordpress/components';
-import { Text } from '@woocommerce/experimental';
-import { pluginsStore, PluginNames } from '@woocommerce/data';
-import { getAdminLink } from '@woocommerce/settings';
-
-/**
- * Internal dependencies
- */
-import {
-	DismissableList,
-	DismissableListHeading,
-} from '../settings-recommendations/dismissable-list';
-import { TrackedLink } from '~/components/tracked-link/tracked-link';
-import { useOptionDismiss } from '~/hooks/use-option-dismiss';
-import { createNoticesFromResponse } from '../lib/notices';
-import AutomateWooItem from './automatewoo-item';
-import MailPoetItem from './mailpoet-item';
-import './abandoned-cart-recovery-recommendations.scss';
-
-/**
- * Install-and-activate hook used by the recommendation card.
- *
- * Tracks which plugin slugs are currently being set up so item buttons can show
- * a busy state and be disabled while any install is in flight. Mirrors the
- * shipping recommendation utilities, with a narrower surface — we only need
- * the combined install+activate path here.
- */
-export const useInstallPlugin = () => {
-	const [ pluginsBeingSetup, setPluginsBeingSetup ] = useState<
-		Array< string >
-	>( [] );
-
-	const { installAndActivatePlugins } = useDispatch( pluginsStore );
-
-	const handleSetup = ( slugs: string[] ): Promise< void > => {
-		if ( pluginsBeingSetup.length > 0 ) {
-			return Promise.resolve();
-		}
-
-		setPluginsBeingSetup( slugs );
-
-		return installAndActivatePlugins( slugs as Partial< PluginNames >[] )
-			.then( () => {
-				setPluginsBeingSetup( [] );
-			} )
-			.catch( ( response: { errors: Record< string, string > } ) => {
-				createNoticesFromResponse( response );
-				setPluginsBeingSetup( [] );
-
-				return Promise.reject();
-			} );
-	};
-
-	return [ pluginsBeingSetup, handleSetup ] as const;
-};
-
-export const AbandonedCartRecoveryRecommendationsList = ( {
-	children,
-}: {
-	children: React.ReactNode;
-} ) => {
-	const { isDismissed, onDismiss } = useOptionDismiss(
-		'woocommerce_abandoned_cart_recovery_recommendations_hidden'
-	);
-
-	return (
-		<DismissableList
-			className="woocommerce-recommended-abandoned-cart-recovery-extensions"
-			isDismissed={ isDismissed }
-		>
-			<DismissableListHeading onDismiss={ onDismiss }>
-				<Text variant="title.small" as="p" size="20" lineHeight="28px">
-					{ __( 'Recover more abandoned carts', 'woocommerce' ) }
-				</Text>
-				<Text
-					className="woocommerce-recommended-abandoned-cart-recovery__header-heading"
-					variant="caption"
-					as="p"
-					size="12"
-					lineHeight="16px"
-				>
-					{ __(
-						'Add multi-step recovery flows, customer segmentation, and ongoing email marketing to win back more shoppers.',
-						'woocommerce'
-					) }
-				</Text>
-			</DismissableListHeading>
-			<ul className="woocommerce-list">
-				{ Children.map( children, ( item ) => (
-					<li className="woocommerce-list__item">{ item }</li>
-				) ) }
-			</ul>
-			<CardFooter>
-				<TrackedLink
-					message={ __(
-						// translators: {{Link}} is a placeholder for a html element.
-						'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more email marketing and customer engagement solutions.',
-						'woocommerce'
-					) }
-					targetUrl={ getAdminLink(
-						'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=marketing'
-					) }
-					linkType="wc-admin"
-					eventName="abandoned_cart_recovery_visit_marketplace_click"
-				/>
-			</CardFooter>
-		</DismissableList>
-	);
-};
-
-const AbandonedCartRecoveryRecommendations = () => {
-	const activePlugins = useSelect(
-		( select ) => select( pluginsStore ).getActivePlugins() ?? [],
-		[]
-	);
-
-	const [ pluginsBeingSetup, setupPlugin ] = useInstallPlugin();
-
-	const hasAutomateWoo = activePlugins.includes( 'automatewoo' );
-	const hasMailPoet = activePlugins.includes( 'mailpoet' );
-
-	// Both already installed — nothing to recommend.
-	if ( hasAutomateWoo && hasMailPoet ) {
-		return null;
-	}
-
-	return (
-		<AbandonedCartRecoveryRecommendationsList>
-			{ ! hasMailPoet && (
-				<MailPoetItem
-					pluginsBeingSetup={ pluginsBeingSetup }
-					onSetupClick={ setupPlugin }
-				/>
-			) }
-			{ ! hasAutomateWoo && <AutomateWooItem /> }
-		</AbandonedCartRecoveryRecommendationsList>
-	);
-};
-
-export default AbandonedCartRecoveryRecommendations;
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/automatewoo-item.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/automatewoo-item.tsx
deleted file mode 100644
index d6b3473f573..00000000000
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/automatewoo-item.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * External dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { Button } from '@wordpress/components';
-import { recordEvent } from '@woocommerce/tracks';
-
-/**
- * Internal dependencies
- */
-import { ProductIcon } from '~/marketing/components';
-
-const AUTOMATEWOO_URL =
-	'https://woocommerce.com/products/automatewoo/?utm_source=woocommerce&utm_medium=product&utm_campaign=abandoned-cart-recovery-recommendation';
-
-const AutomateWooItem = () => {
-	const handleClick = () => {
-		recordEvent( 'abandoned_cart_recovery_recommendation_click', {
-			plugin: 'automatewoo',
-		} );
-	};
-
-	return (
-		<div className="woocommerce-list__item-inner woocommerce-abandoned-cart-recovery-recommendation-item">
-			<div className="woocommerce-list__item-before">
-				<ProductIcon product="automatewoo" />
-			</div>
-			<div className="woocommerce-list__item-text">
-				<span className="woocommerce-list__item-title">
-					{ __( 'AutomateWoo', 'woocommerce' ) }
-				</span>
-				<span className="woocommerce-list__item-content">
-					{ __(
-						'Set up multi-step abandoned cart sequences, win-back flows, and review requests. Track exactly which campaigns earn the most revenue.',
-						'woocommerce'
-					) }
-				</span>
-			</div>
-			<div className="woocommerce-list__item-after">
-				<Button
-					variant="secondary"
-					href={ AUTOMATEWOO_URL }
-					target="_blank"
-					rel="noopener noreferrer"
-					onClick={ handleClick }
-				>
-					{ __( 'Learn more', 'woocommerce' ) }
-				</Button>
-			</div>
-		</div>
-	);
-};
-
-export default AutomateWooItem;
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/index.ts b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/index.ts
deleted file mode 100644
index ddf7caa22eb..00000000000
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './abandoned-cart-recovery-recommendations-wrapper';
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/mailpoet-item.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/mailpoet-item.tsx
deleted file mode 100644
index 17224a8956a..00000000000
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/mailpoet-item.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * External dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { Button } from '@wordpress/components';
-import { Pill } from '@woocommerce/components';
-import { recordEvent } from '@woocommerce/tracks';
-import { useDispatch } from '@wordpress/data';
-import { getAdminLink } from '@woocommerce/settings';
-
-/**
- * Internal dependencies
- */
-import { ProductIcon } from '~/marketing/components';
-
-const MAILPOET_SLUG = 'mailpoet';
-
-type MailPoetItemProps = {
-	pluginsBeingSetup: ReadonlyArray< string >;
-	onSetupClick: ( slugs: string[] ) => Promise< void >;
-};
-
-const MailPoetItem = ( {
-	pluginsBeingSetup,
-	onSetupClick,
-}: MailPoetItemProps ) => {
-	const { createSuccessNotice } = useDispatch( 'core/notices' );
-
-	const handleSetupClick = () => {
-		recordEvent( 'abandoned_cart_recovery_recommendation_click', {
-			plugin: MAILPOET_SLUG,
-		} );
-
-		onSetupClick( [ MAILPOET_SLUG ] )
-			.then( () => {
-				createSuccessNotice(
-					__( '🎉 MailPoet is installed!', 'woocommerce' ),
-					{
-						actions: [
-							{
-								url: getAdminLink(
-									'admin.php?page=mailpoet-newsletters'
-								),
-								label: __( 'Set up MailPoet', 'woocommerce' ),
-							},
-						],
-					}
-				);
-			} )
-			.catch( () => {
-				// Error notice handled by createNoticesFromResponse in the install hook.
-			} );
-	};
-
-	return (
-		<div className="woocommerce-list__item-inner woocommerce-abandoned-cart-recovery-recommendation-item">
-			<div className="woocommerce-list__item-before">
-				<ProductIcon product="mailpoet" />
-			</div>
-			<div className="woocommerce-list__item-text">
-				<span className="woocommerce-list__item-title">
-					{ __( 'MailPoet', 'woocommerce' ) }
-					<Pill>{ __( 'Recommended', 'woocommerce' ) }</Pill>
-				</span>
-				<span className="woocommerce-list__item-content">
-					{ __(
-						'Send newsletters and automated welcome series from your WooCommerce dashboard. Free and installs in one click.',
-						'woocommerce'
-					) }
-				</span>
-			</div>
-			<div className="woocommerce-list__item-after">
-				<Button
-					variant="secondary"
-					onClick={ handleSetupClick }
-					isBusy={ pluginsBeingSetup.includes( MAILPOET_SLUG ) }
-					disabled={ pluginsBeingSetup.length > 0 }
-				>
-					{ __( 'Get started', 'woocommerce' ) }
-				</Button>
-			</div>
-		</div>
-	);
-};
-
-export default MailPoetItem;
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations-wrapper.test.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations-wrapper.test.tsx
deleted file mode 100644
index 7f38d5b1365..00000000000
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations-wrapper.test.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * External dependencies
- */
-import { render } from '@testing-library/react';
-import { useSelect } from '@wordpress/data';
-import { useUser } from '@woocommerce/data';
-
-/**
- * Internal dependencies
- */
-import { AbandonedCartRecoveryRecommendations } from '../abandoned-cart-recovery-recommendations-wrapper';
-
-jest.mock( '@wordpress/data', () => ( {
-	...jest.requireActual( '@wordpress/data' ),
-	useSelect: jest.fn(),
-	useDispatch: jest.fn(),
-} ) );
-
-jest.mock( '@woocommerce/data', () => ( {
-	useUser: jest.fn(),
-} ) );
-
-jest.mock( '@wordpress/element', () => ( {
-	...jest.requireActual( '@wordpress/element' ),
-	Suspense: () => <div>Abandoned cart recovery recommendations</div>,
-} ) );
-
-const eligibleSelectReturn = {
-	getOption: () => 'yes',
-	hasStartedResolution: () => true,
-	hasFinishedResolution: () => true,
-};
-
-describe( 'AbandonedCartRecoveryRecommendations wrapper', () => {
-	beforeEach( () => {
-		( useSelect as jest.Mock ).mockImplementation( ( fn ) =>
-			fn( () => eligibleSelectReturn )
-		);
-		( useUser as jest.Mock ).mockReturnValue( {
-			currentUserCan: () => true,
-		} );
-	} );
-
-	it( 'should not render outside wc-settings', () => {
-		const { queryByText } = render(
-			<AbandonedCartRecoveryRecommendations
-				page="wc-admin"
-				tab="email"
-				section="wc_email_customer_abandoned_cart_recovery"
-			/>
-		);
-
-		expect(
-			queryByText( 'Abandoned cart recovery recommendations' )
-		).not.toBeInTheDocument();
-	} );
-
-	it( 'should not render on a non-email settings tab', () => {
-		const { queryByText } = render(
-			<AbandonedCartRecoveryRecommendations
-				page="wc-settings"
-				tab="shipping"
-				section="wc_email_customer_abandoned_cart_recovery"
-			/>
-		);
-
-		expect(
-			queryByText( 'Abandoned cart recovery recommendations' )
-		).not.toBeInTheDocument();
-	} );
-
-	it( 'should not render on the email list page (no section)', () => {
-		const { queryByText } = render(
-			<AbandonedCartRecoveryRecommendations
-				page="wc-settings"
-				tab="email"
-				section={ undefined }
-			/>
-		);
-
-		expect(
-			queryByText( 'Abandoned cart recovery recommendations' )
-		).not.toBeInTheDocument();
-	} );
-
-	it( 'should not render on a different email section', () => {
-		const { queryByText } = render(
-			<AbandonedCartRecoveryRecommendations
-				page="wc-settings"
-				tab="email"
-				section="wc_email_customer_completed_order"
-			/>
-		);
-
-		expect(
-			queryByText( 'Abandoned cart recovery recommendations' )
-		).not.toBeInTheDocument();
-	} );
-
-	it( 'should not render when marketplace suggestions are disabled', () => {
-		( useSelect as jest.Mock ).mockImplementation( ( fn ) =>
-			fn( () => ( {
-				...eligibleSelectReturn,
-				getOption: () => 'no',
-			} ) )
-		);
-
-		const { queryByText } = render(
-			<AbandonedCartRecoveryRecommendations
-				page="wc-settings"
-				tab="email"
-				section="wc_email_customer_abandoned_cart_recovery"
-			/>
-		);
-
-		expect(
-			queryByText( 'Abandoned cart recovery recommendations' )
-		).not.toBeInTheDocument();
-	} );
-
-	it( 'should not render when the user lacks install_plugins capability', () => {
-		( useUser as jest.Mock ).mockReturnValue( {
-			currentUserCan: () => false,
-		} );
-
-		const { queryByText } = render(
-			<AbandonedCartRecoveryRecommendations
-				page="wc-settings"
-				tab="email"
-				section="wc_email_customer_abandoned_cart_recovery"
-			/>
-		);
-
-		expect(
-			queryByText( 'Abandoned cart recovery recommendations' )
-		).not.toBeInTheDocument();
-	} );
-
-	it( 'should render on the abandoned-cart-recovery email section page when eligible', () => {
-		const { getByText } = render(
-			<AbandonedCartRecoveryRecommendations
-				page="wc-settings"
-				tab="email"
-				section="wc_email_customer_abandoned_cart_recovery"
-			/>
-		);
-
-		expect(
-			getByText( 'Abandoned cart recovery recommendations' )
-		).toBeInTheDocument();
-	} );
-} );
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx
deleted file mode 100644
index 57d55aa3a51..00000000000
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * External dependencies
- */
-import { render, screen } from '@testing-library/react';
-import { useSelect, useDispatch } from '@wordpress/data';
-
-/**
- * Internal dependencies
- */
-import AbandonedCartRecoveryRecommendations from '../abandoned-cart-recovery-recommendations';
-
-jest.mock( '@wordpress/data', () => ( {
-	...jest.requireActual( '@wordpress/data' ),
-	useSelect: jest.fn(),
-	useDispatch: jest.fn(),
-} ) );
-
-jest.mock( '../../settings-recommendations/dismissable-list', () => ( {
-	DismissableList: ( { children }: { children: React.ReactNode } ) =>
-		children,
-	DismissableListHeading: ( { children }: { children: React.ReactNode } ) =>
-		children,
-} ) );
-
-jest.mock( '~/hooks/use-option-dismiss', () => ( {
-	useOptionDismiss: () => ( { isDismissed: false, onDismiss: jest.fn() } ),
-} ) );
-
-const mockActivePlugins = ( plugins: string[] ) => {
-	( useSelect as jest.Mock ).mockImplementation( ( fn ) =>
-		fn( () => ( {
-			getActivePlugins: () => plugins,
-		} ) )
-	);
-};
-
-describe( 'AbandonedCartRecoveryRecommendations', () => {
-	beforeEach( () => {
-		// The recommendations card calls useDispatch( pluginsStore ) for the
-		// install hook and useDispatch( 'core/notices' ) inside MailPoetItem
-		// for the success notice. A single stub covers both since neither test
-		// exercises the install flow itself.
-		( useDispatch as jest.Mock ).mockReturnValue( {
-			installAndActivatePlugins: jest.fn().mockResolvedValue( undefined ),
-			createSuccessNotice: jest.fn(),
-		} );
-	} );
-
-	it( 'renders both items when neither plugin is active', () => {
-		mockActivePlugins( [] );
-
-		render( <AbandonedCartRecoveryRecommendations /> );
-
-		expect( screen.queryByText( 'AutomateWoo' ) ).toBeInTheDocument();
-		expect( screen.queryByText( 'MailPoet' ) ).toBeInTheDocument();
-	} );
-
-	it( 'hides the AutomateWoo item when AutomateWoo is active', () => {
-		mockActivePlugins( [ 'automatewoo' ] );
-
-		render( <AbandonedCartRecoveryRecommendations /> );
-
-		expect( screen.queryByText( 'AutomateWoo' ) ).not.toBeInTheDocument();
-		expect( screen.queryByText( 'MailPoet' ) ).toBeInTheDocument();
-	} );
-
-	it( 'hides the MailPoet item when MailPoet is active', () => {
-		mockActivePlugins( [ 'mailpoet' ] );
-
-		render( <AbandonedCartRecoveryRecommendations /> );
-
-		expect( screen.queryByText( 'MailPoet' ) ).not.toBeInTheDocument();
-		expect( screen.queryByText( 'AutomateWoo' ) ).toBeInTheDocument();
-	} );
-
-	it( 'returns null when both plugins are already active', () => {
-		mockActivePlugins( [ 'automatewoo', 'mailpoet' ] );
-
-		const { container } = render(
-			<AbandonedCartRecoveryRecommendations />
-		);
-
-		expect( container ).toBeEmptyDOMElement();
-	} );
-} );
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/promo-items.test.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/promo-items.test.tsx
deleted file mode 100644
index bbfdaa83dd7..00000000000
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/promo-items.test.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-/**
- * External dependencies
- */
-import { render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { recordEvent } from '@woocommerce/tracks';
-import { useDispatch } from '@wordpress/data';
-
-/**
- * Internal dependencies
- */
-import AutomateWooItem from '../automatewoo-item';
-import MailPoetItem from '../mailpoet-item';
-
-jest.mock( '@woocommerce/tracks', () => ( {
-	recordEvent: jest.fn(),
-} ) );
-
-jest.mock( '@wordpress/data', () => ( {
-	...jest.requireActual( '@wordpress/data' ),
-	useDispatch: jest.fn(),
-} ) );
-
-describe( 'AutomateWooItem', () => {
-	beforeEach( () => {
-		( recordEvent as jest.Mock ).mockClear();
-	} );
-
-	it( 'renders the AutomateWoo title, description, and Learn more CTA', () => {
-		render( <AutomateWooItem /> );
-
-		expect( screen.getByText( 'AutomateWoo' ) ).toBeInTheDocument();
-		expect(
-			screen.getByText( /multi-step abandoned cart sequences/i )
-		).toBeInTheDocument();
-		expect(
-			screen.getByRole( 'link', { name: /learn more/i } )
-		).toHaveAttribute(
-			'href',
-			expect.stringContaining( 'woocommerce.com/products/automatewoo' )
-		);
-	} );
-
-	it( 'fires the abandoned_cart_recovery_recommendation_click track on CTA click', async () => {
-		render( <AutomateWooItem /> );
-
-		await userEvent.click(
-			screen.getByRole( 'link', { name: /learn more/i } )
-		);
-
-		expect( recordEvent ).toHaveBeenCalledWith(
-			'abandoned_cart_recovery_recommendation_click',
-			{ plugin: 'automatewoo' }
-		);
-	} );
-} );
-
-describe( 'MailPoetItem', () => {
-	const createSuccessNoticeMock = jest.fn();
-
-	beforeEach( () => {
-		( recordEvent as jest.Mock ).mockClear();
-		createSuccessNoticeMock.mockClear();
-		( useDispatch as jest.Mock ).mockReturnValue( {
-			createSuccessNotice: createSuccessNoticeMock,
-		} );
-	} );
-
-	it( 'renders the MailPoet title, description, and Get started CTA', () => {
-		render(
-			<MailPoetItem
-				pluginsBeingSetup={ [] }
-				onSetupClick={ () => Promise.resolve() }
-			/>
-		);
-
-		expect( screen.getByText( 'MailPoet' ) ).toBeInTheDocument();
-		expect(
-			screen.getByText( /newsletters and automated welcome series/i )
-		).toBeInTheDocument();
-		expect(
-			screen.getByRole( 'button', { name: /get started/i } )
-		).toBeInTheDocument();
-	} );
-
-	it( 'calls onSetupClick with the mailpoet slug and fires the Tracks event', async () => {
-		const onSetupClick = jest.fn().mockResolvedValue( undefined );
-
-		render(
-			<MailPoetItem
-				pluginsBeingSetup={ [] }
-				onSetupClick={ onSetupClick }
-			/>
-		);
-
-		await userEvent.click(
-			screen.getByRole( 'button', { name: /get started/i } )
-		);
-
-		expect( recordEvent ).toHaveBeenCalledWith(
-			'abandoned_cart_recovery_recommendation_click',
-			{ plugin: 'mailpoet' }
-		);
-		expect( onSetupClick ).toHaveBeenCalledWith( [ 'mailpoet' ] );
-	} );
-
-	it( 'creates a success notice with a Set up MailPoet action when install completes', async () => {
-		const onSetupClick = jest.fn().mockResolvedValue( undefined );
-
-		render(
-			<MailPoetItem
-				pluginsBeingSetup={ [] }
-				onSetupClick={ onSetupClick }
-			/>
-		);
-
-		await userEvent.click(
-			screen.getByRole( 'button', { name: /get started/i } )
-		);
-
-		await waitFor( () => {
-			expect( createSuccessNoticeMock ).toHaveBeenCalledWith(
-				'🎉 MailPoet is installed!',
-				expect.objectContaining( {
-					actions: expect.arrayContaining( [
-						expect.objectContaining( {
-							label: 'Set up MailPoet',
-						} ),
-					] ),
-				} )
-			);
-		} );
-	} );
-
-	it( 'shows the busy state when mailpoet is in flight and disables the button', () => {
-		render(
-			<MailPoetItem
-				pluginsBeingSetup={ [ 'mailpoet' ] }
-				onSetupClick={ () => Promise.resolve() }
-			/>
-		);
-
-		const button = screen.getByRole( 'button', { name: /get started/i } );
-		expect( button ).toBeDisabled();
-	} );
-} );
diff --git a/plugins/woocommerce/client/admin/client/embedded-body-layout/embedded-body-layout.tsx b/plugins/woocommerce/client/admin/client/embedded-body-layout/embedded-body-layout.tsx
index 7be6d6b5905..2ddf398f737 100644
--- a/plugins/woocommerce/client/admin/client/embedded-body-layout/embedded-body-layout.tsx
+++ b/plugins/woocommerce/client/admin/client/embedded-body-layout/embedded-body-layout.tsx
@@ -16,7 +16,6 @@ import QueryString, { parse } from 'qs';
 import { PaymentRecommendations } from '../payments';
 import { ShippingRecommendations } from '../shipping';
 import { TaxRecommendations } from '../tax';
-import { AbandonedCartRecoveryRecommendations } from '../abandoned-cart-recovery';
 import { EmbeddedBodyProps } from './embedded-body-props';
 import './style.scss';

@@ -32,7 +31,6 @@ const EMBEDDED_BODY_COMPONENT_LIST: React.ElementType[] = [
 	PaymentRecommendations,
 	ShippingRecommendations,
 	TaxRecommendations,
-	AbandonedCartRecoveryRecommendations,
 ];

 /**
diff --git a/plugins/woocommerce/includes/class-wc-emails.php b/plugins/woocommerce/includes/class-wc-emails.php
index 67b8f263c01..88466c05046 100644
--- a/plugins/woocommerce/includes/class-wc-emails.php
+++ b/plugins/woocommerce/includes/class-wc-emails.php
@@ -309,9 +309,6 @@ class WC_Emails {
 		if ( FeaturesUtil::feature_is_enabled( 'customer_review_request' ) ) {
 			$emails['WC_Email_Customer_Review_Request'] = __DIR__ . '/emails/class-wc-email-customer-review-request.php';
 		}
-		if ( FeaturesUtil::feature_is_enabled( 'abandoned_cart_recovery' ) ) {
-			$emails['WC_Email_Customer_Abandoned_Cart_Recovery'] = __DIR__ . '/emails/class-wc-email-customer-abandoned-cart-recovery.php';
-		}

 		// Prime caches to reduce future queries.
 		wp_prime_option_caches(
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 2d9979b09f0..a7c47fbd0a5 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -351,6 +351,7 @@ class WC_Install {
 			'wc_update_11101_remove_deprecated_variation_gallery_option',
 		),
 		'11.2.0'   => array(
+			'wc_update_1120_remove_abandoned_cart_recovery',
 			'wc_update_1120_migrate_stock_notifications_alpha_constant',
 		),
 	);
@@ -1793,9 +1794,7 @@ class WC_Install {
 		// Stock Notifications Table Schema.
 		$stock_notifications_table_schema = wc_get_container()->get( StockNotificationsDataStore::class )->get_database_schema();

-		// Email Unsubscribes table — generic across email types; each row pairs an email hash with an email-kind identifier.
-		$email_unsubscribes_table_schema = wc_get_container()->get( \Automattic\WooCommerce\Internal\Email\Unsubscribes\Storage::class )->get_database_schema();
-		$order_stats_table_schema        = self::get_order_stats_table_schema( $collate );
+		$order_stats_table_schema = self::get_order_stats_table_schema( $collate );

 		$mysql_version = wc_get_server_database_version()['number'];
 		if ( version_compare( $mysql_version, '5.6', '>=' ) ) {
@@ -2141,7 +2140,6 @@ CREATE TABLE {$wpdb->prefix}wc_category_lookup (
 ) $collate;
 $hpos_table_schema;
 $stock_notifications_table_schema;
-$email_unsubscribes_table_schema;
 		";

 		return $tables;
@@ -2181,6 +2179,12 @@ $email_unsubscribes_table_schema;
 			"{$wpdb->prefix}wc_product_attributes_lookup",
 			"{$wpdb->prefix}wc_stock_notifications",
 			"{$wpdb->prefix}wc_stock_notificationmeta",
+
+			/*
+			 * No longer created: the abandoned cart recovery feature that owned this table was
+			 * removed in 11.2.0. It stays listed here so that a site which uninstalls before the
+			 * removal migration has run is not left with an orphaned table.
+			 */
 			"{$wpdb->prefix}wc_email_unsubscribes",

 			// WCA Tables.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 5ef90427e34..0c1ed0361c2 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -339,8 +339,6 @@ final class WooCommerce {
 		add_action( 'load-post-new.php', array( $this, 'includes' ) );
 		add_action( 'init', array( $this, 'init' ), 0 );
 		add_action( 'init', array( $this, 'maybe_init_order_reviews' ), 1 );
-		add_action( 'init', array( $this, 'maybe_init_abandoned_cart_recovery' ), 1 );
-		add_action( 'init', array( $this, 'init_email_unsubscribes' ), 1 );
 		add_action( 'init', array( 'WC_Shortcodes', 'init' ) );
 		add_action( 'init', array( 'WC_Emails', 'init_transactional_emails' ) );
 		add_action( 'init', array( $this, 'add_image_sizes' ) );
@@ -1043,44 +1041,6 @@ final class WooCommerce {
 		$container->get( \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::class );
 	}

-	/**
-	 * Resolve the AbandonedCartRecovery services when the `abandoned_cart_recovery`
-	 * feature flag is on. Hooked to `init` priority 1 from `init_hooks()`
-	 * so the order-edit action listener is registered before
-	 * `WC_Meta_Box_Order_Actions::save()` dispatches its hook on POST.
-	 *
-	 * @since 11.0.0
-	 * @internal
-	 */
-	public function maybe_init_abandoned_cart_recovery(): void {
-		if ( ! \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'abandoned_cart_recovery' ) ) {
-			return;
-		}
-		wc_get_container()->get( \Automattic\WooCommerce\Internal\AbandonedCartRecovery\ManualSendHandler::class );
-		wc_get_container()->get( \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler::class );
-	}
-
-	/**
-	 * Resolve the generic email-unsubscribe services unconditionally.
-	 *
-	 * The `wc_email_unsubscribes` table can contain rows for any email kind
-	 * — current and future — and is installed via `WC_Install::get_schema()`
-	 * regardless of any feature flag. Registering the storage's privacy eraser
-	 * and the public unsubscribe endpoint from a feature-gated init point
-	 * would mean a site that later turns off `abandoned_cart_recovery` would
-	 * lose the GDPR eraser coverage and the existing unsubscribe links would
-	 * stop working. Both consequences are wrong, so this method runs even
-	 * when no specific email kind that uses it is currently active.
-	 *
-	 * @since 11.0.0
-	 * @internal
-	 */
-	public function init_email_unsubscribes(): void {
-		$container = wc_get_container();
-		$container->get( \Automattic\WooCommerce\Internal\Email\Unsubscribes\Storage::class );
-		$container->get( \Automattic\WooCommerce\Internal\Email\Unsubscribes\Endpoint::class );
-	}
-
 	/**
 	 * Load Localisation files.
 	 *
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
deleted file mode 100644
index 9114faaea7d..00000000000
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
+++ /dev/null
@@ -1,671 +0,0 @@
-<?php
-/**
- * Class WC_Email_Customer_Abandoned_Cart_Recovery file.
- *
- * @package WooCommerce\Emails
- */
-
-use Automattic\WooCommerce\Enums\OrderStatus;
-use Automattic\WooCommerce\Internal\AbandonedCartRecovery\ManualSendHandler;
-use Automattic\WooCommerce\Internal\Email\Unsubscribes\Endpoint as UnsubscribesEndpoint;
-use Automattic\WooCommerce\Internal\Email\Unsubscribes\Storage as UnsubscribesStorage;
-use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
-
-defined( 'ABSPATH' ) || exit;
-
-if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
-
-	/**
-	 * Customer Abandoned Cart Recovery email.
-	 *
-	 * A transactional email that prompts the customer to complete a checkout they
-	 * left pending. The send is scheduled via Action Scheduler two hours after
-	 * the pending order is created, gated on the merchant's `automated` setting.
-	 * Merchants can also trigger the email manually from the order edit page.
-	 *
-	 * @class    WC_Email_Customer_Abandoned_Cart_Recovery
-	 * @version  11.0.0
-	 * @package  WooCommerce\Classes\Emails
-	 */
-	class WC_Email_Customer_Abandoned_Cart_Recovery extends WC_Email {
-
-		/**
-		 * Email identifier — kept in `$this->id` for the rest of WC_Email's
-		 * machinery but also exposed as a constant so static methods (and
-		 * external callers using the unsubscribe storage) can reference the
-		 * same string without it drifting out of sync with the constructor.
-		 */
-		public const EMAIL_ID = 'customer_abandoned_cart_recovery';
-
-		/**
-		 * Plugins known to provide their own abandoned cart recovery flow.
-		 *
-		 * Detection is install-only.
-		 */
-		public const KNOWN_RECOVERY_HANDLERS = array(
-			'automatewoo/automatewoo.php' => 'AutomateWoo',
-			'mailpoet/mailpoet.php'       => 'MailPoet',
-		);
-
-		/**
-		 * Order meta key recording the timestamp of the most recent send.
-		 *
-		 * Written by `trigger()` after a successful dispatch (manual or automated) of the abandoned cart recovery email.
-		 */
-		public const META_KEY_SENT_AT = '_abandoned_cart_recovery_email_sent_at';
-
-		/**
-		 * Order action id used by the recovery email send item on the order edit page.
-		 *
-		 * Re-exports `ManualSendHandler::MANUAL_SEND_ACTION` so external callers
-		 * (and tests) can reference it from the email class. Source of truth lives
-		 * on the dispatcher because its hook registration runs before this email
-		 * class file is included.
-		 */
-		public const MANUAL_RECOVERY_EMAIL_SEND_ACTION = ManualSendHandler::MANUAL_SEND_ACTION;
-
-		/**
-		 * Order statuses that represent an abandoned checkout for the purposes of
-		 * the manual-send action and the trigger-level status gate.
-		 *
-		 * - `pending`        — classic checkout reached "place order" but no payment yet.
-		 * - `checkout-draft` — block checkout (Store API) parks the order here while
-		 *                     the customer is mid-flow. May have no billing email yet,
-		 *                     in which case `trigger()` no-ops.
-		 *
-		 * Covers block checkout as well as classic. The automated scheduler is
-		 * scoped to `pending`.
-		 *
-		 * @see \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler::get_eligible_statuses()
-		 *
-		 * @var string[]
-		 */
-		private const ABANDONED_STATUSES = array(
-			OrderStatus::PENDING,
-			OrderStatus::CHECKOUT_DRAFT,
-		);
-
-		/**
-		 * Minimum age (in seconds, from `date_created`) before an order is considered
-		 * actually abandoned. Gives the customer a window to come back and complete
-		 * the checkout on their own before merchants can nudge them.
-		 */
-		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.
-		 */
-		public function __construct() {
-			$this->id             = self::EMAIL_ID;
-			$this->customer_email = true;
-			$this->title          = __( 'Abandoned cart recovery', 'woocommerce' );
-			$this->email_group    = 'order-updates';
-			$this->template_html  = 'emails/customer-abandoned-cart-recovery.php';
-			$this->template_plain = 'emails/plain/customer-abandoned-cart-recovery.php';
-			$this->template_block = 'emails/block/customer-abandoned-cart-recovery.php';
-			$this->placeholders   = array(
-				'{site_title}'   => $this->get_blogname(),
-				'{site_address}' => wp_parse_url( home_url(), PHP_URL_HOST ),
-				'{order_date}'   => '',
-				'{order_number}' => '',
-			);
-
-			parent::__construct();
-
-			// Must be after parent's constructor which sets `email_improvements_enabled` property.
-			$this->description = __( 'Win back shoppers who almost bought. Automatically email customers who didn\'t finish checking out, with a one-click link back to their order.', 'woocommerce' );
-		}
-
-		/**
-		 * Trigger the sending of this email.
-		 *
-		 * 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 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 ): bool {
-			if ( self::is_suppressed() ) {
-				return false;
-			}
-
-			$this->setup_locale();
-
-			// Reset state from any previous invocation so a call with an invalid order id
-			// cannot re-use the previous recipient / placeholders.
-			$this->object                         = false;
-			$this->recipient                      = '';
-			$this->placeholders['{order_date}']   = '';
-			$this->placeholders['{order_number}'] = '';
-
-			$order = $order_id ? wc_get_order( $order_id ) : false;
-
-			if ( $order instanceof WC_Order ) {
-				$this->object                         = $order;
-				$this->recipient                      = $order->get_billing_email();
-				$date_created                         = $order->get_date_created();
-				$this->placeholders['{order_date}']   = $date_created ? wc_format_datetime( $date_created ) : '';
-				$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 )
-			) {
-				$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.
-				// 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;
-		}
-
-		/**
-		 * Add the manual-send item to the order actions dropdown.
-		 *
-		 * Surfaced for the statuses listed in `ABANDONED_STATUSES` (pending +
-		 * checkout-draft) so merchants can't accidentally email a "pick up where
-		 * you left off" prompt to a customer whose order has already moved past
-		 * abandonment. A capability check guards against role configurations that
-		 * grant the order edit page to users without `edit_shop_orders`.
-		 *
-		 * @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.
-		 * @return array
-		 */
-		public function register_order_action( $actions, $order ): array {
-			if ( ! $order instanceof WC_Order ) {
-				return $actions;
-			}
-
-			if ( ! current_user_can( 'edit_shop_orders' ) ) {
-				return $actions;
-			}
-
-			// Mirror trigger()'s preconditions: don't surface an action that would
-			// silently no-op when the merchant clicks it.
-			if ( ! $this->is_enabled() || self::is_suppressed() ) {
-				return $actions;
-			}
-
-			if ( ! $this->is_order_eligible_for_recovery( $order ) ) {
-				return $actions;
-			}
-
-			// Customer-side preference wins over merchant action — don't surface
-			// "Send" if the recipient has already opted out.
-			if ( self::is_recipient_unsubscribed( $order->get_billing_email() ) ) {
-				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;
-		}
-
-		/**
-		 * Whether an order is in a state that warrants a recovery email.
-		 *
-		 * The order must (a) be in one of the eligible statuses, (b) have lived
-		 * in that state for at least `ABANDONMENT_THRESHOLD_SECONDS` (so we don't
-		 * nudge customers who are actively still on the page), and (c) have a
-		 * valid billing email — checkout-draft orders in particular can land in
-		 * the eligible status without a recipient.
-		 *
-		 * Single source of truth: called both by `trigger()` (defence-in-depth at
-		 * send time) and by the manual-send dropdown gates. Partners can widen the
-		 * eligible-status set via `woocommerce_abandoned_cart_recovery_eligible_statuses`
-		 * and both paths will agree.
-		 *
-		 * @since 11.0.0
-		 *
-		 * @param WC_Order $order Order to evaluate.
-		 * @return bool
-		 */
-		protected function is_order_eligible_for_recovery( WC_Order $order ): bool {
-			/**
-			 * Filter the order statuses that are eligible to receive the abandoned cart recovery email.
-			 *
-			 * The filter is applied at two points in the order lifecycle, which pass different
-			 * defaults because they cover different statuses:
-			 *
-			 * - At send time (here), the default is `pending` and `checkout-draft`, covering
-			 *   abandonment from both the classic and the block checkout, used in manual email trigger path
-			 *   from the order edit page.
-			 * - When the automated send is scheduled, the default is `pending` only.
-			 *   Store API orders are generally created in `checkout-draft`, which is not supported
-			 *   yet for automatic scheduled email.
-			 *   {@see \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler::get_eligible_statuses()}
-			 *
-			 * @since 11.0.0
-			 *
-			 * @param string[]      $eligible_statuses Default: `pending` and `checkout-draft` at manual send time, `pending` when scheduling.
-			 * @param WC_Order|null $order             Order being inspected, or null if it could not be loaded.
-			 */
-			$eligible_statuses = (array) apply_filters(
-				'woocommerce_abandoned_cart_recovery_eligible_statuses',
-				self::ABANDONED_STATUSES,
-				$order
-			);
-			if ( ! in_array( $order->get_status(), $eligible_statuses, true ) ) {
-				return false;
-			}
-
-			$date_created = $order->get_date_created();
-			if ( ! $date_created ) {
-				return false;
-			}
-
-			if ( ( time() - $date_created->getTimestamp() ) < self::ABANDONMENT_THRESHOLD_SECONDS ) {
-				return false;
-			}
-
-			return is_email( $order->get_billing_email() ) !== false;
-		}
-
-		/**
-		 * Handle a merchant-initiated send from the order edit page.
-		 *
-		 * Fired by `woocommerce_order_action_send_abandoned_cart_recovery_email` after
-		 * the order metabox save flow has validated the request. We re-check the
-		 * capability and order status as defense in depth in case the hook is
-		 * invoked from a non-metabox path.
-		 *
-		 * @since 11.0.0
-		 *
-		 * @param WC_Order $order The order whose customer should receive the email.
-		 * @return void
-		 */
-		public function handle_recovery_email_send( $order ): void {
-			if ( ! $order instanceof WC_Order ) {
-				return;
-			}
-
-			if ( ! current_user_can( 'edit_shop_orders' ) ) {
-				return;
-			}
-
-			// Don't record an order note for a send that the underlying trigger
-			// would silently bail on.
-			if ( ! $this->is_enabled() || self::is_suppressed() ) {
-				return;
-			}
-
-			if ( ! $this->is_order_eligible_for_recovery( $order ) ) {
-				return;
-			}
-
-			// Customer-side preference wins over merchant action — don't bypass
-			// an unsubscribe by manual send.
-			if ( self::is_recipient_unsubscribed( $order->get_billing_email() ) ) {
-				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 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 );
-
-			// 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' ),
-				0,
-				true,
-				array( 'note_group' => OrderNoteGroup::EMAIL_NOTIFICATION )
-			);
-
-			/**
-			 * Fires after the abandoned cart recovery email has been manually resent.
-			 *
-			 * @since 11.0.0
-			 *
-			 * @param WC_Order $order      Order being recovered.
-			 * @param string   $email_type Email identifier ('customer_abandoned_cart_recovery').
-			 */
-			do_action( 'woocommerce_after_resend_order_email', $order, $this->id );
-
-			// Reuse the existing "Order updated. Email sent." admin notice (message id 11) on the
-			// classic order edit page. HPOS uses a different redirect pipeline that sets the
-			// message directly in Edit.php, so this filter is a no-op there — matching the
-			// behavior of the built-in `send_order_details` action.
-			add_filter( 'redirect_post_location', array( 'WC_Meta_Box_Order_Actions', 'set_email_sent_message' ) );
-		}
-
-		/**
-		 * Whether the merchant has opted into automated scheduling.
-		 *
-		 * When false, the email is only dispatched via the manual-send action on the
-		 * order edit page. The Action Scheduler integration consults this before
-		 * scheduling a send.
-		 *
-		 * @since 11.0.0
-		 * @return bool
-		 */
-		public function is_automated(): bool {
-			return 'yes' === $this->get_option( 'automated', 'no' );
-		}
-
-		/**
-		 * Currently-active known recovery handlers, keyed by plugin file path with the display name as value.
-		 *
-		 * @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 {
-			if ( ! function_exists( 'is_plugin_active' ) ) {
-				require_once ABSPATH . 'wp-admin/includes/plugin.php';
-			}
-
-			return array_filter(
-				self::KNOWN_RECOVERY_HANDLERS,
-				static fn( $name, $slug ) => is_plugin_active( $slug ),
-				ARRAY_FILTER_USE_BOTH
-			);
-		}
-
-		/**
-		 * Whether the recovery email should be skipped.
-		 *
-		 * The merchant's own opt-out lives on the `enabled` toggle in Settings → Emails,
-		 * which `trigger()` checks via `is_enabled()`. This method is the additional gate
-		 * partner plugins (AutomateWoo, MailPoet, etc.) can hook into to short-circuit
-		 * the send without touching the merchant's saved settings. Static so the
-		 * manual-send handler and the scheduler can call it without instantiating
-		 * the email class.
-		 *
-		 * @since 11.0.0
-		 *
-		 * @return bool
-		 */
-		public static function is_suppressed(): bool {
-			/**
-			 * Filter to suppress the abandoned cart recovery email send.
-			 *
-			 * Partner plugins that handle abandoned cart recovery themselves can
-			 * return true here to prevent core from sending a duplicate email.
-			 *
-			 * @since 11.0.0
-			 *
-			 * @param bool $suppress Default false.
-			 */
-			return (bool) apply_filters( 'woocommerce_abandoned_cart_recovery_suppress', false );
-		}
-
-		/**
-		 * Get the URL the recovery email should send the customer to.
-		 *
-		 * Returns the order's pay endpoint, which resumes the checkout for the
-		 * 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  11.0.0
-		 * @return string
-		 */
-		public function get_recovery_url() {
-			if ( ! $this->object instanceof WC_Order ) {
-				return '';
-			}
-
-			/**
-			 * Filter the URL included in the abandoned cart recovery email.
-			 *
-			 * @since 11.0.0
-			 *
-			 * @param string   $url   Default: the pending order's pay endpoint.
-			 * @param WC_Order $order Order being recovered.
-			 */
-			return (string) apply_filters( 'woocommerce_abandoned_cart_recovery_url', $this->object->get_checkout_payment_url(), $this->object );
-		}
-
-		/**
-		 * Get the unsubscribe URL for the currently-bound order's recipient.
-		 *
-		 * Returns an HMAC-signed URL routed through `Endpoint::QUERY_VAR`
-		 * (`?wc-email-unsubscribe=…`) and handled by `UnsubscribesEndpoint`.
-		 * Empty when no order is bound or the order has no billing email —
-		 * both states mean there's no recipient to unsubscribe and the
-		 * template should suppress the footer link.
-		 *
-		 * @since  11.0.0
-		 * @return string
-		 */
-		public function get_unsubscribe_url() {
-			if ( ! $this->object instanceof WC_Order ) {
-				return '';
-			}
-			$email = $this->object->get_billing_email();
-			if ( '' === $email ) {
-				return '';
-			}
-			return UnsubscribesEndpoint::url_for( $this->object->get_id(), $email, $this->id );
-		}
-
-		/**
-		 * Whether the given email has opted out of checkout recovery emails.
-		 *
-		 * Static so the gate can be reused from the trigger-side check, the
-		 * dropdown gate, and any future auto-send scheduler — without each
-		 * caller needing to thread the repository through.
-		 *
-		 * @since  11.0.0
-		 *
-		 * @param string $email Raw recipient email.
-		 * @return bool
-		 */
-		public static function is_recipient_unsubscribed( string $email ): bool {
-			if ( '' === $email ) {
-				return false;
-			}
-			return wc_get_container()->get( UnsubscribesStorage::class )->is_unsubscribed( $email, self::EMAIL_ID );
-		}
-
-		/**
-		 * Get default email subject.
-		 *
-		 * @since  11.0.0
-		 * @return string
-		 */
-		public function get_default_subject() {
-			return __( 'Still want it?', 'woocommerce' );
-		}
-
-		/**
-		 * Get default email heading.
-		 *
-		 * @since  11.0.0
-		 * @return string
-		 */
-		public function get_default_heading() {
-			return __( 'Pick up where you left off', 'woocommerce' );
-		}
-
-		/**
-		 * Default content to show below main email content.
-		 *
-		 * @since  11.0.0
-		 * @return string
-		 */
-		public function get_default_additional_content() {
-			return __( 'If you have any questions, reply to this email and we\'ll help out.', 'woocommerce' );
-		}
-
-		/**
-		 * Get content html.
-		 *
-		 * @return string
-		 */
-		public function get_content_html() {
-			return wc_get_template_html(
-				$this->template_html,
-				array(
-					'order'              => $this->object,
-					'email_heading'      => $this->get_heading(),
-					'recovery_url'       => $this->get_recovery_url(),
-					'unsubscribe_url'    => $this->get_unsubscribe_url(),
-					'additional_content' => $this->get_additional_content(),
-					'sent_to_admin'      => false,
-					'plain_text'         => false,
-					'email'              => $this,
-				)
-			);
-		}
-
-		/**
-		 * Get content plain.
-		 *
-		 * @return string
-		 */
-		public function get_content_plain() {
-			return wc_get_template_html(
-				$this->template_plain,
-				array(
-					'order'              => $this->object,
-					'email_heading'      => $this->get_heading(),
-					'recovery_url'       => $this->get_recovery_url(),
-					'unsubscribe_url'    => $this->get_unsubscribe_url(),
-					'additional_content' => $this->get_additional_content(),
-					'sent_to_admin'      => false,
-					'plain_text'         => true,
-					'email'              => $this,
-				)
-			);
-		}
-
-		/**
-		 * Initialise settings form fields.
-		 *
-		 * Adds an `automated` field on top of the standard WC_Email fields so merchants
-		 * can choose between scheduled automatic sends and manual-only dispatch.
-		 */
-		public function init_form_fields(): void {
-			$placeholder_text = sprintf(
-				/* translators: %s: list of placeholders */
-				__( 'Available placeholders: %s', 'woocommerce' ),
-				'<code>' . implode( '</code>, <code>', array_map( 'esc_html', array_keys( $this->placeholders ) ) ) . '</code>'
-			);
-
-			$active_handlers     = self::get_active_recovery_handlers();
-			$enabled_default     = empty( $active_handlers ) ? 'yes' : 'no';
-			$enabled_description = empty( $active_handlers )
-				? ''
-				: sprintf(
-					/* translators: %s: comma-separated list of detected plugins that already handle abandoned cart recovery (e.g. "AutomateWoo, MailPoet"). */
-					__( '%s is active on this site and already handles abandoned cart recovery. We\'ve turned this off so customers don\'t receive duplicate emails. Enable anyway if you want WooCommerce to handle recovery instead.', 'woocommerce' ),
-					implode( ', ', $active_handlers )
-				);
-
-			$this->form_fields = array(
-				'enabled'            => array(
-					'title'       => __( 'Enable/Disable', 'woocommerce' ),
-					'type'        => 'checkbox',
-					'label'       => __( 'Enable this email notification', 'woocommerce' ),
-					'description' => $enabled_description,
-					'default'     => $enabled_default,
-					'desc_tip'    => '' !== $enabled_description,
-				),
-				'automated'          => array(
-					'title'       => __( 'Send automatically', 'woocommerce' ),
-					'type'        => 'checkbox',
-					'label'       => __( 'Schedule the recovery email to send 2 hours after a checkout is abandoned', 'woocommerce' ),
-					'description' => __( 'When disabled, the email is only sent when you trigger it manually from the order edit page.', 'woocommerce' ),
-					'default'     => 'no',
-					'desc_tip'    => true,
-				),
-				'subject'            => array(
-					'title'       => __( 'Subject', 'woocommerce' ),
-					'type'        => 'text',
-					'desc_tip'    => true,
-					'description' => $placeholder_text,
-					'placeholder' => $this->get_default_subject(),
-					'default'     => '',
-				),
-				'heading'            => array(
-					'title'       => __( 'Email heading', 'woocommerce' ),
-					'type'        => 'text',
-					'desc_tip'    => true,
-					'description' => $placeholder_text,
-					'placeholder' => $this->get_default_heading(),
-					'default'     => '',
-				),
-				'additional_content' => array(
-					'title'       => __( 'Additional content', 'woocommerce' ),
-					'description' => __( 'Text to appear below the main email content.', 'woocommerce' ) . ' ' . $placeholder_text,
-					'css'         => 'width:400px; height: 75px;',
-					'placeholder' => __( 'N/A', 'woocommerce' ),
-					'type'        => 'textarea',
-					'default'     => $this->get_default_additional_content(),
-					'desc_tip'    => true,
-				),
-				'email_type'         => array(
-					'title'       => __( 'Email type', 'woocommerce' ),
-					'type'        => 'select',
-					'description' => __( 'Choose which format of email to send.', 'woocommerce' ),
-					'default'     => 'html',
-					'class'       => 'email_type wc-enhanced-select',
-					'options'     => $this->get_email_type_options(),
-					'desc_tip'    => true,
-				),
-			);
-		}
-	}
-
-endif;
-
-return new WC_Email_Customer_Abandoned_Cart_Recovery();
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 67875534a88..0791bf0430f 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -3638,6 +3638,47 @@ function wc_update_1110_flush_product_count_cache() {
 	}
 }

+/**
+ * Clean up the state left behind by the removed abandoned cart recovery feature.
+ *
+ * The feature shipped in 11.0.x behind the experimental, default-off
+ * `abandoned_cart_recovery` flag and has now been removed in full: the email, its
+ * settings, the manual-send order action, the settings-page recommendations, and the
+ * email-unsubscribe endpoint and table. Sites that opted in can be left holding
+ * queued Action Scheduler sends, options and unsubscribe rows that no remaining code
+ * reads, so clear them here rather than orphaning them.
+ *
+ *
+ * Names are hardcoded rather than referenced through the classes that used to own
+ * them, because those classes no longer exist.
+ *
+ * @since 11.2.0
+ *
+ * @return void
+ */
+function wc_update_1120_remove_abandoned_cart_recovery() {
+	global $wpdb;
+
+	// Cancel queued automated sends. Nothing listens to the hook any more, so a
+	// due action would run as an inert no-op, but leaving it queued keeps dead
+	// rows in the Action Scheduler store until then.
+	if ( function_exists( 'as_unschedule_all_actions' ) ) {
+		as_unschedule_all_actions( 'woocommerce_send_abandoned_cart_recovery_notification' );
+	}
+
+	delete_option( 'woocommerce_feature_abandoned_cart_recovery_enabled' );
+	delete_option( 'woocommerce_customer_abandoned_cart_recovery_settings' );
+	delete_option( 'woocommerce_abandoned_cart_recovery_recommendations_hidden' );
+	delete_transient( 'wc_abandoned_cart_recovery_enabled_notice' );
+
+	// The unsubscribes table only ever held opt-outs for this email, and the GDPR
+	// eraser that covered it is gone too, so drop it rather than leave hashed
+	// recipient addresses behind with no erasure path.
+	$unsubscribes_table = $wpdb->prefix . 'wc_email_unsubscribes';
+	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name built from the wpdb prefix.
+	$wpdb->query( "DROP TABLE IF EXISTS {$unsubscribes_table}" );
+}
+
 /**
  * Migrate the Back in Stock Notifications alpha opt-in from the
  * WOOCOMMERCE_BIS_ALPHA_ENABLED constant to the feature toggle.
diff --git a/plugins/woocommerce/src/Admin/API/Options.php b/plugins/woocommerce/src/Admin/API/Options.php
index a8c419b2048..b608334b1d4 100644
--- a/plugins/woocommerce/src/Admin/API/Options.php
+++ b/plugins/woocommerce/src/Admin/API/Options.php
@@ -187,7 +187,6 @@ class Options extends \WC_REST_Data_Controller {
 			'woocommerce_settings_shipping_recommendations_hidden',
 			'woocommerce_task_list_dismissed_tasks',
 			'woocommerce_setting_payments_recommendations_hidden',
-			'woocommerce_abandoned_cart_recovery_recommendations_hidden',
 			'woocommerce_navigation_favorites_tooltip_hidden',
 			'woocommerce_admin_transient_notices_queue',
 			'woocommerce_task_list_hidden',
diff --git a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php
deleted file mode 100644
index 3e11df33ab1..00000000000
--- a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php
+++ /dev/null
@@ -1,116 +0,0 @@
-<?php
-/**
- * ManualSendHandler class file.
- */
-
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Internal\AbandonedCartRecovery;
-
-use WC_Email_Customer_Abandoned_Cart_Recovery;
-use WC_Order;
-
-/**
- * Registers the order-edit "Send abandoned cart recovery email" action and routes
- * the merchant click to the email class's handler.
- *
- * This lives outside the email class because the email is only instantiated
- * when `WC_Emails::instance()` is first called, which doesn't reliably happen
- * before `WC_Meta_Box_Order_Actions::save()` dispatches the order-action hook.
- * Registering the hooks from this container-managed service guarantees the
- * listener is in place at admin POST time. The callbacks lazy-load the email
- * instance via the mailer so the heavy email class only gets loaded when it's
- * actually needed.
- *
- * 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 ManualSendHandler {
-
-	/**
-	 * Order action id used by the manual-send dropdown item.
-	 *
-	 * Source of truth lives here (PSR-4 autoloaded) rather than on
-	 * `WC_Email_Customer_Abandoned_Cart_Recovery` because this class registers hooks
-	 * at WP `init` priority 1 — earlier than `WC_Emails::init()` includes the
-	 * legacy email file, so the email class isn't loadable yet.
-	 *
-	 * Kept in sync with `WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION`,
-	 * which re-exports this same value for the email-class callers that use it.
-	 */
-	public const MANUAL_SEND_ACTION = 'send_abandoned_cart_recovery_email';
-
-	/**
-	 * Register hooks and filters.
-	 *
-	 * Auto-called by the WC dependency container after instantiation.
-	 *
-	 * @internal
-	 */
-	final public function init(): void {
-		add_filter( 'woocommerce_order_actions', array( $this, 'register_order_action' ), 10, 2 );
-		add_action(
-			'woocommerce_order_action_' . self::MANUAL_SEND_ACTION,
-			array( $this, 'handle_order_action' ),
-			10,
-			1
-		);
-	}
-
-	/**
-	 * Filter callback that delegates to the email class's dropdown gate.
-	 *
-	 * @internal
-	 *
-	 * @param array         $actions Existing order actions keyed by action id.
-	 * @param WC_Order|null $order   Order being rendered, or null in contexts without one.
-	 * @return array
-	 */
-	public function register_order_action( $actions, $order ): array {
-		if ( ! is_array( $actions ) ) {
-			$actions = array();
-		}
-
-		$email = $this->get_email();
-		if ( ! $email ) {
-			return $actions;
-		}
-
-		return $email->register_order_action( $actions, $order );
-	}
-
-	/**
-	 * Action callback fired from `WC_Meta_Box_Order_Actions::save()` when the
-	 * merchant submits the dropdown action.
-	 *
-	 * @internal
-	 *
-	 * @param WC_Order $order Order the action was invoked on.
-	 */
-	public function handle_order_action( $order ): void {
-		$email = $this->get_email();
-		if ( ! $email ) {
-			return;
-		}
-
-		$email->handle_recovery_email_send( $order );
-	}
-
-	/**
-	 * Resolve the registered email instance from WC_Emails. Returns null when
-	 * the feature flag is off (in which case `WC_Emails::init()` does not
-	 * include the class file) so callers can short-circuit cleanly.
-	 *
-	 * @return WC_Email_Customer_Abandoned_Cart_Recovery|null
-	 */
-	private function get_email(): ?WC_Email_Customer_Abandoned_Cart_Recovery {
-		$emails = WC()->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/AbandonedCartRecovery/Scheduler.php b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
deleted file mode 100644
index fb50c71f62e..00000000000
--- a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
+++ /dev/null
@@ -1,281 +0,0 @@
-<?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 an abandoned-checkout 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 the
- * eligible-status set or is trashed/deleted, so a customer who completes
- * checkout before the delay elapses never receives the nudge. Eligibility runs
- * through the same `woocommerce_abandoned_cart_recovery_eligible_statuses`
- * filter the send/manual paths use, with a default scoped to `pending` —
- * {@see get_eligible_statuses()}.
- *
- * 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';
-
-	/**
-	 * Order-creation origins (`created_via`) eligible for an automated send:
-	 * the classic checkout and the block (Store API) checkout.
-	 *
-	 * Store API orders are generally created in `checkout-draft`, which is not scheduled
-	 * yet. `store-api` is listed here for future block checkout support.
-	 *
-	 * @var string[]
-	 */
-	private const ELIGIBLE_CREATED_VIA = array( 'checkout', 'store-api' );
-
-	/**
-	 * 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 the eligible set 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, 4 );
-		add_action( 'woocommerce_trash_order', array( $this, 'handle_cancellation' ), 10, 1 );
-		add_action( 'woocommerce_before_delete_order', array( $this, 'handle_cancellation' ), 10, 2 );
-		add_action( self::ACTION_HOOK, array( $this, 'handle_scheduled_send' ), 10, 1 );
-	}
-
-	/**
-	 * Schedule the automated send when an order is created in an eligible status.
-	 *
-	 * No-op when the order is not eligible, when it was not created by a
-	 * customer checkout flow, 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;
-		}
-
-		// Only nudge customers who abandoned a checkout — pending orders can
-		// also originate from admin invoices, the REST API, or renewals.
-		if ( ! in_array( $order->get_created_via(), self::ELIGIBLE_CREATED_VIA, true ) ) {
-			return;
-		}
-
-		if ( ! in_array( $order->get_status(), $this->get_eligible_statuses( $order ), true ) ) {
-			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
-	 * eligible-status set. `woocommerce_order_status_changed` fires for every
-	 * transition, so a single listener covers all statuses in one place, and
-	 * transitions inside a filter-widened eligible set keep the send queued.
-	 *
-	 * @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).
-	 * @param WC_Order|null $order      Order passed by the hook; looked up when absent.
-	 */
-	public function handle_status_changed( int $order_id, string $old_status, string $new_status, $order = null ): void {
-		if ( ! $order instanceof WC_Order ) {
-			$order = wc_get_order( $order_id );
-		}
-		if ( ! $order instanceof WC_Order ) {
-			return;
-		}
-
-		$eligible_statuses = $this->get_eligible_statuses( $order );
-
-		$was_eligible = in_array( $old_status, $eligible_statuses, true );
-		$is_eligible  = in_array( $new_status, $eligible_statuses, true );
-
-		if ( ! $was_eligible || $is_eligible ) {
-			return;
-		}
-
-		$this->handle_cancellation( $order_id, $order );
-	}
-
-	/**
-	 * 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.
-	 * @param WC_Order|null $order    Order passed by the caller or hook; looked up when absent, as
-	 *                                some hooks don't supply the order.
-	 */
-	public function handle_cancellation( int $order_id, $order = null ): 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 ) );
-
-		if ( ! $order instanceof WC_Order ) {
-			$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, re-check the automation
-	 * opt-in, and delegate the actual send to `trigger()`, which keeps every
-	 * send-time gate 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;
-		}
-
-		if ( ! $email->is_automated() ) {
-			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 )
-		);
-	}
-
-	/**
-	 * Order statuses that can be scheduled for an automated send. Defaults to `pending`, which is
-	 * where classic checkout leaves an abandoned order at creation time.
-	 *
-	 * @param WC_Order|null $order Order being inspected, or null if it could not be loaded.
-	 * @return string[]
-	 */
-	private function get_eligible_statuses( ?WC_Order $order ): array {
-		/**
-		 * This filter is documented in includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
-		 *
-		 * @since 11.0.0
-		 */
-		return (array) apply_filters(
-			'woocommerce_abandoned_cart_recovery_eligible_statuses',
-			array( OrderStatus::PENDING ),
-			$order
-		);
-	}
-
-	/**
-	 * 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
deleted file mode 100644
index 6a1a4ca72a9..00000000000
--- a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php
+++ /dev/null
@@ -1,170 +0,0 @@
-<?php
-/**
- * Email unsubscribes Endpoint class file.
- */
-
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Internal\Email\Unsubscribes;
-
-/**
- * Public-facing endpoint that handles the unsubscribe links embedded in
- * customer emails.
- *
- * URL shape: `?wc-email-unsubscribe=<order_id>&kind=<email_kind>&email_hash=<sha256>&sig=<hmac>`
- *
- * Signature: HMAC-SHA-256 of `"{order_id}|{email_hash}|{kind}"` using
- * `wp_salt('nonce')` as the key. The kind is part of the payload so a link
- * issued for one email type can't be replayed to opt out of another.
- *
- * No expiry on the link — CAN-SPAM expects unsubscribes to remain valid.
- *
- * @internal Just for internal use.
- *
- * @since 11.0.0
- */
-class Endpoint {
-
-	/**
-	 * Query var carrying the order id. The presence of this var is what
-	 * triggers the endpoint; the value is informational only (lookup is by
-	 * email hash + kind, not order).
-	 */
-	public const QUERY_VAR = 'wc-email-unsubscribe';
-
-	/**
-	 * Query var carrying the SHA-256 hash of the recipient's normalized email.
-	 */
-	public const QUERY_VAR_HASH = 'email_hash';
-
-	/**
-	 * Storage layer.
-	 *
-	 * @var Storage
-	 */
-	private Storage $storage;
-
-	/**
-	 * Container-injected dependencies.
-	 *
-	 * @internal
-	 *
-	 * @param Storage $storage Storage layer.
-	 */
-	final public function init( Storage $storage ): void {
-		$this->storage = $storage;
-		add_action( 'template_redirect', array( $this, 'maybe_handle' ) );
-	}
-
-	/**
-	 * Build the URL the email's unsubscribe link should point to.
-	 *
-	 * The raw email is hashed before it ever lands in the URL — see the class
-	 * docblock for why. Callers pass the raw address (so the API stays
-	 * ergonomic and signing stays in one place), but the rendered link only
-	 * contains the hash.
-	 *
-	 * @param int    $order_id Order id (informational; lookup is by email hash + kind).
-	 * @param string $email    Billing email.
-	 * @param string $kind     Email-kind identifier (the email class's `$this->id`).
-	 * @return string
-	 */
-	public static function url_for( int $order_id, string $email, string $kind ): string {
-		$hash = Storage::hash_email( $email );
-		if ( '' === $hash ) {
-			return '';
-		}
-		$sig = self::sign( $order_id, $hash, $kind );
-
-		return add_query_arg(
-			array(
-				self::QUERY_VAR      => $order_id,
-				'kind'               => $kind,
-				self::QUERY_VAR_HASH => $hash,
-				'sig'                => $sig,
-			),
-			home_url( '/' )
-		);
-	}
-
-	/**
-	 * Fired on `template_redirect`. Quick-bail when the query var is absent so
-	 * normal requests are unaffected.
-	 *
-	 * @internal
-	 */
-	public function maybe_handle(): void {
-		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- signature replaces nonce here; verified below.
-		if ( ! isset( $_GET[ self::QUERY_VAR ] ) ) {
-			return;
-		}
-
-		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- signature verified below.
-		$order_id = absint( $_GET[ self::QUERY_VAR ] );
-		$kind     = isset( $_GET['kind'] ) ? sanitize_key( wp_unslash( $_GET['kind'] ) ) : '';
-		$hash     = isset( $_GET[ self::QUERY_VAR_HASH ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::QUERY_VAR_HASH ] ) ) : '';
-		$sig      = isset( $_GET['sig'] ) ? sanitize_text_field( wp_unslash( $_GET['sig'] ) ) : '';
-		// phpcs:enable
-
-		// Reject anything that doesn't match `Storage::HASH_PATTERN` before we
-		// even hash-compare — a malformed hash can't possibly verify, and the
-		// shared constant means the endpoint and storage agree on what valid
-		// means.
-		if ( '' === $hash || '' === $kind || '' === $sig || 1 !== preg_match( Storage::HASH_PATTERN, $hash ) || ! self::verify( $order_id, $hash, $kind, $sig ) ) {
-			$this->render_invalid();
-			return;
-		}
-
-		$this->storage->mark_unsubscribed_by_hash( $hash, $kind );
-		$this->render_unsubscribed();
-	}
-
-	/**
-	 * Compute the HMAC signature for a (order, hash, kind) triple.
-	 *
-	 * @param int    $order_id Order id.
-	 * @param string $hash     SHA-256 hash of the normalized email.
-	 * @param string $kind     Email-kind identifier.
-	 * @return string Hex digest.
-	 */
-	private static function sign( int $order_id, string $hash, string $kind ): string {
-		return hash_hmac( 'sha256', $order_id . '|' . $hash . '|' . $kind, wp_salt( 'nonce' ) );
-	}
-
-	/**
-	 * Constant-time signature verification.
-	 *
-	 * @param int    $order_id  Order id from the URL.
-	 * @param string $hash      Email hash from the URL (already shape-validated).
-	 * @param string $kind      Kind from the URL (sanitized).
-	 * @param string $signature Signature from the URL.
-	 * @return bool
-	 */
-	private static function verify( int $order_id, string $hash, string $kind, string $signature ): bool {
-		$expected = self::sign( $order_id, $hash, $kind );
-		return hash_equals( $expected, $signature );
-	}
-
-	/**
-	 * Render the "you've been unsubscribed" page.
-	 */
-	private function render_unsubscribed(): void {
-		wp_die(
-			wp_kses_post( '<p>' . esc_html__( 'You won\'t receive any more of these emails from us.', 'woocommerce' ) . '</p>' ),
-			esc_html__( 'Unsubscribed', 'woocommerce' ),
-			array( 'response' => 200 )
-		);
-	}
-
-	/**
-	 * Render the "we couldn't verify this link" page. Same status code as
-	 * success so the response shape doesn't leak whether the email exists.
-	 */
-	private function render_invalid(): void {
-		wp_die(
-			wp_kses_post( '<p>' . esc_html__( 'This unsubscribe link could not be verified. It may have been altered or copied incompletely.', 'woocommerce' ) . '</p>' ),
-			esc_html__( 'Link not valid', 'woocommerce' ),
-			array( 'response' => 200 )
-		);
-	}
-}
diff --git a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php
deleted file mode 100644
index 34892622fa2..00000000000
--- a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php
+++ /dev/null
@@ -1,272 +0,0 @@
-<?php
-/**
- * Email unsubscribes Storage class file.
- */
-
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Internal\Email\Unsubscribes;
-
-/**
- * Storage and lookup for customer "do not send me this kind of email" preferences.
- *
- * Generic across email types: each row pairs a SHA-256 hash of the normalized
- * email with a free-form `email_kind` string (typically the email's `$this->id`,
- * e.g. `customer_checkout_recovery`). Multiple emails can route through the same
- * table without colliding.
- *
- * Stored in a dedicated table (`wp_wc_email_unsubscribes`) rather than user meta
- * so guest checkouts — common for the abandoned-checkout case — can opt out
- * without needing a WP_User record. The hash is computed from the lowercased +
- * trimmed email so casing or whitespace variations resolve to the same row.
- *
- * The table is installed via `WC_Install::get_schema()`. `init()` is auto-called
- * by the container after instantiation; it registers the GDPR personal-data
- * eraser so the WP "Erase Personal Data" tool clears this table too.
- *
- * @internal Just for internal use.
- *
- * @since 11.0.0
- */
-class Storage {
-
-	/**
-	 * The unqualified table name (no `$wpdb->prefix`).
-	 */
-	private const TABLE = 'wc_email_unsubscribes';
-
-	/**
-	 * Action label stored on the row. Kept as a varchar column rather than a
-	 * boolean so we can add further actions (e.g. resubscribed) later without
-	 * a schema migration. Today there's only one.
-	 */
-	public const ACTION_UNSUBSCRIBED = 'unsubscribed';
-
-	/**
-	 * Shape of a valid email hash: 64 lowercase hex chars, matching the output
-	 * of `hash('sha256', …)`. Shared with the public unsubscribe endpoint so
-	 * the two validation sites can't drift apart.
-	 */
-	public const HASH_PATTERN = '/^[a-f0-9]{64}$/';
-
-	/**
-	 * Register the GDPR personal-data eraser.
-	 *
-	 * The table itself is installed via `WC_Install::get_schema()` so it's
-	 * present on every site (including the test bootstrap) regardless of
-	 * whether the checkout-recovery feature flag is enabled.
-	 *
-	 * Auto-called by the WC dependency container after instantiation.
-	 *
-	 * @internal
-	 */
-	final public function init(): void {
-		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_personal_data_eraser' ) );
-	}
-
-	/**
-	 * Database schema for the unsubscribes table.
-	 *
-	 * Called from `WC_Install::get_schema()` so the table is created/updated
-	 * alongside the rest of WC's tables on activate/upgrade.
-	 *
-	 * @return string SQL CREATE TABLE statement.
-	 */
-	public function get_database_schema(): string {
-		global $wpdb;
-		$table   = $this->get_table_name();
-		$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';
-
-		return "CREATE TABLE {$table} (
-			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
-			email_hash char(64) NOT NULL,
-			email_kind varchar(64) NOT NULL,
-			action varchar(20) NOT NULL,
-			created_at datetime NOT NULL,
-			PRIMARY KEY  (id),
-			KEY email_hash_kind (email_hash, email_kind)
-		) {$collate};";
-	}
-
-	/**
-	 * Hash a raw email address for use as the lookup key.
-	 *
-	 * Normalizes (trim + strtolower) before hashing so equivalent addresses
-	 * collide on the same row. Returns an empty string for empty input so
-	 * callers can early-out without raising.
-	 *
-	 * @param string $email Raw email address.
-	 * @return string 64-char hex SHA-256 hash, or '' if input was empty.
-	 */
-	public static function hash_email( string $email ): string {
-		$normalized = strtolower( trim( $email ) );
-		if ( '' === $normalized ) {
-			return '';
-		}
-		return hash( 'sha256', $normalized );
-	}
-
-	/**
-	 * Whether the given email is currently unsubscribed from a specific kind.
-	 *
-	 * @param string $email Raw email address.
-	 * @param string $kind  Email-kind identifier (the email class's `$this->id`).
-	 * @return bool
-	 */
-	public function is_unsubscribed( string $email, string $kind ): bool {
-		$hash = self::hash_email( $email );
-		if ( '' === $hash || '' === $kind ) {
-			return false;
-		}
-
-		global $wpdb;
-		$table = $this->get_table_name();
-
-		// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name hard-coded above; values bound.
-		$action = $wpdb->get_var(
-			$wpdb->prepare(
-				"SELECT action FROM {$table} WHERE email_hash = %s AND email_kind = %s ORDER BY id DESC LIMIT 1",
-				$hash,
-				$kind
-			)
-		);
-		// phpcs:enable
-
-		return self::ACTION_UNSUBSCRIBED === $action;
-	}
-
-	/**
-	 * Record an unsubscribe for the given email + kind. Idempotent — repeated
-	 * calls append new rows but the lookup only cares about the most recent.
-	 *
-	 * @param string $email Raw email address.
-	 * @param string $kind  Email-kind identifier.
-	 * @return bool True if a row was written, false if input was empty.
-	 */
-	public function mark_unsubscribed( string $email, string $kind ): bool {
-		return $this->record_action( self::hash_email( $email ), $kind, self::ACTION_UNSUBSCRIBED );
-	}
-
-	/**
-	 * Record an unsubscribe directly by SHA-256 hash, for callers (e.g. the
-	 * public unsubscribe endpoint) that operate on the hash already and never
-	 * need to handle the raw email.
-	 *
-	 * Validates the hash matches `HASH_PATTERN` as defense in depth — the
-	 * Endpoint already shape-checks the URL value, but any future caller that
-	 * forgets to would otherwise insert a junk row.
-	 *
-	 * @param string $hash SHA-256 hex digest of the normalized email.
-	 * @param string $kind Email-kind identifier.
-	 * @return bool True if a row was written.
-	 */
-	public function mark_unsubscribed_by_hash( string $hash, string $kind ): bool {
-		if ( 1 !== preg_match( self::HASH_PATTERN, $hash ) ) {
-			return false;
-		}
-		return $this->record_action( $hash, $kind, self::ACTION_UNSUBSCRIBED );
-	}
-
-	/**
-	 * Remove all rows (across every kind) for an email — used by the GDPR
-	 * personal-data eraser so a customer's "right to be forgotten" request
-	 * clears their opt-out record along with the rest of their data.
-	 *
-	 * @param string $email Raw email address.
-	 * @return int Number of rows deleted.
-	 */
-	public function erase_for_email( string $email ): int {
-		$hash = self::hash_email( $email );
-		if ( '' === $hash ) {
-			return 0;
-		}
-
-		global $wpdb;
-		$table = $this->get_table_name();
-
-		// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name hard-coded; hash bound.
-		$deleted = $wpdb->query(
-			$wpdb->prepare(
-				"DELETE FROM {$table} WHERE email_hash = %s",
-				$hash
-			)
-		);
-		// phpcs:enable
-
-		return is_numeric( $deleted ) ? (int) $deleted : 0;
-	}
-
-	/**
-	 * Filter callback that adds this repository's eraser to WP's GDPR registry.
-	 *
-	 * @internal
-	 *
-	 * @param array<string, array{eraser_friendly_name: string, callback: callable}> $erasers Existing erasers.
-	 * @return array<string, array{eraser_friendly_name: string, callback: callable}>
-	 */
-	public function register_personal_data_eraser( array $erasers ): array {
-		$erasers['wc-email-unsubscribes'] = array(
-			'eraser_friendly_name' => __( 'WooCommerce Email Unsubscribes', 'woocommerce' ),
-			'callback'             => array( $this, 'handle_personal_data_erasure' ),
-		);
-		return $erasers;
-	}
-
-	/**
-	 * Callback for the WP personal-data eraser.
-	 *
-	 * @internal
-	 *
-	 * @param string $email Email address being erased.
-	 * @return array{items_removed: bool, items_retained: bool, messages: string[], done: bool}
-	 */
-	public function handle_personal_data_erasure( string $email ): array {
-		$removed = $this->erase_for_email( $email ) > 0;
-
-		return array(
-			'items_removed'  => $removed,
-			'items_retained' => false,
-			'messages'       => array(),
-			'done'           => true,
-		);
-	}
-
-	/**
-	 * Append an action row.
-	 *
-	 * @param string $hash   SHA-256 hex digest of the normalized email.
-	 * @param string $kind   Email-kind identifier.
-	 * @param string $action `unsubscribed` or `resubscribed`.
-	 * @return bool
-	 */
-	private function record_action( string $hash, string $kind, string $action ): bool {
-		if ( '' === $hash || '' === $kind ) {
-			return false;
-		}
-
-		global $wpdb;
-
-		// phpcs:disable WordPress.DB.DirectDatabaseQuery -- write to an internal preference table.
-		$inserted = $wpdb->insert(
-			$this->get_table_name(),
-			array(
-				'email_hash' => $hash,
-				'email_kind' => $kind,
-				'action'     => $action,
-				'created_at' => current_time( 'mysql', true ),
-			),
-			array( '%s', '%s', '%s', '%s' )
-		);
-		// phpcs:enable
-
-		return false !== $inserted;
-	}
-
-	/**
-	 * Fully-qualified table name including the wpdb prefix.
-	 */
-	private function get_table_name(): string {
-		global $wpdb;
-		return $wpdb->prefix . self::TABLE;
-	}
-}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTagManager.php b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTagManager.php
index 3bf666135e9..9af78f97a75 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTagManager.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTagManager.php
@@ -9,7 +9,6 @@ use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\CustomerTags
 use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\OrderTagsProvider;
 use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\SiteTagsProvider;
 use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\StoreTagsProvider;
-use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\UnsubscribeTagsProvider;

 defined( 'ABSPATH' ) || exit;

@@ -48,22 +47,14 @@ class PersonalizationTagManager {
 	 */
 	private $store_tags_provider;

-	/**
-	 * The unsubscribe related tags provider.
-	 *
-	 * @var UnsubscribeTagsProvider
-	 */
-	private $unsubscribe_tags_provider;
-
 	/**
 	 * Constructor.
 	 */
 	public function __construct() {
-		$this->customer_tags_provider    = new CustomerTagsProvider();
-		$this->order_tags_provider       = new OrderTagsProvider();
-		$this->site_tags_provider        = new SiteTagsProvider();
-		$this->store_tags_provider       = new StoreTagsProvider();
-		$this->unsubscribe_tags_provider = new UnsubscribeTagsProvider();
+		$this->customer_tags_provider = new CustomerTagsProvider();
+		$this->order_tags_provider    = new OrderTagsProvider();
+		$this->site_tags_provider     = new SiteTagsProvider();
+		$this->store_tags_provider    = new StoreTagsProvider();
 	}

 	/**
@@ -87,7 +78,6 @@ class PersonalizationTagManager {
 		$this->order_tags_provider->register_tags( $registry );
 		$this->site_tags_provider->register_tags( $registry );
 		$this->store_tags_provider->register_tags( $registry );
-		$this->unsubscribe_tags_provider->register_tags( $registry );

 		return $registry;
 	}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/UnsubscribeTagsProvider.php b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/UnsubscribeTagsProvider.php
deleted file mode 100644
index 01e3bc35c76..00000000000
--- a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/UnsubscribeTagsProvider.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags;
-
-use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
-use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
-use Automattic\WooCommerce\Internal\EmailEditor\Integration;
-use WC_Email;
-
-/**
- * Provider for unsubscribe-link personalization tags.
- *
- * Exposes `woocommerce/email-unsubscribe-url` so any email class that
- * implements `get_unsubscribe_url()` (currently only the checkout-recovery
- * email) can surface a signed unsubscribe link inside the block-editor
- * template without having to register its own tag.
- *
- * Returns an empty string for email classes that don't expose the method,
- * which renders the surrounding `<a href="">` as a broken link — merchants
- * should only drop the unsubscribe block into emails where the kind
- * supports it.
- *
- * @internal
- */
-class UnsubscribeTagsProvider extends AbstractTagProvider {
-	/**
-	 * Register unsubscribe tags with the registry.
-	 *
-	 * @param Personalization_Tags_Registry $registry The personalization tags registry.
-	 * @return void
-	 */
-	public function register_tags( Personalization_Tags_Registry $registry ): void {
-		$registry->register(
-			new Personalization_Tag(
-				__( 'Unsubscribe URL', 'woocommerce' ),
-				'woocommerce/email-unsubscribe-url',
-				__( 'Unsubscribe', 'woocommerce' ),
-				function ( array $context ): string {
-					$wc_email = $context['wc_email'] ?? null;
-					if ( ! $wc_email instanceof WC_Email ) {
-						return '';
-					}
-					if ( ! is_callable( array( $wc_email, 'get_unsubscribe_url' ) ) ) {
-						return '';
-					}
-					return (string) $wc_email->get_unsubscribe_url();
-				},
-				array(),
-				null,
-				array( Integration::EMAIL_POST_TYPE ),
-			)
-		);
-	}
-}
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index b8693dc6266..7ef2bd15c22 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -169,8 +169,6 @@ class FeaturesController {
 		add_filter( 'woocommerce_admin_shared_settings', array( $this, 'set_change_feature_enable_nonce' ), 20, 1 );
 		add_action( 'admin_init', array( $this, 'change_feature_enable_from_query_params' ), 20, 0 );
 		add_action( self::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'display_email_improvements_feedback_notice' ), 10, 2 );
-		add_action( self::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'flag_abandoned_cart_recovery_enabled_notice' ), 10, 2 );
-		add_action( 'woocommerce_settings_advanced', array( $this, 'maybe_render_abandoned_cart_recovery_enabled_notice' ), 1 );
 		add_filter( 'woocommerce_settings-advanced', array( $this, 'add_point_of_sale_setting_for_rest_api' ), 10, 1 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
 	}

@@ -468,17 +466,6 @@ class FeaturesController {
 				'enabled_by_default'           => false,
 				'is_experimental'              => false,
 			),
-			'abandoned_cart_recovery'              => array(
-				'name'                         => __( 'Abandoned cart recovery', 'woocommerce' ),
-				'description'                  => __(
-					'Send a reminder email to shoppers who didn\'t finish checking out.',
-					'woocommerce'
-				),
-				'skip_compatibility_checks'    => false,
-				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
-				'enabled_by_default'           => false,
-				'is_experimental'              => true,
-			),
 			'email_improvements'                   => array(
 				'name'                         => __( 'Email improvements', 'woocommerce' ),
 				'description'                  => __(
@@ -2145,58 +2132,6 @@ class FeaturesController {
 		}
 	}

-	/**
-	 * Flag a one-shot transient when the merchant turns on Abandoned cart recovery.
-	 *
-	 * `change_feature_enable` fires this action mid-request before the post-save
-	 * redirect, so the actual notice has to render on the next page load. We
-	 * stash a transient here and `maybe_render_abandoned_cart_recovery_enabled_notice`
-	 * picks it up the next time `woocommerce_settings_advanced` fires.
-	 *
-	 * @param string $feature_id Feature being toggled.
-	 * @param bool   $is_enabled True when turned on, false when turned off.
-	 *
-	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
-	 */
-	public function flag_abandoned_cart_recovery_enabled_notice( $feature_id, $is_enabled ): void {
-		if ( 'abandoned_cart_recovery' === $feature_id && $is_enabled ) {
-			set_transient( 'wc_abandoned_cart_recovery_enabled_notice', 'yes', MINUTE_IN_SECONDS );
-		}
-	}
-
-	/**
-	 * Render a success notice after the merchant enables Abandoned cart recovery,
-	 * pointing them straight at the email settings page where they actually
-	 * configure it.
-	 *
-	 * Hooks into `woocommerce_settings_advanced` at priority 1, which fires
-	 * inside the settings template right after `WC_Admin_Settings::show_messages()`
-	 * (the "Your settings have been saved." notice) and before the form fields.
-	 * That places our notice in the same visual slot below the tabs, alongside
-	 * the standard save confirmation.
-	 *
-	 * `WC_Admin_Settings::add_message()` would be the cleaner API but escapes
-	 * its input via `esc_html()`, which strips the link tag. Direct echo here
-	 * keeps the markup intact while still matching the surrounding notice style.
-	 *
-	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
-	 */
-	public function maybe_render_abandoned_cart_recovery_enabled_notice(): void {
-		if ( 'yes' !== get_transient( 'wc_abandoned_cart_recovery_enabled_notice' ) ) {
-			return;
-		}
-		delete_transient( 'wc_abandoned_cart_recovery_enabled_notice' );
-
-		$settings_url = admin_url( 'admin.php?page=wc-settings&tab=email&section=wc_email_customer_abandoned_cart_recovery' );
-
-		printf(
-			'<div id="wc-abandoned-cart-recovery-enabled-notice" class="updated inline"><p><strong>%1$s <a href="%2$s">%3$s</a></strong></p></div>',
-			esc_html__( 'Abandoned cart recovery is enabled.', 'woocommerce' ),
-			esc_url( $settings_url ),
-			esc_html__( 'Configure the recovery email →', 'woocommerce' )
-		);
-	}
-
 	/**
 	 * Check if the email improvements feature is enabled in preview mode in Settings > Emails.
 	 * This is used to force the email improvements feature without affecting shoppers.
diff --git a/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php b/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php
deleted file mode 100644
index dbc7964cf5c..00000000000
--- a/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-/**
- * Customer abandoned cart recovery email (initial block content)
- *
- * This template can be overridden by editing it in the WooCommerce email editor.
- *
- * HOWEVER, on occasion WooCommerce will need to update template files and you
- * (the theme developer) will need to copy the new files to your theme to
- * maintain compatibility. We try to do this as little as possible, but it does
- * happen. When this occurs the version of the template file will be bumped and
- * the readme will list any important changes.
- *
- * @see https://woocommerce.com/document/template-structure/
- * @package WooCommerce\Templates\Emails\Block
- * @version 11.0.0
- */
-
-defined( 'ABSPATH' ) || exit;
-
-// phpcs:disable Squiz.PHP.EmbeddedPhp.ContentBeforeOpen -- removed to prevent empty new lines.
-// phpcs:disable Squiz.PHP.EmbeddedPhp.ContentAfterEnd -- removed to prevent empty new lines.
-?>
-
-<!-- wp:heading -->
-<h2 class="wp-block-heading"> <?php echo esc_html__( 'Pick up where you left off', 'woocommerce' ); ?> </h2>
-<!-- /wp:heading -->
-
-<!-- wp:paragraph -->
-<p><?php
-	/* translators: %s: Customer first name */
-	printf( esc_html__( 'Hi %s,', 'woocommerce' ), '<!--[woocommerce/customer-first-name]-->' );
-?></p>
-<!-- /wp:paragraph -->
-
-<!-- wp:paragraph -->
-<p> <?php echo esc_html__( 'Your items are still in your cart. We’ve saved everything, so come back when you’re ready.', 'woocommerce' ); ?> </p>
-<!-- /wp:paragraph -->
-
-<!-- wp:paragraph -->
-<p><?php
-/* translators: 1: order number, 2: order date */
-$order_meta_format = esc_html__( 'Order #%1$s (%2$s)', 'woocommerce' );
-printf( $order_meta_format, '<!--[woocommerce/order-number]-->', '<!--[woocommerce/order-date]-->' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $order_meta_format is escaped above; personalization tokens are literal HTML comments.
-?></p>
-<!-- /wp:paragraph -->
-
-<!-- wp:paragraph {"style":{"typography":{"fontSize":"12px"}}} -->
-<p style="font-size:12px"><a href="<!--[woocommerce/email-unsubscribe-url]-->"><?php echo esc_html__( 'Unsubscribe from checkout recovery emails', 'woocommerce' ); ?></a></p>
-<!-- /wp:paragraph -->
diff --git a/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php b/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php
deleted file mode 100644
index d7831312af2..00000000000
--- a/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php
+++ /dev/null
@@ -1,102 +0,0 @@
-<?php
-/**
- * Customer abandoned cart recovery email
- *
- * This template can be overridden by copying it to yourtheme/woocommerce/emails/customer-abandoned-cart-recovery.php.
- *
- * HOWEVER, on occasion WooCommerce will need to update template files and you
- * (the theme developer) will need to copy the new files to your theme to
- * maintain compatibility. We try to do this as little as possible, but it does
- * happen. When this occurs the version of the template file will be bumped and
- * the readme will list any important changes.
- *
- * @see https://woocommerce.com/document/template-structure/
- * @package WooCommerce\Templates\Emails
- * @version 11.0.0
- */
-
-use Automattic\WooCommerce\Utilities\FeaturesUtil;
-
-if ( ! defined( 'ABSPATH' ) ) {
-	exit;
-}
-
-$email_improvements_enabled = FeaturesUtil::feature_is_enabled( 'email_improvements' );
-
-/**
- * Hook for the woocommerce_email_header.
- *
- * @param string   $email_heading The email heading.
- * @param WC_Email $email         The email object.
- * @since 2.5.0
- *
- * @hooked WC_Emails::email_header() Output the email header
- */
-do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
-
-<?php echo $email_improvements_enabled ? '<div class="email-introduction">' : ''; ?>
-<p>
-<?php
-if ( $order instanceof WC_Order && ! empty( $order->get_billing_first_name() ) ) {
-	/* translators: %s: Customer first name */
-	printf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $order->get_billing_first_name() ) );
-} else {
-	printf( esc_html__( 'Hi,', 'woocommerce' ) );
-}
-?>
-</p>
-
-<p><?php esc_html_e( 'Your items are still in your cart. We\'ve saved everything, so come back when you\'re ready.', 'woocommerce' ); ?></p>
-
-<?php if ( ! empty( $recovery_url ) ) : ?>
-<p>
-	<a href="<?php echo esc_url( $recovery_url ); ?>"><?php esc_html_e( 'Finish checking out', 'woocommerce' ); ?></a>
-</p>
-<?php endif; ?>
-<?php echo $email_improvements_enabled ? '</div>' : ''; ?>
-
-<?php if ( $order instanceof WC_Order ) : ?>
-<p style="font-size: 12px; line-height: 16px; color: #4d4d4d; margin-top: 16px;">
-	<?php
-	$date_created = $order->get_date_created();
-	printf(
-	/* translators: 1: order number, 2: order date */
-		esc_html__( 'Order #%1$s (%2$s)', 'woocommerce' ),
-		esc_html( $order->get_order_number() ),
-		esc_html( $date_created ? wc_format_datetime( $date_created ) : '' )
-	);
-	?>
-</p>
-<?php endif; ?>
-
-<?php
-/**
- * Show user-defined additional content - this is set in each email's settings.
- */
-if ( $additional_content ) {
-	echo $email_improvements_enabled ? '<table border="0" cellpadding="0" cellspacing="0" width="100%" role="presentation"><tr><td class="email-additional-content">' : '';
-	echo wp_kses_post( wpautop( wptexturize( $additional_content ) ) );
-	echo $email_improvements_enabled ? '</td></tr></table>' : '';
-}
-
-if ( ! empty( $unsubscribe_url ) ) :
-	?>
-<p style="font-size: 12px; line-height: 16px; color: #4d4d4d; margin-top: 24px;">
-	<a href="<?php echo esc_url( $unsubscribe_url ); ?>" style="color: #4d4d4d;">
-		<?php esc_html_e( 'Unsubscribe from checkout recovery emails', 'woocommerce' ); ?>
-	</a>
-</p>
-	<?php
-endif;
-?>
-
-<?php
-/**
- * Hook for the woocommerce_email_footer.
- *
- * @param WC_Email $email The email object.
- * @since 2.5.0
- *
- * @hooked WC_Emails::email_footer() Output the email footer
- */
-do_action( 'woocommerce_email_footer', $email );
diff --git a/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php b/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php
deleted file mode 100644
index 9059466f001..00000000000
--- a/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php
+++ /dev/null
@@ -1,74 +0,0 @@
-<?php
-/**
- * Customer abandoned cart recovery email (plain text)
- *
- * This template can be overridden by copying it to yourtheme/woocommerce/emails/plain/customer-abandoned-cart-recovery.php.
- *
- * HOWEVER, on occasion WooCommerce will need to update template files and you
- * (the theme developer) will need to copy the new files to your theme to
- * maintain compatibility. We try to do this as little as possible, but it does
- * happen. When this occurs the version of the template file will be bumped and
- * the readme will list any important changes.
- *
- * @see https://woocommerce.com/document/template-structure/
- * @package WooCommerce\Templates\Emails\Plain
- * @version 11.0.0
- */
-
-// phpcs:disable Universal.WhiteSpace.PrecisionAlignment.Found, Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed -- Plain text output needs specific spacing without tabs
-
-defined( 'ABSPATH' ) || exit;
-
-echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
-echo esc_html( wp_strip_all_tags( $email_heading ) );
-echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
-
-if ( $order instanceof WC_Order && ! empty( $order->get_billing_first_name() ) ) {
-	/* translators: %s: Customer first name */
-	echo sprintf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $order->get_billing_first_name() ) ) . "\n\n";
-} else {
-	echo esc_html__( 'Hi,', 'woocommerce' ) . "\n\n";
-}
-
-echo esc_html__( 'Your items are still in your cart. We\'ve saved everything, so come back when you\'re ready.', 'woocommerce' ) . "\n\n";
-
-if ( ! empty( $recovery_url ) ) {
-	echo esc_html__( 'Finish checking out:', 'woocommerce' ) . "\n";
-	echo esc_url( $recovery_url ) . "\n\n";
-}
-
-if ( $order instanceof WC_Order ) {
-	$date_created = $order->get_date_created();
-	printf(
-		/* translators: 1: order number, 2: order date */
-		esc_html__( 'Order #%1$s (%2$s)', 'woocommerce' ),
-		esc_html( $order->get_order_number() ),
-		esc_html( $date_created ? wc_format_datetime( $date_created ) : '' )
-	);
-	echo "\n\n";
-}
-
-echo "----------------------------------------\n\n";
-
-/**
- * Show user-defined additional content - this is set in each email's settings.
- */
-if ( $additional_content ) {
-	echo esc_html( wp_strip_all_tags( wptexturize( $additional_content ) ) );
-	echo "\n\n----------------------------------------\n\n";
-}
-
-if ( ! empty( $unsubscribe_url ) ) {
-	echo esc_html__( 'Unsubscribe from checkout recovery emails:', 'woocommerce' ) . "\n";
-	echo esc_url( $unsubscribe_url ) . "\n\n";
-}
-
-/**
- * Filter the email footer text.
- *
- * @param string $footer_text The footer text.
- * @since 2.3.0
- */
-echo wp_kses_post( apply_filters( 'woocommerce_email_footer_text', get_option( 'woocommerce_email_footer_text' ) ) );
-
-// phpcs:enable Universal.WhiteSpace.PrecisionAlignment.Found, Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed
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
deleted file mode 100644
index 0eb82a2e8a8..00000000000
--- a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php
+++ /dev/null
@@ -1,975 +0,0 @@
-<?php
-declare( strict_types = 1 );
-
-use Automattic\WooCommerce\Enums\OrderStatus;
-
-/**
- * WC_Email_Customer_Abandoned_Cart_Recovery test.
- *
- * @covers WC_Email_Customer_Abandoned_Cart_Recovery
- */
-class WC_Email_Customer_Abandoned_Cart_Recovery_Test extends \WC_Unit_Test_Case {
-
-	/**
-	 * The System Under Test.
-	 *
-	 * @var WC_Email_Customer_Abandoned_Cart_Recovery
-	 */
-	private $sut;
-
-	/**
-	 * Snapshot of the `active_plugins` option 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();
-
-	/**
-	 * Admin user id created for cap-gated assertions.
-	 *
-	 * @var int
-	 */
-	private $admin_user_id = 0;
-
-	/**
-	 * `WC_Emails::init()` only registers the abandoned cart recovery email class
-	 * when the `abandoned_cart_recovery` feature flag is on, so the suite has to
-	 * enable the option (and re-init the mailer to pick up the flag change)
-	 * before exercising the mailer-level registration. Doing it here makes
-	 * every test self-contained rather than relying on the incidental order
-	 * of other suites that flip the flag.
-	 */
-	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';
-
-		$this->sut = new WC_Email_Customer_Abandoned_Cart_Recovery();
-	}
-
-	/**
-	 * Reset the feature flag + saved settings between tests so the suite
-	 * doesn't leak state into unrelated test classes.
-	 */
-	public function tearDown(): void {
-		delete_option( 'woocommerce_feature_abandoned_cart_recovery_enabled' );
-		delete_option( 'woocommerce_customer_abandoned_cart_recovery_settings' );
-		update_option( 'active_plugins', $this->original_active_plugins );
-
-		if ( $this->admin_user_id ) {
-			wp_set_current_user( 0 );
-			wp_delete_user( $this->admin_user_id );
-			$this->admin_user_id = 0;
-		}
-
-		remove_all_actions( 'woocommerce_send_abandoned_cart_recovery_notification' );
-
-		parent::tearDown();
-	}
-
-	/**
-	 * Switch the current user to an administrator so capability-gated paths run.
-	 */
-	private function become_admin(): void {
-		$this->admin_user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
-		wp_set_current_user( $this->admin_user_id );
-	}
-
-	/**
-	 * Backdate the order's `date_created` so it clears the abandonment age threshold.
-	 *
-	 * `OrderHelper::create_order()` returns a freshly-created order, which by
-	 * definition has not yet been abandoned for the required duration. Tests that
-	 * exercise the post-threshold path call this helper to age the order past it.
-	 *
-	 * @param WC_Order $order Order to age.
-	 * @return WC_Order Reloaded order reflecting the persisted date.
-	 */
-	private function age_order_past_threshold( WC_Order $order ): WC_Order {
-		$order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
-		$order->save();
-		return wc_get_order( $order->get_id() );
-	}
-
-	/**
-	 * @testdox Constructor wires the email id, customer flag, group, and template paths (HTML, plain, block).
-	 */
-	public function test_constructor_sets_email_identity(): void {
-		$this->assertSame( 'customer_abandoned_cart_recovery', $this->sut->id );
-		$this->assertTrue( $this->sut->is_customer_email() );
-		$this->assertSame( 'order-updates', $this->sut->email_group );
-		$this->assertSame( 'emails/customer-abandoned-cart-recovery.php', $this->sut->template_html );
-		$this->assertSame( 'emails/plain/customer-abandoned-cart-recovery.php', $this->sut->template_plain );
-		$this->assertSame( 'emails/block/customer-abandoned-cart-recovery.php', $this->sut->template_block );
-	}
-
-	/**
-	 * @testdox Constructor declares the placeholders the default copy and the Available placeholders hint advertise.
-	 */
-	public function test_constructor_declares_expected_placeholders(): void {
-		$this->assertArrayHasKey( '{site_title}', $this->sut->placeholders );
-		$this->assertArrayHasKey( '{site_address}', $this->sut->placeholders );
-		$this->assertArrayHasKey( '{order_date}', $this->sut->placeholders );
-		$this->assertArrayHasKey( '{order_number}', $this->sut->placeholders );
-	}
-
-	/**
-	 * @testdox Defaults wire the expected JTBD-framed subject, heading, and additional content.
-	 */
-	public function test_default_copy(): void {
-		$this->assertSame( 'Still want it?', $this->sut->get_default_subject() );
-		$this->assertSame( 'Pick up where you left off', $this->sut->get_default_heading() );
-		$this->assertStringContainsString( 'reply to this email', $this->sut->get_default_additional_content() );
-	}
-
-	/**
-	 * @testdox Settings form exposes enabled + automated as checkboxes with the chosen defaults (enabled=yes, automated=no). No separate suppress toggle — handler detection drives the enabled default instead.
-	 */
-	public function test_form_fields_expose_enabled_and_automated(): void {
-		$this->sut->init_form_fields();
-
-		$this->assertArrayHasKey( 'enabled', $this->sut->form_fields );
-		$this->assertArrayHasKey( 'automated', $this->sut->form_fields );
-		$this->assertArrayHasKey( 'subject', $this->sut->form_fields );
-		$this->assertArrayHasKey( 'heading', $this->sut->form_fields );
-		$this->assertArrayHasKey( 'additional_content', $this->sut->form_fields );
-
-		$this->assertArrayNotHasKey( 'suppressed', $this->sut->form_fields, 'Suppress toggle should be consolidated into the enabled default.' );
-
-		$this->assertSame( 'yes', $this->sut->form_fields['enabled']['default'] );
-		$this->assertSame( 'checkbox', $this->sut->form_fields['enabled']['type'] );
-
-		$this->assertSame( 'no', $this->sut->form_fields['automated']['default'] );
-		$this->assertSame( 'checkbox', $this->sut->form_fields['automated']['type'] );
-	}
-
-	/**
-	 * @testdox is_automated() reflects the saved option and defaults to off when unset.
-	 */
-	public function test_is_automated_reads_option(): void {
-		$this->assertFalse( $this->sut->is_automated() );
-
-		$this->sut->update_option( 'automated', 'yes' );
-		$this->assertTrue( $this->sut->is_automated() );
-
-		$this->sut->update_option( 'automated', 'no' );
-		$this->assertFalse( $this->sut->is_automated() );
-	}
-
-	/**
-	 * @testdox get_recovery_url() returns the order's pay endpoint once a valid order is bound to the email.
-	 */
-	public function test_recovery_url_uses_order_pay_endpoint(): void {
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$this->sut->trigger( $order->get_id() );
-
-		$url = $this->sut->get_recovery_url();
-
-		$this->assertSame( $order->get_checkout_payment_url(), $url );
-	}
-
-	/**
-	 * @testdox get_recovery_url() returns empty string when no order is bound.
-	 */
-	public function test_recovery_url_empty_without_order(): void {
-		$this->assertSame( '', $this->sut->get_recovery_url() );
-	}
-
-	/**
-	 * @testdox The woocommerce_abandoned_cart_recovery_url filter can replace the generated URL so a follow-up can swap in a tokenized URL without touching templates.
-	 */
-	public function test_recovery_url_is_filterable(): void {
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$this->sut->trigger( $order->get_id() );
-
-		$override = static function () {
-			return 'https://example.test/custom-recovery';
-		};
-		add_filter( 'woocommerce_abandoned_cart_recovery_url', $override );
-
-		try {
-			$this->assertSame( 'https://example.test/custom-recovery', $this->sut->get_recovery_url() );
-		} finally {
-			remove_filter( 'woocommerce_abandoned_cart_recovery_url', $override );
-		}
-	}
-
-	/**
-	 * @testdox Email is registered with WC_Emails when the feature flag is on so the WC Settings → Emails page renders it.
-	 */
-	public function test_is_registered_with_wc_emails(): void {
-		WC()->mailer()->init();
-		$emails = WC()->mailer()->get_emails();
-
-		$this->assertArrayHasKey( 'WC_Email_Customer_Abandoned_Cart_Recovery', $emails );
-	}
-
-	/**
-	 * @testdox Calling trigger() with an invalid order id after a valid call does not dispatch to the previous recipient.
-	 */
-	public function test_trigger_clears_state_on_invalid_order(): void {
-		$this->sut->update_option( 'enabled', 'yes' );
-		$this->sut->enabled = 'yes';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-
-		$this->sut->trigger( $order->get_id() );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-		$this->sut->trigger( 0 );
-		$after = count( $mailer->mock_sent );
-
-		$this->assertSame( $before, $after, 'trigger() must not send to the previous order\'s recipient when called with an invalid id.' );
-		$this->assertSame( '', $this->sut->recipient );
-		$this->assertFalse( $this->sut->object );
-	}
-
-	/**
-	 * @testdox trigger() is a no-op when the email is disabled.
-	 */
-	public function test_trigger_is_noop_when_disabled(): void {
-		$this->sut->update_option( 'enabled', 'no' );
-		$this->sut->enabled = 'no';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-
-		$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, 'Disabled abandoned cart recovery email must not dispatch any mail.' );
-	}
-
-	/**
-	 * @testdox trigger() dispatches the email when enabled and the order has a billing email and is past the abandonment threshold.
-	 */
-	public function test_trigger_sends_when_enabled(): 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 );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-		$this->sut->trigger( $order->get_id() );
-		$after = count( $mailer->mock_sent );
-
-		$this->assertSame( $before + 1, $after, 'Enabled abandoned cart recovery email must dispatch one message.' );
-		$this->assertSame( $order->get_billing_email(), $this->sut->recipient );
-	}
-
-	/**
-	 * @testdox trigger() records the send timestamp on order meta so the future auto-send dedup can skip already-emailed orders.
-	 */
-	public function test_trigger_records_sent_at_meta(): 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 );
-
-		$before_ts = time();
-		$this->sut->trigger( $order->get_id() );
-
-		$fresh = wc_get_order( $order->get_id() );
-		$saved = $fresh->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT );
-
-		$this->assertNotEmpty( $saved, 'Successful trigger() must persist the sent_at meta.' );
-		$this->assertGreaterThanOrEqual( $before_ts, (int) $saved );
-		$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).
-	 */
-	public function test_trigger_does_not_record_meta_when_disabled(): void {
-		$this->sut->update_option( 'enabled', 'no' );
-		$this->sut->enabled = 'no';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-
-		$this->sut->trigger( $order->get_id() );
-
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertSame( '', $fresh->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ) );
-	}
-
-	/**
-	 * @testdox trigger() is a no-op when the woocommerce_abandoned_cart_recovery_suppress filter returns true.
-	 */
-	public function test_trigger_is_suppressed_when_filter_returns_true(): void {
-		$this->sut->update_option( 'enabled', 'yes' );
-		$this->sut->enabled = 'yes';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-
-		$override = static fn() => true;
-		add_filter( 'woocommerce_abandoned_cart_recovery_suppress', $override );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-		try {
-			$this->sut->trigger( $order->get_id() );
-			$after = count( $mailer->mock_sent );
-		} finally {
-			remove_filter( 'woocommerce_abandoned_cart_recovery_suppress', $override );
-		}
-
-		$this->assertSame( $before, $after, 'Filter-suppressed abandoned cart recovery email must not dispatch.' );
-	}
-
-	/**
-	 * @testdox trigger() refuses to send when the order has moved past the abandoned-checkout statuses, even if the action is invoked directly with the order id.
-	 */
-	public function test_trigger_skips_when_order_not_pending(): void {
-		$this->sut->update_option( 'enabled', 'yes' );
-		$this->sut->enabled = 'yes';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$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, 'Recovery email must not dispatch for non-abandoned orders.' );
-	}
-
-	/**
-	 * @testdox The woocommerce_abandoned_cart_recovery_eligible_statuses filter widens the eligible set for trigger().
-	 */
-	public function test_trigger_eligible_statuses_filter_can_widen(): void {
-		$this->sut->update_option( 'enabled', 'yes' );
-		$this->sut->enabled = 'yes';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order->set_status( 'failed' );
-		$order->save();
-		$order = $this->age_order_past_threshold( $order );
-
-		$widen = static function () {
-			return array( 'pending', 'failed' );
-		};
-		add_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-		try {
-			$this->sut->trigger( $order->get_id() );
-			$after = count( $mailer->mock_sent );
-		} finally {
-			remove_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
-		}
-
-		$this->assertSame( $before + 1, $after, 'Widened filter must allow non-default statuses to receive the email.' );
-	}
-
-	/**
-	 * @testdox is_suppressed() returns false by default so the email isn't blocked when no partner filter is registered.
-	 */
-	public function test_is_suppressed_defaults_to_false(): void {
-		$this->assertFalse( WC_Email_Customer_Abandoned_Cart_Recovery::is_suppressed() );
-	}
-
-	/**
-	 * @testdox get_active_recovery_handlers() returns empty when no known recovery-handling plugin is active.
-	 */
-	public function test_active_recovery_handlers_empty_when_none_active(): void {
-		update_option( 'active_plugins', array() );
-
-		$this->assertSame( array(), WC_Email_Customer_Abandoned_Cart_Recovery::get_active_recovery_handlers() );
-	}
-
-	/**
-	 * @testdox get_active_recovery_handlers() returns the AutomateWoo entry when only AutomateWoo is active.
-	 */
-	public function test_active_recovery_handlers_detects_automatewoo(): void {
-		update_option( 'active_plugins', array( 'automatewoo/automatewoo.php' ) );
-
-		$active = WC_Email_Customer_Abandoned_Cart_Recovery::get_active_recovery_handlers();
-
-		$this->assertArrayHasKey( 'automatewoo/automatewoo.php', $active );
-		$this->assertSame( 'AutomateWoo', $active['automatewoo/automatewoo.php'] );
-		$this->assertArrayNotHasKey( 'mailpoet/mailpoet.php', $active );
-	}
-
-	/**
-	 * @testdox get_active_recovery_handlers() detects both AutomateWoo and MailPoet when both are active.
-	 */
-	public function test_active_recovery_handlers_detects_both(): void {
-		update_option( 'active_plugins', array( 'automatewoo/automatewoo.php', 'mailpoet/mailpoet.php' ) );
-
-		$active = WC_Email_Customer_Abandoned_Cart_Recovery::get_active_recovery_handlers();
-
-		$this->assertCount( 2, $active );
-		$this->assertArrayHasKey( 'automatewoo/automatewoo.php', $active );
-		$this->assertArrayHasKey( 'mailpoet/mailpoet.php', $active );
-	}
-
-	/**
-	 * @testdox Enabled field defaults to 'no' when a known recovery handler is active so the merchant is pre-protected from duplicate sends, and the description names the detected plugin.
-	 */
-	public function test_enabled_field_default_is_no_when_handler_active(): void {
-		update_option( 'active_plugins', array( 'automatewoo/automatewoo.php' ) );
-
-		$this->sut->init_form_fields();
-
-		$this->assertSame( 'no', $this->sut->form_fields['enabled']['default'] );
-		$this->assertStringContainsString( 'AutomateWoo', $this->sut->form_fields['enabled']['description'] );
-	}
-
-	/**
-	 * @testdox Enabled field defaults to 'yes' when no known recovery handler is active so core's recovery email runs by default, and the description is empty.
-	 */
-	public function test_enabled_field_default_is_yes_when_no_handler_active(): void {
-		update_option( 'active_plugins', array() );
-
-		$this->sut->init_form_fields();
-
-		$this->assertSame( 'yes', $this->sut->form_fields['enabled']['default'] );
-		$this->assertSame( '', $this->sut->form_fields['enabled']['description'] );
-	}
-
-	/**
-	 * @testdox register_order_action() adds the manual-send entry for a pending order older than the abandonment threshold when the current user has edit_shop_orders.
-	 */
-	public function test_register_order_action_adds_entry_for_pending_order(): void {
-		$this->become_admin();
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order = $this->age_order_past_threshold( $order );
-
-		$actions = $this->sut->register_order_action( array(), $order );
-
-		$this->assertArrayHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
-		$this->assertSame( 'Send abandoned cart recovery email', $actions[ WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION ] );
-	}
-
-	/**
-	 * @testdox register_order_action() also surfaces the action for checkout-draft orders past the abandonment threshold (Blocks Store API parks abandoned-mid-flow orders there).
-	 */
-	public function test_register_order_action_adds_entry_for_checkout_draft_order(): void {
-		$this->become_admin();
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order->set_status( OrderStatus::CHECKOUT_DRAFT );
-		$order->save();
-		$order = $this->age_order_past_threshold( $order );
-
-		$actions = $this->sut->register_order_action( array(), $order );
-
-		$this->assertArrayHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
-	}
-
-	/**
-	 * @testdox register_order_action() hides the entry for an otherwise-eligible order that was created less than the abandonment threshold ago, so we don't nudge customers still on the page.
-	 */
-	public function test_register_order_action_skips_recent_orders(): void {
-		$this->become_admin();
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-
-		$actions = $this->sut->register_order_action( array(), $order );
-
-		$this->assertArrayNotHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
-	}
-
-	/**
-	 * @testdox register_order_action() leaves the dropdown alone once the order has moved past the abandoned-checkout statuses.
-	 */
-	public function test_register_order_action_skips_non_abandoned_orders(): void {
-		$this->become_admin();
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order->set_status( OrderStatus::PROCESSING );
-		$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 register_order_action() does not surface the action for users without edit_shop_orders, even on a pending order.
-	 */
-	public function test_register_order_action_requires_capability(): void {
-		// Logged-out / no caps.
-		wp_set_current_user( 0 );
-
-		// Age the order past the threshold so the capability check — not the
-		// recent-order gate — is what removes the action from the list.
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order = $this->age_order_past_threshold( $order );
-
-		$actions = $this->sut->register_order_action( array(), $order );
-
-		$this->assertArrayNotHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
-	}
-
-	/**
-	 * @testdox register_order_action() returns the unchanged action list when called without an order (e.g. order-list bulk context).
-	 */
-	public function test_register_order_action_passthrough_without_order(): void {
-		$this->become_admin();
-
-		$existing = array( 'foo' => 'Foo' );
-		$actions  = $this->sut->register_order_action( $existing, null );
-
-		$this->assertSame( $existing, $actions );
-	}
-
-	/**
-	 * @testdox handle_recovery_email_send() dispatches the email, persists the sent_at meta, and records an email-notification order note.
-	 */
-	public function test_handle_recovery_email_send_dispatches_and_records_note(): 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 );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-
-		$this->sut->handle_recovery_email_send( $order );
-
-		$this->assertSame( $before + 1, count( $mailer->mock_sent ), 'Manual send must dispatch one message.' );
-
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertNotEmpty( $fresh->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ) );
-
-		$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 from the order actions menu' )
-			),
-			'Manual send must record an order note announcing the email.'
-		);
-	}
-
-	/**
-	 * @testdox handle_recovery_email_send() also dispatches for checkout-draft orders that have a billing email and are past the abandonment threshold, mirroring the dropdown gating.
-	 */
-	public function test_handle_recovery_email_send_dispatches_on_checkout_draft(): void {
-		$this->become_admin();
-		$this->sut->update_option( 'enabled', 'yes' );
-		$this->sut->enabled = 'yes';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order->set_status( OrderStatus::CHECKOUT_DRAFT );
-		$order->save();
-		$order = $this->age_order_past_threshold( $order );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-
-		$this->sut->handle_recovery_email_send( $order );
-
-		$this->assertSame( $before + 1, count( $mailer->mock_sent ), 'Checkout-draft order with a billing email must dispatch.' );
-	}
-
-	/**
-	 * @testdox handle_recovery_email_send() is a no-op for orders that are still inside the abandonment window so a stale dropdown submission cannot fire prematurely.
-	 */
-	public function test_handle_recovery_email_send_bails_on_recent_order(): void {
-		$this->become_admin();
-		$this->sut->update_option( 'enabled', 'yes' );
-		$this->sut->enabled = 'yes';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-
-		$this->sut->handle_recovery_email_send( $order );
-
-		$this->assertSame( $before, count( $mailer->mock_sent ), 'Recent pending order must not dispatch.' );
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertSame( '', $fresh->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ) );
-	}
-
-	/**
-	 * @testdox handle_recovery_email_send() is a no-op when the order has moved past the abandoned-checkout statuses so a stale dropdown submission cannot resend.
-	 */
-	public function test_handle_recovery_email_send_bails_on_non_abandoned_status(): void {
-		$this->become_admin();
-		$this->sut->update_option( 'enabled', 'yes' );
-		$this->sut->enabled = 'yes';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order->set_status( OrderStatus::COMPLETED );
-		$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 ), 'Non-abandoned order must not dispatch.' );
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertSame( '', $fresh->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ) );
-	}
-
-	/**
-	 * @testdox handle_recovery_email_send() is a no-op for users without edit_shop_orders so an unauthorized hook caller cannot fire the email.
-	 */
-	public function test_handle_recovery_email_send_requires_capability(): void {
-		wp_set_current_user( 0 );
-		$this->sut->update_option( 'enabled', 'yes' );
-		$this->sut->enabled = 'yes';
-
-		// Age the order past the threshold so the capability check — not the
-		// recent-order gate — is what blocks the dispatch.
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order = $this->age_order_past_threshold( $order );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-
-		$this->sut->handle_recovery_email_send( $order );
-
-		$this->assertSame( $before, count( $mailer->mock_sent ), 'Unauthorized user must not dispatch the email.' );
-	}
-
-	/**
-	 * @testdox register_order_action() hides the action when the email is disabled, so the dropdown stays in sync with what trigger() would do.
-	 */
-	public function test_register_order_action_skips_when_email_disabled(): void {
-		$this->become_admin();
-		$this->sut->update_option( 'enabled', 'no' );
-		$this->sut->enabled = 'no';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order = $this->age_order_past_threshold( $order );
-
-		$actions = $this->sut->register_order_action( array(), $order );
-
-		$this->assertArrayNotHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
-	}
-
-	/**
-	 * @testdox register_order_action() hides the action when the woocommerce_abandoned_cart_recovery_suppress filter returns true, so merchants don't click a no-op item.
-	 */
-	public function test_register_order_action_skips_when_suppressed(): 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 );
-
-		add_filter( 'woocommerce_abandoned_cart_recovery_suppress', '__return_true' );
-		try {
-			$actions = $this->sut->register_order_action( array(), $order );
-		} finally {
-			remove_filter( 'woocommerce_abandoned_cart_recovery_suppress', '__return_true' );
-		}
-
-		$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.
-	 */
-	public function test_handle_recovery_email_send_bails_when_email_disabled(): void {
-		$this->become_admin();
-		$this->sut->update_option( 'enabled', 'no' );
-		$this->sut->enabled = 'no';
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order = $this->age_order_past_threshold( $order );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-
-		$this->sut->handle_recovery_email_send( $order );
-
-		$this->assertSame( $before, count( $mailer->mock_sent ), 'Disabled email must not dispatch from 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' )
-			),
-			'Disabled email must not record a "sent from the order actions menu" order note.'
-		);
-	}
-
-	/**
-	 * Create an order without a billing email or associated customer.
-	 *
-	 * `OrderHelper::create_order()` always seeds a billing email, and
-	 * `WC_Order::maybe_set_user_billing_email()` re-populates the field from
-	 * the associated user on save. To exercise the no-recipient path we have
-	 * to drop both the customer link and the address email together.
-	 *
-	 * @return WC_Order Reloaded order with empty billing email.
-	 */
-	private function create_order_without_recipient(): WC_Order {
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order->set_customer_id( 0 );
-		$order->set_billing_email( '' );
-		$order->save();
-
-		return $this->age_order_past_threshold( $order );
-	}
-
-	/**
-	 * @testdox register_order_action() hides the action when the order has no billing email — checkout-draft orders can land here mid-flow and a recipient-less send would silently no-op.
-	 */
-	public function test_register_order_action_skips_without_billing_email(): void {
-		$this->become_admin();
-
-		$order = $this->create_order_without_recipient();
-
-		$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() is a no-op when the order has no billing email so we don't record an order note for a send that never went out.
-	 */
-	public function test_handle_recovery_email_send_bails_without_billing_email(): void {
-		$this->become_admin();
-		$this->sut->update_option( 'enabled', 'yes' );
-		$this->sut->enabled = 'yes';
-
-		$order = $this->create_order_without_recipient();
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-
-		$this->sut->handle_recovery_email_send( $order );
-
-		$this->assertSame( $before, count( $mailer->mock_sent ), 'Order without a recipient must not dispatch.' );
-
-		$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' )
-			),
-			'Recipient-less order must not record a "sent from the order actions menu" order note.'
-		);
-	}
-
-	/**
-	 * @testdox handle_recovery_email_send() is a no-op when the woocommerce_abandoned_cart_recovery_suppress filter returns true — avoids writing a misleading order note when trigger() would also bail.
-	 */
-	public function test_handle_recovery_email_send_bails_when_suppressed(): 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 );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-
-		add_filter( 'woocommerce_abandoned_cart_recovery_suppress', '__return_true' );
-		try {
-			$this->sut->handle_recovery_email_send( $order );
-		} finally {
-			remove_filter( 'woocommerce_abandoned_cart_recovery_suppress', '__return_true' );
-		}
-
-		$this->assertSame( $before, count( $mailer->mock_sent ), 'Suppressed email must not dispatch from 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' )
-			),
-			'Suppressed email must not record a "sent from the order actions menu" order note.'
-		);
-	}
-
-	/**
-	 * @testdox trigger() does not dispatch when the recipient has previously unsubscribed — customer preference wins over the merchant's enabled setting.
-	 */
-	public function test_trigger_bails_when_recipient_unsubscribed(): 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 );
-
-		$repository = wc_get_container()->get( \Automattic\WooCommerce\Internal\Email\Unsubscribes\Storage::class );
-		$repository->mark_unsubscribed( $order->get_billing_email(), 'customer_abandoned_cart_recovery' );
-
-		try {
-			$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, 'Unsubscribed recipient must not receive a recovery email.' );
-		} finally {
-			// Cleanup so the row doesn't leak into later tests in this run.
-			$repository->erase_for_email( $order->get_billing_email() );
-		}
-	}
-
-	/**
-	 * @testdox register_order_action() hides the entry when the recipient has unsubscribed, so the merchant can't accidentally override the customer's preference from the dropdown.
-	 */
-	public function test_register_order_action_skips_unsubscribed_recipient(): void {
-		$this->become_admin();
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$order = $this->age_order_past_threshold( $order );
-
-		$repository = wc_get_container()->get( \Automattic\WooCommerce\Internal\Email\Unsubscribes\Storage::class );
-		$repository->mark_unsubscribed( $order->get_billing_email(), 'customer_abandoned_cart_recovery' );
-
-		try {
-			$actions = $this->sut->register_order_action( array(), $order );
-
-			$this->assertArrayNotHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
-		} finally {
-			$repository->erase_for_email( $order->get_billing_email() );
-		}
-	}
-
-	/**
-	 * @testdox handle_recovery_email_send() is a no-op when the recipient has unsubscribed — defense in depth in case the action hook is fired from outside the metabox.
-	 */
-	public function test_handle_recovery_email_send_bails_when_recipient_unsubscribed(): 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 );
-
-		$repository = wc_get_container()->get( \Automattic\WooCommerce\Internal\Email\Unsubscribes\Storage::class );
-		$repository->mark_unsubscribed( $order->get_billing_email(), 'customer_abandoned_cart_recovery' );
-
-		try {
-			$mailer = tests_retrieve_phpmailer_instance();
-			$before = count( $mailer->mock_sent );
-			$this->sut->handle_recovery_email_send( $order );
-
-			$this->assertSame( $before, count( $mailer->mock_sent ), 'Unsubscribed recipient must not receive a manual send.' );
-
-			$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' )
-				),
-				'Unsubscribed recipient must not have a "sent from the order actions menu" order note written.'
-			);
-		} finally {
-			$repository->erase_for_email( $order->get_billing_email() );
-		}
-	}
-
-	/**
-	 * @testdox get_unsubscribe_url() returns a signed URL pointing at the public endpoint once a valid order is bound; empty when there's no order to derive it from.
-	 */
-	public function test_get_unsubscribe_url(): void {
-		$this->assertSame( '', $this->sut->get_unsubscribe_url(), 'No order bound — no URL.' );
-
-		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
-		$this->sut->trigger( $order->get_id() );
-
-		$url = $this->sut->get_unsubscribe_url();
-
-		$this->assertNotEmpty( $url );
-		$this->assertStringContainsString( \Automattic\WooCommerce\Internal\Email\Unsubscribes\Endpoint::QUERY_VAR . '=' . $order->get_id(), $url );
-		$this->assertStringContainsString( 'sig=', $url );
-	}
-}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
deleted file mode 100644
index 91c9cb1c08f..00000000000
--- a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
+++ /dev/null
@@ -1,663 +0,0 @@
-<?php
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Tests\Internal\AbandonedCartRecovery;
-
-use Automattic\WooCommerce\Caches\OrderCache;
-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_created_via( 'checkout' );
-		$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_created_via( 'checkout' );
-		$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_created_via( 'checkout' );
-		$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.
-	 * @dataProvider provide_handle_new_order_test_cases
-	 *
-	 * @param bool $supply_order_argument Whether to supply the order object as the second argument to handle_new_order().
-	 */
-	public function test_handle_new_order_schedules_for_pending_order( bool $supply_order_argument ): void {
-		$order = OrderHelper::create_order();
-		$order->set_created_via( 'checkout' );
-		$order->set_status( OrderStatus::PENDING );
-		$order->save();
-
-		$order_argument = $supply_order_argument ? $order : null;
-
-		if ( $supply_order_argument ) {
-			wc_get_container()->get( OrderCache::class )->remove( $order->get_id() );
-		}
-
-		$this->sut->handle_new_order( $order->get_id(), $order_argument );
-
-		if ( $order_argument ) {
-			$this->assertNotEmpty(
-				$order_argument->get_meta( Scheduler::SCHEDULED_META_KEY ),
-				'Scheduled-at meta must be populated on the supplied order object.'
-			);
-		}
-
-		$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.'
-		);
-	}
-
-	/**
-	 * Provide test cases for {@see test_handle_new_order_schedules_for_pending_order()}.
-	 */
-	public function provide_handle_new_order_test_cases(): array {
-		return array(
-			'New order with no order object' => array( false ),
-			'New order with order object'    => array( true ),
-		);
-	}
-
-	/**
-	 * @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_created_via( 'checkout' );
-		$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_created_via( 'checkout' );
-		$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_created_via( 'checkout' );
-		$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_created_via( 'checkout' );
-		$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_created_via( 'checkout' );
-		$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_created_via( 'checkout' );
-		$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_new_order() is a no-op for pending orders not created by a customer checkout (admin invoices, REST API, renewals) — only abandoned checkouts get the automated nudge.
-	 * @dataProvider provider_non_checkout_origins
-	 *
-	 * @param string $created_via Order-creation origin to test.
-	 */
-	public function test_handle_new_order_skips_non_checkout_origin( string $created_via ): void {
-		$order = OrderHelper::create_order();
-		$order->set_created_via( $created_via );
-		$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 ) );
-		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
-	}
-
-	/**
-	 * Non-checkout order-creation origins.
-	 *
-	 * @return array<string, array<string>>
-	 */
-	public function provider_non_checkout_origins(): array {
-		return array(
-			'admin invoice'        => array( 'admin' ),
-			'REST API'             => array( 'rest-api' ),
-			'subscription renewal' => array( 'subscription' ),
-		);
-	}
-
-	/**
-	 * @testdox handle_new_order() schedules for a store-api (block checkout) order, so both checkout flows are covered by default.
-	 */
-	public function test_handle_new_order_schedules_for_store_api_origin(): void {
-		$order = OrderHelper::create_order();
-		$order->set_created_via( 'store-api' );
-		$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 ) );
-		$this->assertNotFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
-	}
-
-	/**
-	 * @testdox handle_new_order() honors the woocommerce_abandoned_cart_recovery_eligible_statuses filter: an order created in a widened status (e.g. failed) is scheduled, matching the send/manual paths.
-	 */
-	public function test_handle_new_order_schedules_for_filter_widened_status(): void {
-		$widen = static function ( $statuses ) {
-			$statuses[] = OrderStatus::FAILED;
-			return $statuses;
-		};
-
-		$order = OrderHelper::create_order();
-		$order->set_created_via( 'checkout' );
-		$order->set_status( OrderStatus::FAILED );
-		$order->save();
-
-		add_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
-		try {
-			$this->sut->handle_new_order( $order->get_id() );
-		} finally {
-			remove_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
-		}
-
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertNotEmpty(
-			$fresh->get_meta( Scheduler::SCHEDULED_META_KEY ),
-			'A status added via the eligible-statuses filter must be scheduled like the default abandoned statuses.'
-		);
-		$this->assertNotFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
-	}
-
-	/**
-	 * @testdox handle_status_changed() keeps the send queued when the order moves between statuses inside the filter-widened eligible set (e.g. pending → failed with `failed` added).
-	 */
-	public function test_handle_status_changed_keeps_schedule_within_widened_set(): void {
-		$order = $this->schedule_for_pending_order();
-
-		$widen = static function ( $statuses ) {
-			$statuses[] = OrderStatus::FAILED;
-			return $statuses;
-		};
-
-		add_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
-		try {
-			$this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::FAILED );
-		} finally {
-			remove_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
-		}
-
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertNotEmpty(
-			$fresh->get_meta( Scheduler::SCHEDULED_META_KEY ),
-			'A transition inside the widened eligible set must not cancel the queued send.'
-		);
-		$this->assertNotFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
-	}
-
-	/**
-	 * @testdox handle_status_changed() still cancels when the order exits the filter-widened eligible set (e.g. failed → processing with `failed` added).
-	 */
-	public function test_handle_status_changed_cancels_on_exit_from_widened_set(): void {
-		$order = $this->schedule_for_pending_order();
-
-		$widen = static function ( $statuses ) {
-			$statuses[] = OrderStatus::FAILED;
-			return $statuses;
-		};
-
-		add_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
-		try {
-			$this->sut->handle_status_changed( $order->get_id(), OrderStatus::FAILED, OrderStatus::PROCESSING );
-		} finally {
-			remove_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
-		}
-
-		$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_scheduled_send() is a no-op when the merchant disabled automation after the send was queued — the in-flight action must honor the current setting.
-	 */
-	public function test_handle_scheduled_send_skips_when_automation_disabled(): void {
-		$order = OrderHelper::create_order();
-		$order->set_created_via( 'checkout' );
-		$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->email->update_option( 'automated', 'no' );
-
-		$mailer = tests_retrieve_phpmailer_instance();
-		$before = count( $mailer->mock_sent );
-
-		$this->sut->handle_scheduled_send( $order->get_id() );
-
-		$this->assertSame( $before, count( $mailer->mock_sent ), 'A queued send must not dispatch once automation is toggled off.' );
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertSame(
-			'',
-			$fresh->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ),
-			'A skipped send must not record the sent_at meta.'
-		);
-	}
-
-	/**
-	 * @testdox A store-api order is scheduled through the real production wiring when it exits checkout-draft: the data store re-fires woocommerce_new_order on the draft → pending transition.
-	 */
-	public function test_store_api_draft_to_pending_transition_schedules_via_hooks(): void {
-		$order = OrderHelper::create_order();
-		$order->set_created_via( 'store-api' );
-		$order->set_status( OrderStatus::CHECKOUT_DRAFT );
-		$order->save();
-
-		// Register the production hooks; setUp() only wires ACTION_HOOK.
-		$this->sut->init();
-
-		$order->set_status( OrderStatus::PENDING );
-		$order->save();
-
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertNotEmpty(
-			$fresh->get_meta( Scheduler::SCHEDULED_META_KEY ),
-			'The checkout-draft → pending transition must schedule the send via the re-fired woocommerce_new_order hook.'
-		);
-		$this->assertNotFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
-	}
-
-	/**
-	 * @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() reuses the hook-supplied order while cancelling its pending send.
-	 */
-	public function test_handle_status_changed_cancels_scheduled_send_when_order_object_passed(): void {
-		$order = $this->schedule_for_pending_order();
-		$this->assertNotEmpty( $order->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Fixture must prime the supplied order instance with the scheduled-at meta.' );
-		wc_get_container()->get( OrderCache::class )->remove( $order->get_id() );
-
-		$filtered_order = null;
-		$capture_order  = static function ( array $statuses, ?WC_Order $candidate_order ) use ( &$filtered_order ): array {
-			$filtered_order = $candidate_order;
-			return $statuses;
-		};
-
-		add_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $capture_order, 10, 2 );
-		try {
-			$this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::PROCESSING, $order );
-		} finally {
-			remove_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $capture_order );
-		}
-
-		$this->assertSame( $order, $filtered_order, 'Eligibility checks must receive the order instance supplied by the hook.' );
-		$this->assertSame( '', $order->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared on the supplied order instance.' );
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared when the hook supplies the order object.' );
-		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ), 'Queued send must be unscheduled when the hook supplies the order object.' );
-	}
-
-	/**
-	 * @testdox handle_status_changed() falls back to a lookup when the 4th argument is not an order, so a caller passing null behaves like the id-only path.
-	 */
-	public function test_handle_status_changed_cancels_scheduled_send_when_order_object_is_null(): void {
-		$order = $this->schedule_for_pending_order();
-
-		$this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::PROCESSING, null );
-
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared when the order object is absent.' );
-		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ), 'Queued send must be unscheduled when the order object is absent.' );
-	}
-
-	/**
-	 * @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 when no order object is supplied and it falls back to a lookup — the `woocommerce_trash_order` path — so a deleted-then-restored order doesn't fire a stale send.
-	 */
-	public function test_handle_cancellation_clears_state_when_order_object_is_null(): 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() ) ) );
-	}
-
-	/**
-	 * @testdox handle_cancellation() reuses the caller-supplied order while clearing its scheduled state.
-	 */
-	public function test_handle_cancellation_clears_state_when_order_object_passed(): void {
-		$order = $this->schedule_for_pending_order();
-		$this->assertNotEmpty( $order->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Fixture must prime the supplied order instance with the scheduled-at meta.' );
-		wc_get_container()->get( OrderCache::class )->remove( $order->get_id() );
-
-		$this->sut->handle_cancellation( $order->get_id(), $order );
-
-		$this->assertSame( '', $order->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared on the supplied order instance.' );
-		$fresh = wc_get_order( $order->get_id() );
-		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared when the caller supplies the order object.' );
-		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ), 'Queued send must be unscheduled when the caller supplies the order object.' );
-	}
-
-	/**
-	 * 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_created_via( 'checkout' );
-		$order->set_status( OrderStatus::PENDING );
-		$order->save();
-
-		$this->sut->handle_new_order( $order->get_id() );
-
-		return wc_get_order( $order->get_id() );
-	}
-}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/Unsubscribes/EndpointTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/Unsubscribes/EndpointTest.php
deleted file mode 100644
index 520b68da5a4..00000000000
--- a/plugins/woocommerce/tests/php/src/Internal/Email/Unsubscribes/EndpointTest.php
+++ /dev/null
@@ -1,186 +0,0 @@
-<?php
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Tests\Internal\Email\Unsubscribes;
-
-use Automattic\WooCommerce\Internal\Email\Unsubscribes\Endpoint;
-use Automattic\WooCommerce\Internal\Email\Unsubscribes\Storage;
-use WC_Unit_Test_Case;
-use WPDieException;
-
-/**
- * Endpoint test.
- *
- * @covers \Automattic\WooCommerce\Internal\Email\Unsubscribes\Endpoint
- */
-class EndpointTest extends WC_Unit_Test_Case {
-
-	/**
-	 * @var Storage
-	 */
-	private $storage;
-
-	/**
-	 * @var Endpoint
-	 */
-	private $endpoint;
-
-	private const KIND = 'customer_checkout_recovery';
-
-	/**
-	 * Resolve the storage + endpoint singletons from the container so each
-	 * test exercises the same wiring production uses.
-	 */
-	public function setUp(): void {
-		parent::setUp();
-		$this->storage  = wc_get_container()->get( Storage::class );
-		$this->endpoint = wc_get_container()->get( Endpoint::class );
-	}
-
-	/**
-	 * Reset $_GET between tests so a previous payload doesn't bleed in.
-	 */
-	public function tearDown(): void {
-		unset(
-			$_GET[ Endpoint::QUERY_VAR ],
-			$_GET[ Endpoint::QUERY_VAR_HASH ],
-			$_GET['kind'],
-			$_GET['sig']
-		);
-		parent::tearDown();
-	}
-
-	/**
-	 * Drive maybe_handle() like a GET hit, swallowing the wp_die.
-	 *
-	 * @param array<string, mixed> $query Query params to set on $_GET.
-	 */
-	private function dispatch( array $query ): void {
-		foreach ( $query as $key => $value ) {
-			$_GET[ $key ] = $value;
-		}
-		try {
-			$this->endpoint->maybe_handle();
-		} catch ( WPDieException $e ) {
-			// wp_die() is expected — the endpoint always renders.
-			unset( $e );
-		}
-	}
-
-	/**
-	 * @testdox url_for() produces a URL whose signature verifies when maybe_handle() parses it back, completing the round trip.
-	 */
-	public function test_url_round_trip_marks_unsubscribed(): void {
-		$email = 'roundtrip-' . uniqid( '', true ) . '@example.test';
-		$url   = Endpoint::url_for( 12345, $email, self::KIND );
-
-		$parsed = wp_parse_url( $url );
-		parse_str( $parsed['query'] ?? '', $query );
-
-		$this->dispatch( $query );
-
-		$this->assertTrue( $this->storage->is_unsubscribed( $email, self::KIND ) );
-	}
-
-	/**
-	 * @testdox Tampering with the signature causes the endpoint to refuse the request and leave the unsubscribe state unchanged.
-	 */
-	public function test_invalid_signature_bails(): void {
-		$email = 'tamper-' . uniqid( '', true ) . '@example.test';
-		$url   = Endpoint::url_for( 12345, $email, self::KIND );
-
-		$parsed = wp_parse_url( $url );
-		parse_str( $parsed['query'] ?? '', $query );
-		$query['sig'] = str_repeat( '0', 64 );
-
-		$this->dispatch( $query );
-
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, self::KIND ) );
-	}
-
-	/**
-	 * @testdox A link signed for one kind cannot be replayed to opt out of a different kind — the kind is part of the signed payload.
-	 */
-	public function test_kind_mismatch_bails(): void {
-		$email = 'kind-' . uniqid( '', true ) . '@example.test';
-		$url   = Endpoint::url_for( 12345, $email, self::KIND );
-
-		$parsed = wp_parse_url( $url );
-		parse_str( $parsed['query'] ?? '', $query );
-		$query['kind'] = 'different_kind';
-
-		$this->dispatch( $query );
-
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, 'different_kind' ) );
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, self::KIND ) );
-	}
-
-	/**
-	 * @testdox Missing email-hash param is rejected even when the query-var is present.
-	 */
-	public function test_missing_hash_bails(): void {
-		$email = 'missing-' . uniqid( '', true ) . '@example.test';
-		$url   = Endpoint::url_for( 12345, $email, self::KIND );
-
-		$parsed = wp_parse_url( $url );
-		parse_str( $parsed['query'] ?? '', $query );
-		unset( $query[ Endpoint::QUERY_VAR_HASH ] );
-
-		$this->dispatch( $query );
-
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, self::KIND ) );
-	}
-
-	/**
-	 * @testdox An email-hash that doesn't match the SHA-256 hex shape is rejected without invoking storage, so the endpoint never writes a row for a malformed payload.
-	 */
-	public function test_malformed_hash_bails(): void {
-		$email = 'shape-' . uniqid( '', true ) . '@example.test';
-		$url   = Endpoint::url_for( 12345, $email, self::KIND );
-
-		$parsed = wp_parse_url( $url );
-		parse_str( $parsed['query'] ?? '', $query );
-		$query[ Endpoint::QUERY_VAR_HASH ] = 'not-a-hash';
-
-		$this->dispatch( $query );
-
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, self::KIND ) );
-	}
-
-	/**
-	 * @testdox url_for() never embeds the raw recipient email in the URL — the hash is what travels, so the address can't leak via logs, Referer headers, or link previews.
-	 */
-	public function test_url_does_not_contain_raw_email(): void {
-		$email = 'pii-leak-' . uniqid( '', true ) . '@example.test';
-		$url   = Endpoint::url_for( 12345, $email, self::KIND );
-
-		$this->assertStringNotContainsString( $email, $url );
-		$this->assertStringNotContainsString( rawurlencode( $email ), $url );
-		$this->assertStringNotContainsString( '@example.test', $url );
-	}
-
-	/**
-	 * @testdox Missing kind param is rejected even when everything else verifies.
-	 */
-	public function test_missing_kind_bails(): void {
-		$email = 'missing-kind-' . uniqid( '', true ) . '@example.test';
-		$url   = Endpoint::url_for( 12345, $email, self::KIND );
-
-		$parsed = wp_parse_url( $url );
-		parse_str( $parsed['query'] ?? '', $query );
-		unset( $query['kind'] );
-
-		$this->dispatch( $query );
-
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, self::KIND ) );
-	}
-
-	/**
-	 * @testdox Requests without the query-var are passed through (maybe_handle returns without rendering).
-	 */
-	public function test_no_query_var_passes_through(): void {
-		// No exception should be raised here — quick-bail path.
-		$this->endpoint->maybe_handle();
-		$this->assertTrue( true );
-	}
-}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/Unsubscribes/StorageTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/Unsubscribes/StorageTest.php
deleted file mode 100644
index f7dd51be991..00000000000
--- a/plugins/woocommerce/tests/php/src/Internal/Email/Unsubscribes/StorageTest.php
+++ /dev/null
@@ -1,130 +0,0 @@
-<?php
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Tests\Internal\Email\Unsubscribes;
-
-use Automattic\WooCommerce\Internal\Email\Unsubscribes\Storage;
-use WC_Unit_Test_Case;
-
-/**
- * Storage test.
- *
- * @covers \Automattic\WooCommerce\Internal\Email\Unsubscribes\Storage
- */
-class StorageTest extends WC_Unit_Test_Case {
-
-	/**
-	 * @var Storage
-	 */
-	private $storage;
-
-	/**
-	 * Arbitrary kind used by these tests — represents whichever email class
-	 * happens to be exercising the table.
-	 */
-	private const KIND = 'customer_checkout_recovery';
-
-	/**
-	 * Resolve the storage singleton from the container so each test exercises
-	 * the same wiring production uses.
-	 */
-	public function setUp(): void {
-		parent::setUp();
-		$this->storage = wc_get_container()->get( Storage::class );
-	}
-
-	/**
-	 * @testdox hash_email() normalizes case and whitespace before hashing so equivalent addresses collide on the same row.
-	 */
-	public function test_hash_email_normalizes_case_and_whitespace(): void {
-		$canonical = Storage::hash_email( 'jane@example.test' );
-
-		$this->assertSame( $canonical, Storage::hash_email( '  jane@example.test  ' ) );
-		$this->assertSame( $canonical, Storage::hash_email( 'JANE@Example.Test' ) );
-		$this->assertSame( 64, strlen( $canonical ) );
-	}
-
-	/**
-	 * @testdox hash_email() returns empty string for empty input so callers can early-out without raising.
-	 */
-	public function test_hash_email_empty_input(): void {
-		$this->assertSame( '', Storage::hash_email( '' ) );
-		$this->assertSame( '', Storage::hash_email( '   ' ) );
-	}
-
-	/**
-	 * @testdox mark_unsubscribed() then is_unsubscribed() returns true; a different email or kind is unaffected.
-	 */
-	public function test_mark_and_check_unsubscribed(): void {
-		$email = 'roundtrip-' . uniqid( '', true ) . '@example.test';
-
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, self::KIND ) );
-		$this->assertTrue( $this->storage->mark_unsubscribed( $email, self::KIND ) );
-		$this->assertTrue( $this->storage->is_unsubscribed( $email, self::KIND ) );
-
-		$this->assertFalse( $this->storage->is_unsubscribed( 'unrelated-' . uniqid( '', true ) . '@example.test', self::KIND ) );
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, 'unrelated_kind' ), 'A different kind must not inherit the opt-out.' );
-	}
-
-	/**
-	 * @testdox mark_unsubscribed_by_hash() records the row under the given hash so callers that already operate on the hash (the public Endpoint) never have to recover the raw address.
-	 */
-	public function test_mark_unsubscribed_by_hash(): void {
-		$email = 'by-hash-' . uniqid( '', true ) . '@example.test';
-		$hash  = Storage::hash_email( $email );
-
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, self::KIND ) );
-		$this->assertTrue( $this->storage->mark_unsubscribed_by_hash( $hash, self::KIND ) );
-		$this->assertTrue( $this->storage->is_unsubscribed( $email, self::KIND ) );
-	}
-
-	/**
-	 * @testdox mark_unsubscribed_by_hash() rejects empty hash or empty kind without writing a row.
-	 */
-	public function test_mark_unsubscribed_by_hash_rejects_empty(): void {
-		$this->assertFalse( $this->storage->mark_unsubscribed_by_hash( '', self::KIND ) );
-		$this->assertFalse( $this->storage->mark_unsubscribed_by_hash( str_repeat( 'a', 64 ), '' ) );
-	}
-
-	/**
-	 * @testdox mark_unsubscribed_by_hash() refuses inputs that don't match HASH_PATTERN — a future caller that forgets to pre-validate can't insert junk rows.
-	 */
-	public function test_mark_unsubscribed_by_hash_rejects_malformed(): void {
-		$this->assertFalse( $this->storage->mark_unsubscribed_by_hash( 'not-a-hash', self::KIND ) );
-		$this->assertFalse( $this->storage->mark_unsubscribed_by_hash( str_repeat( 'g', 64 ), self::KIND ), 'Non-hex chars must be rejected.' );
-		$this->assertFalse( $this->storage->mark_unsubscribed_by_hash( str_repeat( 'a', 63 ), self::KIND ), 'Wrong length must be rejected.' );
-		$this->assertFalse( $this->storage->mark_unsubscribed_by_hash( str_repeat( 'A', 64 ), self::KIND ), 'Uppercase hex must be rejected — hash() returns lowercase.' );
-	}
-
-	/**
-	 * @testdox erase_for_email() removes all rows for an address across every kind — the GDPR eraser clears all preferences for the requested email.
-	 */
-	public function test_erase_for_email_removes_all_kinds(): void {
-		$email = 'erase-' . uniqid( '', true ) . '@example.test';
-
-		$this->storage->mark_unsubscribed( $email, self::KIND );
-		$this->storage->mark_unsubscribed( $email, 'another_kind' );
-
-		$deleted = $this->storage->erase_for_email( $email );
-
-		$this->assertGreaterThanOrEqual( 2, $deleted );
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, self::KIND ) );
-		$this->assertFalse( $this->storage->is_unsubscribed( $email, 'another_kind' ) );
-	}
-
-	/**
-	 * @testdox The personal-data eraser callback reports items_removed=true when rows existed, false otherwise.
-	 */
-	public function test_personal_data_eraser_callback_reports_outcome(): void {
-		$with_rows = 'eraser-with-' . uniqid( '', true ) . '@example.test';
-		$this->storage->mark_unsubscribed( $with_rows, self::KIND );
-
-		$result = $this->storage->handle_personal_data_erasure( $with_rows );
-		$this->assertTrue( $result['items_removed'] );
-		$this->assertTrue( $result['done'] );
-
-		$result = $this->storage->handle_personal_data_erasure( 'eraser-empty-' . uniqid( '', true ) . '@example.test' );
-		$this->assertFalse( $result['items_removed'] );
-		$this->assertTrue( $result['done'] );
-	}
-}