Commit a5bbf97e4b1 for woocommerce

commit a5bbf97e4b1b71c9f62b4e590e9a486808331efd
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date:   Tue May 5 20:45:50 2026 +0100

    [Email Editor] Add selective apply for block email template updates (#64497)

    * [Email Editor] Add selective apply + undo backend for template updates (RSM-143)

    Backend foundation for the Review drawer's "Keep yours / Use core"
    workflow. Lands the merger, the apply / undo REST surface, and the
    deprecated-namespace migration — frontend (Review drawer component)
    follows in a separate PR per the RSM-143 plan.

    WCEmailTemplateSelectiveApplier:
    - apply_selectively($post_id, $choices) — produces a merged post_content
      that preserves the merchant's spine and layers core changes on top
      per the v1 algorithm:
        - copy_changes default keep_yours; merchant must explicitly opt
          into use_core to discard their edit (matches the design's
          "Default is always Keep yours").
        - added_blocks always applied (auto-resolved Apply core); inserted
          via core's path with best-effort fallback to closest-level append.
        - removed_blocks always preserved (auto-resolved Keep yours).
        - structural_changes (nest / reorder) punted in v1; surfaced as
          structural_skipped: true so consumers can mention it. Conservative
          — reorder transformations on customised trees risk surprising
          layouts; revisit when telemetry shows demand.
    - undo($post_id, $revision_id) — restores the pre-apply snapshot via
      meta key _wc_email_template_pre_apply_snapshot. Single-step (each
      apply overwrites the prior snapshot); a stale or missing revision_id
      returns 410 Gone. Matches the design's toast Undo + footer "Revision
      recorded for rollback."
    - is_applying() flag mirrors WCEmailTemplateAutoApplier's so future
      save_post listeners can ignore system-initiated writes.
    - On success, stamps the four sync meta keys (version, source_hash,
      synced_at, status=in_sync) — same as the auto-applier's wholesale
      path. Invalidates WCEmailTemplateChangeSummary's transient cache so
      the next read reflects the merged state.

    Deprecated-namespace migration: an unconditional final pass rewrites
    wp:woo/email-content to the canonical wp:woocommerce/email-content
    (blockName + the wp-block-woo-email-content CSS class on the inner
    div). Targeted to this single rename by design — woo/email-content is
    a known alias of the canonical block, not a customisation worth
    preserving. Surfaced on the response via aliases_migrated so consumers
    can mention it in success copy. Migration is reversible — undo
    restores the original namespace from the snapshot.

    REST routes on EmailApiController:
    - POST /woocommerce-email-editor/v1/emails/{id}/apply with
      body { choices: [{path, decision}, ...] }. 404 path mirrors the
      sibling routes; 422 fires when summarize() returns is_fallback (post
      outside the sync registry, or the inversion guard tripped).
    - POST /woocommerce-email-editor/v1/emails/{id}/undo with
      body { revision_id }. 410 fires for missing or stale snapshots.

    WCEmailTemplateChangeSummary::flatten_blocks() and lcs_matches()
    promoted from private to public (still @internal-marked) so the
    SelectiveApplier reuses the same matched-pair alignment without
    re-implementing the algorithm. Internal-namespace-bounded; not part
    of any external contract.

    Tests: 14 service tests (keep_yours default, use_core text replace,
    added_blocks insert, removed_blocks preserve, structural punt,
    snapshot write, sync meta stamp, undo round-trip, undo 410 paths,
    inversion-guard refusal, second-apply overwrites snapshot,
    woo→woocommerce namespace migration, 404 for posts with no email
    type) + 4 controller tests for the apply / undo routes. Total 35
    tests / 218 assertions pass. Lint clean. PHPStan clean (no baseline
    additions).

    Verified end-to-end on a divergent wp-env post: per-conflict use_core
    swaps the merchant's text for core's; auto-resolved Keep yours
    preserves the merchant's custom blocks (gallery, images, group);
    structural_skipped flagged when a Group wrap was punted; undo
    round-trip is byte-identical; second undo with the same revision_id
    returns 410.

    Frontend (RSM-143 Part B — Review drawer component) lands separately.

    * [Email Editor] Add Review drawer UI for selective template updates (RSM-143)

    Frontend half of RSM-143. Adds an interim "Review template update" button
    in the email actions slot that opens a 480px right-side drawer with the
    change-summary diff and per-conflict Keep yours / Use core radios. Apply
    calls POST /apply (backend in 3563f1451b), syncs the editor entity to
    the merged content, and offers Undo via POST /undo from a snackbar.

    The drawer renders via React portal to document.body because PluginArea
    (scope: woocommerce-email-editor) mounts non-Slot output inside a hidden
    wrapper, which would otherwise trap the fixed-position panel.

    The button is interim — RSM-141 will replace it with the editor banner.

    * Add @wordpress/ui@0.12.0 to client/admin

    * [Email Editor] Rebuild Review drawer on @wordpress/ui Drawer + match design

    Replace the hand-rolled drawer (portal, focus trap, key handlers, slide
    animation, ~200 lines of skeleton CSS) with `@wordpress/ui`'s `Drawer`
    primitive — Root, Popup, Header, Title, Description, Content, Footer,
    CloseIcon, Action. Switch the trigger button to `@wordpress/ui` Button
    (outline / neutral).

    Restyle the body to match the handoff: dotted section heading (amber for
    conflicts, brand blue for auto-resolved), per-row title + subtitle + tag
    pill (Conflict amber / Apply core blueberry / Keep yours gray), the
    two-up choice grid with label + hint cards instead of flat radios, and
    the small "Revision recorded for rollback." note in the footer.

    `Spinner` is the only `@wordpress/components` import that remains —
    `@wordpress/ui` doesn't ship one yet.

    * [Email Editor] Adopt @wordpress/ui Drawer compound + design polish

    Drop the bespoke conflict tag span, item title/sub divs, and most of the
    drawer chrome CSS in favour of `@wordpress/ui` `Badge`, `Text`, and
    `Drawer.Action`. Apply uses `Drawer.Action loading={isApplying}` so the
    compound's dismiss + aria-busy semantics stay consistent. Keep the
    two-up choice cards with label + hint per the design — the picker's
    two-line treatment doesn't fit `ToggleGroupControl`'s single-label API.

    Pin the footer buttons to their natural width (`flex-shrink: 0` +
    `white-space: nowrap`) so labels like "Apply (11)" stay on a single
    line; the note absorbs the shrinkage instead.

    * [Email Editor] Drive review drawer via integration store + drop trigger button

    Add a small `@wordpress/data` store at `woocommerce/email-editor-integration`
    that owns the review drawer's open / close state. Other surfaces — the
    RSM-141 banner, future list-page row actions, browser console — open
    the drawer with:

      wp.data.dispatch( 'woocommerce/email-editor-integration' )
        .openReviewDrawer();

    This unblocks RSM-141, whose trigger button lives in a different React
    subtree from the drawer.

    The interim "Review template update" button + its `EmailActionsFill`
    slotfill, change-summary prefetch, and the imports that supported them
    are removed from `review-update-plugin.tsx`. The component now exists
    only to mount the drawer at plugin scope and feed it the current post
    ID + title; `isOpen` and `onOpenChange` come from the store.

    * Add @wordpress/ui to wp-min catalog and use catalog ref

    The package was previously pinned at `0.12.0` directly in
    `client/admin/package.json`, which the syncpack pre-push hook flags as
    inconsistent with the catalog pattern used for all other `@wordpress/*`
    deps. Add the version to the `wp-min` catalog and switch the dependency
    to `catalog:wp-min` so the rule passes.

    * [Email Editor] Sync editor via receiveEntityRecords after server-side write

    After both `/apply` and `/reset`, the server has already persisted the
    new post_content. The previous flow then called `editEntityRecord` plus
    `saveEditedEntityRecord({})` from the client — the only purpose of the
    trailing save was to settle core-data's dirty state, which fires a
    PUT/POST that re-uploads content the server already has.

    Replace it with `receiveEntityRecords`. The action pushes the new
    canonical content into core-data's cache directly, and its reducer
    (`packages/core-data/src/reducer.js`, `RECEIVE_ITEMS` case in the
    `edits` slice) auto-clears any pending edit whose value matches the new
    canonical — so the post stays consistent without an extra REST round
    trip. `useEntityBlockEditor` re-reads from the updated record on the
    next render, so the canvas refreshes without a page reload.

    Per-file:

    - `hooks/use-apply-update.ts` — drop `parse`/`serialize`, drop
      `editEntityRecord`/`saveEditedEntityRecord`. Read the current record
      via `select(coreStore).getEntityRecord` and dispatch
      `receiveEntityRecords` with `{ ...current, content: { ...content, raw } }`.
      `syncEditorState` is no longer async; both call sites drop their
      awaits.
    - `reset-notification-email-content.tsx` — same swap inside the action's
      Reset confirm handler.

    * [Email Editor] Fix STATUS_META_KEY drift after selective apply

    Reviewer flagged on PR #64497 that `apply_selectively()` stamped
    STATUS_IN_SYNC even when the merchant kept their own version (any
    keep_yours choice or any preserved removed_block produces merged
    content that still differs from canonical). Verified end-to-end: post
    still had merchant text, but `_wc_email_template_status` ended up
    in_sync — misleading until the next divergence sweep overwrites it.

    Underlying cause: three writers (auto-applier, selective applier, undo)
    each duplicated their own version of the classification rules, so any
    time the rules diverged between writers, the meta drifted from reality.
    The selective applier hard-coded STATUS_IN_SYNC; the undo path blindly
    restored the snapshot's prior_status (which goes stale if core moves
    between apply and undo); the auto-applier hard-coded STATUS_IN_SYNC
    too (correct in practice today, but fragile if a partial-apply path is
    ever added).

    Fix:

    - Extract `WCEmailTemplateDivergenceDetector::reclassify( int \$post_id )`
      as the single owner of STATUS_META_KEY writes. Runs the existing
      classifier against current post / canonical / source-hash state and
      stamps the truthful status. \`run_sweep()\` delegates to it per post.
    - Selective applier \`apply_selectively()\` and \`undo()\` route their
      status writes through \`reclassify()\` instead of literals; auto-applier
      follows the same pattern for consistency. \`prior_status\` drops out
      of the snapshot — recomputing on undo is more correct than restoring
      a stale value.
    - Document STATUS_META_KEY and VERSION_META_KEY as orthogonal signals.
      STATUS_META_KEY answers "does the post differ from canonical right
      now?". VERSION_META_KEY answers "has the merchant reviewed the
      current core update?". RSM-141's banner reads VERSION_META_KEY, which
      closes the related concern that a deliberate keep-yours apply would
      otherwise leave the indicator on forever (post still differs from
      canonical → STATUS = core_updated_customized, but VERSION = current,
      so no banner).

    Tests pin: extracted classifier helper (in_sync + customized paths),
    reviewer's empty-choices keep-yours scenario, undo-after-core-release,
    and auto-applier-via-classifier contract.

    * [Email Editor] Add changelog entry for status-meta classifier fix

    * [Email Editor] Note alias migration in merged_content schema doc

    The namespace-alias rewrite inside `apply_selectively()` runs
    unconditionally — even when every choice was `keep_yours` and no other
    change applied. Without a note in the schema, a downstream consumer
    reading `get_apply_schema()` could assume all keep_yours = no-op and be
    wrong on posts with the legacy alias.

    Add one sentence to `merged_content`'s description pointing at
    `aliases_migrated` so consumers know when this can happen.

    Reviewer feedback: PR #64497 r3169878311.

    * [Email Editor] Pin the closest-level fallback in insert_block_at_path

    The "best-effort, append at closest level" fallback in
    `insert_block_at_path()` was verified by docblock only — every existing
    `added_blocks` test case has the post and core trees aligned up to the
    insertion point, so the fallback never executed.

    The fallback also isn't reachable through `apply_selectively()` end-to-end:
    the public flow's LCS + structural-wrapper skip consume the only
    realistic deep-path scenarios before the fallback can fire. The new test
    reaches into the private helper via reflection — same pattern the
    PHP-tests CLAUDE.md allows when the public API can't reach a contract
    worth pinning.

    Asserts: when an intermediate path index doesn't exist on the post side,
    the new block is appended to the closest reachable level, not dropped.
    A future refactor that turned the append into a skip would fail loudly.

    Reviewer feedback: PR #64497 r3169878318.

    * [Email Editor] Use sentence case for the Conflict badge label

    Per the WooCommerce copy guidelines, source i18n strings should be
    sentence case — translators see the source, and many target locales
    don't have a meaningful all-caps form. Sibling Badge labels in the
    same row (`Apply core`, `Keep yours`) are already sentence case;
    `CONFLICT` was the lone outlier.

    Visual stays as it was (`Badge` from `@wordpress/ui` does not
    auto-uppercase), so this is a pure i18n fix with no design impact.

    Reviewer feedback: PR #64497 r3169878324.

    * [Email Editor] Stamp source_hash from saved post_content

    Classifier hashes the persisted post_content, but writers stamped the
    in-memory string before WordPress ran content_save_pre filters. The
    mismatch caused false drift on every classifier read.

    Now hash get_post()->post_content after wp_update_post / wp_insert_post
    returns at three sites: selective applier, auto-applier, and generator.
    Adds one regression test in the selective applier suite that installs
    a deliberately-mutating content_save_pre filter and asserts the stored
    hash equals sha1(saved post_content).

    Backfill is intentionally skipped. Case C must keep stamping
    sha1(canonical) so the next core bump correctly surfaces customized
    posts as core_updated_customized; sha1(saved) would silently downgrade
    them to core_updated_uncustomized and erase the customization signal.

    * [Email Editor] Drop @wordpress/ui, restore @wordpress/components drawer

    @wordpress/ui@0.12.0 pulls in @wordpress/theme, which calls
    __dangerouslyOptInToUnstableAPIsOnlyForCoreModules at module load.
    WordPress 6.8.x's private-apis registry doesn't list @wordpress/theme,
    so the opt-in throws and the email editor never mounts.

    Drop the @wordpress/ui dep and rebuild the Review drawer's chrome with
    a hand-rolled portal, semantic HTML pills, and @wordpress/components
    Button. Keep the integration store, ChoiceCard 2-up grid, and entity
    sync untouched. Also remove a stale changelog file that the new
    classifier routing supersedes.

    * [Email Editor] Prevent auto-overwrite after keep_yours apply

    Two audit-driven fixes for the email-template sync subsystem.

    Selective applier keep_yours: stamping sha1(saved post_content) made
    the next divergence sweep see "no merchant edits since sync" →
    STATUS_CORE_UPDATED_UNCUSTOMIZED → auto-applier silently overwrote
    the merchant's customisation on the next core bump. Now branch the
    stamp: aligned with canonical → sha1(saved); diverges → sha1(canonical)
    and hard-stamp STATUS_CORE_UPDATED_CUSTOMIZED so the auto-applier
    (filtered to UNCUSTOMIZED) skips the post.

    Generator: stamp STATUS_META_KEY = STATUS_IN_SYNC at insert. Posts
    previously had no status until the first sweep, breaking UI surfaces
    that expected a populated value.

    Adds a regression test for the keep_yours path and updates existing
    tests whose preconditions shifted.

    * [Email Editor] Auto-open review drawer from URL param

    * [Email Editor] Clear stale change-summary state on disable or postId change

    * [Email Editor] Return persisted post_content from apply_to_post

diff --git a/plugins/woocommerce/changelog/rsm-143-selective-apply b/plugins/woocommerce/changelog/rsm-143-selective-apply
new file mode 100644
index 00000000000..caed2ca2cc0
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-143-selective-apply
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add selective apply + undo REST endpoints for block email template updates.
diff --git a/plugins/woocommerce/changelog/rsm-143-selective-apply-drawer b/plugins/woocommerce/changelog/rsm-143-selective-apply-drawer
new file mode 100644
index 00000000000..3d2116109d7
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-143-selective-apply-drawer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add Review drawer UI for selectively applying block email template updates.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts
new file mode 100644
index 00000000000..aa3edc2eb63
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts
@@ -0,0 +1,178 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import apiFetch from '@wordpress/api-fetch';
+import { select, useDispatch } from '@wordpress/data';
+import { store as noticesStore } from '@wordpress/notices';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Shape of an entry on the `choices` array sent to /apply.
+ */
+export interface ApplyChoice {
+	/** Path of a `copy_changes` entry from the change-summary. */
+	path: Array< number | string >;
+	/** `keep_yours` (default) or `use_core`. */
+	decision: 'keep_yours' | 'use_core';
+}
+
+/**
+ * Shape of the /apply response.
+ */
+interface ApplyResponse {
+	merged_content: string;
+	revision_id: string;
+	version_to: string;
+	status: 'applied';
+	structural_skipped: boolean;
+	aliases_migrated: string[];
+}
+
+/**
+ * Shape of the /undo response.
+ */
+interface UndoResponse {
+	restored_content: string;
+	status: 'restored';
+}
+
+interface UseApplyUpdateResult {
+	apply: ( choices: ApplyChoice[] ) => Promise< ApplyResponse | null >;
+	isApplying: boolean;
+}
+
+/**
+ * Drive the apply + undo flow for a `woo_email` post.
+ *
+ * On apply success: shows a snackbar with an Undo action wired to the
+ * `/undo` endpoint, syncs the editor's in-memory entity to the merged
+ * content (so the canvas reflects the apply without a page reload), and
+ * surfaces the migrated alias list in the snackbar copy when applicable.
+ *
+ * Sync uses `receiveEntityRecords` to push the server's freshly-saved
+ * content straight into core-data's cache. The reducer auto-clears any
+ * matching pending edits, so no follow-up `saveEditedEntityRecord`
+ * round-trip is needed.
+ *
+ * @param postId The `woo_email` post ID.
+ */
+export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {
+	const { createSuccessNotice, createErrorNotice } =
+		useDispatch( noticesStore );
+	const { receiveEntityRecords } = useDispatch( coreStore );
+	const [ isApplying, setIsApplying ] = useState< boolean >( false );
+
+	const syncEditorState = useCallback(
+		( content: string ) => {
+			if ( ! postId ) {
+				return;
+			}
+			// Read the current canonical record so the patched record we
+			// hand to `receiveEntityRecords` keeps every other field
+			// (title, status, meta, …) intact. Only `content.raw` changes.
+			const current = select( coreStore ).getEntityRecord(
+				'postType',
+				'woo_email',
+				postId
+			) as { content?: { raw?: string } } | undefined;
+			if ( ! current ) {
+				return;
+			}
+			receiveEntityRecords(
+				'postType',
+				'woo_email',
+				[
+					{
+						...current,
+						content: { ...current.content, raw: content },
+					},
+				],
+				undefined,
+				false,
+				undefined,
+				undefined
+			);
+		},
+		[ postId, receiveEntityRecords ]
+	);
+
+	const undo = useCallback(
+		async ( revisionId: string ) => {
+			if ( ! postId ) {
+				return;
+			}
+			try {
+				const res = ( await apiFetch( {
+					path: `/woocommerce-email-editor/v1/emails/${ postId }/undo`,
+					method: 'POST',
+					data: { revision_id: revisionId },
+				} ) ) as UndoResponse;
+
+				syncEditorState( res.restored_content );
+
+				createSuccessNotice( __( 'Update reverted.', 'woocommerce' ), {
+					type: 'snackbar',
+				} );
+			} catch ( err: unknown ) {
+				const message =
+					err && typeof err === 'object' && 'message' in err
+						? String( err.message )
+						: __( 'Could not revert the update.', 'woocommerce' );
+				createErrorNotice( message, { type: 'snackbar' } );
+			}
+		},
+		[ postId, createSuccessNotice, createErrorNotice, syncEditorState ]
+	);
+
+	const apply = useCallback(
+		async ( choices: ApplyChoice[] ): Promise< ApplyResponse | null > => {
+			if ( ! postId ) {
+				return null;
+			}
+			setIsApplying( true );
+			try {
+				const res = ( await apiFetch( {
+					path: `/woocommerce-email-editor/v1/emails/${ postId }/apply`,
+					method: 'POST',
+					data: { choices },
+				} ) ) as ApplyResponse;
+
+				syncEditorState( res.merged_content );
+
+				createSuccessNotice( __( 'Update applied.', 'woocommerce' ), {
+					type: 'snackbar',
+					actions: [
+						{
+							label: __( 'Undo', 'woocommerce' ),
+							onClick: () => {
+								void undo( res.revision_id );
+							},
+						},
+					],
+				} );
+
+				return res;
+			} catch ( err: unknown ) {
+				const message =
+					err && typeof err === 'object' && 'message' in err
+						? String( err.message )
+						: __( 'Could not apply the update.', 'woocommerce' );
+				createErrorNotice( message, { type: 'snackbar' } );
+				return null;
+			} finally {
+				setIsApplying( false );
+			}
+		},
+		[
+			postId,
+			createSuccessNotice,
+			createErrorNotice,
+			undo,
+			syncEditorState,
+		]
+	);
+
+	return { apply, isApplying };
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
new file mode 100644
index 00000000000..93e4b73ac19
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
@@ -0,0 +1,137 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useEffect, useState } from '@wordpress/element';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Shape of a single block-level entry on the `added_blocks` /
+ * `removed_blocks` arrays returned by the change-summary REST endpoint.
+ */
+export interface ChangeSummaryBlockEntry {
+	/** Post-alias-normalized block name, e.g. `core/heading`. */
+	name: string;
+	/** Humanized label for display, e.g. `Heading`. */
+	label: string;
+	/** Index path through the parsed block tree on the relevant side. */
+	path: Array< number | string >;
+}
+
+/**
+ * Shape of a single text-conflict entry on the `copy_changes` array.
+ */
+export interface ChangeSummaryCopyChange {
+	/** Humanized block label, e.g. `Paragraph`. */
+	block: string;
+	/** Merchant's current text, truncated to 120 chars. */
+	before: string;
+	/** Canonical core text, truncated to 120 chars. */
+	after: string;
+	/** 1-indexed position among matched blocks of this type. */
+	occurrence: number;
+	/** Total matched blocks of this type on the core side. */
+	total: number;
+	/** Post-side index path of the conflicting block. */
+	path: Array< number | string >;
+}
+
+/**
+ * Shape of a single structural-change entry.
+ */
+export interface ChangeSummaryStructuralChange {
+	/** `nest` or `reorder`. */
+	kind: string;
+	/** Pre-localized one-line description. */
+	description: string;
+	/** Index path of the affected block; absent for `kind: 'reorder'`. */
+	path?: Array< number | string >;
+}
+
+/**
+ * Full change-summary payload returned by
+ * `GET /woocommerce-email-editor/v1/emails/{id}/change-summary`.
+ */
+export interface ChangeSummary {
+	version_from: string;
+	version_to: string;
+	added_blocks: ChangeSummaryBlockEntry[];
+	removed_blocks: ChangeSummaryBlockEntry[];
+	copy_changes: ChangeSummaryCopyChange[];
+	structural_changes: ChangeSummaryStructuralChange[];
+	summary_lines: string[];
+	is_fallback: boolean;
+	cache_hit: boolean;
+}
+
+interface UseChangeSummaryResult {
+	summary: ChangeSummary | null;
+	isLoading: boolean;
+	error: Error | null;
+	refetch: () => void;
+}
+
+/**
+ * Fetch the change-summary for a `woo_email` post and re-render when it
+ * arrives. Aborts the in-flight request on unmount or post-id change.
+ *
+ * @param postId  The `woo_email` post ID.
+ * @param enabled When false, no fetch is issued and `summary` stays null.
+ *                Useful when the drawer is closed.
+ */
+export function useChangeSummary(
+	postId: number | null,
+	enabled: boolean
+): UseChangeSummaryResult {
+	const [ summary, setSummary ] = useState< ChangeSummary | null >( null );
+	const [ isLoading, setIsLoading ] = useState< boolean >( false );
+	const [ error, setError ] = useState< Error | null >( null );
+	const [ refreshKey, setRefreshKey ] = useState< number >( 0 );
+
+	const refetch = useCallback( () => {
+		setRefreshKey( ( k ) => k + 1 );
+	}, [] );
+
+	useEffect( () => {
+		if ( ! enabled || ! postId ) {
+			// Reset state so the drawer never renders a previous template's
+			// summary (or keeps Apply enabled) after the post-id changes or
+			// the drawer is re-disabled.
+			setSummary( null );
+			setError( null );
+			setIsLoading( false );
+			return;
+		}
+
+		let cancelled = false;
+		setSummary( null );
+		setIsLoading( true );
+		setError( null );
+
+		apiFetch< ChangeSummary >( {
+			path: `/woocommerce-email-editor/v1/emails/${ postId }/change-summary`,
+		} )
+			.then( ( res ) => {
+				if ( ! cancelled ) {
+					setSummary( res );
+				}
+			} )
+			.catch( ( err: unknown ) => {
+				if ( ! cancelled ) {
+					setError(
+						err instanceof Error ? err : new Error( String( err ) )
+					);
+				}
+			} )
+			.finally( () => {
+				if ( ! cancelled ) {
+					setIsLoading( false );
+				}
+			} );
+
+		return () => {
+			cancelled = true;
+		};
+	}, [ postId, enabled, refreshKey ] );
+
+	return { summary, isLoading, error, refetch };
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
index 87f5aedd04d..ecdfd872f00 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
@@ -3,8 +3,10 @@
 /**
  * External dependencies
  */
+import { dispatch } from '@wordpress/data';
 import { addFilter, addAction } from '@wordpress/hooks';
 import { __ } from '@wordpress/i18n';
+import { registerPlugin } from '@wordpress/plugins';
 import {
 	initializeEditor,
 	registerEntityAction,
@@ -18,6 +20,11 @@ import { modifyTemplateSidebar } from './templates';
 import { modifySidebar } from './sidebar_settings';
 import { registerEmailValidationRules } from './email-validation';
 import getResetNotificationEmailContentAction from './reset-notification-email-content';
+import { ReviewUpdatePlugin } from './review-update-plugin';
+import {
+	registerStore as registerIntegrationStore,
+	STORE_NAME as INTEGRATION_STORE_NAME,
+} from './store';

 import './style.scss';

@@ -69,10 +76,36 @@ addFilter( 'woocommerce_email_editor_create_coupon_handler', NAME_SPACE, () => {
 	};
 } );

+// Register the integration's @wordpress/data store before any plugin
+// renders, so consumers (review drawer, future RSM-141 banner, etc.)
+// can dispatch into it from anywhere.
+registerIntegrationStore();
+
 modifySidebar();
 modifyTemplateSidebar();
 registerEmailValidationRules();

+// Register the review-update plugin (RSM-143). Mounts the review drawer
+// into the email editor — its open / close state is driven by the
+// `woocommerce/email-editor-integration` store, so any other surface
+// (RSM-141 banner, list-page row action, browser console) can open it
+// via `wp.data.dispatch( 'woocommerce/email-editor-integration' )
+// .openReviewDrawer()`.
+registerPlugin( 'woocommerce-email-editor-review-update', {
+	scope: 'woocommerce-email-editor',
+	render: ReviewUpdatePlugin,
+} );
+
+// Deep-link contract: opens the review drawer when arriving with
+// `?wc_email_review_drawer=1` (set by the email list page's update indicator).
+if (
+	new URLSearchParams( window.location.search ).get(
+		'wc_email_review_drawer'
+	) === '1'
+) {
+	dispatch( INTEGRATION_STORE_NAME ).openReviewDrawer();
+}
+
 /**
  * Register the reset notification email content entity action for the woo_email post type.
  * This action allows users to reset the email content to the original state as distributed by the plugin.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
index 2a4682f46dd..884e8eceafa 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
@@ -6,7 +6,7 @@ import { store as noticesStore } from '@wordpress/notices';
 import { store as coreStore } from '@wordpress/core-data';
 import { backup } from '@wordpress/icons';
 import { useState } from '@wordpress/element';
-import { useDispatch } from '@wordpress/data';
+import { select, useDispatch } from '@wordpress/data';
 import {
 	Button,
 	__experimentalText as Text,
@@ -14,7 +14,6 @@ import {
 	__experimentalVStack as VStack,
 } from '@wordpress/components';
 import { decodeEntities } from '@wordpress/html-entities';
-import { parse, serialize } from '@wordpress/blocks';
 import apiFetch from '@wordpress/api-fetch';

 // eslint-disable-next-line @woocommerce/dependency-group
@@ -70,8 +69,7 @@ const getResetNotificationEmailContentAction = () => {
 			const [ isBusy, setIsBusy ] = useState( false );
 			const { createSuccessNotice, createErrorNotice } =
 				useDispatch( noticesStore );
-			const { editEntityRecord, saveEditedEntityRecord } =
-				useDispatch( coreStore );
+			const { receiveEntityRecords } = useDispatch( coreStore );

 			const item = items[ 0 ];
 			const modalTitle = sprintf(
@@ -108,32 +106,40 @@ const getResetNotificationEmailContentAction = () => {
 										method: 'POST',
 									} ) ) as { content: string };

-									// Server has already persisted post_content + sync meta.
-									// Sync the editor's in-memory state so the user sees the
-									// reset content without a page reload. The trailing
-									// saveEditedEntityRecord is a content no-op (matches what
-									// the server just wrote) but keeps core-data's dirty
-									// tracking in a consistent state.
-									const blocks = parse(
-										response.content || ''
-									);
-
-									await editEntityRecord(
-										'postType',
-										item.type,
-										item.id,
-										{
-											blocks,
-											content: serialize( blocks ),
-										}
-									);
-
-									await saveEditedEntityRecord(
+									// Server has already persisted post_content + sync
+									// meta. Push the new canonical content into core-data
+									// via `receiveEntityRecords` so the editor refreshes
+									// without a page reload — the reducer auto-clears any
+									// matching pending edits, so no extra REST round-trip
+									// is needed.
+									const current = select(
+										coreStore
+									).getEntityRecord(
 										'postType',
 										item.type,
-										item.id,
-										{}
-									);
+										item.id
+									) as
+										| { content?: { raw?: string } }
+										| undefined;
+									if ( current ) {
+										receiveEntityRecords(
+											'postType',
+											item.type,
+											[
+												{
+													...current,
+													content: {
+														...current.content,
+														raw: response.content,
+													},
+												},
+											],
+											undefined,
+											false,
+											undefined,
+											undefined
+										);
+									}

 									const successMessage = sprintf(
 										/* translators: The email's title. */
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx
new file mode 100644
index 00000000000..768d2735b74
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx
@@ -0,0 +1,588 @@
+/**
+ * External dependencies
+ */
+import { createPortal, useEffect, useRef, useState } from '@wordpress/element';
+import { __, sprintf, _n } from '@wordpress/i18n';
+import { Button, Spinner } from '@wordpress/components';
+import { closeSmall } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import {
+	useChangeSummary,
+	type ChangeSummary,
+	type ChangeSummaryCopyChange,
+	type ChangeSummaryStructuralChange,
+} from './hooks/use-change-summary';
+import { useApplyUpdate, type ApplyChoice } from './hooks/use-apply-update';
+
+interface Props {
+	postId: number | null;
+	emailTitle: string;
+	isOpen: boolean;
+	onOpenChange: ( open: boolean ) => void;
+}
+
+type ChoiceMap = Record< string, 'keep_yours' | 'use_core' >;
+type AutoTag = 'apply_core' | 'keep_yours';
+
+/** Stable string key for a path array, used as the choice-map key. */
+function pathKey( path: Array< number | string > ): string {
+	return JSON.stringify( path );
+}
+
+/** Decorative leading dot for section headings (color-coded). */
+const SectionDot = ( { tone }: { tone: 'warning' | 'brand' } ) => (
+	<span
+		aria-hidden="true"
+		className={ `woocommerce-review-drawer__dot woocommerce-review-drawer__dot--${ tone }` }
+	/>
+);
+
+/**
+ * Per-conflict choice card. Two cards live side-by-side in a 2-column
+ * grid; selecting one toggles the merchant's decision for that block.
+ * The label + hint sublabel comes from the design handoff —
+ * `ToggleGroupControl` only fits a single label, so we keep bespoke
+ * buttons with `role="radio"` for the same a11y semantics.
+ */
+const ChoiceCard = ( {
+	label,
+	hint,
+	active,
+	onClick,
+}: {
+	label: string;
+	hint: string;
+	active: boolean;
+	onClick: () => void;
+} ) => (
+	<button
+		type="button"
+		role="radio"
+		aria-checked={ active }
+		onClick={ onClick }
+		className={ [
+			'woocommerce-review-drawer__choice-card',
+			active && 'is-active',
+		]
+			.filter( Boolean )
+			.join( ' ' ) }
+	>
+		<span className="woocommerce-review-drawer__choice-label">
+			{ label }
+		</span>
+		<span className="woocommerce-review-drawer__choice-hint">{ hint }</span>
+	</button>
+);
+
+const ConflictsGroup = ( {
+	conflicts,
+	choices,
+	onChoose,
+}: {
+	conflicts: ChangeSummaryCopyChange[];
+	choices: ChoiceMap;
+	onChoose: (
+		path: Array< number | string >,
+		decision: 'keep_yours' | 'use_core'
+	) => void;
+} ) => {
+	if ( conflicts.length === 0 ) {
+		return null;
+	}
+
+	const heading = sprintf(
+		/* translators: %d: number of conflicts. */
+		_n(
+			'Needs your attention · %d conflict',
+			'Needs your attention · %d conflicts',
+			conflicts.length,
+			'woocommerce'
+		),
+		conflicts.length
+	);
+
+	return (
+		<section
+			className="woocommerce-review-drawer__group"
+			aria-labelledby="woocommerce-review-drawer-conflicts-heading"
+		>
+			<h3
+				id="woocommerce-review-drawer-conflicts-heading"
+				className="woocommerce-review-drawer__group-h"
+			>
+				<SectionDot tone="warning" />
+				{ heading }
+			</h3>
+			{ conflicts.map( ( conflict ) => {
+				const key = pathKey( conflict.path );
+				const decision = choices[ key ] ?? 'keep_yours';
+				const blockTitle =
+					conflict.total > 1
+						? sprintf(
+								/* translators: 1: block name; 2: occurrence; 3: total. */
+								__( '%1$s %2$d of %3$d', 'woocommerce' ),
+								conflict.block,
+								conflict.occurrence,
+								conflict.total
+						  )
+						: conflict.block;
+
+				return (
+					<div
+						key={ key }
+						className="woocommerce-review-drawer__item"
+					>
+						<div className="woocommerce-review-drawer__item-h">
+							<h4 className="woocommerce-review-drawer__item-title">
+								{ blockTitle }
+							</h4>
+							<span className="woocommerce-review-drawer__tag woocommerce-review-drawer__tag--conflict">
+								{ __( 'Conflict', 'woocommerce' ) }
+							</span>
+						</div>
+						<p className="woocommerce-review-drawer__item-sub">
+							{ __(
+								'Core changed this text. Pick which version to keep.',
+								'woocommerce'
+							) }
+						</p>
+						<div
+							className="woocommerce-review-drawer__diff"
+							role="group"
+							aria-label={ __( 'Diff', 'woocommerce' ) }
+						>
+							<div className="woocommerce-review-drawer__diff-row woocommerce-review-drawer__diff-row--minus">
+								{ conflict.before }
+							</div>
+							<div className="woocommerce-review-drawer__diff-row woocommerce-review-drawer__diff-row--plus">
+								{ conflict.after }
+							</div>
+						</div>
+						<div
+							className="woocommerce-review-drawer__choice"
+							role="radiogroup"
+							aria-label={ __(
+								'Choose which version to apply',
+								'woocommerce'
+							) }
+						>
+							<ChoiceCard
+								label={ __( 'Keep yours', 'woocommerce' ) }
+								hint={ __( 'Default · safe', 'woocommerce' ) }
+								active={ decision === 'keep_yours' }
+								onClick={ () =>
+									onChoose( conflict.path, 'keep_yours' )
+								}
+							/>
+							<ChoiceCard
+								label={ __( 'Use core', 'woocommerce' ) }
+								hint={ __(
+									'Discard your edit',
+									'woocommerce'
+								) }
+								active={ decision === 'use_core' }
+								onClick={ () =>
+									onChoose( conflict.path, 'use_core' )
+								}
+							/>
+						</div>
+					</div>
+				);
+			} ) }
+		</section>
+	);
+};
+
+const AutoResolvedItem = ( {
+	title,
+	sub,
+	tag,
+}: {
+	title: string;
+	sub: string;
+	tag: AutoTag;
+} ) => (
+	<div className="woocommerce-review-drawer__item">
+		<div className="woocommerce-review-drawer__item-h">
+			<h4 className="woocommerce-review-drawer__item-title">{ title }</h4>
+			<span
+				className={ [
+					'woocommerce-review-drawer__tag',
+					`woocommerce-review-drawer__tag--${
+						tag === 'apply_core' ? 'apply-core' : 'keep-yours'
+					}`,
+				].join( ' ' ) }
+			>
+				{ tag === 'apply_core'
+					? __( 'Apply core', 'woocommerce' )
+					: __( 'Keep yours', 'woocommerce' ) }
+			</span>
+		</div>
+		<p className="woocommerce-review-drawer__item-sub">{ sub }</p>
+	</div>
+);
+
+const AutoResolvedGroup = ( { summary }: { summary: ChangeSummary } ) => {
+	const total =
+		summary.added_blocks.length +
+		summary.removed_blocks.length +
+		summary.structural_changes.length;
+
+	if ( total === 0 ) {
+		return null;
+	}
+
+	const heading = sprintf(
+		/* translators: %d: number of auto-resolved blocks. */
+		_n(
+			'Auto-resolved · %d block',
+			'Auto-resolved · %d blocks',
+			total,
+			'woocommerce'
+		),
+		total
+	);
+
+	return (
+		<section
+			className="woocommerce-review-drawer__group"
+			aria-labelledby="woocommerce-review-drawer-auto-heading"
+		>
+			<h3
+				id="woocommerce-review-drawer-auto-heading"
+				className="woocommerce-review-drawer__group-h"
+			>
+				<SectionDot tone="brand" />
+				{ heading }
+			</h3>
+
+			{ summary.added_blocks.map( ( entry ) => (
+				<AutoResolvedItem
+					key={ `added-${ pathKey( entry.path ) }` }
+					title={ entry.label }
+					sub={ __(
+						'Added by core. Will appear in your email.',
+						'woocommerce'
+					) }
+					tag="apply_core"
+				/>
+			) ) }
+			{ summary.removed_blocks.map( ( entry ) => (
+				<AutoResolvedItem
+					key={ `removed-${ pathKey( entry.path ) }` }
+					title={ entry.label }
+					sub={ __(
+						'Not in core. Your block is preserved.',
+						'woocommerce'
+					) }
+					tag="keep_yours"
+				/>
+			) ) }
+			{ summary.structural_changes.map(
+				( change: ChangeSummaryStructuralChange, idx: number ) => (
+					<AutoResolvedItem
+						key={ `structural-${ idx }` }
+						title={ change.description }
+						sub={ __(
+							'Structural change applied automatically.',
+							'woocommerce'
+						) }
+						tag="apply_core"
+					/>
+				)
+			) }
+		</section>
+	);
+};
+
+/**
+ * Review drawer — surfaces the change-summary diff and lets the merchant
+ * pick per-conflict "Keep yours / Use core" choices, then commits via the
+ * /apply endpoint.
+ *
+ * Hand-rolled drawer (right-side, 480px, scrim, slide animation, focus
+ * trap, Escape close) rendered via `createPortal` to `document.body` so
+ * the fixed-position panel isn't trapped inside the `display: none`
+ * `<PluginArea scope="woocommerce-email-editor">` wrapper. The choice
+ * picker is the bespoke `ChoiceCard` two-up grid (the design's two-line
+ * label + hint doesn't fit `ToggleGroupControl`'s single-label API);
+ * tag pills and typography are plain `<span>` / `<h*>` / `<p>` styled
+ * via SCSS.
+ */
+export const ReviewDrawer = ( {
+	postId,
+	emailTitle,
+	isOpen,
+	onOpenChange,
+}: Props ) => {
+	const drawerRef = useRef< HTMLDivElement >( null );
+	const previousFocusRef = useRef< HTMLElement | null >( null );
+
+	const [ choices, setChoices ] = useState< ChoiceMap >( {} );
+	const { summary, isLoading, error } = useChangeSummary( postId, isOpen );
+	const { apply, isApplying } = useApplyUpdate( postId );
+
+	// Reset choices whenever a new diff is loaded.
+	useEffect( () => {
+		if ( summary ) {
+			setChoices( {} );
+		}
+	}, [ summary ] );
+
+	// Focus management — save the previously focused element on open,
+	// move focus into the panel, restore on close.
+	useEffect( () => {
+		let rafId1: number;
+		let rafId2: number;
+		if ( isOpen ) {
+			const drawerElement = drawerRef.current;
+			if ( drawerElement ) {
+				previousFocusRef.current = drawerElement.ownerDocument
+					.activeElement as HTMLElement;
+				rafId1 = requestAnimationFrame( () => {
+					rafId2 = requestAnimationFrame( () => {
+						drawerElement.focus();
+					} );
+				} );
+			}
+		} else if ( previousFocusRef.current?.isConnected ) {
+			previousFocusRef.current.focus();
+		}
+		return () => {
+			cancelAnimationFrame( rafId1 );
+			cancelAnimationFrame( rafId2 );
+		};
+	}, [ isOpen ] );
+
+	// Escape closes; Tab/Shift+Tab traps inside the drawer.
+	useEffect( () => {
+		const handleKeyDown = ( event: KeyboardEvent ) => {
+			if ( ! isOpen ) {
+				return;
+			}
+			if ( event.key === 'Escape' ) {
+				onOpenChange( false );
+				return;
+			}
+			if ( event.key === 'Tab' ) {
+				const drawerElement = drawerRef.current;
+				if ( ! drawerElement ) {
+					return;
+				}
+				const focusable = drawerElement.querySelectorAll(
+					'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])'
+				);
+				if ( focusable.length === 0 ) {
+					return;
+				}
+				const first = focusable[ 0 ] as HTMLElement;
+				const last = focusable[ focusable.length - 1 ] as HTMLElement;
+				const active = drawerElement.ownerDocument
+					.activeElement as HTMLElement;
+				if ( event.shiftKey ) {
+					if ( active === first || active === drawerElement ) {
+						event.preventDefault();
+						last?.focus();
+					}
+				} else if ( active === last ) {
+					event.preventDefault();
+					first?.focus();
+				}
+			}
+		};
+		if ( isOpen ) {
+			document.addEventListener( 'keydown', handleKeyDown );
+		}
+		return () => {
+			document.removeEventListener( 'keydown', handleKeyDown );
+		};
+	}, [ isOpen, onOpenChange ] );
+
+	const setChoice = (
+		path: Array< number | string >,
+		decision: 'keep_yours' | 'use_core'
+	) => {
+		setChoices( ( prev ) => ( {
+			...prev,
+			[ pathKey( path ) ]: decision,
+		} ) );
+	};
+
+	const handleApply = async () => {
+		const choiceList: ApplyChoice[] = Object.entries( choices ).map(
+			( [ key, decision ] ) => ( {
+				path: JSON.parse( key ) as Array< number | string >,
+				decision,
+			} )
+		);
+		const res = await apply( choiceList );
+		if ( res ) {
+			onOpenChange( false );
+		}
+	};
+
+	const totalChanges = summary
+		? summary.copy_changes.length +
+		  summary.added_blocks.length +
+		  summary.removed_blocks.length +
+		  summary.structural_changes.length
+		: 0;
+
+	const subtitle = sprintf(
+		/* translators: 1: email name; 2: WooCommerce version; 3: number of changes. */
+		_n(
+			'%1$s · WooCommerce %2$s · %3$d change',
+			'%1$s · WooCommerce %2$s · %3$d changes',
+			totalChanges,
+			'woocommerce'
+		),
+		emailTitle,
+		summary?.version_to ?? '',
+		totalChanges
+	);
+
+	const applyLabel = sprintf(
+		/* translators: %d: total number of changes that will be applied. */
+		__( 'Apply (%d)', 'woocommerce' ),
+		totalChanges
+	);
+
+	const applyDisabled =
+		isApplying ||
+		isLoading ||
+		! summary ||
+		summary.is_fallback ||
+		totalChanges === 0;
+
+	return createPortal(
+		<>
+			<div
+				className="woocommerce-review-drawer__overlay"
+				onClick={ () => onOpenChange( false ) }
+				role="presentation"
+				style={ { display: isOpen ? 'block' : 'none' } }
+				aria-hidden={ ! isOpen }
+			/>
+			<div className="woocommerce-review-drawer">
+				<aside
+					ref={ drawerRef }
+					className={ [
+						'woocommerce-review-drawer__panel',
+						isOpen ? 'is-open' : 'is-closed',
+					].join( ' ' ) }
+					role="dialog"
+					aria-modal="true"
+					aria-labelledby="woocommerce-review-drawer-title"
+					aria-hidden={ ! isOpen }
+					tabIndex={ -1 }
+				>
+					<header className="woocommerce-review-drawer__header">
+						<div className="woocommerce-review-drawer__h-stack">
+							<h2
+								id="woocommerce-review-drawer-title"
+								className="woocommerce-review-drawer__title"
+							>
+								{ __(
+									'Review template update',
+									'woocommerce'
+								) }
+							</h2>
+							<p className="woocommerce-review-drawer__subtitle">
+								{ subtitle }
+							</p>
+						</div>
+						<Button
+							icon={ closeSmall }
+							label={ __( 'Close', 'woocommerce' ) }
+							onClick={ () => onOpenChange( false ) }
+							className="woocommerce-review-drawer__close"
+						/>
+					</header>
+
+					<div className="woocommerce-review-drawer__body">
+						{ isLoading && (
+							<div
+								role="status"
+								aria-live="polite"
+								aria-label={ __(
+									'Loading diff',
+									'woocommerce'
+								) }
+								className="woocommerce-review-drawer__status"
+							>
+								<Spinner />
+							</div>
+						) }
+
+						{ error && (
+							<div
+								role="alert"
+								className="woocommerce-review-drawer__status"
+							>
+								{ __(
+									'Could not load the change summary.',
+									'woocommerce'
+								) }
+							</div>
+						) }
+
+						{ summary && summary.is_fallback && (
+							<div className="woocommerce-review-drawer__status">
+								{ summary.summary_lines[ 0 ] ??
+									__(
+										'Template updated — see release notes.',
+										'woocommerce'
+									) }
+							</div>
+						) }
+
+						{ summary && ! summary.is_fallback && (
+							<>
+								<ConflictsGroup
+									conflicts={ summary.copy_changes }
+									choices={ choices }
+									onChoose={ setChoice }
+								/>
+								<AutoResolvedGroup summary={ summary } />
+							</>
+						) }
+					</div>
+
+					<footer className="woocommerce-review-drawer__footer">
+						<p className="woocommerce-review-drawer__foot-note">
+							{ __(
+								'Revision recorded for rollback.',
+								'woocommerce'
+							) }
+						</p>
+						<div className="woocommerce-review-drawer__footer-actions">
+							<Button
+								variant="tertiary"
+								onClick={ () => onOpenChange( false ) }
+								disabled={ isApplying }
+								__next40pxDefaultSize
+							>
+								{ __( 'Cancel', 'woocommerce' ) }
+							</Button>
+							<Button
+								variant="primary"
+								onClick={ () => {
+									void handleApply();
+								} }
+								disabled={ applyDisabled }
+								isBusy={ isApplying }
+								__next40pxDefaultSize
+							>
+								{ applyLabel }
+							</Button>
+						</div>
+					</footer>
+				</aside>
+			</div>
+		</>,
+		document.body
+	);
+};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-update-plugin.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-update-plugin.tsx
new file mode 100644
index 00000000000..e130dd6e00b
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-update-plugin.tsx
@@ -0,0 +1,93 @@
+/**
+ * External dependencies
+ */
+import { useDispatch, useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import { ReviewDrawer } from './review-drawer';
+import { STORE_NAME } from './store';
+
+/** Extract a human-readable title from a core-data post entity. */
+function extractTitle( post: { title: unknown } ): string {
+	const { title } = post;
+	if ( typeof title === 'string' ) {
+		return title;
+	}
+	if ( title && typeof title === 'object' ) {
+		if ( 'rendered' in title && typeof title.rendered === 'string' ) {
+			return title.rendered;
+		}
+		if ( 'raw' in title && typeof title.raw === 'string' ) {
+			return title.raw;
+		}
+	}
+	return '';
+}
+
+/**
+ * Mounts the review drawer into the WooCommerce email editor's plugin
+ * scope. The drawer's open / close state lives in the
+ * `woocommerce/email-editor-integration` store, so any other surface
+ * can open it via:
+ *
+ * ```
+ * wp.data.dispatch( 'woocommerce/email-editor-integration' )
+ *   .openReviewDrawer();
+ * ```
+ *
+ * RSM-141 will wire the dispatch to the design's floating editor
+ * banner. Until then, opening the drawer for testing happens from the
+ * browser console using the same dispatch call.
+ */
+export const ReviewUpdatePlugin = () => {
+	const { setReviewDrawerOpen } = useDispatch( STORE_NAME );
+	const isDrawerOpen = useSelect(
+		( select ) => select( STORE_NAME ).isReviewDrawerOpen(),
+		[]
+	);
+
+	// Resolve the current woo_email post ID. The block editor's core/editor
+	// store exposes it via getCurrentPostId(); we typecheck loosely because
+	// the global is typed as `any` upstream.
+	const postId = useSelect( ( select ) => {
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		const editorStore = ( select as any )( 'core/editor' );
+		const id = editorStore?.getCurrentPostId?.();
+		return typeof id === 'number' ? id : null;
+	}, [] );
+
+	const post = useSelect(
+		( select ) => {
+			if ( ! postId ) {
+				return null;
+			}
+			return select( coreStore ).getEntityRecord(
+				'postType',
+				'woo_email',
+				postId
+			);
+		},
+		[ postId ]
+	);
+
+	const emailTitle =
+		post && typeof post === 'object' && 'title' in post
+			? extractTitle( post as { title: unknown } )
+			: '';
+
+	if ( ! postId ) {
+		return null;
+	}
+
+	return (
+		<ReviewDrawer
+			postId={ postId }
+			emailTitle={ emailTitle }
+			isOpen={ isDrawerOpen }
+			onOpenChange={ setReviewDrawerOpen }
+		/>
+	);
+};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts
new file mode 100644
index 00000000000..b8a16f23b6e
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts
@@ -0,0 +1,75 @@
+/**
+ * External dependencies
+ */
+import { createReduxStore, register, select } from '@wordpress/data';
+import type {
+	ReduxStoreConfig,
+	StoreDescriptor as GenericStoreDescriptor,
+} from '@wordpress/data/build-types/types';
+
+/**
+ * Integration-layer UI state for the WooCommerce email editor.
+ *
+ * Internal — consumers in this directory open/close the review drawer
+ * (and any future integration-level UI state) via this store rather
+ * than passing props across siblings, since the trigger and the drawer
+ * live in different React subtrees mounted by `registerPlugin`.
+ *
+ * @internal
+ */
+export const STORE_NAME = 'woocommerce/email-editor-integration';
+
+interface State {
+	isReviewDrawerOpen: boolean;
+}
+
+const initialState: State = {
+	isReviewDrawerOpen: false,
+};
+
+type Action = { type: 'SET_REVIEW_DRAWER_OPEN'; open: boolean };
+
+const reducer = ( state: State = initialState, action: Action ): State => {
+	switch ( action.type ) {
+		case 'SET_REVIEW_DRAWER_OPEN':
+			return { ...state, isReviewDrawerOpen: action.open };
+		default:
+			return state;
+	}
+};
+
+const actions = {
+	setReviewDrawerOpen: ( open: boolean ) =>
+		( { type: 'SET_REVIEW_DRAWER_OPEN', open } as const ),
+	openReviewDrawer: () =>
+		( { type: 'SET_REVIEW_DRAWER_OPEN', open: true } as const ),
+	closeReviewDrawer: () =>
+		( { type: 'SET_REVIEW_DRAWER_OPEN', open: false } as const ),
+};
+
+const selectors = {
+	isReviewDrawerOpen: ( state: State ): boolean => state.isReviewDrawerOpen,
+};
+
+const config = { reducer, actions, selectors };
+
+/**
+ * Register the integration store. Called once from the entrypoint;
+ * guarded against double-registration so HMR / repeated boots are
+ * safe (mirrors the pattern in the upstream `@woocommerce/email-editor`
+ * store).
+ */
+export function registerStore(): void {
+	if ( select( STORE_NAME ) !== undefined ) {
+		return;
+	}
+	register( createReduxStore( STORE_NAME, config ) );
+}
+
+declare module '@wordpress/data' {
+	interface StoreRegistry {
+		[ STORE_NAME ]: GenericStoreDescriptor<
+			ReduxStoreConfig< State, typeof actions, typeof selectors >
+		>;
+	}
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/style.scss b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/style.scss
index d6e7dbd264a..08004095bd1 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/style.scss
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/style.scss
@@ -36,3 +36,308 @@
 .editor-post-excerpt__dropdown button {
 	display: none;
 }
+
+/*
+	Review drawer (RSM-143). Hand-rolled right-side drawer rendered via
+	`createPortal` to `document.body` (the `<PluginArea>` wrapper is
+	`display: none`, which would trap a fixed-position panel). Chrome,
+	tag pills, and typography are all styled here — no `@wordpress/ui`
+	dependency.
+ */
+
+/* ---------- Chrome (overlay + panel + header + body + footer) ---------- */
+
+.woocommerce-review-drawer__overlay {
+	position: fixed;
+	inset: 0;
+	background: rgba(0, 0, 0, 0.4);
+	z-index: 100000;
+}
+
+.woocommerce-review-drawer {
+	position: fixed;
+	inset: 0;
+	z-index: 100001;
+	pointer-events: none;
+}
+
+.woocommerce-review-drawer__panel {
+	position: absolute;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	width: 480px;
+	max-width: 100vw;
+	background: #fff;
+	box-shadow: -2px 0 16px rgba(0, 0, 0, 0.12);
+	display: flex;
+	flex-direction: column;
+	pointer-events: auto;
+	transform: translateX(100%);
+	transition: transform 240ms cubic-bezier(0.08, 0.52, 0.52, 1);
+
+	&.is-open {
+		transform: translateX(0);
+	}
+
+	&:focus-visible {
+		outline: none;
+	}
+}
+
+.woocommerce-review-drawer__header {
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	gap: 12px;
+	padding: 16px 20px;
+	border-bottom: 1px solid #f0f0f1;
+}
+
+.woocommerce-review-drawer__h-stack {
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+	min-width: 0;
+}
+
+.woocommerce-review-drawer__title {
+	margin: 0;
+	font-size: 15px;
+	font-weight: 600;
+	line-height: 1.3;
+	color: #1d2327;
+}
+
+.woocommerce-review-drawer__subtitle {
+	margin: 0;
+	font-size: 12px;
+	color: #50575e;
+	line-height: 1.4;
+}
+
+.woocommerce-review-drawer__close {
+	flex: 0 0 auto;
+}
+
+.woocommerce-review-drawer__body {
+	flex: 1 1 auto;
+	overflow-y: auto;
+	padding: 0;
+}
+
+.woocommerce-review-drawer__footer {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	padding: 12px 20px;
+	border-top: 1px solid #f0f0f1;
+}
+
+.woocommerce-review-drawer__foot-note {
+	margin: 0;
+	flex: 1 1 auto;
+	font-size: 12px;
+	color: #6b7280;
+}
+
+.woocommerce-review-drawer__footer-actions {
+	display: flex;
+	gap: 8px;
+	flex-shrink: 0;
+
+	/*
+		Keep Cancel / Apply at their natural width so the label never
+		wraps onto two lines when the count grows (e.g. "Apply (11)").
+		The footer note absorbs the shrinkage instead.
+	*/
+	> button {
+		flex-shrink: 0;
+		white-space: nowrap;
+	}
+}
+
+/* ---------- Body (status, groups, items, diff, choice grid, tags) ---------- */
+
+.woocommerce-review-drawer__status {
+	padding: 24px 20px;
+	color: #50575e;
+}
+
+.woocommerce-review-drawer__group {
+	padding: 12px 0;
+	border-bottom: 1px solid #f0f0f1;
+
+	&:last-child {
+		border-bottom: 0;
+	}
+}
+
+.woocommerce-review-drawer__group-h {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	margin: 0;
+	padding: 4px 20px 8px;
+	font-size: 11px;
+	font-weight: 600;
+	letter-spacing: 0.04em;
+	text-transform: uppercase;
+	color: #50575e;
+}
+
+.woocommerce-review-drawer__dot {
+	width: 8px;
+	height: 8px;
+	border-radius: 50%;
+	display: inline-block;
+	flex: 0 0 auto;
+}
+
+.woocommerce-review-drawer__dot--warning {
+	background: #b26200;
+}
+
+.woocommerce-review-drawer__dot--brand {
+	background: var(--wp-admin-theme-color, #3858e9);
+}
+
+.woocommerce-review-drawer__item {
+	display: flex;
+	flex-direction: column;
+	gap: 6px;
+	padding: 12px 20px;
+	border-top: 1px solid #f0f0f1;
+
+	&:first-of-type {
+		border-top: 0;
+	}
+}
+
+.woocommerce-review-drawer__item-h {
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	gap: 10px;
+}
+
+.woocommerce-review-drawer__item-title {
+	margin: 0;
+	font-size: 14px;
+	font-weight: 500;
+	line-height: 1.3;
+	color: #1d2327;
+}
+
+.woocommerce-review-drawer__item-sub {
+	margin: 0;
+	font-size: 13px;
+	color: #4b5563;
+	line-height: 1.4;
+}
+
+.woocommerce-review-drawer__tag {
+	display: inline-flex;
+	align-items: center;
+	padding: 2px 8px;
+	border-radius: 9999px;
+	font-size: 11px;
+	font-weight: 500;
+	line-height: 1.4;
+	white-space: nowrap;
+	flex: 0 0 auto;
+}
+
+.woocommerce-review-drawer__tag--conflict {
+	background: #fef3c7;
+	color: #92400e;
+}
+
+.woocommerce-review-drawer__tag--apply-core {
+	background: #dbeafe;
+	color: #1e40af;
+}
+
+.woocommerce-review-drawer__tag--keep-yours {
+	background: #f3f4f6;
+	color: #374151;
+}
+
+.woocommerce-review-drawer__diff {
+	border: 1px solid #f0f0f1;
+	border-radius: 2px;
+	overflow: hidden;
+	font-family: Menlo, Consolas, monospace;
+	font-size: 11px;
+	line-height: 1.6;
+}
+
+.woocommerce-review-drawer__diff-row {
+	padding: 6px 10px;
+}
+
+.woocommerce-review-drawer__diff-row--minus {
+	background: #fff6f5;
+	color: #6b1b1b;
+
+	&::before {
+		content: "−  ";
+		color: #cc1818;
+	}
+}
+
+.woocommerce-review-drawer__diff-row--plus {
+	background: #ebffed;
+	color: #003814;
+
+	&::before {
+		content: "+  ";
+		color: #008030;
+	}
+}
+
+.woocommerce-review-drawer__choice {
+	display: grid;
+	grid-template-columns: 1fr 1fr;
+	gap: 6px;
+	margin-top: 4px;
+}
+
+.woocommerce-review-drawer__choice-card {
+	background: #fff;
+	border: 1px solid #ddd;
+	border-radius: 2px;
+	padding: 10px 12px;
+	text-align: left;
+	cursor: pointer;
+	display: grid;
+	gap: 2px;
+	font: inherit;
+	color: inherit;
+
+	&:hover {
+		border-color: #999;
+	}
+
+	&:focus-visible {
+		outline: 2px solid var(--wp-admin-theme-color, #3858e9);
+		outline-offset: 1px;
+	}
+
+	&.is-active {
+		background: #f5f8ff;
+		border-color: var(--wp-admin-theme-color, #3858e9);
+		box-shadow: inset 0 0 0 1px var(--wp-admin-theme-color, #3858e9);
+	}
+}
+
+.woocommerce-review-drawer__choice-label {
+	font-size: 12px;
+	font-weight: 600;
+	color: #1d2327;
+}
+
+.woocommerce-review-drawer__choice-hint {
+	font-size: 11px;
+	color: #50575e;
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
index 2f543779d86..d1a0664f5bb 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Internal\EmailEditor;
 use Automattic\WooCommerce\EmailEditor\Validator\Builder;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSelectiveApplier;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
 use WC_Email;
@@ -327,6 +328,59 @@ class EmailApiController {
 				'schema'              => array( $this, 'get_change_summary_schema' ),
 			)
 		);
+
+		register_rest_route(
+			'woocommerce-email-editor/v1',
+			'/emails/(?P<id>\d+)/apply',
+			array(
+				'methods'             => \WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'apply_response' ),
+				'permission_callback' => function () {
+					return current_user_can( 'manage_woocommerce' );
+				},
+				'args'                => array(
+					'id'      => array(
+						'description'       => __( 'The ID of the woo_email post.', 'woocommerce' ),
+						'type'              => 'integer',
+						'required'          => true,
+						'sanitize_callback' => 'absint',
+					),
+					'choices' => array(
+						'description' => __( 'Per-conflict apply decisions: an array of {path, decision} entries. `decision` is `keep_yours` or `use_core`.', 'woocommerce' ),
+						'type'        => 'array',
+						'required'    => false,
+						'default'     => array(),
+					),
+				),
+				'schema'              => array( $this, 'get_apply_schema' ),
+			)
+		);
+
+		register_rest_route(
+			'woocommerce-email-editor/v1',
+			'/emails/(?P<id>\d+)/undo',
+			array(
+				'methods'             => \WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'undo_response' ),
+				'permission_callback' => function () {
+					return current_user_can( 'manage_woocommerce' );
+				},
+				'args'                => array(
+					'id'          => array(
+						'description'       => __( 'The ID of the woo_email post.', 'woocommerce' ),
+						'type'              => 'integer',
+						'required'          => true,
+						'sanitize_callback' => 'absint',
+					),
+					'revision_id' => array(
+						'description' => __( 'The revision_id returned by the prior /apply call.', 'woocommerce' ),
+						'type'        => 'string',
+						'required'    => true,
+					),
+				),
+				'schema'              => array( $this, 'get_undo_schema' ),
+			)
+		);
 	}

 	/**
@@ -644,4 +698,173 @@ class EmailApiController {
 			200
 		);
 	}
+
+	/**
+	 * Get the schema for the apply endpoint response.
+	 *
+	 * @return array
+	 */
+	public function get_apply_schema(): array {
+		return array(
+			'$schema'    => 'http://json-schema.org/draft-04/schema#',
+			'title'      => 'woo_email_apply',
+			'type'       => 'object',
+			'properties' => array(
+				'merged_content'     => array(
+					'description' => __( 'The merged block content written to the post. May differ from the input `post_content` even when every choice was `keep_yours` — the namespace-alias migration (see `aliases_migrated`) rewrites legacy block names unconditionally.', 'woocommerce' ),
+					'type'        => 'string',
+					'readonly'    => true,
+				),
+				'revision_id'        => array(
+					'description' => __( 'A UUID identifying the pre-apply snapshot. Use as the revision_id on a subsequent /undo call.', 'woocommerce' ),
+					'type'        => 'string',
+					'readonly'    => true,
+				),
+				'version_to'         => array(
+					'description' => __( 'The core template version stamped on the post after applying.', 'woocommerce' ),
+					'type'        => 'string',
+					'readonly'    => true,
+				),
+				'status'             => array(
+					'description' => __( 'The post-apply status (always `applied` on success).', 'woocommerce' ),
+					'type'        => 'string',
+					'readonly'    => true,
+				),
+				'structural_skipped' => array(
+					'description' => __( 'True when one or more structural deltas (nest / reorder) existed in the diff but were not applied. v1 punts structural changes; the merchant\'s structure is preserved.', 'woocommerce' ),
+					'type'        => 'boolean',
+					'readonly'    => true,
+				),
+				'aliases_migrated'   => array(
+					'description' => __( 'List of deprecated block-name aliases rewritten to their canonical form during the apply (e.g. `["woo/email-content"]`). Empty when no migration was needed. Targeted to known deprecated aliases only.', 'woocommerce' ),
+					'type'        => 'array',
+					'items'       => array( 'type' => 'string' ),
+					'readonly'    => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Get the schema for the undo endpoint response.
+	 *
+	 * @return array
+	 */
+	public function get_undo_schema(): array {
+		return array(
+			'$schema'    => 'http://json-schema.org/draft-04/schema#',
+			'title'      => 'woo_email_undo',
+			'type'       => 'object',
+			'properties' => array(
+				'restored_content' => array(
+					'description' => __( 'The pre-apply post content that was restored.', 'woocommerce' ),
+					'type'        => 'string',
+					'readonly'    => true,
+				),
+				'status'           => array(
+					'description' => __( 'The post-undo status (always `restored` on success).', 'woocommerce' ),
+					'type'        => 'string',
+					'readonly'    => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Apply a partial set of core template changes to a `woo_email` post,
+	 * driven by per-conflict merchant choices. Thin wrapper over
+	 * {@see WCEmailTemplateSelectiveApplier::apply_selectively()}.
+	 *
+	 * The 404 path mirrors {@see self::get_change_summary_response()} — when
+	 * the email type cannot be resolved from the post ID, the post is either
+	 * non-existent or not a `woo_email`. 422 fires when the change-summary
+	 * has no actionable diff (e.g. post outside the sync registry, or the
+	 * inversion guard tripped).
+	 *
+	 * @param WP_REST_Request $request The REST request.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return WP_REST_Response|WP_Error
+	 *
+	 * @since 10.9.0
+	 */
+	public function apply_response( WP_REST_Request $request ) {
+		if ( ! ( $this->post_manager && $this->posts_generator ) ) {
+			return new WP_Error(
+				'woocommerce_email_editor_not_initialized',
+				__( 'Email editor is not initialized.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		$post_id    = (int) $request->get_param( 'id' );
+		$email_type = $this->post_manager->get_email_type_from_post_id( $post_id );
+		$email      = $this->get_email_by_type( $email_type ?? '' );
+
+		if ( ! $email ) {
+			return new WP_Error(
+				'woocommerce_email_not_found',
+				__( 'No email found for the given post ID.', 'woocommerce' ),
+				array( 'status' => 404 )
+			);
+		}
+
+		$choices = $request->get_param( 'choices' );
+		if ( ! is_array( $choices ) ) {
+			$choices = array();
+		}
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, $choices );
+
+		if ( is_wp_error( $result ) ) {
+			return $result;
+		}
+
+		return new WP_REST_Response( $result, 200 );
+	}
+
+	/**
+	 * Restore the pre-apply snapshot for a `woo_email` post. Thin wrapper
+	 * over {@see WCEmailTemplateSelectiveApplier::undo()}.
+	 *
+	 * Returns 410 Gone when no snapshot exists for the given post or when
+	 * the supplied `revision_id` doesn't match the latest snapshot —
+	 * matches the design's single-step undo model.
+	 *
+	 * @param WP_REST_Request $request The REST request.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return WP_REST_Response|WP_Error
+	 *
+	 * @since 10.9.0
+	 */
+	public function undo_response( WP_REST_Request $request ) {
+		if ( ! ( $this->post_manager && $this->posts_generator ) ) {
+			return new WP_Error(
+				'woocommerce_email_editor_not_initialized',
+				__( 'Email editor is not initialized.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		$post_id    = (int) $request->get_param( 'id' );
+		$email_type = $this->post_manager->get_email_type_from_post_id( $post_id );
+		$email      = $this->get_email_by_type( $email_type ?? '' );
+
+		if ( ! $email ) {
+			return new WP_Error(
+				'woocommerce_email_not_found',
+				__( 'No email found for the given post ID.', 'woocommerce' ),
+				array( 'status' => 404 )
+			);
+		}
+
+		$revision_id = (string) $request->get_param( 'revision_id' );
+
+		$result = WCEmailTemplateSelectiveApplier::undo( $post_id, $revision_id );
+
+		if ( is_wp_error( $result ) ) {
+			return $result;
+		}
+
+		return new WP_REST_Response( $result, 200 );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
index b061e9abf47..600bee39057 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
@@ -174,17 +174,30 @@ class WCEmailTemplateAutoApplier {
 				return $updated;
 			}

+			// Read back the persisted post_content. The `content_save_pre` filter
+			// chain can mutate `$canonical` between the in-memory string and what
+			// lands in the DB, so both the returned `content` field and the
+			// stamped source hash must reflect what the database actually holds.
+			// See the same note in `WCEmailTemplateSelectiveApplier::apply_selectively()`.
+			$saved_post = get_post( $post_id );
+			$saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : $canonical;
+			$canonical  = $saved_body;
+
 			if ( null !== $sync_config ) {
 				$source_hash = sha1( $canonical );
 				$synced_at   = gmdate( 'Y-m-d H:i:s' );
-				$status      = WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC;
 				$version     = (string) $sync_config['version'];

 				update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $version );
 				update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
 				update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
-				update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, $status );
-			}
+
+				// Status comes from the classifier so all writers stay consistent.
+				// In this path we always write canonical, so the classifier returns
+				// IN_SYNC, but going through the same helper as the selective applier
+				// avoids drift if a future partial-apply path is added here.
+				$status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+			}//end if
 		} finally {
 			self::$is_auto_applying = false;
 		}//end try
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
index 3fbda035e44..c8d91defad3 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
@@ -350,13 +350,20 @@ class WCEmailTemplateChangeSummary {
 	 * {@see self::STRUCTURAL_BLOCK_NAMES}. Null-name entries (raw HTML wrappers
 	 * between blocks) are skipped.
 	 *
+	 * Public so the RSM-143 selective-merge engine
+	 * ({@see WCEmailTemplateSelectiveApplier}) can reuse this and
+	 * {@see self::lcs_matches()} to align matched pairs without duplicating the
+	 * algorithm. Internal namespace-bounded; not part of any external contract.
+	 *
+	 * @internal
+	 *
 	 * @param array<int|string, array<string, mixed>> $blocks      Output of `parse_blocks()`.
 	 * @param array<int|string>                       $path        Current index path from root.
 	 * @param string|null                             $parent_name Normalized parent block name, null at root.
 	 *
 	 * @return array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}>
 	 */
-	private static function flatten_blocks( array $blocks, array $path = array(), ?string $parent_name = null ): array {
+	public static function flatten_blocks( array $blocks, array $path = array(), ?string $parent_name = null ): array {
 		$records = array();
 		foreach ( $blocks as $idx => $block ) {
 			if ( ! is_array( $block ) || null === ( $block['blockName'] ?? null ) ) {
@@ -657,12 +664,18 @@ class WCEmailTemplateChangeSummary {
 	 * their edited version with core's original (high word overlap) instead of
 	 * with an unrelated paragraph that happens to be in the right position.
 	 *
+	 * Public so {@see WCEmailTemplateSelectiveApplier} can reuse the same
+	 * matched-pair alignment when applying merchant choices. Internal-namespace
+	 * bounded; not part of any external contract.
+	 *
+	 * @internal
+	 *
 	 * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $a Core records.
 	 * @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $b Post records.
 	 *
 	 * @return array<int, array{0:int, 1:int}>
 	 */
-	private static function lcs_matches( array $a, array $b ): array {
+	public static function lcs_matches( array $a, array $b ): array {
 		$n = count( $a );
 		$m = count( $b );
 		if ( 0 === $n || 0 === $m ) {
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index 00e1da46701..85006ec1d5e 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -5,6 +5,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;

 use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
 use Automattic\WooCommerce\Internal\EmailEditor\Logger;

 /**
@@ -42,7 +43,20 @@ class WCEmailTemplateDivergenceDetector {
 	public const BACKFILL_COMPLETE_OPTION = 'woocommerce_email_template_sync_backfill_complete';

 	/**
-	 * Post meta key the detector writes.
+	 * Cached classification of "does the post currently differ from the
+	 * canonical core render?". Computed by {@see self::classify_post()}
+	 * and written by {@see self::reclassify()}; read by surfaces that
+	 * filter posts by state (auto-applier targeting, list-page status
+	 * column, sweep optimisation).
+	 *
+	 * **Single writer.** All code paths that mutate `post_content` for a
+	 * `woo_email` post — auto-applier, selective applier, undo — call
+	 * `reclassify()` after the write. Direct `update_post_meta()` against
+	 * this key from any other call site will desync the cache.
+	 *
+	 * **Not the banner / indicator signal.** Whether the merchant has
+	 * reviewed the latest core update is a separate question; that
+	 * answer lives on {@see self::VERSION_META_KEY}.
 	 *
 	 * @var string
 	 */
@@ -56,8 +70,28 @@ class WCEmailTemplateDivergenceDetector {
 	public const SOURCE_HASH_META_KEY = '_wc_email_template_source_hash';

 	/**
-	 * Post meta key storing the version of the block template the post was stamped
-	 * against. Written by the generator and by the RSM-149 backfill.
+	 * The canonical core version the merchant most recently reviewed for
+	 * this post — written by every applier path that runs as a deliberate
+	 * merchant action (auto-apply, selective apply with any choices,
+	 * reset-to-default).
+	 *
+	 * **Load-bearing for the "update available" indicator.** RSM-141's
+	 * editor banner and list-page filter chip compare this value against
+	 * {@see WCEmailTemplateSyncRegistry}'s current canonical version for
+	 * the email type:
+	 *
+	 * ```
+	 * $reviewed = (string) get_post_meta( $post_id, self::VERSION_META_KEY, true );
+	 * $current  = (string) ( $sync_registry[ $email_id ]['version'] ?? '' );
+	 * $show_indicator = $current !== '' && version_compare( $reviewed, $current, '<' );
+	 * ```
+	 *
+	 * Distinct from {@see self::STATUS_META_KEY}: the merchant can
+	 * deliberately choose `keep_yours` for every conflict (post still
+	 * differs from canonical → STATUS = core_updated_customized) and
+	 * still have addressed the update for this version (VERSION = current
+	 * → no indicator). Direct merchant edits to post_content do not
+	 * advance this stamp; only review-driven applies do.
 	 *
 	 * @var string
 	 */
@@ -187,62 +221,16 @@ class WCEmailTemplateDivergenceDetector {
 			return;
 		}

-		$posts_manager    = WCTransactionalEmailPostsManager::get_instance();
-		$canonical_emails = $posts_manager->get_emails_by_id();
+		$posts_manager = WCTransactionalEmailPostsManager::get_instance();

 		foreach ( $registry as $email_id => $_config ) {
 			try {
-				$email = $canonical_emails[ $email_id ] ?? null;
-				if ( ! $email instanceof \WC_Email ) {
-					// Extension providing the email was deactivated; nothing to classify.
-					continue;
-				}
-
 				$post = $posts_manager->get_email_post( (string) $email_id );
 				if ( ! $post instanceof \WP_Post ) {
 					continue;
 				}

-				$stored_source_hash = (string) get_post_meta( (int) $post->ID, self::SOURCE_HASH_META_KEY, true );
-				if ( '' === $stored_source_hash ) {
-					// This should not normally occur post-backfill: the generator always stamps
-					// this meta and RSM-149 is supposed to have backfilled pre-existing posts.
-					// Surface at warning so it's visible in the default WC log UI without
-					// requiring operators to lower the email-editor logging threshold.
-					self::get_logger()->warning(
-						sprintf(
-							'Email template divergence sweep skipped post %d for email "%s": no stored source hash.',
-							(int) $post->ID,
-							(string) $email_id
-						),
-						array(
-							'email_id' => (string) $email_id,
-							'post_id'  => (int) $post->ID,
-							'context'  => 'email_template_divergence_detector',
-						)
-					);
-					continue;
-				}
-
-				$status = self::classify_post(
-					(int) $post->ID,
-					$email,
-					array(
-						'post_content'       => (string) $post->post_content,
-						'stored_source_hash' => $stored_source_hash,
-					)
-				);
-
-				if ( null === $status ) {
-					continue;
-				}
-
-				$existing_status = (string) get_post_meta( (int) $post->ID, self::STATUS_META_KEY, true );
-				if ( $existing_status === $status ) {
-					continue;
-				}
-
-				update_post_meta( (int) $post->ID, self::STATUS_META_KEY, $status );
+				self::reclassify( (int) $post->ID );
 			} catch ( \Throwable $e ) {
 				self::get_logger()->error(
 					sprintf(
@@ -323,6 +311,89 @@ class WCEmailTemplateDivergenceDetector {
 			: self::STATUS_CORE_UPDATED_CUSTOMIZED;
 	}

+	/**
+	 * Run the divergence classifier on a single post and stamp the resulting
+	 * status. Used both by {@see self::run_sweep()} and by every code path
+	 * that mutates `post_content` for a `woo_email` post (auto-applier,
+	 * selective applier, undo). Centralising the write here keeps
+	 * {@see self::STATUS_META_KEY} consistent regardless of which writer
+	 * triggered the change.
+	 *
+	 * Returns the stamped status, or `null` when the classifier cannot
+	 * decide (no stored source hash yet, post / email cannot be resolved).
+	 * In the `null` case the meta is left untouched.
+	 *
+	 * @param int $post_id The `woo_email` post ID.
+	 *
+	 * @return string|null One of the STATUS_* constants, or null when no
+	 *                     decision was made.
+	 *
+	 * @since 10.9.0
+	 */
+	public static function reclassify( int $post_id ): ?string {
+		$post = get_post( $post_id );
+		if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) {
+			return null;
+		}
+
+		$posts_manager = WCTransactionalEmailPostsManager::get_instance();
+		$email_id      = (string) $posts_manager->get_email_type_from_post_id( $post_id );
+		if ( '' === $email_id ) {
+			return null;
+		}
+
+		$canonical_emails = $posts_manager->get_emails_by_id();
+		$email            = $canonical_emails[ $email_id ] ?? null;
+		if ( ! $email instanceof \WC_Email ) {
+			return null;
+		}
+
+		$stored_source_hash = (string) get_post_meta( $post_id, self::SOURCE_HASH_META_KEY, true );
+		if ( '' === $stored_source_hash ) {
+			// This should not normally occur post-backfill: the generator always stamps
+			// this meta and RSM-149 is supposed to have backfilled pre-existing posts.
+			// Surface at warning so it's visible in the default WC log UI without
+			// requiring operators to lower the email-editor logging threshold.
+			self::get_logger()->warning(
+				sprintf(
+					'Email template divergence reclassify skipped post %d for email "%s": no stored source hash.',
+					$post_id,
+					$email_id
+				),
+				array(
+					'email_id' => $email_id,
+					'post_id'  => $post_id,
+					'context'  => 'email_template_divergence_detector',
+				)
+			);
+			return null;
+		}
+
+		$status = self::classify_post(
+			$post_id,
+			$email,
+			array(
+				'post_content'       => (string) $post->post_content,
+				'stored_source_hash' => $stored_source_hash,
+			)
+		);
+
+		if ( null === $status ) {
+			return null;
+		}
+
+		// Idempotent write: skip the meta call entirely when the status hasn't shifted,
+		// so successive reclassifies on unchanged state produce zero DB writes (and zero
+		// `update_post_metadata` filter calls observed by tests / observers).
+		$existing_status = (string) get_post_meta( $post_id, self::STATUS_META_KEY, true );
+		if ( $existing_status === $status ) {
+			return $status;
+		}
+
+		update_post_meta( $post_id, self::STATUS_META_KEY, $status );
+		return $status;
+	}
+
 	/**
 	 * Override the logger implementation. Intended for tests only.
 	 *
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
new file mode 100644
index 00000000000..7e124ecafc3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
@@ -0,0 +1,717 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\Logger;
+
+/**
+ * Applies a partial set of core template changes to a customised `woo_email`
+ * post, driven by per-conflict merchant choices. Pairs with
+ * {@see WCEmailTemplateChangeSummary} (the diff data source) and
+ * {@see WCEmailTemplateAutoApplier} (the wholesale-apply primitive) to power
+ * the Review drawer's "Keep yours / Use core" workflow.
+ *
+ * V1 algorithm (spine = the merchant's post):
+ *
+ * - **`copy_changes`** (matched pair, different inner_text): default decision
+ *   is `keep_yours`. When the merchant explicitly opts into `use_core`, the
+ *   matched block's `innerHTML` / `innerContent` is replaced with core's
+ *   version. Block `attrs` are preserved from the post side (no attribute
+ *   diff in v1).
+ * - **`added_blocks`** (in core, not in post): always applied. Inserted at
+ *   the equivalent position from core's path; if the path can't be navigated
+ *   in the post tree, falls back to appending at the closest level.
+ * - **`removed_blocks`** (in post, not in core): always preserved (Keep
+ *   yours).
+ * - **`structural_changes`** (`nest` / `reorder`): not applied in v1. The
+ *   merchant's structure is preserved; the response carries
+ *   `structural_skipped: true` if any structural delta was observed.
+ *
+ * Undo: each apply writes a single-step snapshot of the prior `post_content`
+ * to {@see self::SNAPSHOT_META_KEY}, keyed by a UUID `revision_id`. A
+ * subsequent apply overwrites the snapshot. {@see self::undo()} restores from
+ * the snapshot when the supplied `revision_id` matches.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since   10.9.0
+ */
+class WCEmailTemplateSelectiveApplier {
+	/**
+	 * Post meta key for the single-step pre-apply snapshot. Stores an array
+	 * with `revision_id`, `content`, and `snapshot_at` (UTC `Y-m-d H:i:s`).
+	 * The snapshot does **not** record the prior status — on undo the status
+	 * is recomputed via
+	 * {@see WCEmailTemplateDivergenceDetector::reclassify()} so it reflects
+	 * the world as it stands at undo time (core may have shipped a release
+	 * since the apply).
+	 *
+	 * @var string
+	 */
+	public const SNAPSHOT_META_KEY = '_wc_email_template_pre_apply_snapshot';
+
+	/**
+	 * Re-entrancy flag set while the applier rewrites a post. Mirrors
+	 * {@see WCEmailTemplateAutoApplier::is_auto_applying()}; future
+	 * `save_post` listeners (RSM-145 Tracks event firing) should consult both
+	 * before treating a write as a merchant edit.
+	 *
+	 * @var bool
+	 */
+	private static bool $is_applying = false;
+
+	/**
+	 * Logger instance. Lazily instantiated on first use; overridable for tests.
+	 *
+	 * @var Email_Editor_Logger_Interface|null
+	 */
+	private static ?Email_Editor_Logger_Interface $logger = null;
+
+	/**
+	 * Apply the selected set of core template changes to a `woo_email` post.
+	 *
+	 * @param int                                                        $post_id The `woo_email` post ID.
+	 * @param array<int, array{path:array<int|string>, decision:string}> $choices Per-conflict choices keyed implicitly by `path`. `decision` is `'keep_yours'` (default if absent) or `'use_core'`. Choices for paths that don't correspond to a `copy_changes` entry are ignored — auto-resolved entries are non-overridable in v1.
+	 *
+	 * @return array<string, mixed>|\WP_Error On success, an array with keys
+	 *                                        `merged_content`, `revision_id`,
+	 *                                        `version_to`, `status` ('applied'),
+	 *                                        `structural_skipped`, and
+	 *                                        `aliases_migrated` (a list of
+	 *                                        deprecated block-name aliases
+	 *                                        rewritten to canonical form
+	 *                                        during the apply, e.g.
+	 *                                        `['woo/email-content']`).
+	 *
+	 * @since 10.9.0
+	 */
+	public static function apply_selectively( int $post_id, array $choices ) {
+		$post = get_post( $post_id );
+		if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) {
+			return new \WP_Error(
+				'post_not_found',
+				sprintf(
+					/* translators: %d: post ID */
+					__( 'No woo_email post found for ID %d.', 'woocommerce' ),
+					$post_id
+				),
+				array( 'status' => 404 )
+			);
+		}
+
+		$posts_manager = WCTransactionalEmailPostsManager::get_instance();
+		$email_id      = $posts_manager->get_email_type_from_post_id( $post_id );
+		if ( ! is_string( $email_id ) || '' === $email_id ) {
+			return new \WP_Error(
+				'email_not_found',
+				__( 'No email type associated with the given post ID.', 'woocommerce' ),
+				array( 'status' => 404 )
+			);
+		}
+
+		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id );
+		if ( null === $sync_config ) {
+			return new \WP_Error(
+				'not_sync_enabled',
+				sprintf(
+					/* translators: %s: email ID */
+					__( 'Email "%s" is not registered for template sync; selective apply is unavailable.', 'woocommerce' ),
+					$email_id
+				),
+				array( 'status' => 422 )
+			);
+		}
+
+		$emails = $posts_manager->get_emails_by_id();
+		$email  = $emails[ $email_id ] ?? null;
+		if ( ! $email instanceof \WC_Email ) {
+			return new \WP_Error(
+				'email_not_found',
+				sprintf(
+					/* translators: %s: email ID */
+					__( 'Email instance for "%s" is unavailable.', 'woocommerce' ),
+					$email_id
+				),
+				array( 'status' => 404 )
+			);
+		}
+
+		$summary = WCEmailTemplateChangeSummary::summarize( $post_id );
+		if ( ! empty( $summary['is_fallback'] ) ) {
+			return new \WP_Error(
+				'no_actionable_summary',
+				__( 'No actionable diff is available for this post; refusing to apply.', 'woocommerce' ),
+				array( 'status' => 422 )
+			);
+		}
+
+		$post_content = (string) $post->post_content;
+
+		try {
+			$core_content = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+		} catch ( \Throwable $e ) {
+			self::get_logger()->error(
+				sprintf(
+					'Selective apply failed to compute canonical content for email "%s": %s',
+					$email_id,
+					$e->getMessage()
+				),
+				array(
+					'email_id' => $email_id,
+					'post_id'  => $post_id,
+					'context'  => 'email_template_selective_applier',
+				)
+			);
+			return new \WP_Error(
+				'canonical_render_failed',
+				__( 'Failed to compute the canonical core render.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}//end try
+
+		$merged_result      = self::merge( $post_content, $core_content, $choices );
+		$merged_content     = $merged_result['content'];
+		$structural_skipped = $merged_result['structural_skipped'];
+		$aliases_migrated   = $merged_result['aliases_migrated'];
+
+		$revision_id = wp_generate_uuid4();
+		$snapshot    = array(
+			'revision_id' => $revision_id,
+			'content'     => $post_content,
+			'snapshot_at' => gmdate( 'Y-m-d H:i:s' ),
+		);
+		update_post_meta( $post_id, self::SNAPSHOT_META_KEY, $snapshot );
+
+		self::$is_applying = true;
+		try {
+			$updated = wp_update_post(
+				array(
+					'ID'           => $post_id,
+					'post_content' => $merged_content,
+				),
+				true
+			);
+
+			if ( is_wp_error( $updated ) ) {
+				delete_post_meta( $post_id, self::SNAPSHOT_META_KEY );
+				return $updated;
+			}
+
+			$saved_post = get_post( $post_id );
+			$saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : $merged_content;
+
+			// When merged content diverges from canonical (any keep_yours or
+			// preserved removed-block), stamp sha1(canonical) and hard-stamp
+			// STATUS_CORE_UPDATED_CUSTOMIZED so the auto-applier (which only
+			// acts on STATUS_CORE_UPDATED_UNCUSTOMIZED) can't silently overwrite
+			// the merchant's choice on the next core bump.
+			$is_aligned_with_canonical = ( $merged_content === $core_content );
+			$source_hash               = $is_aligned_with_canonical
+				? sha1( $saved_body )
+				: sha1( $core_content );
+			$synced_at                 = gmdate( 'Y-m-d H:i:s' );
+			$version_to                = (string) $sync_config['version'];
+
+			update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $version_to );
+			update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
+			update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
+
+			if ( $is_aligned_with_canonical ) {
+				WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+			} else {
+				// reclassify() returns null in this branch (current_core ===
+				// stored, current_post !== stored) and would leave prior status
+				// untouched, so stamp directly.
+				update_post_meta(
+					$post_id,
+					WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+					WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED
+				);
+			}
+		} finally {
+			self::$is_applying = false;
+		}//end try
+
+		// Invalidate the change-summary cache so the next read reflects the merged state.
+		WCEmailTemplateChangeSummary::reset_cache();
+
+		return array(
+			'merged_content'     => $merged_content,
+			'revision_id'        => $revision_id,
+			'version_to'         => $version_to,
+			'status'             => 'applied',
+			'structural_skipped' => $structural_skipped,
+			'aliases_migrated'   => $aliases_migrated,
+		);
+	}
+
+	/**
+	 * Restore the pre-apply snapshot for a post. Single-step undo only: the
+	 * snapshot meta is consumed (deleted) on success, so a second undo
+	 * without an intervening apply returns 410 Gone.
+	 *
+	 * @param int    $post_id     The `woo_email` post ID.
+	 * @param string $revision_id The UUID returned by the prior `apply_selectively()` call.
+	 *
+	 * @return array<string, mixed>|\WP_Error On success, an array with keys
+	 *                                        `restored_content` and `status`
+	 *                                        ('restored').
+	 *
+	 * @since 10.9.0
+	 */
+	public static function undo( int $post_id, string $revision_id ) {
+		$post = get_post( $post_id );
+		if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) {
+			return new \WP_Error(
+				'post_not_found',
+				sprintf(
+					/* translators: %d: post ID */
+					__( 'No woo_email post found for ID %d.', 'woocommerce' ),
+					$post_id
+				),
+				array( 'status' => 404 )
+			);
+		}
+
+		$snapshot = get_post_meta( $post_id, self::SNAPSHOT_META_KEY, true );
+		if ( ! is_array( $snapshot ) || ! isset( $snapshot['revision_id'], $snapshot['content'] ) ) {
+			return new \WP_Error(
+				'undo_unavailable',
+				__( 'No pre-apply snapshot is available for this post.', 'woocommerce' ),
+				array( 'status' => 410 )
+			);
+		}
+
+		if ( (string) $snapshot['revision_id'] !== $revision_id ) {
+			return new \WP_Error(
+				'undo_unavailable',
+				__( 'The supplied revision ID does not match the latest snapshot for this post.', 'woocommerce' ),
+				array( 'status' => 410 )
+			);
+		}
+
+		$restored_content = (string) $snapshot['content'];
+
+		self::$is_applying = true;
+		try {
+			$updated = wp_update_post(
+				array(
+					'ID'           => $post_id,
+					'post_content' => $restored_content,
+				),
+				true
+			);
+
+			if ( is_wp_error( $updated ) ) {
+				return $updated;
+			}
+
+			// The snapshot's prior_status was correct at snapshot time, but
+			// the world may have moved since (core released, canonical
+			// changed). Ask the classifier for the truth against current
+			// state instead of stamping a stale value.
+			WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+			delete_post_meta( $post_id, self::SNAPSHOT_META_KEY );
+		} finally {
+			self::$is_applying = false;
+		}//end try
+
+		WCEmailTemplateChangeSummary::reset_cache();
+
+		return array(
+			'restored_content' => $restored_content,
+			'status'           => 'restored',
+		);
+	}
+
+	/**
+	 * Whether the applier is currently rewriting a post. Mirrors the
+	 * auto-applier's flag so downstream listeners can ignore system writes.
+	 *
+	 * @since 10.9.0
+	 */
+	public static function is_applying(): bool {
+		return self::$is_applying;
+	}
+
+	/**
+	 * Override the logger implementation. Intended for tests only.
+	 *
+	 * @internal
+	 *
+	 * @param Email_Editor_Logger_Interface|null $logger The logger implementation, or null to restore the default.
+	 */
+	public static function set_logger( ?Email_Editor_Logger_Interface $logger ): void {
+		self::$logger = $logger;
+	}
+
+	/**
+	 * Compute the merged block tree, starting from the merchant's post and
+	 * layering on core's changes per the v1 algorithm.
+	 *
+	 * @param string                                                     $post_content Merchant's current `post_content`.
+	 * @param string                                                     $core_content Canonical core render.
+	 * @param array<int, array{path:array<int|string>, decision:string}> $choices      Per-conflict choices.
+	 *
+	 * @return array{content:string, structural_skipped:bool, aliases_migrated:string[]}
+	 */
+	private static function merge( string $post_content, string $core_content, array $choices ): array {
+		$post_blocks = parse_blocks( $post_content );
+		$core_blocks = parse_blocks( $core_content );
+
+		if ( empty( $post_blocks ) || empty( $core_blocks ) ) {
+			return array(
+				'content'            => $post_content,
+				'structural_skipped' => false,
+				'aliases_migrated'   => array(),
+			);
+		}
+
+		$post_records = WCEmailTemplateChangeSummary::flatten_blocks( $post_blocks );
+		$core_records = WCEmailTemplateChangeSummary::flatten_blocks( $core_blocks );
+		$matches      = WCEmailTemplateChangeSummary::lcs_matches( $core_records, $post_records );
+
+		$choice_map = array();
+		foreach ( $choices as $choice ) {
+			if ( ! is_array( $choice ) || ! isset( $choice['path'] ) || ! is_array( $choice['path'] ) ) {
+				continue;
+			}
+			$decision = (string) ( $choice['decision'] ?? 'keep_yours' );
+			if ( 'use_core' !== $decision && 'keep_yours' !== $decision ) {
+				continue;
+			}
+			$choice_map[ self::path_key( $choice['path'] ) ] = $decision;
+		}
+
+		// Pass 1: matched pairs. Apply use_core decisions on copy changes;
+		// detect parent-name diffs (structural punted, but we still flag
+		// `structural_skipped` so the caller can surface it).
+		$structural_skipped = false;
+		$matched_core_set   = array();
+		$matched_post_set   = array();
+		foreach ( $matches as $pair ) {
+			$matched_core_set[ $pair[0] ] = true;
+			$matched_post_set[ $pair[1] ] = true;
+
+			$core_rec = $core_records[ $pair[0] ];
+			$post_rec = $post_records[ $pair[1] ];
+
+			if ( $core_rec['parent_name'] !== $post_rec['parent_name'] ) {
+				$structural_skipped = true;
+			}
+
+			if ( $core_rec['inner_text'] === $post_rec['inner_text'] ) {
+				continue;
+			}
+
+			$decision = $choice_map[ self::path_key( $post_rec['path'] ) ] ?? 'keep_yours';
+			if ( 'use_core' !== $decision ) {
+				continue;
+			}
+
+			$core_block = self::block_at_path( $core_blocks, $core_rec['path'] );
+			if ( null === $core_block ) {
+				continue;
+			}
+			$post_blocks = self::replace_block_content_at_path( $post_blocks, $post_rec['path'], $core_block );
+		}//end foreach
+
+		// Pass 2: unmatched core records. Insert non-structural blocks at
+		// the equivalent path; flag structural wrappers as skipped.
+		$insertions = array();
+		foreach ( $core_records as $i => $rec ) {
+			if ( isset( $matched_core_set[ $i ] ) ) {
+				continue;
+			}
+			if ( self::is_structural_block( $rec['name'] ) ) {
+				$structural_skipped = true;
+				continue;
+			}
+			$core_block = self::block_at_path( $core_blocks, $rec['path'] );
+			if ( null === $core_block ) {
+				continue;
+			}
+			$insertions[] = array(
+				'path'  => $rec['path'],
+				'block' => $core_block,
+			);
+		}
+
+		// Insert in order of decreasing path-depth+index so each insert's
+		// target index isn't shifted by a prior insert at the same level.
+		usort(
+			$insertions,
+			static function ( array $a, array $b ): int {
+				$path_a    = $a['path'];
+				$path_b    = $b['path'];
+				$depth_cmp = count( $path_b ) - count( $path_a );
+				if ( 0 !== $depth_cmp ) {
+					return $depth_cmp;
+				}
+				$last_a = end( $path_a );
+				$last_b = end( $path_b );
+				return ( (int) $last_b ) - ( (int) $last_a );
+			}
+		);
+		foreach ( $insertions as $insertion ) {
+			$post_blocks = self::insert_block_at_path( $post_blocks, $insertion['path'], $insertion['block'] );
+		}
+
+		// Pass 3: unmatched post records (`removed_blocks`). Auto-resolved
+		// as Keep yours — no change. Detect structural wrappers solely so
+		// we can flag `structural_skipped` honestly.
+		foreach ( $post_records as $i => $rec ) {
+			if ( isset( $matched_post_set[ $i ] ) ) {
+				continue;
+			}
+			if ( self::is_structural_block( $rec['name'] ) ) {
+				$structural_skipped = true;
+			}
+		}
+
+		// Final pass: explicit deprecated-namespace migration. Whenever a
+		// `wp:woo/email-content` block is found in the merged tree, rewrite
+		// it to the canonical `wp:woocommerce/email-content` form, including
+		// the `wp-block-{old}` CSS class on the inner div so the comment and
+		// class stay consistent. The block's `attrs` and inner content are
+		// preserved — only the namespace label changes. This is unconditional
+		// (independent of `choices`) because `woo/email-content` is a known
+		// alias of the canonical core block, not a customisation worth
+		// preserving.
+		$aliases_migrated = array();
+		$post_blocks      = self::migrate_woo_email_content_namespace( $post_blocks, $aliases_migrated );
+
+		return array(
+			// $post_blocks originates from parse_blocks() and our mutations only
+			// rewrite well-typed fields; serialize_blocks accepts the same shape.
+			// PHPStan can't follow the mutation chain, so the explicit ignore.
+			// @phpstan-ignore-next-line argument.type
+			'content'            => serialize_blocks( $post_blocks ),
+			'structural_skipped' => $structural_skipped,
+			'aliases_migrated'   => array_values( array_unique( $aliases_migrated ) ),
+		);
+	}
+
+	/**
+	 * Walk the merged tree and rewrite every `wp:woo/email-content` block to
+	 * the canonical `wp:woocommerce/email-content` form. Touches the
+	 * `blockName` and the `wp-block-woo-email-content` CSS class in the
+	 * block's `innerHTML` and each `innerContent` segment. The block's
+	 * `attrs` and inner content are otherwise preserved.
+	 *
+	 * Targeted to a single known alias by design — this is not a general
+	 * alias-migration framework. Add new entries here only when a real
+	 * deprecated→canonical rename ships and we want it auto-migrated on
+	 * apply.
+	 *
+	 * @param array<int|string, array<string, mixed>> $blocks   Mutable block tree.
+	 * @param string[]                                $migrated Names of aliases that were rewritten (out param, appended to).
+	 *
+	 * @return array<int|string, array<string, mixed>>
+	 */
+	private static function migrate_woo_email_content_namespace( array $blocks, array &$migrated ): array {
+		$out = array();
+		foreach ( $blocks as $block ) {
+			if ( ! is_array( $block ) ) {
+				$out[] = $block;
+				continue;
+			}
+
+			if ( 'woo/email-content' === ( $block['blockName'] ?? null ) ) {
+				$block['blockName'] = 'woocommerce/email-content';
+
+				if ( isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ) {
+					$block['innerHTML'] = str_replace(
+						'wp-block-woo-email-content',
+						'wp-block-woocommerce-email-content',
+						$block['innerHTML']
+					);
+				}
+
+				if ( isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) {
+					foreach ( $block['innerContent'] as $i => $segment ) {
+						if ( is_string( $segment ) ) {
+							$block['innerContent'][ $i ] = str_replace(
+								'wp-block-woo-email-content',
+								'wp-block-woocommerce-email-content',
+								$segment
+							);
+						}
+					}
+				}
+
+				$migrated[] = 'woo/email-content';
+			}//end if
+
+			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
+				$block['innerBlocks'] = self::migrate_woo_email_content_namespace( $block['innerBlocks'], $migrated );
+			}
+
+			$out[] = $block;
+		}//end foreach
+		return $out;
+	}
+
+	/**
+	 * Replace the block at the given path with another block's content.
+	 * Preserves the post block's `attrs` (no attribute-level apply in v1);
+	 * copies the source block's `innerHTML`, `innerContent`, and
+	 * `innerBlocks` over the target.
+	 *
+	 * @param array<int|string, array<string, mixed>> $blocks       Mutable block tree.
+	 * @param array<int|string>                       $path         Index path through `parse_blocks` output.
+	 * @param array<string, mixed>                    $source_block The block whose content to copy in.
+	 *
+	 * @return array<int|string, array<string, mixed>>
+	 */
+	private static function replace_block_content_at_path( array $blocks, array $path, array $source_block ): array {
+		if ( empty( $path ) ) {
+			return $blocks;
+		}
+		return self::replace_recursive( $blocks, array_values( $path ), 0, $source_block );
+	}
+
+	/**
+	 * Recursive worker for {@see self::replace_block_content_at_path()}.
+	 *
+	 * @param array<int|string, array<string, mixed>> $blocks       Current level of the tree.
+	 * @param array<int|string>                       $path         Path indices.
+	 * @param int                                     $depth        Current depth.
+	 * @param array<string, mixed>                    $source_block Source block to copy content from.
+	 *
+	 * @return array<int|string, array<string, mixed>>
+	 */
+	private static function replace_recursive( array $blocks, array $path, int $depth, array $source_block ): array {
+		$idx = (int) $path[ $depth ];
+		if ( ! isset( $blocks[ $idx ] ) ) {
+			return $blocks;
+		}
+
+		if ( count( $path ) - 1 === $depth ) {
+			$blocks[ $idx ]['innerHTML']    = $source_block['innerHTML'] ?? '';
+			$blocks[ $idx ]['innerContent'] = $source_block['innerContent'] ?? array();
+			$blocks[ $idx ]['innerBlocks']  = $source_block['innerBlocks'] ?? array();
+			return $blocks;
+		}
+
+		$inner                         = $blocks[ $idx ]['innerBlocks'] ?? array();
+		$blocks[ $idx ]['innerBlocks'] = self::replace_recursive( is_array( $inner ) ? $inner : array(), $path, $depth + 1, $source_block );
+		return $blocks;
+	}
+
+	/**
+	 * Insert a block at the equivalent position in the merged tree using
+	 * core's path as a guide. Best-effort: if a parent in the path doesn't
+	 * exist in the post tree, append at the closest level.
+	 *
+	 * @param array<int|string, array<string, mixed>> $blocks    Mutable block tree.
+	 * @param array<int|string>                       $path      Core-side index path of the block to insert.
+	 * @param array<string, mixed>                    $new_block The block to insert.
+	 *
+	 * @return array<int|string, array<string, mixed>>
+	 */
+	private static function insert_block_at_path( array $blocks, array $path, array $new_block ): array {
+		if ( empty( $path ) ) {
+			return $blocks;
+		}
+		return self::insert_recursive( $blocks, array_values( $path ), 0, $new_block );
+	}
+
+	/**
+	 * Recursive worker for {@see self::insert_block_at_path()}.
+	 *
+	 * @param array<int|string, array<string, mixed>> $blocks    Current level.
+	 * @param array<int|string>                       $path      Path indices.
+	 * @param int                                     $depth     Current depth.
+	 * @param array<string, mixed>                    $new_block Block to insert.
+	 *
+	 * @return array<int|string, array<string, mixed>>
+	 */
+	private static function insert_recursive( array $blocks, array $path, int $depth, array $new_block ): array {
+		$idx = (int) $path[ $depth ];
+
+		if ( count( $path ) - 1 === $depth ) {
+			$insert_at = max( 0, min( $idx, count( $blocks ) ) );
+			array_splice( $blocks, $insert_at, 0, array( $new_block ) );
+			return $blocks;
+		}
+
+		if ( ! isset( $blocks[ $idx ] ) ) {
+			// The parent on the core side doesn't exist in the post tree —
+			// fall back to appending at this level so the block isn't lost.
+			$blocks[] = $new_block;
+			return $blocks;
+		}
+
+		$inner                         = $blocks[ $idx ]['innerBlocks'] ?? array();
+		$blocks[ $idx ]['innerBlocks'] = self::insert_recursive( is_array( $inner ) ? $inner : array(), $path, $depth + 1, $new_block );
+		return $blocks;
+	}
+
+	/**
+	 * Walk a parsed block tree along a path and return the block at that
+	 * path, or null if any segment is missing.
+	 *
+	 * @param array<int|string, array<string, mixed>> $blocks Parsed block tree.
+	 * @param array<int|string>                       $path   Index path.
+	 *
+	 * @return array<string, mixed>|null
+	 */
+	private static function block_at_path( array $blocks, array $path ): ?array {
+		if ( empty( $path ) ) {
+			return null;
+		}
+		$current = $blocks;
+		$last    = count( $path ) - 1;
+		foreach ( array_values( $path ) as $depth => $idx ) {
+			$idx = (int) $idx;
+			if ( ! isset( $current[ $idx ] ) || ! is_array( $current[ $idx ] ) ) {
+				return null;
+			}
+			if ( $depth === $last ) {
+				return $current[ $idx ];
+			}
+			$inner   = $current[ $idx ]['innerBlocks'] ?? array();
+			$current = is_array( $inner ) ? $inner : array();
+		}
+		return null;
+	}
+
+	/**
+	 * Whether the given post-alias-normalized block name is a structural
+	 * wrapper (matches the same set RSM-142 uses for nest detection).
+	 *
+	 * @param string $name Normalized block name (e.g. `core/group`).
+	 */
+	private static function is_structural_block( string $name ): bool {
+		return in_array(
+			$name,
+			array( 'core/group', 'core/columns', 'core/column', 'core/row' ),
+			true
+		);
+	}
+
+	/**
+	 * Stable string key for a path array, used as the choice-map key.
+	 *
+	 * @param array<int|string> $path Path indices.
+	 */
+	private static function path_key( array $path ): string {
+		$encoded = wp_json_encode( array_values( $path ) );
+		return false === $encoded ? '[]' : $encoded;
+	}
+
+	/**
+	 * Return the logger instance, lazily creating it the first time.
+	 */
+	private static function get_logger(): Email_Editor_Logger_Interface {
+		if ( null === self::$logger ) {
+			self::$logger = new Logger( wc_get_logger() );
+		}
+		return self::$logger;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
index 3bab49498ed..9ed7d696260 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
@@ -399,13 +399,16 @@ class WCTransactionalEmailPostsGenerator {
 		$post_data = self::build_filtered_post_data( (string) $email_type, $email_data );

 		// Sync meta stamp for emails participating in template update propagation.
+		// VERSION + LAST_SYNCED_AT are filter-independent and can ride on `meta_input`
+		// during the insert. SOURCE_HASH must reflect the post_content WordPress
+		// actually persisted (post-`content_save_pre` filter chain), so we stamp it
+		// after the insert returns and re-fetch the post to hash its saved content.
 		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_data->id );
 		if ( null !== $sync_config ) {
 			if ( ! isset( $post_data['meta_input'] ) || ! is_array( $post_data['meta_input'] ) ) {
 				$post_data['meta_input'] = array();
 			}
 			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ]        = (string) $sync_config['version'];
-			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY ]    = sha1( (string) ( $post_data['post_content'] ?? '' ) );
 			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY ] = gmdate( 'Y-m-d H:i:s' );
 		}

@@ -415,6 +418,22 @@ class WCTransactionalEmailPostsGenerator {
 			throw new \Exception( esc_html( $post_id->get_error_message() ) );
 		}

+		if ( null !== $sync_config ) {
+			$saved_post = get_post( $post_id );
+			$saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : (string) ( $post_data['post_content'] ?? '' );
+			update_post_meta(
+				(int) $post_id,
+				WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+				sha1( $saved_body )
+			);
+			// Freshly generated posts match canonical core by construction.
+			update_post_meta(
+				(int) $post_id,
+				WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+				WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC
+			);
+		}
+
 		$this->template_manager->save_email_template_post_id( $email_type, $post_id );

 		return $post_id;
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index 081ded321ea..125d3936c8e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Internal\EmailEditor\EmailApiController;
 use Automattic\WooCommerce\Internal\EmailEditor\Integration;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSelectiveApplier;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
@@ -466,6 +467,7 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 		$baseline_version     = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true );
 		$baseline_source_hash = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
 		$baseline_synced_at   = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true );
+		$baseline_status      = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true );

 		// Simulate a customised post so the content reset is observable.
 		wp_update_post(
@@ -521,9 +523,9 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 			'_wc_email_last_synced_at must not be touched when the email is not sync-enabled.'
 		);
 		$this->assertSame(
-			'',
+			$baseline_status,
 			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
-			'_wc_email_template_status must not be set when the email is not sync-enabled.'
+			'_wc_email_template_status must not be touched when the email is not sync-enabled.'
 		);
 	}

@@ -670,6 +672,152 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 		$this->assertSame( 404, $result->get_error_data()['status'] );
 	}

+	/**
+	 * @testdox Should return merged content + revision_id from the apply route.
+	 */
+	public function test_apply_route_returns_merged_content_and_revision_id(): void {
+		$email_type = 'customer_new_account';
+
+		$generator = new WCTransactionalEmailPostsGenerator();
+		$generator->init_default_transactional_emails();
+
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();
+		$post_manager->clear_caches();
+		$post_manager->delete_email_template( $email_type );
+		WCEmailTemplateSyncRegistry::reset_cache();
+		WCEmailTemplateChangeSummary::reset_cache();
+
+		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
+		$this->assertIsInt( $post_id );
+
+		// Diverge the post so the change-summary has something to apply.
+		wp_update_post(
+			array(
+				'ID'           => $post_id,
+				'post_content' => "<!-- wp:paragraph -->\n<p>Merchant edit.</p>\n<!-- /wp:paragraph -->",
+			)
+		);
+
+		$request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/apply' );
+		$request->set_param( 'id', $post_id );
+		$request->set_param( 'choices', array() );
+
+		$result = $this->email_api_controller->apply_response( $request );
+
+		$this->assertInstanceOf( \WP_REST_Response::class, $result );
+		$this->assertSame( 200, $result->get_status() );
+
+		$data = $result->get_data();
+		$this->assertIsArray( $data );
+		$this->assertArrayHasKey( 'merged_content', $data );
+		$this->assertArrayHasKey( 'revision_id', $data );
+		$this->assertArrayHasKey( 'version_to', $data );
+		$this->assertArrayHasKey( 'status', $data );
+		$this->assertArrayHasKey( 'structural_skipped', $data );
+		$this->assertSame( 'applied', $data['status'] );
+		$this->assertMatchesRegularExpression( '/^[0-9a-f-]{36}$/', (string) $data['revision_id'] );
+	}
+
+	/**
+	 * @testdox Should return 404 from the apply route when no email matches the post.
+	 */
+	public function test_apply_route_returns_404_for_unknown_post(): void {
+		$unassociated_post = $this->factory()->post->create_and_get(
+			array(
+				'post_title'  => 'Unknown Email',
+				'post_name'   => 'unknown_email_for_apply',
+				'post_type'   => Integration::EMAIL_POST_TYPE,
+				'post_status' => 'draft',
+			)
+		);
+
+		$request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $unassociated_post->ID . '/apply' );
+		$request->set_param( 'id', $unassociated_post->ID );
+
+		$result = $this->email_api_controller->apply_response( $request );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'woocommerce_email_not_found', $result->get_error_code() );
+		$this->assertSame( 404, $result->get_error_data()['status'] );
+	}
+
+	/**
+	 * @testdox Should restore pre-apply content via the undo route.
+	 */
+	public function test_undo_route_restores_pre_apply_content(): void {
+		$email_type = 'customer_new_account';
+
+		$generator = new WCTransactionalEmailPostsGenerator();
+		$generator->init_default_transactional_emails();
+
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();
+		$post_manager->clear_caches();
+		$post_manager->delete_email_template( $email_type );
+		WCEmailTemplateSyncRegistry::reset_cache();
+		WCEmailTemplateChangeSummary::reset_cache();
+
+		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
+		$this->assertIsInt( $post_id );
+
+		$pre_apply_content = "<!-- wp:paragraph -->\n<p>Merchant edit.</p>\n<!-- /wp:paragraph -->";
+		wp_update_post(
+			array(
+				'ID'           => $post_id,
+				'post_content' => $pre_apply_content,
+			)
+		);
+
+		$apply_request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/apply' );
+		$apply_request->set_param( 'id', $post_id );
+		$apply_request->set_param( 'choices', array() );
+
+		$apply_result = $this->email_api_controller->apply_response( $apply_request );
+		$this->assertInstanceOf( \WP_REST_Response::class, $apply_result );
+
+		$revision_id = (string) $apply_result->get_data()['revision_id'];
+
+		$undo_request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/undo' );
+		$undo_request->set_param( 'id', $post_id );
+		$undo_request->set_param( 'revision_id', $revision_id );
+
+		$undo_result = $this->email_api_controller->undo_response( $undo_request );
+		$this->assertInstanceOf( \WP_REST_Response::class, $undo_result );
+		$this->assertSame( 200, $undo_result->get_status() );
+
+		$data = $undo_result->get_data();
+		$this->assertIsArray( $data );
+		$this->assertSame( 'restored', $data['status'] );
+		$this->assertSame( $pre_apply_content, $data['restored_content'] );
+	}
+
+	/**
+	 * @testdox Should return 410 from the undo route when no snapshot exists.
+	 */
+	public function test_undo_route_returns_410_when_no_snapshot(): void {
+		$email_type = 'customer_new_account';
+
+		$generator = new WCTransactionalEmailPostsGenerator();
+		$generator->init_default_transactional_emails();
+
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();
+		$post_manager->clear_caches();
+		$post_manager->delete_email_template( $email_type );
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
+		$this->assertIsInt( $post_id );
+
+		$request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/undo' );
+		$request->set_param( 'id', $post_id );
+		$request->set_param( 'revision_id', 'never-applied' );
+
+		$result = $this->email_api_controller->undo_response( $request );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'undo_unavailable', $result->get_error_code() );
+		$this->assertSame( 410, $result->get_error_data()['status'] );
+	}
+
 	/**
 	 * @testdox Should register a POST /reset route alongside the existing default-content route.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
index 10700fd80bf..7ce8cb81bff 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
@@ -145,6 +145,31 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should stamp STATUS_IN_SYNC via the classifier after a successful apply_to_post.
+	 *
+	 * The auto-applier always writes full canonical content, so the classifier
+	 * naturally returns IN_SYNC. This test pins the contract that the stamped
+	 * status comes through the classifier rather than from a hard-coded literal,
+	 * so a future regression that introduces a partial-apply path would trip it.
+	 */
+	public function test_apply_to_post_stamps_status_via_classifier(): void {
+		$email_id = 'auto_applier_classifier_path';
+		$post_id  = $this->generate_stamped_post( $email_id );
+
+		$emails_by_id = $this->posts_manager->get_emails_by_id();
+		$email        = $emails_by_id[ $email_id ];
+		$this->assertInstanceOf( \WC_Email::class, $email );
+
+		$result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+		$this->assertIsArray( $result );
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+	}
+
 	/**
 	 * apply_to_post() with require_uncustomized=true must return a WP_Error and
 	 * leave the post untouched when the merchant has edited it since stamping.
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
index ecb02832632..29f486575e6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -228,6 +228,11 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 		$email_id = 'wc_test_divergence_idempotency';
 		$post_id  = $this->generate_stamped_post( $email_id );

+		// Clear the status the generator stamped at insert so the first
+		// sweep has classification work to do; otherwise both sweeps are
+		// no-ops and the test loses signal on first-write behaviour.
+		delete_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY );
+
 		$write_count = 0;
 		$counter     = static function ( $check, int $object_id, string $meta_key ) use ( &$write_count, $post_id ) {
 			if ( $object_id === $post_id && WCEmailTemplateDivergenceDetector::STATUS_META_KEY === $meta_key ) {
@@ -260,8 +265,10 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 		$email_id = 'wc_test_divergence_missing_hash';
 		$post_id  = $this->generate_stamped_post( $email_id );

-		// Simulate a legacy (pre-RSM-137) post by removing the source-hash stamp.
+		// Simulate a legacy (pre-RSM-137) post by removing both the
+		// source-hash and status stamps the modern generator writes.
 		delete_post_meta( $post_id, '_wc_email_template_source_hash' );
+		delete_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY );

 		WCEmailTemplateDivergenceDetector::run_sweep();

@@ -351,6 +358,50 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 		do_action( 'init' );
 	}

+	/**
+	 * @testdox Should stamp STATUS_CORE_UPDATED_CUSTOMIZED on a post that differs from canonical core after a stored stamp.
+	 */
+	public function test_reclassify_stamps_customized_when_post_differs_from_canonical(): void {
+		$email_id = 'reclassify_customized';
+		$this->register_fixture_email( $email_id );
+
+		$canonical = "<!-- wp:paragraph -->\n<p>Core paragraph.</p>\n<!-- /wp:paragraph -->";
+		$post_html = "<!-- wp:paragraph -->\n<p>Merchant paragraph.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $canonical );
+		$post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+		// Pre-stamp source_hash to a prior canonical so the classifier sees "core moved".
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( "<!-- wp:paragraph -->\n<p>Old core paragraph.</p>\n<!-- /wp:paragraph -->" ) );
+
+		$status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+		$this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED, $status );
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+	}
+
+	/**
+	 * @testdox Should stamp STATUS_IN_SYNC when the post matches the canonical render at the stored stamp.
+	 */
+	public function test_reclassify_stamps_in_sync_when_post_matches_canonical(): void {
+		$email_id = 'reclassify_in_sync';
+		$this->register_fixture_email( $email_id );
+
+		$canonical = "<!-- wp:paragraph -->\n<p>Same on both sides.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $canonical );
+		$post_id = $this->create_woo_email_post( $email_id, $canonical );
+
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( $canonical ) );
+
+		$status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+		$this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $status );
+	}
+
 	/**
 	 * Build a WC_Email stub backed by the third-party-with-version.php fixture, inject it
 	 * into WC_Emails::$emails, and opt the email ID into the block-editor filter so the
@@ -427,6 +478,56 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 		return $post_id;
 	}

+	/**
+	 * Hook the canonical content filter so `compute_canonical_post_content()`
+	 * returns the supplied string for the given email_id, bypassing the
+	 * file-rendered template body. Lets tests express "what core would render"
+	 * directly inline.
+	 *
+	 * @param string $email_id The email ID to override content for.
+	 * @param string $content  The canonical content to inject.
+	 */
+	private function use_canonical_content( string $email_id, string $content ): void {
+		add_filter(
+			'woocommerce_email_content_post_data',
+			static function ( array $post_data, string $type ) use ( $email_id, $content ): array {
+				if ( $type === $email_id ) {
+					$post_data['post_content'] = $content;
+				}
+				return $post_data;
+			},
+			10,
+			2
+		);
+	}
+
+	/**
+	 * Create a `woo_email` post and associate it with the supplied email_id
+	 * via the canonical option key the manager expects.
+	 *
+	 * @param string $email_id     The email ID to associate.
+	 * @param string $post_content Initial post content.
+	 * @return int Post ID.
+	 */
+	private function create_woo_email_post( string $email_id, string $post_content ): int {
+		$post_id = wp_insert_post(
+			array(
+				'post_title'   => 'Fixture for ' . $email_id,
+				'post_name'    => $email_id,
+				'post_type'    => Integration::EMAIL_POST_TYPE,
+				'post_content' => $post_content,
+				'post_status'  => 'publish',
+			)
+		);
+
+		$this->assertIsInt( $post_id );
+		$this->assertGreaterThan( 0, $post_id );
+
+		$this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
+		return (int) $post_id;
+	}
+
 	/**
 	 * Remove any stubs we injected into WC_Emails::$emails during the test.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
new file mode 100644
index 00000000000..864c9eb71e8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
@@ -0,0 +1,808 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSelectiveApplier;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+
+/**
+ * Tests for the WCEmailTemplateSelectiveApplier class.
+ */
+class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
+	/**
+	 * Absolute path to the change-summary fixtures directory (reused).
+	 *
+	 * @var string
+	 */
+	private string $fixtures_base;
+
+	/**
+	 * Keys injected into \WC_Emails::$emails during the current test.
+	 *
+	 * @var string[]
+	 */
+	private array $injected_email_keys = array();
+
+	/**
+	 * Transactional email post manager singleton.
+	 *
+	 * @var WCTransactionalEmailPostsManager
+	 */
+	private WCTransactionalEmailPostsManager $posts_manager;
+
+	/**
+	 * Setup test case.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+
+		$this->fixtures_base = __DIR__ . '/fixtures/';
+		$this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+
+		$this->posts_manager->clear_caches();
+		WCEmailTemplateSyncRegistry::reset_cache();
+		WCEmailTemplateChangeSummary::reset_cache();
+	}
+
+	/**
+	 * Cleanup after test.
+	 */
+	public function tearDown(): void {
+		$this->cleanup_injected_emails();
+
+		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+		remove_all_filters( 'woocommerce_email_content_post_data' );
+
+		WCEmailTemplateSyncRegistry::reset_cache();
+		WCEmailTemplateChangeSummary::reset_cache();
+		WCEmailTemplateChangeSummary::set_logger( null );
+		WCEmailTemplateSelectiveApplier::set_logger( null );
+
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * One conflict, no `choices` → default is `keep_yours`. Merged content
+	 * keeps the merchant's edited paragraph text.
+	 */
+	public function test_apply_selectively_keep_yours_default_skips_text_change(): void {
+		$email_id = 'sa_keep_yours_default';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Original copy.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Edited copy.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'applied', $result['status'] );
+		$this->assertStringContainsString( 'Edited copy.', $result['merged_content'] );
+		$this->assertStringNotContainsString( 'Original copy.', $result['merged_content'] );
+	}
+
+	/**
+	 * One conflict with `decision: 'use_core'` → merged paragraph reads core's text.
+	 */
+	public function test_apply_selectively_use_core_replaces_text(): void {
+		$email_id = 'sa_use_core';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Core copy.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Merchant copy.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively(
+			$post_id,
+			array(
+				array(
+					'path'     => array( 0 ),
+					'decision' => 'use_core',
+				),
+			)
+		);
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'applied', $result['status'] );
+		$this->assertStringContainsString( 'Core copy.', $result['merged_content'] );
+		$this->assertStringNotContainsString( 'Merchant copy.', $result['merged_content'] );
+	}
+
+	/**
+	 * Core has a paragraph not in post; merged content includes the new
+	 * paragraph at the position core has it (auto-resolved Apply core).
+	 */
+	public function test_apply_selectively_inserts_core_added_blocks(): void {
+		$email_id = 'sa_added_block';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>First.</p>\n<!-- /wp:paragraph -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>New from core.</p>\n<!-- /wp:paragraph -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Last.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>First.</p>\n<!-- /wp:paragraph -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Last.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+		$this->assertIsArray( $result );
+		$this->assertStringContainsString( 'New from core.', $result['merged_content'] );
+		$this->assertStringContainsString( 'First.', $result['merged_content'] );
+		$this->assertStringContainsString( 'Last.', $result['merged_content'] );
+	}
+
+	/**
+	 * Pins the "best-effort, append at closest level" fallback inside
+	 * {@see WCEmailTemplateSelectiveApplier::insert_block_at_path()}: when
+	 * core's path requires descending into an index that doesn't exist on
+	 * the post side at an intermediate depth, the new block is appended at
+	 * that level rather than dropped.
+	 *
+	 * The fallback isn't reachable through `apply_selectively()` end-to-end
+	 * with current fixtures (the public flow's LCS + structural-wrapper
+	 * skip combine to consume the only realistic deep-path scenario), so
+	 * this test reaches into the private helper directly via reflection.
+	 * Reviewer's risk on PR #64497: without a test, a future refactor could
+	 * silently turn the fallback's `$blocks[] = $new_block;` append into a
+	 * drop and no fixture would catch it.
+	 *
+	 * Setup: $blocks = [ Group with empty innerBlocks ]. $path = [ 0, 1, 0 ]
+	 * — top-level idx 0 exists (Group), idx 1 at the inner level does NOT,
+	 * so the recursive walker hits the fallback at depth=1 before reaching
+	 * the leaf. Expectation: the new block lands inside Group's innerBlocks.
+	 *
+	 * @testdox Should append a deeply-nested added block at the closest reachable level when the post tree is shallower than core's insertion path.
+	 */
+	public function test_insert_block_at_path_falls_back_to_closest_level_when_post_is_shallower(): void {
+		$reflection = new \ReflectionClass( WCEmailTemplateSelectiveApplier::class );
+		$method     = $reflection->getMethod( 'insert_block_at_path' );
+		$method->setAccessible( true );
+
+		$post_blocks = array(
+			array(
+				'blockName'    => 'core/group',
+				'attrs'        => array(),
+				'innerBlocks'  => array(),
+				'innerHTML'    => '<div class="wp-block-group"></div>',
+				'innerContent' => array( '<div class="wp-block-group"></div>' ),
+			),
+		);
+		$path        = array( 0, 1, 0 );
+		$new_block   = array(
+			'blockName'    => 'core/paragraph',
+			'attrs'        => array(),
+			'innerBlocks'  => array(),
+			'innerHTML'    => '<p>Deeply nested addition.</p>',
+			'innerContent' => array( '<p>Deeply nested addition.</p>' ),
+		);
+
+		$result = $method->invoke( null, $post_blocks, $path, $new_block );
+
+		$this->assertIsArray( $result );
+		$this->assertCount( 1, $result, 'Top-level shape unchanged: still one Group at the root.' );
+		$this->assertSame( 'core/group', $result[0]['blockName'] );
+
+		$inner = $result[0]['innerBlocks'] ?? array();
+		$this->assertCount(
+			1,
+			$inner,
+			'Fallback must append the new block to the Group\'s innerBlocks rather than dropping it.'
+		);
+		$this->assertSame(
+			'core/paragraph',
+			$inner[0]['blockName'] ?? null,
+			'Appended block must be the paragraph passed in, not anything else.'
+		);
+		$this->assertSame(
+			'<p>Deeply nested addition.</p>',
+			$inner[0]['innerHTML'] ?? null,
+			'Block content must survive the append unchanged.'
+		);
+	}
+
+	/**
+	 * Post has a custom block not in core; merged content still contains it
+	 * (auto-resolved Keep yours — `removed_blocks` are preserved).
+	 */
+	public function test_apply_selectively_preserves_merchant_only_blocks(): void {
+		$email_id = 'sa_preserve_merchant_block';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Core only.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Core only.</p>\n<!-- /wp:paragraph -->\n\n"
+			. "<!-- wp:image -->\n<figure class=\"wp-block-image\"><img src=\"x\"/></figure>\n<!-- /wp:image -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+		$this->assertIsArray( $result );
+		$this->assertStringContainsString( 'wp:image', $result['merged_content'], 'Merchant-only image block must be preserved.' );
+	}
+
+	/**
+	 * Group-wrap of an existing block: merchant has Group [Paragraph], core has
+	 * Paragraph at top level. v1 punts structural changes — merchant's Group
+	 * stays; the response surfaces `structural_skipped: true`.
+	 */
+	public function test_apply_selectively_skips_structural_changes_in_v1(): void {
+		$email_id = 'sa_structural_skip';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Wrapped.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:group -->\n<div class=\"wp-block-group\">"
+			. "<!-- wp:paragraph -->\n<p>Wrapped.</p>\n<!-- /wp:paragraph -->"
+			. "</div>\n<!-- /wp:group -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+		$this->assertIsArray( $result );
+		$this->assertTrue( $result['structural_skipped'], 'Structural delta on a Group wrap must flag structural_skipped.' );
+		$this->assertStringContainsString( 'wp:group', $result['merged_content'], 'Merchant Group wrapper must be preserved.' );
+	}
+
+	/**
+	 * After apply, the snapshot meta exists with the original content and a
+	 * UUID-shaped revision_id.
+	 */
+	public function test_apply_selectively_writes_pre_apply_snapshot(): void {
+		$email_id = 'sa_snapshot';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+		$this->assertIsArray( $result );
+
+		$snapshot = get_post_meta( $post_id, WCEmailTemplateSelectiveApplier::SNAPSHOT_META_KEY, true );
+		$this->assertIsArray( $snapshot );
+		$this->assertSame( $post_content, $snapshot['content'] );
+		$this->assertSame( $result['revision_id'], $snapshot['revision_id'] );
+		$this->assertMatchesRegularExpression( '/^[0-9a-f-]{36}$/', $snapshot['revision_id'] );
+	}
+
+	/**
+	 * Successful apply stamps the four sync meta keys.
+	 */
+	public function test_apply_selectively_stamps_sync_meta_on_success(): void {
+		$email_id = 'sa_sync_meta';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+		$this->assertIsArray( $result );
+
+		$this->assertSame(
+			$result['version_to'],
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true )
+		);
+		// Empty choices → keep_yours → diverges from canonical → stamp
+		// sha1(canonical), not sha1(merged). See the keep_yours regression
+		// test for the rationale.
+		$this->assertSame(
+			sha1( $core_content ),
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true )
+		);
+		$this->assertMatchesRegularExpression(
+			'/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/',
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true )
+		);
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+	}
+
+	/**
+	 * @testdox Should stamp source_hash from the post_content WordPress actually persisted on a use_core apply.
+	 *
+	 * Pins the saved-content invariant for the `merged_content === $core_content`
+	 * branch. The keep_yours branch deliberately stamps `sha1($core_content)`
+	 * instead — see the keep_yours regression test below.
+	 */
+	public function test_apply_selectively_use_core_stamps_source_hash_from_saved_post_content(): void {
+		$email_id = 'sa_source_hash_after_save_use_core';
+		$this->register_fixture_email( $email_id );
+
+		$canonical = "<!-- wp:paragraph -->\n<p>Canonical.</p>\n<!-- /wp:paragraph -->";
+		$post_html = "<!-- wp:paragraph -->\n<p>Merchant.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $canonical );
+		$post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+		// content_save_pre mutates the body so saved !== in-memory merged.
+		$mutator = static function ( $content ) {
+			return $content . "\n<!-- filter mutated -->";
+		};
+		add_filter( 'content_save_pre', $mutator, 99 );
+
+		try {
+			$result = WCEmailTemplateSelectiveApplier::apply_selectively(
+				$post_id,
+				array(
+					array(
+						'path'     => array( 0 ),
+						'decision' => 'use_core',
+					),
+				)
+			);
+			$this->assertIsArray( $result );
+
+			$persisted   = get_post( $post_id );
+			$stored_hash = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+
+			$this->assertInstanceOf( \WP_Post::class, $persisted );
+			$this->assertStringContainsString( '<!-- filter mutated -->', (string) $persisted->post_content );
+			$this->assertSame(
+				sha1( (string) $persisted->post_content ),
+				$stored_hash,
+				'use_core apply must stamp sha1(persisted post_content), not sha1(in-memory merged).'
+			);
+		} finally {
+			remove_filter( 'content_save_pre', $mutator, 99 );
+		}//end try
+	}
+
+	/**
+	 * @testdox Should stamp source_hash to sha1(canonical) and status to core_updated_customized when the merchant keeps any of their version.
+	 *
+	 * Regression: stamping sha1(saved post_content) after a keep_yours apply
+	 * made the next divergence sweep see "no merchant edits since sync" →
+	 * STATUS_CORE_UPDATED_UNCUSTOMIZED → auto-applier silently overwrote the
+	 * merchant's customisation on the next core bump.
+	 */
+	public function test_apply_selectively_keep_yours_stamps_canonical_hash_and_customized_status(): void {
+		$email_id = 'sa_keep_yours_canonical_hash';
+		$this->register_fixture_email( $email_id );
+
+		$canonical = "<!-- wp:paragraph -->\n<p>Canonical copy from core.</p>\n<!-- /wp:paragraph -->";
+		$post_html = "<!-- wp:paragraph -->\n<p>Merchant-edited copy.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $canonical );
+		$post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+		$this->assertIsArray( $result );
+
+		$persisted     = get_post( $post_id );
+		$stored_hash   = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+		$stored_status = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true );
+
+		$this->assertInstanceOf( \WP_Post::class, $persisted );
+		$this->assertSame( sha1( $canonical ), $stored_hash );
+		$this->assertNotSame(
+			sha1( (string) $persisted->post_content ),
+			$stored_hash,
+			'keep_yours must NOT stamp sha1(saved post_content) — would mis-classify on next core bump.'
+		);
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+			$stored_status
+		);
+	}
+
+	/**
+	 * @testdox Should restore post_content and consume the snapshot meta on undo.
+	 *
+	 * Apply → undo round-trip: post_content matches the original and the
+	 * snapshot meta is consumed. Status is recomputed by the classifier
+	 * after the restore (see `test_undo_reclassifies_status_after_restoring_snapshot`
+	 * for the core-moved scenario), so this test only pins the
+	 * content-and-snapshot invariants — not a stale "prior status survives"
+	 * assertion that the snapshot used to carry.
+	 */
+	public function test_undo_restores_pre_apply_snapshot(): void {
+		$email_id = 'sa_undo_round_trip';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$apply_result = WCEmailTemplateSelectiveApplier::apply_selectively(
+			$post_id,
+			array(
+				array(
+					'path'     => array( 0 ),
+					'decision' => 'use_core',
+				),
+			)
+		);
+		$this->assertIsArray( $apply_result );
+
+		$undo_result = WCEmailTemplateSelectiveApplier::undo( $post_id, $apply_result['revision_id'] );
+
+		$this->assertIsArray( $undo_result );
+		$this->assertSame( 'restored', $undo_result['status'] );
+		$this->assertSame( $post_content, $undo_result['restored_content'] );
+
+		$persisted = get_post( $post_id );
+		$this->assertInstanceOf( \WP_Post::class, $persisted );
+		$this->assertSame( $post_content, $persisted->post_content );
+
+		// Status meta must be populated after undo; the precise classifier output is
+		// pinned by `test_undo_reclassifies_status_after_restoring_snapshot`.
+		$this->assertNotEmpty( get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+
+		$this->assertSame(
+			'',
+			(string) get_post_meta( $post_id, WCEmailTemplateSelectiveApplier::SNAPSHOT_META_KEY, true ),
+			'Snapshot meta must be consumed by undo.'
+		);
+	}
+
+	/**
+	 * @testdox Should reclassify after undo rather than restore the stored prior_status verbatim.
+	 */
+	public function test_undo_reclassifies_status_after_restoring_snapshot(): void {
+		$email_id = 'undo_reclassify';
+		$this->register_fixture_email( $email_id );
+
+		$pre_apply_canonical = "<!-- wp:paragraph -->\n<p>Old canonical.</p>\n<!-- /wp:paragraph -->";
+		$post_html           = "<!-- wp:paragraph -->\n<p>Old canonical.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $pre_apply_canonical );
+		$post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+		// Stamp a matching source_hash so initial state is in_sync.
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( $pre_apply_canonical ) );
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC );
+
+		// Apply against the same canonical (no real diff — just exercise the path so a snapshot exists).
+		$apply_result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+		$this->assertIsArray( $apply_result );
+
+		// Now mutate the canonical underneath — simulating a core release between apply and undo.
+		$new_canonical = "<!-- wp:paragraph -->\n<p>New canonical from a core release.</p>\n<!-- /wp:paragraph -->";
+		$this->use_canonical_content( $email_id, $new_canonical );
+
+		$undo_result = WCEmailTemplateSelectiveApplier::undo( $post_id, $apply_result['revision_id'] );
+		$this->assertIsArray( $undo_result );
+
+		// After undo, the post is back to its pre-apply content but core has moved.
+		// Classifier output: stored hash != current canonical → core moved; post matches stored hash → uncustomized.
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'undo() must reclassify against current canonical, not blindly restore the snapshot prior_status.'
+		);
+	}
+
+	/**
+	 * Undo without a prior apply (no snapshot) returns 410.
+	 */
+	public function test_undo_returns_410_when_no_snapshot(): void {
+		$email_id = 'sa_undo_no_snapshot';
+		$this->register_fixture_email( $email_id );
+
+		$content = "<!-- wp:paragraph -->\n<p>Untouched.</p>\n<!-- /wp:paragraph -->";
+		$this->use_canonical_content( $email_id, $content );
+		$post_id = $this->create_woo_email_post( $email_id, $content );
+
+		$result = WCEmailTemplateSelectiveApplier::undo( $post_id, 'never-applied' );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'undo_unavailable', $result->get_error_code() );
+		$this->assertSame( 410, $result->get_error_data()['status'] );
+	}
+
+	/**
+	 * Undo with a stale revision_id (no longer matches the latest snapshot)
+	 * returns 410.
+	 */
+	public function test_undo_revision_id_mismatch_returns_410(): void {
+		$email_id = 'sa_undo_stale';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$apply_result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+		$this->assertIsArray( $apply_result );
+
+		$result = WCEmailTemplateSelectiveApplier::undo( $post_id, 'a-different-uuid' );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'undo_unavailable', $result->get_error_code() );
+		$this->assertSame( 410, $result->get_error_data()['status'] );
+	}
+
+	/**
+	 * `is_fallback: true` from the change-summary (e.g. post not in registry,
+	 * inversion guard tripped) refuses to apply with 422.
+	 */
+	public function test_apply_selectively_returns_422_when_summary_is_fallback(): void {
+		// No fixture email registered, so the change-summary returns is_fallback: true.
+		$post_id = $this->create_woo_email_post(
+			'sa_unregistered',
+			"<!-- wp:paragraph -->\n<p>Anything.</p>\n<!-- /wp:paragraph -->"
+		);
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$status_code = $result->get_error_data()['status'] ?? null;
+		$this->assertContains(
+			$status_code,
+			array( 404, 422 ),
+			'Unregistered post must return either 404 (no email) or 422 (no actionable summary).'
+		);
+	}
+
+	/**
+	 * Apply followed by a second apply: the snapshot is overwritten (single-
+	 * step undo), and the second revision_id is what undo requires.
+	 */
+	public function test_second_apply_overwrites_pre_apply_snapshot(): void {
+		$email_id = 'sa_second_apply';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+		$post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$first  = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+		$second = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+		$this->assertIsArray( $first );
+		$this->assertIsArray( $second );
+		$this->assertNotSame( $first['revision_id'], $second['revision_id'] );
+
+		// Undo with the FIRST revision_id should now fail (snapshot was overwritten).
+		$undo_first = WCEmailTemplateSelectiveApplier::undo( $post_id, $first['revision_id'] );
+		$this->assertInstanceOf( \WP_Error::class, $undo_first );
+
+		// Undo with the SECOND revision_id succeeds.
+		$undo_second = WCEmailTemplateSelectiveApplier::undo( $post_id, $second['revision_id'] );
+		$this->assertIsArray( $undo_second );
+	}
+
+	/**
+	 * Apply rewrites the deprecated `wp:woo/email-content` namespace to the
+	 * canonical `wp:woocommerce/email-content` form, including the
+	 * `wp-block-woo-email-content` CSS class on the inner div. The
+	 * migration runs unconditionally during apply (independent of `choices`)
+	 * because `woo/email-content` is a known alias of the canonical block,
+	 * not a customisation worth preserving. The response surfaces it via
+	 * `aliases_migrated`.
+	 */
+	public function test_apply_selectively_migrates_woo_email_content_to_woocommerce_namespace(): void {
+		$email_id = 'sa_alias_migration';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = '<!-- wp:woocommerce/email-content {"lock":{"move":false,"remove":true}} -->'
+			. '<div class="wp-block-woocommerce-email-content"> ##WOO_CONTENT## </div>'
+			. '<!-- /wp:woocommerce/email-content -->';
+
+		$post_content = '<!-- wp:woo/email-content {"lock":{"move":false,"remove":true}} -->'
+			. '<div class="wp-block-woo-email-content"> ##WOO_CONTENT## </div>'
+			. '<!-- /wp:woo/email-content -->';
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+		$this->assertIsArray( $result );
+		$this->assertSame( 'applied', $result['status'] );
+
+		$merged = (string) $result['merged_content'];
+		$this->assertStringContainsString( 'wp:woocommerce/email-content', $merged, 'Block name comment must be migrated to canonical form.' );
+		$this->assertStringContainsString( 'wp-block-woocommerce-email-content', $merged, 'CSS class must be migrated to canonical form.' );
+		$this->assertStringNotContainsString( 'wp:woo/email-content', $merged, 'Deprecated namespace comment must not survive the apply.' );
+		$this->assertStringNotContainsString( 'wp-block-woo-email-content', $merged, 'Deprecated CSS class must not survive the apply.' );
+
+		// Block attrs and inner content are preserved — only the namespace label changes.
+		$this->assertStringContainsString( '"lock":{"move":false,"remove":true}', $merged, 'Block attrs must be preserved.' );
+		$this->assertStringContainsString( '##WOO_CONTENT##', $merged, 'Placeholder content must be preserved.' );
+
+		$this->assertContains( 'woo/email-content', $result['aliases_migrated'], 'Response must surface the migration via aliases_migrated.' );
+		$this->assertCount( 1, $result['aliases_migrated'], 'aliases_migrated must be deduped.' );
+
+		// The persisted post reflects the migrated content too.
+		$persisted = get_post( $post_id );
+		$this->assertInstanceOf( \WP_Post::class, $persisted );
+		$this->assertStringContainsString( 'wp:woocommerce/email-content', $persisted->post_content );
+		$this->assertStringNotContainsString( 'wp:woo/email-content', $persisted->post_content );
+	}
+
+	/**
+	 * @testdox Should NOT stamp STATUS_IN_SYNC after an empty-choices apply when the merged content still differs from canonical.
+	 */
+	public function test_apply_selectively_with_empty_choices_does_not_falsely_stamp_in_sync(): void {
+		$email_id = 'apply_keep_yours_status';
+		$this->register_fixture_email( $email_id );
+
+		$canonical = "<!-- wp:paragraph -->\n<p>Hi there [woocommerce/customer-username],</p>\n<!-- /wp:paragraph -->";
+		$post_html = "<!-- wp:paragraph -->\n<p>Hello, [woocommerce/customer-username]!</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $canonical );
+		$post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+		// Pre-stamp a stale source_hash so the classifier sees "core moved".
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( "<!-- wp:paragraph -->\n<p>Old core copy.</p>\n<!-- /wp:paragraph -->" ) );
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+		$this->assertIsArray( $result );
+
+		$this->assertNotSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'keep_yours apply must not stamp in_sync — post still differs from canonical.'
+		);
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+	}
+
+	/**
+	 * Apply on a post outside the sync registry returns 404 (no email
+	 * resolved) — same gate as the change-summary endpoint.
+	 */
+	public function test_apply_selectively_returns_404_for_post_with_no_email_type(): void {
+		$unassociated = $this->factory()->post->create_and_get(
+			array(
+				'post_title'  => 'Unassociated',
+				'post_type'   => Integration::EMAIL_POST_TYPE,
+				'post_status' => 'draft',
+			)
+		);
+
+		$result = WCEmailTemplateSelectiveApplier::apply_selectively( (int) $unassociated->ID, array() );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'email_not_found', $result->get_error_code() );
+		$this->assertSame( 404, $result->get_error_data()['status'] );
+	}
+
+	/**
+	 * Register a fixture email and wire up the sync registry to pick it up.
+	 *
+	 * @param string $email_id Email ID to assign to the stub.
+	 * @return \WC_Email Registered fixture email instance.
+	 */
+	private function register_fixture_email( string $email_id ): \WC_Email {
+		$stub = $this->getMockBuilder( \WC_Email::class )
+			->disableOriginalConstructor()
+			->getMock();
+		$stub->method( 'get_title' )->willReturn( 'Fixture email for selective-applier tests' );
+		$stub->method( 'get_description' )->willReturn( 'Fixture email used to cover selective-applier scenarios.' );
+		$stub->id             = $email_id;
+		$stub->template_base  = $this->fixtures_base;
+		$stub->template_block = 'block/third-party-with-version.php';
+		$stub->template_plain = null;
+
+		$class_key = 'WC_Test_Email_' . $email_id;
+
+		$emails_container = \WC_Emails::instance();
+		$reflection       = new \ReflectionClass( $emails_container );
+		$property         = $reflection->getProperty( 'emails' );
+		$property->setAccessible( true );
+		$current               = $property->getValue( $emails_container );
+		$current[ $class_key ] = $stub;
+		$property->setValue( $emails_container, $current );
+
+		$this->injected_email_keys[] = $class_key;
+
+		add_filter(
+			'woocommerce_transactional_emails_for_block_editor',
+			static function ( array $emails ) use ( $email_id ): array {
+				if ( ! in_array( $email_id, $emails, true ) ) {
+					$emails[] = $email_id;
+				}
+				return $emails;
+			}
+		);
+
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		return $stub;
+	}
+
+	/**
+	 * Hook the canonical content filter so `compute_canonical_post_content()`
+	 * returns the supplied string for the given email_id, bypassing the
+	 * file-rendered template body.
+	 *
+	 * @param string $email_id The email ID to override content for.
+	 * @param string $content  The canonical content to inject.
+	 */
+	private function use_canonical_content( string $email_id, string $content ): void {
+		add_filter(
+			'woocommerce_email_content_post_data',
+			static function ( array $post_data, string $type ) use ( $email_id, $content ): array {
+				if ( $type === $email_id ) {
+					$post_data['post_content'] = $content;
+				}
+				return $post_data;
+			},
+			10,
+			2
+		);
+	}
+
+	/**
+	 * Create a `woo_email` post and associate it with the supplied email_id.
+	 *
+	 * @param string $email_id     The email ID to associate.
+	 * @param string $post_content Initial post content.
+	 * @return int Post ID.
+	 */
+	private function create_woo_email_post( string $email_id, string $post_content ): int {
+		$post_id = wp_insert_post(
+			array(
+				'post_title'   => 'Fixture for ' . $email_id,
+				'post_name'    => $email_id,
+				'post_type'    => Integration::EMAIL_POST_TYPE,
+				'post_content' => $post_content,
+				'post_status'  => 'publish',
+			)
+		);
+
+		$this->assertIsInt( $post_id );
+		$this->assertGreaterThan( 0, $post_id );
+
+		$this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
+		return (int) $post_id;
+	}
+
+	/**
+	 * Remove any stubs we injected into WC_Emails::$emails during the test.
+	 */
+	private function cleanup_injected_emails(): void {
+		if ( empty( $this->injected_email_keys ) ) {
+			return;
+		}
+
+		$emails_container = \WC_Emails::instance();
+		$reflection       = new \ReflectionClass( $emails_container );
+		$property         = $reflection->getProperty( 'emails' );
+		$property->setAccessible( true );
+		$current = $property->getValue( $emails_container );
+
+		foreach ( $this->injected_email_keys as $class_key ) {
+			unset( $current[ $class_key ] );
+		}
+
+		$property->setValue( $emails_container, $current );
+		$this->injected_email_keys = array();
+	}
+}