Commit c5efd8647be for woocommerce

commit c5efd8647bea90fa6455662bfca51ca39966488b
Author: Mayisha <33387139+Mayisha@users.noreply.github.com>
Date:   Wed Jun 3 22:50:56 2026 +0600

    [RSM] Add Checkout Recovery email settings behind an experimental feature flag (#65136)

    * Gate Abandoned Checkout Recovery behind a FeaturesController flag

    * Add email templates for customer checkout recovery

    * Add checkout recovery email class

    * Enable checkout recovery feature flag for new installs

    * Add tests for email class

    * Suppress checkout recovery email when a known recovery handler is active

    * add test for email suppress

    * add changelog

    * Fix is_suppressed() to respect detection-based default

    * Address AI review feedback

    * Fix lint

    * Gate trigger() by order status

    * Remove hook callbacks in checkout recovery email tests

    * Drop is_experimental flag on checkout_recovery feature

    * Apply suggested copy review to settings + email defaults

    * Rename "Checkout recovery" → "Abandoned cart recovery"

    * Show success notice when abandoned cart recovery is enabled

    * Drop suppress toggle, fold handler detection into enabled default

    * Fix lint

    * [RSM] Add recommendation on Checkout Recovery settings (#65140)

    * Recommend AutomateWoo and MailPoet on the Checkout Recovery settings page

    * add tests for recommendation

    * add changelog

    * Address AI review feedback

    * Rename React module: checkout-recovery → abandoned-cart-recovery

    * Whitelist dismiss option

    * Tighten recommendation copy, drop AutomateWoo pill, add marketplace footer

    * Install MailPoet in-place from the recommendation card

    * Add brand logos to abandoned cart recovery recommendation items

    * fix lint errors

    * Address feedback

    * [RSM] Add manual send action for the Checkout Recovery email (#65164)

    * Add manual recovery email send from order edit page

    * add tests for manual recovery email

    * Register manual-send order action via Internal service

    * add changelog

    * Sweep stale checkout_recovery references after merge

    * Address PR review on checkout recovery manual send

    * Update order note

    * [RSM] Add customer unsubscribe to the Checkout Recovery email (#65165)

    * Generic email-unsubscribe storage and endpoint

    * Hook unsubscribe into the checkout-recovery email

    * Expose unsubscribe URL in the block email editor

    * add changelog

    * Address PR review on checkout-recovery unsubscribe

    * Address nitpick comments

    * fix test

    * fix lint

    * update changelog

diff --git a/plugins/woocommerce/changelog/abandoned-cart-recovery-email b/plugins/woocommerce/changelog/abandoned-cart-recovery-email
new file mode 100644
index 00000000000..cad033f0e9a
--- /dev/null
+++ b/plugins/woocommerce/changelog/abandoned-cart-recovery-email
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a new transactional abandoned cart recovery email that prompts customers to complete a pending checkout, behind the new `checkout_recovery` feature flag.
diff --git a/plugins/woocommerce/changelog/abandoned-cart-recovery-recommendations b/plugins/woocommerce/changelog/abandoned-cart-recovery-recommendations
new file mode 100644
index 00000000000..f2d3f583ff5
--- /dev/null
+++ b/plugins/woocommerce/changelog/abandoned-cart-recovery-recommendations
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a recommendation card on the Abandoned cart recovery email settings page.
diff --git a/plugins/woocommerce/changelog/checkout-recovery-manual-send b/plugins/woocommerce/changelog/checkout-recovery-manual-send
new file mode 100644
index 00000000000..e13480891b4
--- /dev/null
+++ b/plugins/woocommerce/changelog/checkout-recovery-manual-send
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a "Send checkout recovery email" action to the order actions dropdown so merchants can manually send the Checkout Recovery email for pending or checkout-draft orders past the 1-hour abandonment threshold.
diff --git a/plugins/woocommerce/changelog/checkout-recovery-unsubscribe b/plugins/woocommerce/changelog/checkout-recovery-unsubscribe
new file mode 100644
index 00000000000..c9cee1ab4c7
--- /dev/null
+++ b/plugins/woocommerce/changelog/checkout-recovery-unsubscribe
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Customers can now unsubscribe from Checkout Recovery emails via a one-click link in the email footer.
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
new file mode 100644
index 00000000000..1cfa1f50160
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations-wrapper.tsx
@@ -0,0 +1,46 @@
+/**
+ * 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
new file mode 100644
index 00000000000..2e4acf5d31b
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.scss
@@ -0,0 +1,57 @@
+.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
new file mode 100644
index 00000000000..bece6d8387f
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx
@@ -0,0 +1,139 @@
+/**
+ * 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 { 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 hook (`shipping-recommendations.tsx#useInstallPlugin`)
+ * with a narrower surface — we only need the combined install+activate path.
+ */
+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;
+} ) => (
+	<DismissableList
+		className="woocommerce-recommended-abandoned-cart-recovery-extensions"
+		dismissOptionName="woocommerce_abandoned_cart_recovery_recommendations_hidden"
+	>
+		<DismissableListHeading>
+			<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
new file mode 100644
index 00000000000..d6b3473f573
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/automatewoo-item.tsx
@@ -0,0 +1,54 @@
+/**
+ * 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
new file mode 100644
index 00000000000..ddf7caa22eb
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/index.ts
@@ -0,0 +1 @@
+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
new file mode 100644
index 00000000000..17224a8956a
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/mailpoet-item.tsx
@@ -0,0 +1,86 @@
+/**
+ * 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
new file mode 100644
index 00000000000..7f38d5b1365
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations-wrapper.test.tsx
@@ -0,0 +1,152 @@
+/**
+ * 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
new file mode 100644
index 00000000000..2eff5378c46
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx
@@ -0,0 +1,81 @@
+/**
+ * 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,
+} ) );
+
+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
new file mode 100644
index 00000000000..bbfdaa83dd7
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/promo-items.test.tsx
@@ -0,0 +1,146 @@
+/**
+ * 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 4f902d2805f..63a85518b1d 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
@@ -15,6 +15,7 @@ import QueryString, { parse } from 'qs';
  */
 import { PaymentRecommendations } from '../payments';
 import { ShippingRecommendations } from '../shipping';
+import { AbandonedCartRecoveryRecommendations } from '../abandoned-cart-recovery';
 import { EmbeddedBodyProps } from './embedded-body-props';
 import './style.scss';

@@ -29,6 +30,7 @@ function isWPPage(
 const EMBEDDED_BODY_COMPONENT_LIST: React.ElementType[] = [
 	PaymentRecommendations,
 	ShippingRecommendations,
+	AbandonedCartRecoveryRecommendations,
 ];

 /**
diff --git a/plugins/woocommerce/includes/class-wc-emails.php b/plugins/woocommerce/includes/class-wc-emails.php
index 6441c8c6fd6..b7fc234e24f 100644
--- a/plugins/woocommerce/includes/class-wc-emails.php
+++ b/plugins/woocommerce/includes/class-wc-emails.php
@@ -310,6 +310,9 @@ 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 c290f9ec4ef..1de81ce2c5c 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -373,6 +373,7 @@ class WC_Install {
 		add_action( 'woocommerce_newly_installed', array( __CLASS__, 'maybe_enable_hpos' ), 20 );
 		add_action( 'woocommerce_newly_installed', array( __CLASS__, 'add_coming_soon_option' ), 20 );
 		add_action( 'woocommerce_newly_installed', array( __CLASS__, 'enable_email_improvements_for_newly_installed' ), 20 );
+		add_action( 'woocommerce_newly_installed', array( __CLASS__, 'enable_abandoned_cart_recovery_for_newly_installed' ), 20 );
 		add_action( 'woocommerce_newly_installed', array( __CLASS__, 'enable_customer_stock_notifications_signups' ), 20 );
 		add_action( 'woocommerce_newly_installed', array( __CLASS__, 'enable_analytics_scheduled_import' ), 20 );
 		add_action( 'woocommerce_newly_installed', array( __CLASS__, 'enable_product_instance_caching_for_newly_installed' ), 20 );
@@ -1284,6 +1285,20 @@ class WC_Install {
 		update_option( 'woocommerce_email_improvements_enabled_count', 1 );
 	}

+	/**
+	 * Enable the abandoned cart recovery feature by default for new shops.
+	 *
+	 * Existing stores receiving this as a plugin update remain default-off and
+	 * must opt in via WooCommerce → Settings → Advanced → Features.
+	 *
+	 * @since 10.9.0
+	 *
+	 * @return void
+	 */
+	public static function enable_abandoned_cart_recovery_for_newly_installed() {
+		wc_get_container()->get( FeaturesController::class )->change_feature_enable( 'abandoned_cart_recovery', true );
+	}
+
 	/**
 	 * Enable customer stock notifications signups by default for new shops.
 	 *
@@ -1751,7 +1766,10 @@ class WC_Install {

 		// Stock Notifications Table Schema.
 		$stock_notifications_table_schema = wc_get_container()->get( StockNotificationsDataStore::class )->get_database_schema();
-		$order_stats_table_schema         = self::get_order_stats_table_schema( $collate );
+
+		// 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 );

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

 		return $tables;
@@ -2134,6 +2153,7 @@ $stock_notifications_table_schema;
 			"{$wpdb->prefix}wc_product_attributes_lookup",
 			"{$wpdb->prefix}wc_stock_notifications",
 			"{$wpdb->prefix}wc_stock_notificationmeta",
+			"{$wpdb->prefix}wc_email_unsubscribes",

 			// WCA Tables.
 			"{$wpdb->prefix}wc_order_stats",
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index e94f4071c8a..e22d316e314 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -326,6 +326,8 @@ final class WooCommerce {
 		add_action( 'load-post.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' ) );
@@ -987,6 +989,43 @@ 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 10.9.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 );
+	}
+
+	/**
+	 * 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 10.9.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
new file mode 100644
index 00000000000..9780c73616a
--- /dev/null
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
@@ -0,0 +1,627 @@
+<?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  10.9.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.
+		 *
+		 * @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;
+
+		/**
+		 * 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}' => '',
+			);
+
+			// Trigger fires after Action Scheduler dispatches `woocommerce_send_abandoned_cart_recovery_notification`,
+			// or when the merchant invokes the manual-send action from the order edit page.
+			// The order-edit action hooks live in `Internal\AbandonedCartRecovery\ManualSendHandler`
+			// so the listener is in place before the admin POST runs the order-meta save flow
+			// (which happens before the mailer would otherwise be instantiated).
+			add_action( 'woocommerce_send_abandoned_cart_recovery_notification', array( $this, 'trigger' ), 10, 1 );
+
+			parent::__construct();
+
+			// Must be after parent's constructor which sets `email_improvements_enabled` property.
+			$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.
+		 *
+		 * Wired to `woocommerce_send_abandoned_cart_recovery_notification`, which Action
+		 * Scheduler fires with the order id as its single argument. Also called
+		 * directly by the manual-send action on the order edit page.
+		 *
+		 * @since 10.9.0
+		 *
+		 * @param int $order_id The order ID.
+		 */
+		public function trigger( $order_id ): void {
+			if ( self::is_suppressed() ) {
+				return;
+			}
+
+			$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();
+			}
+
+			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() )
+			) {
+				$sent = $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
+
+				// Only record the send timestamp when the dispatch actually succeeded.
+				if ( $sent ) {
+					$this->object->update_meta_data( self::META_KEY_SENT_AT, (string) time() );
+					$this->object->save_meta_data();
+				}
+			}
+
+			$this->restore_locale();
+		}
+
+		/**
+		 * 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 10.9.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;
+			}
+
+			$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 10.9.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.
+			 *
+			 * Defaults to the abandoned-checkout statuses (`pending`, `checkout-draft`). Partner
+			 * integrations or merchants who want recovery to fire for other states (e.g. `failed`)
+			 * can widen the list here.
+			 *
+			 * @since 10.9.0
+			 *
+			 * @param string[] $eligible_statuses Default: ABANDONED_STATUSES.
+			 * @param WC_Order $order             Order being inspected.
+			 */
+			$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 10.9.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;
+			}
+
+			/**
+			 * Fires before the abandoned cart recovery email is manually resent.
+			 *
+			 * @since 10.9.0
+			 *
+			 * @param WC_Order $order      Order being recovered.
+			 * @param string   $email_type Email identifier ('customer_abandoned_cart_recovery').
+			 */
+			do_action( 'woocommerce_before_resend_order_emails', $order, $this->id );
+
+			$this->trigger( $order->get_id() );
+
+			$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 10.9.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 10.9.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 10.9.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 10.9.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 10.9.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  10.9.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 10.9.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  10.9.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  10.9.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  10.9.0
+		 * @return string
+		 */
+		public function get_default_subject() {
+			return __( 'Still want it?', 'woocommerce' );
+		}
+
+		/**
+		 * Get default email heading.
+		 *
+		 * @since  10.9.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  10.9.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/src/Admin/API/Options.php b/plugins/woocommerce/src/Admin/API/Options.php
index b608334b1d4..a8c419b2048 100644
--- a/plugins/woocommerce/src/Admin/API/Options.php
+++ b/plugins/woocommerce/src/Admin/API/Options.php
@@ -187,6 +187,7 @@ 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
new file mode 100644
index 00000000000..1883aebb330
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php
@@ -0,0 +1,116 @@
+<?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 10.9.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/Email/Unsubscribes/Endpoint.php b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php
new file mode 100644
index 00000000000..faaf2f96d7b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php
@@ -0,0 +1,170 @@
+<?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 10.9.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
new file mode 100644
index 00000000000..6b7ae670f8b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php
@@ -0,0 +1,272 @@
+<?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 10.9.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 9af78f97a75..3bf666135e9 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTagManager.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTagManager.php
@@ -9,6 +9,7 @@ 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;

@@ -47,14 +48,22 @@ 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->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();
 	}

 	/**
@@ -78,6 +87,7 @@ 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
new file mode 100644
index 00000000000..01e3bc35c76
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/PersonalizationTags/UnsubscribeTagsProvider.php
@@ -0,0 +1,56 @@
+<?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 501c86d507b..3c6789c8c1a 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -166,6 +166,8 @@ 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 );
 	}

 	/**
@@ -451,6 +453,18 @@ 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 like the other opt-in transactional-email features.
+				'skip_compatibility_checks'    => true,
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+				'enabled_by_default'           => false,
+				'is_experimental'              => false,
+			),
 			'email_improvements'                 => array(
 				'name'                         => __( 'Email improvements', 'woocommerce' ),
 				'description'                  => __(
@@ -2066,6 +2080,58 @@ 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
new file mode 100644
index 00000000000..b730479f9ac
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php
@@ -0,0 +1,49 @@
+<?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 10.9.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
new file mode 100644
index 00000000000..dfffe09b065
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php
@@ -0,0 +1,102 @@
+<?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 10.9.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
new file mode 100644
index 00000000000..dcf4ea84d9d
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php
@@ -0,0 +1,74 @@
+<?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 10.9.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
new file mode 100644
index 00000000000..7e380d146ef
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php
@@ -0,0 +1,907 @@
+<?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';
+
+		WC()->mailer()->init();
+
+		$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 {
+		$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 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 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/Email/Unsubscribes/EndpointTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/Unsubscribes/EndpointTest.php
new file mode 100644
index 00000000000..520b68da5a4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/Unsubscribes/EndpointTest.php
@@ -0,0 +1,186 @@
+<?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
new file mode 100644
index 00000000000..f7dd51be991
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/Unsubscribes/StorageTest.php
@@ -0,0 +1,130 @@
+<?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'] );
+	}
+}