Commit af9cb1ae398 for woocommerce

commit af9cb1ae398fffefb4b72e4e2eab39caf5fa303e
Author: Patrick Zielinski <patrick.zielinski@a8c.com>
Date:   Fri May 8 11:29:22 2026 -0400

    [Email Editor] Surface update-available indicator in the editor (RSM-141) (#64675)

    * Expose source_hash and backfilled meta over REST on woo_email

    * Add source_hash_to to email template change-summary response

    * Add banner dismiss and viewed-dedup state to email integration store

    * Add suppressSnackbarOnError option to useApplyUpdate

    * Add useUpdateBanner glue hook with eligibility, gates, and Tracks events

    * Add UpdateBanner component and Portal-mounted plugin entry

    * Mount update banner plugin and strip review-drawer URL param

    * Add changelog entry for RSM-141 update banner

    * Skip dismissed Tracks event on success morph auto-dismiss

    * Address CodeRabbit review feedback

    * Address review feedback on banner stability and snackbar copy

    * Stamp _wc_email_backfilled flag during RSM-149 sync backfill

    * Gate update indicator on canonical version compare across both surfaces

    * Hide banner when summary reports no actual changes

    * Defer backfilled-flag writer to follow-up PR and fix SCSS lint

    ---------

    Co-authored-by: PZ01 <patrick.zielinski@automattic.com>

diff --git a/plugins/woocommerce/changelog/rsm-141-update-banner b/plugins/woocommerce/changelog/rsm-141-update-banner
new file mode 100644
index 00000000000..a002390ecf9
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-141-update-banner
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Surface update-available indicator in the block email editor (RSM-141)
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
index 2a2a02e5875..245cb8122f6 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
@@ -44,6 +44,7 @@ const baseEmail: EmailType = {
 	status: 'enabled',
 	templateStatus: null,
 	templateVersion: null,
+	currentVersion: null,
 };

 describe( '<UpdatesCell>', () => {
@@ -66,12 +67,56 @@ describe( '<UpdatesCell>', () => {
 		( window as any ).location = originalLocation;
 	} );

-	it( 'renders a Review update button when status is core_updated_customized', () => {
+	it( 'renders a Review update button when status is core_updated_customized and merchant version is older than current', () => {
 		render(
 			<UpdatesCell
 				post={ {
 					...baseEmail,
 					templateStatus: 'core_updated_customized',
+					templateVersion: '10.6.0',
+					currentVersion: '10.7.0',
+				} }
+			/>
+		);
+
+		expect(
+			screen.getByRole( 'button', { name: /review update/i } )
+		).toBeInTheDocument();
+	} );
+
+	it( 'renders em-dash when status is core_updated_customized but merchant version equals current (already reviewed)', () => {
+		// Canonical detector check: status alone isn't enough — the merchant
+		// is "up to date" once they've reviewed this version, even if they
+		// kept some customizations during the apply.
+		render(
+			<UpdatesCell
+				post={ {
+					...baseEmail,
+					templateStatus: 'core_updated_customized',
+					templateVersion: '10.7.0',
+					currentVersion: '10.7.0',
+				} }
+			/>
+		);
+
+		expect(
+			screen.queryByRole( 'button', { name: /review update/i } )
+		).not.toBeInTheDocument();
+		expect( screen.getByLabelText( /up to date/i ) ).toHaveTextContent(
+			'—'
+		);
+	} );
+
+	it( 'falls back to status-only gating when version metadata is missing (legacy posts)', () => {
+		// Posts that haven't been backfilled yet won't have templateVersion;
+		// keep showing the indicator on status alone so legacy posts surface.
+		render(
+			<UpdatesCell
+				post={ {
+					...baseEmail,
+					templateStatus: 'core_updated_customized',
+					templateVersion: null,
+					currentVersion: '10.7.0',
 				} }
 			/>
 		);
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
index 9afc2a4f3f4..bce10524a99 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
@@ -17,6 +17,7 @@ import {
 	EmailStatus,
 	TemplateStatus,
 } from './settings-email-listing-slotfill';
+import { shouldShowReviewUpdate } from './settings-email-listing-update-state';
 import { getAdminSetting } from '~/utils/admin-settings';

 /**
@@ -105,12 +106,25 @@ export const useTransactionalEmails = (
 						? rawVersion
 						: null;

+				// PHP serializes the registry's current version under
+				// `current_version` (snake) on the slotfill payload; project to
+				// `currentVersion` (camel) for the row's TS contract.
+				const rawCurrentVersion = (
+					emailType as unknown as { current_version?: string | null }
+				 ).current_version;
+				const currentVersion: string | null =
+					typeof rawCurrentVersion === 'string' &&
+					rawCurrentVersion.length > 0
+						? rawCurrentVersion
+						: null;
+
 				return {
 					...emailType,
 					link: post?.link || '',
 					status: status as EmailStatus,
 					templateStatus,
 					templateVersion,
+					currentVersion,
 				};
 			} ),
 		[ emailTypesData, emailPosts, postIdsMap ]
@@ -212,10 +226,9 @@ export const useTransactionalEmails = (
 		const selected = Array.isArray( updatesFilter.value )
 			? ( updatesFilter.value as string[] )
 			: [ updatesFilter.value as string ];
-		const emailValue =
-			email.templateStatus === 'core_updated_customized'
-				? 'available'
-				: 'none';
+		const emailValue = shouldShowReviewUpdate( email )
+			? 'available'
+			: 'none';
 		return selected.includes( emailValue );
 	} );

diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
index 8d1c54162a0..c1f2f849798 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
@@ -14,6 +14,7 @@ import { DataViews, View } from '@wordpress/dataviews/wp'; // eslint-disable-lin
  */
 import { EmailType } from './settings-email-listing-slotfill';
 import { useTransactionalEmails } from './settings-email-listing-data';
+import { shouldShowReviewUpdate } from './settings-email-listing-update-state';
 import { Status, EMAIL_STATUSES } from './settings-email-listing-status';
 import { RecipientsList } from './settings-email-listing-recipients';
 import { UpdatesCell } from './settings-email-listing-update-cell';
@@ -111,9 +112,7 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 				enableHiding: true,
 				enableSorting: false,
 				getValue: ( { item }: { item: EmailType } ) =>
-					item.templateStatus === 'core_updated_customized'
-						? 'available'
-						: 'none',
+					shouldShowReviewUpdate( item ) ? 'available' : 'none',
 				elements: [
 					{
 						value: 'available',
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
index 8df8a061b7a..85fc70cdb24 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
@@ -44,6 +44,16 @@ export type EmailType = {
 	status?: EmailStatus;
 	templateStatus: TemplateStatus | null;
 	templateVersion: string | null;
+	/**
+	 * Registry-side current version of the canonical core template for this
+	 * email. Sourced from `WCEmailTemplateSyncRegistry::get_email_sync_config()`
+	 * server-side; serialized as `current_version` in the slotfill payload
+	 * and projected to camelCase in the data hook. Combined with
+	 * `templateVersion` to gate the "update available" indicator on both
+	 * surfaces (list cell + RSM-141 editor banner): show only when the
+	 * merchant has not yet reviewed this version.
+	 */
+	currentVersion: string | null;
 };

 const { Fill } = createSlotFill( SETTINGS_SLOT_FILL_CONSTANT );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
index 1a5b53d04cf..d28ec62c29a 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
@@ -15,6 +15,7 @@ const baseEmail: EmailType = {
 	recipients: { to: '', cc: '', bcc: '' },
 	templateStatus: null,
 	templateVersion: null,
+	currentVersion: null,
 };

 export const CoreUpdatedCustomized = () => (
@@ -22,6 +23,8 @@ export const CoreUpdatedCustomized = () => (
 		post={ {
 			...baseEmail,
 			templateStatus: 'core_updated_customized',
+			templateVersion: '10.6.0',
+			currentVersion: '10.7.0',
 		} }
 	/>
 );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
index 7dfdababc6c..a753d9df1b7 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
@@ -27,13 +27,14 @@ import { getAdminLink } from '@woocommerce/settings';
  */
 import type { EmailType } from './settings-email-listing-slotfill';
 import { buildEmailEditorReviewUrl } from './build-email-editor-review-url';
+import { shouldShowReviewUpdate } from './settings-email-listing-update-state';

 interface UpdatesCellProps {
 	post: EmailType;
 }

 export const UpdatesCell = ( { post }: UpdatesCellProps ) => {
-	if ( post.templateStatus !== 'core_updated_customized' ) {
+	if ( ! shouldShowReviewUpdate( post ) ) {
 		return <span aria-label={ __( 'Up to date', 'woocommerce' ) }>—</span>;
 	}

diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-state.ts b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-state.ts
new file mode 100644
index 00000000000..0a742f5878f
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-state.ts
@@ -0,0 +1,52 @@
+/**
+ * Internal dependencies
+ */
+import type { EmailType } from './settings-email-listing-slotfill';
+
+/**
+ * Numeric semver compare. Negative if `a < b`, zero if equal, positive
+ * if `a > b`. Lightweight on purpose — template versions don't carry
+ * pre-release / build metadata so we don't need full semver semantics.
+ */
+export function compareTemplateVersions( a: string, b: string ): number {
+	const partsA = a.split( '.' ).map( ( s ) => parseInt( s, 10 ) || 0 );
+	const partsB = b.split( '.' ).map( ( s ) => parseInt( s, 10 ) || 0 );
+	const len = Math.max( partsA.length, partsB.length );
+	for ( let i = 0; i < len; i++ ) {
+		const diff = ( partsA[ i ] ?? 0 ) - ( partsB[ i ] ?? 0 );
+		if ( diff !== 0 ) {
+			return diff;
+		}
+	}
+	return 0;
+}
+
+/**
+ * Mirrors the canonical "update available" check from
+ * {@see WCEmailTemplateDivergenceDetector}'s docblock:
+ *
+ *   $reviewed = (string) get_post_meta( $post_id, VERSION_META_KEY, true );
+ *   $current  = (string) ( $sync_registry[ $email_id ]['version'] ?? '' );
+ *   $show_indicator = $current !== '' && version_compare( $reviewed, $current, '<' );
+ *
+ * Status alone isn't enough — a post stays `core_updated_customized` after
+ * a drawer apply where the merchant kept some customizations on purpose,
+ * but they have reviewed the version. Both surfaces (the email list cell
+ * and RSM-141's editor banner) gate on this combined check so they stay
+ * in lockstep.
+ *
+ * Falls back to status-only when version metadata is missing — that
+ * happens for legacy posts before the RSM-149 backfill, so we surface
+ * the indicator rather than silently hide it.
+ */
+export function shouldShowReviewUpdate( post: EmailType ): boolean {
+	if ( post.templateStatus !== 'core_updated_customized' ) {
+		return false;
+	}
+	if ( ! post.templateVersion || ! post.currentVersion ) {
+		return true;
+	}
+	return (
+		compareTemplateVersions( post.templateVersion, post.currentVersion ) < 0
+	);
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/store.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/store.test.ts
new file mode 100644
index 00000000000..077bbc3d02c
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/store.test.ts
@@ -0,0 +1,89 @@
+/**
+ * External dependencies
+ */
+import { dispatch, select } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { registerStore, STORE_NAME } from '../store';
+
+describe( 'email-editor-integration store', () => {
+	beforeAll( () => {
+		registerStore();
+	} );
+
+	describe( 'dismiss state', () => {
+		it( 'returns false for an unseen postId', () => {
+			expect(
+				select( STORE_NAME ).isUpdateBannerDismissedFor( 100 )
+			).toBe( false );
+		} );
+
+		it( 'flips to true only for the dismissed postId', () => {
+			dispatch( STORE_NAME ).dismissUpdateBanner( 101 );
+
+			expect(
+				select( STORE_NAME ).isUpdateBannerDismissedFor( 101 )
+			).toBe( true );
+			expect(
+				select( STORE_NAME ).isUpdateBannerDismissedFor( 200 )
+			).toBe( false );
+		} );
+
+		it( 'clearDismissedForPost removes the entry', () => {
+			dispatch( STORE_NAME ).dismissUpdateBanner( 9001 );
+			expect(
+				select( STORE_NAME ).isUpdateBannerDismissedFor( 9001 )
+			).toBe( true );
+
+			dispatch( STORE_NAME ).clearDismissedForPost( 9001 );
+			expect(
+				select( STORE_NAME ).isUpdateBannerDismissedFor( 9001 )
+			).toBe( false );
+		} );
+
+		it( 'clearing an absent postId is a no-op', () => {
+			expect( () => {
+				dispatch( STORE_NAME ).clearDismissedForPost( 424242 );
+			} ).not.toThrow();
+			expect(
+				select( STORE_NAME ).isUpdateBannerDismissedFor( 424242 )
+			).toBe( false );
+		} );
+	} );
+
+	describe( 'viewed dedup state', () => {
+		it( 'returns false for an unseen (postId, versionTo) pair', () => {
+			expect(
+				select( STORE_NAME ).wasUpdateBannerViewedFor( 300, '9.5' )
+			).toBe( false );
+		} );
+
+		it( 'flips only the marked pair, not other versions or other posts', () => {
+			dispatch( STORE_NAME ).markUpdateBannerViewed( 300, '9.5' );
+
+			expect(
+				select( STORE_NAME ).wasUpdateBannerViewedFor( 300, '9.5' )
+			).toBe( true );
+			// Same post, different version stays false.
+			expect(
+				select( STORE_NAME ).wasUpdateBannerViewedFor( 300, '9.6' )
+			).toBe( false );
+			// Different post, same version stays false.
+			expect(
+				select( STORE_NAME ).wasUpdateBannerViewedFor( 301, '9.5' )
+			).toBe( false );
+		} );
+	} );
+
+	describe( 'unrelated drawer state preserved', () => {
+		it( 'openReviewDrawer + closeReviewDrawer still work after extensions', () => {
+			dispatch( STORE_NAME ).openReviewDrawer();
+			expect( select( STORE_NAME ).isReviewDrawerOpen() ).toBe( true );
+
+			dispatch( STORE_NAME ).closeReviewDrawer();
+			expect( select( STORE_NAME ).isReviewDrawerOpen() ).toBe( false );
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner-plugin.test.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner-plugin.test.tsx
new file mode 100644
index 00000000000..a9624e13152
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner-plugin.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { UpdateBannerPlugin } from '../update-banner-plugin';
+
+// ---- useUpdateBanner mock -------------------------------------------------
+//
+// Drive the plugin's render decision from the test by overriding the
+// hook's return value. Each test sets `useUpdateBannerMock` to the
+// shape it wants, then renders the plugin; the mock is consulted on
+// render and again after the rAF in the impl, so changing it
+// per-test is sufficient.
+const useUpdateBannerMock = jest.fn();
+jest.mock( '../hooks/use-update-banner', () => ( {
+	useUpdateBanner: () => useUpdateBannerMock(),
+} ) );
+
+const baseHookReturn = {
+	shouldRender: false,
+	summary: null,
+	isLoadingSummary: false,
+	summaryError: null,
+	applyState: 'idle' as const,
+	canApply: true,
+	canReview: true,
+	disabledReason: null,
+	hasConflicts: false,
+	expanded: false,
+	toggleExpanded: jest.fn(),
+	apply: jest.fn(),
+	openReview: jest.fn(),
+	dismiss: jest.fn(),
+	autoDismiss: jest.fn(),
+};
+
+describe( 'UpdateBannerPlugin', () => {
+	beforeEach( () => {
+		// Reset the DOM between tests so the canvas selector / body
+		// fallback assertions stay isolated.
+		document.body.innerHTML = '';
+		useUpdateBannerMock.mockReset();
+	} );
+
+	it( 'returns null when shouldRender is false', () => {
+		useUpdateBannerMock.mockReturnValue( {
+			...baseHookReturn,
+			shouldRender: false,
+		} );
+
+		const { container } = render( <UpdateBannerPlugin /> );
+		expect( container.firstChild ).toBeNull();
+	} );
+
+	it( 'portals the banner into the editor canvas target when present', () => {
+		const target = document.createElement( 'div' );
+		target.className = 'edit-post-visual-editor';
+		document.body.appendChild( target );
+
+		useUpdateBannerMock.mockReturnValue( {
+			...baseHookReturn,
+			shouldRender: true,
+		} );
+
+		render( <UpdateBannerPlugin /> );
+
+		expect( target.querySelector( '.wc-update-banner' ) ).not.toBeNull();
+	} );
+
+	it( 'falls back to document.body when no canvas target is present', () => {
+		useUpdateBannerMock.mockReturnValue( {
+			...baseHookReturn,
+			shouldRender: true,
+		} );
+
+		render( <UpdateBannerPlugin /> );
+
+		expect(
+			document.body.querySelector( '.wc-update-banner' )
+		).not.toBeNull();
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner.test.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner.test.tsx
new file mode 100644
index 00000000000..a4539a332ab
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/update-banner.test.tsx
@@ -0,0 +1,315 @@
+/**
+ * External dependencies
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { UpdateBanner } from '../update-banner';
+import type { ChangeSummary } from '../hooks/use-change-summary';
+import type { ApplyState } from '../hooks/use-update-banner';
+
+const baseSummary: ChangeSummary = {
+	version_from: '1.0.0',
+	version_to: '1.1.0',
+	source_hash_to: 'abc123',
+	added_blocks: [],
+	removed_blocks: [],
+	copy_changes: [],
+	structural_changes: [],
+	summary_lines: [
+		'Added Heading and Paragraph blocks.',
+		'Updated footer copy.',
+	],
+	is_fallback: false,
+	cache_hit: false,
+};
+
+interface BannerOverrides {
+	summary?: ChangeSummary | null;
+	applyState?: ApplyState;
+	canApply?: boolean;
+	canReview?: boolean;
+	disabledReason?: 'dirty' | 'read_only' | 'has_conflicts' | null;
+	expanded?: boolean;
+	onApply?: jest.Mock;
+	onReview?: jest.Mock;
+	onDismiss?: jest.Mock;
+	onAutoDismiss?: jest.Mock;
+	onToggleExpanded?: jest.Mock;
+}
+
+function renderBanner( overrides: BannerOverrides = {} ) {
+	const onApply = overrides.onApply ?? jest.fn();
+	const onReview = overrides.onReview ?? jest.fn();
+	const onDismiss = overrides.onDismiss ?? jest.fn();
+	const onAutoDismiss = overrides.onAutoDismiss ?? jest.fn();
+	const onToggleExpanded = overrides.onToggleExpanded ?? jest.fn();
+
+	const result = render(
+		<UpdateBanner
+			summary={
+				overrides.summary === undefined
+					? baseSummary
+					: overrides.summary
+			}
+			applyState={ overrides.applyState ?? 'idle' }
+			canApply={ overrides.canApply ?? true }
+			canReview={ overrides.canReview ?? true }
+			disabledReason={ overrides.disabledReason ?? null }
+			expanded={ overrides.expanded ?? false }
+			onApply={ onApply }
+			onReview={ onReview }
+			onDismiss={ onDismiss }
+			onAutoDismiss={ onAutoDismiss }
+			onToggleExpanded={ onToggleExpanded }
+		/>
+	);
+
+	return {
+		...result,
+		onApply,
+		onReview,
+		onDismiss,
+		onAutoDismiss,
+		onToggleExpanded,
+	};
+}
+
+describe( 'UpdateBanner', () => {
+	afterEach( () => {
+		jest.useRealTimers();
+	} );
+
+	it( 'renders the default state with title, summary subtitle, and actions', () => {
+		renderBanner();
+
+		expect(
+			screen.getByText( 'Template update available' )
+		).toBeInTheDocument();
+		expect(
+			screen.getByText( 'Added Heading and Paragraph blocks.' )
+		).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', { name: /^apply$/i } )
+		).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', { name: /^review$/i } )
+		).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', {
+				name: /dismiss for this session/i,
+			} )
+		).toBeInTheDocument();
+	} );
+
+	it( 'renders the generic fallback subtitle when summary.is_fallback is true', () => {
+		renderBanner( {
+			summary: { ...baseSummary, is_fallback: true },
+		} );
+
+		expect(
+			screen.getByText( 'WooCommerce updated this template.' )
+		).toBeInTheDocument();
+		// Expand toggle is hidden in the fallback variant.
+		expect(
+			screen.queryByRole( 'button', { name: /show \d+ change/i } )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'renders the bullet list when expanded is true', () => {
+		renderBanner( { expanded: true } );
+
+		expect(
+			screen.getByRole( 'button', { name: /hide changes/i } )
+		).toBeInTheDocument();
+
+		const items = screen.getAllByRole( 'listitem' );
+		expect( items.length ).toBe( 2 );
+		expect( items[ 0 ] ).toHaveTextContent(
+			'Added Heading and Paragraph blocks.'
+		);
+		expect( items[ 1 ] ).toHaveTextContent( 'Updated footer copy.' );
+	} );
+
+	it( 'replaces the Apply button with a primary "Review changes" button when has_conflicts', () => {
+		renderBanner( {
+			canApply: false,
+			disabledReason: 'has_conflicts',
+		} );
+
+		expect(
+			screen.getByRole( 'button', { name: /review changes/i } )
+		).toBeInTheDocument();
+		expect(
+			screen.queryByRole( 'button', { name: /^apply$/i } )
+		).not.toBeInTheDocument();
+		// The plain tertiary "Review" should NOT also be present.
+		expect(
+			screen.queryByRole( 'button', { name: /^review$/i } )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'aria-disables the Apply button and surfaces a tooltip when dirty', () => {
+		renderBanner( {
+			canApply: false,
+			disabledReason: 'dirty',
+		} );
+
+		const applyButton = screen.getByRole( 'button', { name: /^apply$/i } );
+		expect( applyButton ).toHaveAttribute( 'aria-disabled', 'true' );
+
+		// The tip is rendered inline as visually-hidden text alongside
+		// the disabled button, so it's announced by screen readers and
+		// queryable here directly.
+		expect(
+			screen.getAllByText( 'Save your changes first.' ).length
+		).toBeGreaterThan( 0 );
+	} );
+
+	it( 'aria-disables both Apply and Review and surfaces a tooltip when read_only', () => {
+		renderBanner( {
+			canApply: false,
+			canReview: false,
+			disabledReason: 'read_only',
+		} );
+
+		const applyButton = screen.getByRole( 'button', { name: /^apply$/i } );
+		const reviewButton = screen.getByRole( 'button', {
+			name: /^review$/i,
+		} );
+		expect( applyButton ).toHaveAttribute( 'aria-disabled', 'true' );
+		expect( reviewButton ).toHaveAttribute( 'aria-disabled', 'true' );
+
+		expect(
+			screen.getAllByText(
+				"You don't have permission to update this email."
+			).length
+		).toBeGreaterThan( 0 );
+	} );
+
+	it( 'shows the applying label and disables Review/Dismiss when applyState is applying', () => {
+		renderBanner( { applyState: 'applying' } );
+
+		// "Applying…" label.
+		const applyButton = screen.getByRole( 'button', {
+			name: /applying/i,
+		} );
+		expect( applyButton ).toBeInTheDocument();
+
+		const reviewButton = screen.getByRole( 'button', {
+			name: /^review$/i,
+		} );
+		const dismissButton = screen.getByRole( 'button', {
+			name: /dismiss for this session/i,
+		} );
+		expect( reviewButton ).toBeDisabled();
+		expect( dismissButton ).toBeDisabled();
+	} );
+
+	it( 'renders the success morph and auto-dismisses after 2s via onAutoDismiss', () => {
+		jest.useFakeTimers();
+		const onDismiss = jest.fn();
+		const onAutoDismiss = jest.fn();
+		renderBanner( { applyState: 'applied', onDismiss, onAutoDismiss } );
+
+		expect( screen.getByText( 'Template updated' ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( 'Your customizations were preserved.' )
+		).toBeInTheDocument();
+
+		act( () => {
+			jest.advanceTimersByTime( 2000 );
+		} );
+		// Spec §9.2: success auto-dismiss does NOT fire `_dismissed`. The
+		// banner routes through the no-event path so the hook can skip
+		// `recordEvent`.
+		expect( onAutoDismiss ).toHaveBeenCalledTimes( 1 );
+		expect( onDismiss ).not.toHaveBeenCalled();
+	} );
+
+	it( 'success-state × button routes to onAutoDismiss, not onDismiss', () => {
+		const onDismiss = jest.fn();
+		const onAutoDismiss = jest.fn();
+		renderBanner( { applyState: 'applied', onDismiss, onAutoDismiss } );
+
+		fireEvent.click(
+			screen.getByRole( 'button', {
+				name: /dismiss for this session/i,
+			} )
+		);
+		expect( onAutoDismiss ).toHaveBeenCalledTimes( 1 );
+		expect( onDismiss ).not.toHaveBeenCalled();
+	} );
+
+	it( 'renders the failure morph with role=alert and does not auto-dismiss', () => {
+		jest.useFakeTimers();
+		const onDismiss = jest.fn();
+		const { container } = renderBanner( {
+			applyState: 'failed',
+			onDismiss,
+		} );
+
+		expect( screen.getByText( /couldn't apply/i ) ).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', { name: /try again/i } )
+		).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', { name: /review changes/i } )
+		).toBeInTheDocument();
+
+		// Use the container query — failure morph carries role="alert".
+		expect( container.querySelector( '[role="alert"]' ) ).not.toBeNull();
+
+		act( () => {
+			jest.advanceTimersByTime( 5000 );
+		} );
+		expect( onDismiss ).not.toHaveBeenCalled();
+	} );
+
+	it( 'renders default container with role=status and aria-live=polite', () => {
+		const { container } = renderBanner();
+		const statusEl = container.querySelector(
+			'[role="status"][aria-live="polite"]'
+		);
+		expect( statusEl ).not.toBeNull();
+	} );
+
+	it( 'fires the click callbacks for Apply, Review, and Dismiss', () => {
+		const onApply = jest.fn();
+		const onReview = jest.fn();
+		const onDismiss = jest.fn();
+		const onAutoDismiss = jest.fn();
+		renderBanner( { onApply, onReview, onDismiss, onAutoDismiss } );
+
+		fireEvent.click( screen.getByRole( 'button', { name: /^apply$/i } ) );
+		fireEvent.click( screen.getByRole( 'button', { name: /^review$/i } ) );
+		fireEvent.click(
+			screen.getByRole( 'button', {
+				name: /dismiss for this session/i,
+			} )
+		);
+
+		expect( onApply ).toHaveBeenCalledTimes( 1 );
+		expect( onReview ).toHaveBeenCalledTimes( 1 );
+		// Default-state × routes to onDismiss (the user-initiated path that
+		// fires the `_dismissed` Tracks event in the hook).
+		expect( onDismiss ).toHaveBeenCalledTimes( 1 );
+		expect( onAutoDismiss ).not.toHaveBeenCalled();
+	} );
+
+	it( 'failure-state × routes to onDismiss (Tracks event fires)', () => {
+		const onDismiss = jest.fn();
+		const onAutoDismiss = jest.fn();
+		renderBanner( { applyState: 'failed', onDismiss, onAutoDismiss } );
+
+		fireEvent.click(
+			screen.getByRole( 'button', {
+				name: /dismiss for this session/i,
+			} )
+		);
+		expect( onDismiss ).toHaveBeenCalledTimes( 1 );
+		expect( onAutoDismiss ).not.toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-apply-update-suppress-snackbar.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-apply-update-suppress-snackbar.test.ts
new file mode 100644
index 00000000000..23c10626fa6
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-apply-update-suppress-snackbar.test.ts
@@ -0,0 +1,161 @@
+/**
+ * External dependencies
+ */
+import { renderHook, act } from '@testing-library/react';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { useApplyUpdate } from '../hooks/use-apply-update';
+
+const createSuccessNoticeMock = jest.fn();
+const createErrorNoticeMock = jest.fn();
+const receiveEntityRecordsMock = jest.fn();
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+jest.mock( '@wordpress/data', () => ( {
+	useDispatch: () => ( {
+		createSuccessNotice: createSuccessNoticeMock,
+		createErrorNotice: createErrorNoticeMock,
+		receiveEntityRecords: receiveEntityRecordsMock,
+	} ),
+	select: () => ( {
+		getEntityRecord: () => ( { content: { raw: 'old content' } } ),
+	} ),
+} ) );
+jest.mock( '@wordpress/notices', () => ( { store: 'core/notices' } ) );
+jest.mock( '@wordpress/core-data', () => ( { store: 'core' } ) );
+
+const mockedApiFetch = apiFetch as unknown as jest.Mock;
+
+describe( 'useApplyUpdate — suppressSnackbarOnError option', () => {
+	beforeEach( () => {
+		createSuccessNoticeMock.mockClear();
+		createErrorNoticeMock.mockClear();
+		receiveEntityRecordsMock.mockClear();
+		mockedApiFetch.mockReset();
+	} );
+
+	it( 'fires error snackbar by default when /apply fails', async () => {
+		mockedApiFetch.mockRejectedValueOnce( new Error( 'boom' ) );
+
+		const { result } = renderHook( () => useApplyUpdate( 42 ) );
+
+		await act( async () => {
+			await result.current.apply( [] );
+		} );
+
+		expect( createErrorNoticeMock ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'suppresses error snackbar when suppressSnackbarOnError is true', async () => {
+		mockedApiFetch.mockRejectedValueOnce( new Error( 'boom' ) );
+
+		const { result } = renderHook( () =>
+			useApplyUpdate( 42, { suppressSnackbarOnError: true } )
+		);
+
+		await act( async () => {
+			await result.current.apply( [] );
+		} );
+
+		expect( createErrorNoticeMock ).not.toHaveBeenCalled();
+	} );
+
+	it( 'still fires success snackbar when suppressSnackbarOnError is true', async () => {
+		mockedApiFetch.mockResolvedValueOnce( {
+			merged_content: 'merged',
+			revision_id: 'rev1',
+			version_to: '9.5',
+			status: 'applied',
+			structural_skipped: false,
+			aliases_migrated: [],
+		} );
+
+		const { result } = renderHook( () =>
+			useApplyUpdate( 42, { suppressSnackbarOnError: true } )
+		);
+
+		await act( async () => {
+			await result.current.apply( [] );
+		} );
+
+		expect( createSuccessNoticeMock ).toHaveBeenCalledWith(
+			'Update applied · customizations preserved',
+			expect.objectContaining( { type: 'snackbar' } )
+		);
+	} );
+
+	it( 'success snackbar copy is the new RSM-141 string', async () => {
+		mockedApiFetch.mockResolvedValueOnce( {
+			merged_content: 'merged',
+			revision_id: 'rev1',
+			version_to: '9.5',
+			status: 'applied',
+			structural_skipped: false,
+			aliases_migrated: [],
+		} );
+
+		const { result } = renderHook( () => useApplyUpdate( 42 ) );
+
+		await act( async () => {
+			await result.current.apply( [] );
+		} );
+
+		expect( createSuccessNoticeMock ).toHaveBeenCalledWith(
+			'Update applied · customizations preserved',
+			expect.objectContaining( { type: 'snackbar' } )
+		);
+	} );
+
+	it( 'snackbar copy drops the "customizations preserved" suffix when every choice is use_core', async () => {
+		mockedApiFetch.mockResolvedValueOnce( {
+			merged_content: 'merged',
+			revision_id: 'rev1',
+			version_to: '9.5',
+			status: 'applied',
+			structural_skipped: false,
+			aliases_migrated: [],
+		} );
+
+		const { result } = renderHook( () => useApplyUpdate( 42 ) );
+
+		await act( async () => {
+			await result.current.apply( [
+				{ path: [ 0 ], decision: 'use_core' },
+				{ path: [ 1 ], decision: 'use_core' },
+			] );
+		} );
+
+		expect( createSuccessNoticeMock ).toHaveBeenCalledWith(
+			'Update applied',
+			expect.objectContaining( { type: 'snackbar' } )
+		);
+	} );
+
+	it( 'snackbar keeps the suffix when at least one choice is keep_yours', async () => {
+		mockedApiFetch.mockResolvedValueOnce( {
+			merged_content: 'merged',
+			revision_id: 'rev1',
+			version_to: '9.5',
+			status: 'applied',
+			structural_skipped: false,
+			aliases_migrated: [],
+		} );
+
+		const { result } = renderHook( () => useApplyUpdate( 42 ) );
+
+		await act( async () => {
+			await result.current.apply( [
+				{ path: [ 0 ], decision: 'use_core' },
+				{ path: [ 1 ], decision: 'keep_yours' },
+			] );
+		} );
+
+		expect( createSuccessNoticeMock ).toHaveBeenCalledWith(
+			'Update applied · customizations preserved',
+			expect.objectContaining( { type: 'snackbar' } )
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts
new file mode 100644
index 00000000000..bf381c7db47
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts
@@ -0,0 +1,603 @@
+/**
+ * External dependencies
+ */
+import { act, renderHook } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { useUpdateBanner } from '../hooks/use-update-banner';
+
+// jsdom ships `crypto` (Node global) but not `crypto.subtle`, and
+// doesn't expose `TextEncoder` either. The hook's `sha1Hex` helper
+// relies on both. The actual hash value doesn't matter for these
+// tests — `had_customizations` is asserted with `expect.any(Boolean)` —
+// so a no-op mock that resolves to a fixed digest is sufficient.
+if (
+	typeof ( globalThis as { TextEncoder?: unknown } ).TextEncoder ===
+	'undefined'
+) {
+	// eslint-disable-next-line @typescript-eslint/no-var-requires
+	const { TextEncoder: NodeTextEncoder } = require( 'util' );
+	(
+		globalThis as unknown as { TextEncoder: typeof TextEncoder }
+	 ).TextEncoder = NodeTextEncoder;
+}
+if ( ! ( globalThis as { crypto?: { subtle?: unknown } } ).crypto?.subtle ) {
+	Object.defineProperty( globalThis, 'crypto', {
+		value: {
+			...( globalThis as { crypto?: object } ).crypto,
+			subtle: {
+				digest: jest
+					.fn()
+					.mockResolvedValue( new Uint8Array( 20 ).buffer ),
+			},
+		},
+		configurable: true,
+		writable: true,
+	} );
+}
+
+// ---- recordEvent mock (Tracks) -------------------------------------------
+const recordEventMock = jest.fn();
+jest.mock( '@woocommerce/tracks', () => ( {
+	recordEvent: ( ...args: unknown[] ) => recordEventMock( ...args ),
+} ) );
+
+// ---- @wordpress/data mock ------------------------------------------------
+//
+// `useSelect` is mocked to return whatever the per-test `selectShape`
+// describes. The hook's `useSelect` lambda is bypassed; we hand back the
+// flat shape directly (matches the lambda's computed return type).
+//
+// `useDispatch` returns spies for every action the hook may dispatch
+// against the integration store. `select` (imperative) is used by the
+// hook to consult the viewed-pair dedup selector at the moment of firing
+// `_viewed`.
+const dispatchMocks = {
+	dismissUpdateBanner: jest.fn(),
+	clearDismissedForPost: jest.fn(),
+	markUpdateBannerViewed: jest.fn(),
+	openReviewDrawer: jest.fn(),
+};
+let wasViewedReturn = false;
+let useSelectReturn: Record< string, unknown > = {};
+let useEntityRecordReturn: {
+	record: {
+		slug?: string;
+		meta?: Record< string, unknown >;
+		content?: { raw?: string };
+	} | null;
+} = { record: null };
+
+jest.mock( '@wordpress/data', () => ( {
+	// `useSelect` is called by the hook with a lambda + deps; we ignore both
+	// and return the per-test flat shape directly.
+	useSelect: () => useSelectReturn,
+	useDispatch: () => dispatchMocks,
+	select: () => ( {
+		wasUpdateBannerViewedFor: () => wasViewedReturn,
+	} ),
+} ) );
+
+// Stubs for store-shaped imports the hook makes from `@wordpress/core-data`
+// and the project's own modules.
+jest.mock( '@wordpress/core-data', () => ( {
+	store: 'core',
+	useEntityRecord: () => useEntityRecordReturn,
+} ) );
+
+// Default summary mock: returns whatever `useChangeSummaryReturn` says.
+let useChangeSummaryReturn: {
+	summary: unknown;
+	isLoading: boolean;
+	error: Error | null;
+	refetch: () => void;
+} = {
+	summary: null,
+	isLoading: false,
+	error: null,
+	refetch: jest.fn(),
+};
+jest.mock( '../hooks/use-change-summary', () => ( {
+	useChangeSummary: () => useChangeSummaryReturn,
+} ) );
+
+// useApplyUpdate mock; per-test override available via `applyMock`.
+let applyMock: jest.Mock = jest.fn().mockResolvedValue( {
+	merged_content: 'merged',
+	revision_id: 'rev-1',
+	version_to: '9.5',
+	status: 'applied',
+	structural_skipped: false,
+	aliases_migrated: [],
+} );
+jest.mock( '../hooks/use-apply-update', () => ( {
+	useApplyUpdate: () => ( {
+		apply: ( ...args: unknown[] ) => applyMock( ...args ),
+		isApplying: false,
+	} ),
+} ) );
+
+// ---- Per-test setup helpers ---------------------------------------------
+
+interface SelectShape {
+	postId: number | null;
+	postType: string | null;
+	isDirty: boolean;
+	canUserUpdate: boolean;
+	isDismissed: boolean;
+	wasViewed: boolean;
+}
+
+const defaultSelectShape: SelectShape = {
+	postId: 42,
+	postType: 'woo_email',
+	isDirty: false,
+	canUserUpdate: true,
+	isDismissed: false,
+	wasViewed: false,
+};
+
+const defaultRecord = {
+	slug: 'customer_processing_order',
+	meta: {
+		_wc_email_template_status: 'core_updated_customized',
+		_wc_email_template_version: '9.4',
+		_wc_email_template_source_hash: 'abc123',
+		_wc_email_backfilled: false,
+	},
+	content: { raw: '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->' },
+};
+
+function summaryFixture( overrides: Record< string, unknown > = {} ) {
+	return {
+		version_from: '9.4',
+		version_to: '9.5',
+		source_hash_to: 'def456',
+		added_blocks: [],
+		removed_blocks: [],
+		copy_changes: [],
+		structural_changes: [],
+		summary_lines: [ 'Header logo updated.', 'Footer text refreshed.' ],
+		is_fallback: false,
+		cache_hit: false,
+		...overrides,
+	};
+}
+
+function setUpMocks(
+	overrides: {
+		selectShape?: Partial< SelectShape >;
+		record?: typeof defaultRecord | null | Partial< typeof defaultRecord >;
+		recordMeta?: Record< string, unknown >;
+		summary?: unknown;
+		summaryLoading?: boolean;
+		summaryError?: Error | null;
+		wasViewed?: boolean;
+		apply?: jest.Mock;
+	} = {}
+) {
+	useSelectReturn = {
+		...defaultSelectShape,
+		...( overrides.selectShape ?? {} ),
+	};
+
+	if ( overrides.record === null ) {
+		useEntityRecordReturn = { record: null };
+	} else {
+		const baseRecord = {
+			...defaultRecord,
+			...( overrides.record ?? {} ),
+		};
+		if ( overrides.recordMeta ) {
+			baseRecord.meta = { ...baseRecord.meta, ...overrides.recordMeta };
+		}
+		useEntityRecordReturn = { record: baseRecord };
+	}
+
+	useChangeSummaryReturn = {
+		summary: overrides.summary !== undefined ? overrides.summary : null,
+		isLoading: overrides.summaryLoading ?? false,
+		error: overrides.summaryError ?? null,
+		refetch: jest.fn(),
+	};
+
+	wasViewedReturn = overrides.wasViewed ?? false;
+
+	if ( overrides.apply ) {
+		applyMock = overrides.apply;
+	} else {
+		applyMock = jest.fn().mockResolvedValue( {
+			merged_content: 'merged',
+			revision_id: 'rev-1',
+			version_to: '9.5',
+			status: 'applied',
+			structural_skipped: false,
+			aliases_migrated: [],
+		} );
+	}
+}
+
+beforeEach( () => {
+	dispatchMocks.dismissUpdateBanner.mockClear();
+	dispatchMocks.clearDismissedForPost.mockClear();
+	dispatchMocks.markUpdateBannerViewed.mockClear();
+	dispatchMocks.openReviewDrawer.mockClear();
+	recordEventMock.mockClear();
+	setUpMocks();
+} );
+
+// ==========================================================================
+// Sub-phase 6a — eligibility predicate
+// ==========================================================================
+describe( 'useUpdateBanner — eligibility (6a)', () => {
+	it.each( [
+		[ 'in_sync' ],
+		[ 'core_updated_uncustomized' ],
+		[ null ],
+		[ 'something_unexpected' ],
+	] )(
+		'shouldRender is false when status is %p',
+		( status: string | null ) => {
+			setUpMocks( {
+				recordMeta: { _wc_email_template_status: status },
+			} );
+			const { result } = renderHook( () => useUpdateBanner() );
+			expect( result.current.shouldRender ).toBe( false );
+		}
+	);
+
+	it( 'shouldRender is true when status is core_updated_customized and all gates pass', () => {
+		setUpMocks();
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.shouldRender ).toBe( true );
+	} );
+
+	it( 'shouldRender is false when postId is null', () => {
+		setUpMocks( { selectShape: { postId: null } } );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.shouldRender ).toBe( false );
+	} );
+
+	it( 'shouldRender is false when postType is not woo_email', () => {
+		setUpMocks( { selectShape: { postType: 'post' } } );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.shouldRender ).toBe( false );
+	} );
+
+	it( 'shouldRender flips to false when isDismissed is true', () => {
+		setUpMocks( { selectShape: { isDismissed: true } } );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.shouldRender ).toBe( false );
+	} );
+} );
+
+// ==========================================================================
+// Sub-phase 6b — change-summary integration + conflict gate
+// ==========================================================================
+describe( 'useUpdateBanner — change summary + conflicts (6b)', () => {
+	it( 'exposes the summary returned by useChangeSummary', () => {
+		const fixture = summaryFixture();
+		setUpMocks( { summary: fixture } );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.summary ).toEqual( fixture );
+	} );
+
+	it( 'hasConflicts is true when copy_changes is non-empty', () => {
+		setUpMocks( {
+			summary: summaryFixture( {
+				copy_changes: [
+					{
+						block: 'Paragraph',
+						before: 'old',
+						after: 'new',
+						occurrence: 1,
+						total: 1,
+						path: [ 0 ],
+					},
+				],
+			} ),
+		} );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.hasConflicts ).toBe( true );
+	} );
+
+	it( 'hasConflicts stays false when only structural_changes are present', () => {
+		setUpMocks( {
+			summary: summaryFixture( {
+				structural_changes: [
+					{
+						kind: 'reorder',
+						description: 'Two top-level blocks reordered.',
+					},
+				],
+			} ),
+		} );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.hasConflicts ).toBe( false );
+	} );
+
+	it( 'shouldRender flips to false when summary reports merchant reviewed this version (version_from >= version_to)', () => {
+		// Canonical detector check: when stored version >= current registry
+		// version the merchant has already reviewed this release — hide the
+		// indicator even if status is still customized (which happens after
+		// a drawer apply that kept any customizations).
+		setUpMocks( {
+			summary: summaryFixture( {
+				version_from: '10.7.0',
+				version_to: '10.7.0',
+			} ),
+		} );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.shouldRender ).toBe( false );
+	} );
+
+	it( 'shouldRender stays true when merchant version is older than current (version_from < version_to)', () => {
+		setUpMocks( {
+			summary: summaryFixture( {
+				version_from: '10.6.0',
+				version_to: '10.7.0',
+			} ),
+		} );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.shouldRender ).toBe( true );
+	} );
+
+	it( 'shouldRender flips to false when summary reports no real diff (status stale despite older version)', () => {
+		// Defensive guard: even when version-compare says merchant hasn't
+		// reviewed yet, hide the banner if the summary has no real changes.
+		// Avoids surfacing a "Review update" → drawer with `Apply (0)` dead
+		// end (test fixtures or stale meta can produce this state).
+		setUpMocks( {
+			summary: summaryFixture( {
+				version_from: '9.4.0-test',
+				version_to: '10.7.0',
+				summary_lines: [],
+				added_blocks: [],
+				removed_blocks: [],
+				copy_changes: [],
+				structural_changes: [],
+				is_fallback: false,
+			} ),
+		} );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.shouldRender ).toBe( false );
+	} );
+} );
+
+// ==========================================================================
+// Sub-phase 6c — apply state machine + dirty/read-only gates + dispatchers
+// ==========================================================================
+describe( 'useUpdateBanner — apply / gates / dispatchers (6c)', () => {
+	it( 'apply transitions idle -> applying -> applied on success', async () => {
+		// `applyResolve` lets us hold the in-flight promise so we can
+		// observe the intermediate `applying` state.
+		let applyResolve: ( v: unknown ) => void = () => {};
+		const apply = jest.fn(
+			() =>
+				new Promise( ( resolve ) => {
+					applyResolve = resolve;
+				} )
+		);
+		setUpMocks( {
+			summary: summaryFixture(),
+			apply: apply as unknown as jest.Mock,
+		} );
+
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.applyState ).toBe( 'idle' );
+
+		let applyPromise: Promise< void > = Promise.resolve();
+		// `apply()` flips to `'applying'` synchronously before the sha1
+		// microtask, so a single microtask flush is enough.
+		await act( async () => {
+			applyPromise = result.current.apply();
+			await Promise.resolve();
+		} );
+		// In flight.
+		expect( result.current.applyState ).toBe( 'applying' );
+
+		await act( async () => {
+			applyResolve( {
+				merged_content: 'merged',
+				revision_id: 'rev-1',
+				version_to: '9.5',
+				status: 'applied',
+				structural_skipped: false,
+				aliases_migrated: [],
+			} );
+			await applyPromise;
+		} );
+
+		expect( result.current.applyState ).toBe( 'applied' );
+	} );
+
+	it( 'apply transitions idle -> applying -> failed when doApply resolves with null (falsy result treated as failure)', async () => {
+		setUpMocks( {
+			summary: summaryFixture(),
+			apply: jest.fn().mockResolvedValue( null ),
+		} );
+
+		const { result } = renderHook( () => useUpdateBanner() );
+
+		await act( async () => {
+			await result.current.apply();
+		} );
+
+		expect( result.current.applyState ).toBe( 'failed' );
+	} );
+
+	it( 'apply transitions to failed when doApply rejects (so banner can recover)', async () => {
+		setUpMocks( {
+			summary: summaryFixture(),
+			apply: jest.fn().mockRejectedValue( new Error( 'network down' ) ),
+		} );
+
+		const { result } = renderHook( () => useUpdateBanner() );
+
+		await act( async () => {
+			await result.current.apply();
+		} );
+
+		expect( result.current.applyState ).toBe( 'failed' );
+	} );
+
+	it( 'canApply is false and disabledReason is "dirty" when post is dirty; canReview is true', () => {
+		setUpMocks( {
+			summary: summaryFixture(),
+			selectShape: { isDirty: true },
+		} );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.canApply ).toBe( false );
+		expect( result.current.canReview ).toBe( true );
+		expect( result.current.disabledReason ).toBe( 'dirty' );
+	} );
+
+	it( 'canApply and canReview are both false when canUserUpdate is false; disabledReason is "read_only"', () => {
+		setUpMocks( {
+			summary: summaryFixture(),
+			selectShape: { canUserUpdate: false },
+		} );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.canApply ).toBe( false );
+		expect( result.current.canReview ).toBe( false );
+		expect( result.current.disabledReason ).toBe( 'read_only' );
+	} );
+
+	it( 'canApply is false and canReview is true when conflicts exist; disabledReason is "has_conflicts"', () => {
+		setUpMocks( {
+			summary: summaryFixture( {
+				copy_changes: [
+					{
+						block: 'Paragraph',
+						before: 'old',
+						after: 'new',
+						occurrence: 1,
+						total: 1,
+						path: [ 0 ],
+					},
+				],
+			} ),
+		} );
+		const { result } = renderHook( () => useUpdateBanner() );
+		expect( result.current.canApply ).toBe( false );
+		expect( result.current.canReview ).toBe( true );
+		expect( result.current.disabledReason ).toBe( 'has_conflicts' );
+	} );
+
+	it( 'dismiss() dispatches dismissUpdateBanner with the postId', () => {
+		setUpMocks( { summary: summaryFixture() } );
+		const { result } = renderHook( () => useUpdateBanner() );
+		act( () => result.current.dismiss() );
+		expect( dispatchMocks.dismissUpdateBanner ).toHaveBeenCalledWith( 42 );
+	} );
+
+	it( 'openReview() dispatches openReviewDrawer', () => {
+		setUpMocks( { summary: summaryFixture() } );
+		const { result } = renderHook( () => useUpdateBanner() );
+		act( () => result.current.openReview() );
+		expect( dispatchMocks.openReviewDrawer ).toHaveBeenCalledTimes( 1 );
+	} );
+} );
+
+// ==========================================================================
+// Sub-phase 6d — Tracks events
+// ==========================================================================
+describe( 'useUpdateBanner — Tracks (6d)', () => {
+	const sharedPayloadMatcher = {
+		email_type: 'customer_processing_order',
+		template_version_from: '9.4',
+		template_version_to: '9.5',
+		source_hash_from: 'abc123',
+		source_hash_to: 'def456',
+		classification: 'core_updated_customized',
+		was_backfilled: false,
+	};
+
+	beforeEach( () => {
+		recordEventMock.mockClear();
+	} );
+
+	it( '_viewed fires on first eligible render with the shared payload', () => {
+		setUpMocks( { summary: summaryFixture() } );
+		renderHook( () => useUpdateBanner() );
+		expect( recordEventMock ).toHaveBeenCalledWith(
+			'woocommerce_block_email_update_viewed',
+			expect.objectContaining( {
+				...sharedPayloadMatcher,
+				viewed_from: 'editor_banner',
+			} )
+		);
+	} );
+
+	it( '_viewed does NOT fire when the dedup selector reports the pair was already viewed', () => {
+		setUpMocks( { summary: summaryFixture(), wasViewed: true } );
+		renderHook( () => useUpdateBanner() );
+		expect( recordEventMock ).not.toHaveBeenCalledWith(
+			'woocommerce_block_email_update_viewed',
+			expect.anything()
+		);
+	} );
+
+	it( '_dismissed fires when dismiss() is called, with the shared payload', () => {
+		setUpMocks( { summary: summaryFixture() } );
+		const { result } = renderHook( () => useUpdateBanner() );
+		recordEventMock.mockClear();
+		act( () => result.current.dismiss() );
+		expect( recordEventMock ).toHaveBeenCalledWith(
+			'woocommerce_block_email_update_dismissed',
+			expect.objectContaining( sharedPayloadMatcher )
+		);
+	} );
+
+	it( 'autoDismiss() does NOT fire _dismissed but DOES dispatch dismissUpdateBanner', () => {
+		// Spec §9.2: the success-morph auto-dismiss path must NOT fire the
+		// `_dismissed` Tracks event. The store dispatch still has to fire,
+		// otherwise the banner wouldn't unmount on success morph timeout.
+		setUpMocks( { summary: summaryFixture() } );
+		const { result } = renderHook( () => useUpdateBanner() );
+		recordEventMock.mockClear();
+		dispatchMocks.dismissUpdateBanner.mockClear();
+		act( () => result.current.autoDismiss() );
+		expect( recordEventMock ).not.toHaveBeenCalledWith(
+			'woocommerce_block_email_update_dismissed',
+			expect.anything()
+		);
+		expect( dispatchMocks.dismissUpdateBanner ).toHaveBeenCalledWith( 42 );
+	} );
+
+	it( '_applied fires on apply success with shared payload + applied_from + auto_resolved + had_customizations', async () => {
+		setUpMocks( { summary: summaryFixture() } );
+		const { result } = renderHook( () => useUpdateBanner() );
+		recordEventMock.mockClear();
+		await act( async () => {
+			await result.current.apply();
+		} );
+		expect( recordEventMock ).toHaveBeenCalledWith(
+			'woocommerce_block_email_update_applied',
+			expect.objectContaining( {
+				...sharedPayloadMatcher,
+				applied_from: 'editor_banner',
+				auto_resolved: true,
+				had_customizations: expect.any( Boolean ),
+			} )
+		);
+	} );
+
+	it( '_applied does NOT fire on apply failure', async () => {
+		setUpMocks( {
+			summary: summaryFixture(),
+			apply: jest.fn().mockResolvedValue( null ),
+		} );
+		const { result } = renderHook( () => useUpdateBanner() );
+		recordEventMock.mockClear();
+		await act( async () => {
+			await result.current.apply();
+		} );
+		expect( recordEventMock ).not.toHaveBeenCalledWith(
+			'woocommerce_block_email_update_applied',
+			expect.anything()
+		);
+	} );
+} );
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
index aa3edc2eb63..b55794340fd 100644
--- 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
@@ -38,6 +38,20 @@ interface UndoResponse {
 	status: 'restored';
 }

+interface UseApplyUpdateOptions {
+	/**
+	 * When true, suppress the `createErrorNotice` snackbar fired on
+	 * `/apply` failure. Use when the calling surface (e.g., RSM-141's
+	 * editor banner) carries the error feedback itself and would
+	 * otherwise produce a double-error UI.
+	 *
+	 * Default: false — existing behavior (error snackbar fires).
+	 *
+	 * @since RSM-141
+	 */
+	suppressSnackbarOnError?: boolean;
+}
+
 interface UseApplyUpdateResult {
 	apply: ( choices: ApplyChoice[] ) => Promise< ApplyResponse | null >;
 	isApplying: boolean;
@@ -56,9 +70,13 @@ interface UseApplyUpdateResult {
  * matching pending edits, so no follow-up `saveEditedEntityRecord`
  * round-trip is needed.
  *
- * @param postId The `woo_email` post ID.
+ * @param postId  The `woo_email` post ID.
+ * @param options Optional behavior flags (e.g., `suppressSnackbarOnError`).
  */
-export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {
+export function useApplyUpdate(
+	postId: number | null,
+	options: UseApplyUpdateOptions = {}
+): UseApplyUpdateResult {
 	const { createSuccessNotice, createErrorNotice } =
 		useDispatch( noticesStore );
 	const { receiveEntityRecords } = useDispatch( coreStore );
@@ -141,7 +159,21 @@ export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {

 				syncEditorState( res.merged_content );

-				createSuccessNotice( __( 'Update applied.', 'woocommerce' ), {
+				// Snackbar wording follows what the merchant actually chose.
+				// Smart Apply (`choices: []`) defaults every conflict to
+				// `keep_yours`, so customizations are preserved. If the
+				// drawer-driven path picked `use_core` for everything, the
+				// merchant explicitly chose to overwrite — say so plainly.
+				const allUseCore =
+					choices.length > 0 &&
+					choices.every( ( c ) => c.decision === 'use_core' );
+				const successMessage = allUseCore
+					? __( 'Update applied', 'woocommerce' )
+					: __(
+							'Update applied · customizations preserved',
+							'woocommerce'
+					  );
+				createSuccessNotice( successMessage, {
 					type: 'snackbar',
 					actions: [
 						{
@@ -159,7 +191,9 @@ export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {
 					err && typeof err === 'object' && 'message' in err
 						? String( err.message )
 						: __( 'Could not apply the update.', 'woocommerce' );
-				createErrorNotice( message, { type: 'snackbar' } );
+				if ( ! options.suppressSnackbarOnError ) {
+					createErrorNotice( message, { type: 'snackbar' } );
+				}
 				return null;
 			} finally {
 				setIsApplying( false );
@@ -171,6 +205,7 @@ export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {
 			createErrorNotice,
 			undo,
 			syncEditorState,
+			options.suppressSnackbarOnError,
 		]
 	);

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
index 93e4b73ac19..26c980f1ca4 100644
--- 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
@@ -54,6 +54,8 @@ export interface ChangeSummaryStructuralChange {
 export interface ChangeSummary {
 	version_from: string;
 	version_to: string;
+	/** sha1 of the canonical core content; consumed by RSM-145 Tracks instrumentation. */
+	source_hash_to: string;
 	added_blocks: ChangeSummaryBlockEntry[];
 	removed_blocks: ChangeSummaryBlockEntry[];
 	copy_changes: ChangeSummaryCopyChange[];
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
new file mode 100644
index 00000000000..006e675bd74
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
@@ -0,0 +1,528 @@
+/**
+ * External dependencies
+ */
+import {
+	useCallback,
+	useEffect,
+	useMemo,
+	useRef,
+	useState,
+} from '@wordpress/element';
+import { select, useDispatch, useSelect } from '@wordpress/data';
+import { useEntityRecord } from '@wordpress/core-data';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { useChangeSummary, type ChangeSummary } from './use-change-summary';
+import { useApplyUpdate } from './use-apply-update';
+import { STORE_NAME } from '../store';
+
+/**
+ * Shape of the Tracks payload shared by every banner-fired event.
+ *
+ * RSM-145 standardizes these keys across the divergence-related events
+ * so they can be correlated end-to-end (`viewed` → `applied`/`dismissed`).
+ */
+interface SharedTracksPayload {
+	email_type: string;
+	template_version_from: string;
+	template_version_to: string | null;
+	source_hash_from: string | null;
+	source_hash_to: string | null;
+	classification: string;
+	was_backfilled: boolean;
+	// `recordEvent` types its payload as `{ [k: string]: unknown }`;
+	// the index signature lets us pass `SharedTracksPayload` directly.
+	[ key: string ]: unknown;
+}
+
+interface SharedPayloadInputs {
+	record: {
+		slug?: unknown;
+		meta?: Record< string, unknown >;
+	} | null;
+	summary: ChangeSummary | null;
+}
+
+/**
+ * Build the shared Tracks payload from the current entity record + summary.
+ *
+ * Returns `null` when there's no record yet (eligibility hasn't fired,
+ * so no event should fire either). Falsy `summary` is permitted: in
+ * that case the version_to / source_hash_to fields are `null`.
+ */
+function buildSharedTracksPayload( {
+	record,
+	summary,
+}: SharedPayloadInputs ): SharedTracksPayload | null {
+	const meta = record?.meta;
+	if ( ! meta ) {
+		return null;
+	}
+	const slug = typeof record?.slug === 'string' ? record.slug : '';
+	const versionFrom =
+		typeof meta._wc_email_template_version === 'string'
+			? ( meta._wc_email_template_version as string )
+			: '';
+	const sourceHashFrom =
+		typeof meta._wc_email_template_source_hash === 'string'
+			? ( meta._wc_email_template_source_hash as string )
+			: null;
+	const wasBackfilled =
+		meta._wc_email_backfilled === true ||
+		meta._wc_email_backfilled === '1' ||
+		meta._wc_email_backfilled === 1;
+	const classification =
+		typeof meta._wc_email_template_status === 'string'
+			? ( meta._wc_email_template_status as string )
+			: '';
+
+	return {
+		email_type: slug,
+		template_version_from: versionFrom,
+		template_version_to: summary?.version_to ?? null,
+		source_hash_from: sourceHashFrom,
+		source_hash_to: summary?.source_hash_to ?? null,
+		classification,
+		was_backfilled: wasBackfilled,
+	};
+}
+
+/**
+ * Numeric semver compare. Returns negative if `a < b`, zero if equal, positive
+ * if `a > b`. Lightweight implementation — template versions don't carry
+ * pre-release / build metadata so we don't need full semver semantics.
+ * Mirrors the helper in `settings-email-listing-update-cell.tsx` so the
+ * banner and the list cell stay in lockstep.
+ */
+function compareTemplateVersions( a: string, b: string ): number {
+	const partsA = a.split( '.' ).map( ( s ) => parseInt( s, 10 ) || 0 );
+	const partsB = b.split( '.' ).map( ( s ) => parseInt( s, 10 ) || 0 );
+	const len = Math.max( partsA.length, partsB.length );
+	for ( let i = 0; i < len; i++ ) {
+		const diff = ( partsA[ i ] ?? 0 ) - ( partsB[ i ] ?? 0 );
+		if ( diff !== 0 ) {
+			return diff;
+		}
+	}
+	return 0;
+}
+
+/**
+ * Compute sha1(input) as a lowercase hex string. Used to detect whether
+ * the merchant has customized the post body (`had_customizations`) by
+ * comparing against the `source_hash_from` recorded at upgrade time.
+ */
+async function sha1Hex( input: string ): Promise< string > {
+	const buf = new TextEncoder().encode( input );
+	const digest = await crypto.subtle.digest( 'SHA-1', buf );
+	return Array.from( new Uint8Array( digest ) )
+		.map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) )
+		.join( '' );
+}
+
+/**
+ * Apply state machine values surfaced by `useUpdateBanner`.
+ *
+ * `idle`     — no apply has been initiated, or the previous apply settled
+ *              and the banner is back to its default surface.
+ * `applying` — `/apply` is in flight.
+ * `applied`  — `/apply` succeeded; the editor canvas now reflects the
+ *              merged content.
+ * `failed`   — `/apply` failed (network, permission, conflict, …).
+ */
+export type ApplyState = 'idle' | 'applying' | 'applied' | 'failed';
+
+interface UseUpdateBannerResult {
+	shouldRender: boolean;
+	summary: ChangeSummary | null;
+	isLoadingSummary: boolean;
+	summaryError: Error | null;
+	applyState: ApplyState;
+	canApply: boolean;
+	canReview: boolean;
+	disabledReason: 'dirty' | 'read_only' | 'has_conflicts' | null;
+	hasConflicts: boolean;
+	expanded: boolean;
+	toggleExpanded: () => void;
+	apply: () => Promise< void >;
+	openReview: () => void;
+	/**
+	 * User-initiated dismiss. Removes the banner for this session and
+	 * fires the `_dismissed` Tracks event (spec §9.2).
+	 */
+	dismiss: () => void;
+	/**
+	 * Non-user dismiss path used by the success morph (auto-dismiss timer
+	 * and the success-state × click). Removes the banner without firing
+	 * the `_dismissed` Tracks event — spec §9.2 explicitly excludes the
+	 * post-success unmount from the dismissed-event surface.
+	 */
+	autoDismiss: () => void;
+}
+
+/**
+ * The single template-divergence status that should surface the banner.
+ * Anything else (`in_sync`, `core_updated_uncustomized`, null, unknown
+ * future values) is treated as "do nothing".
+ */
+const VALID_STATUS = 'core_updated_customized' as const;
+
+/**
+ * Glue hook for the "update available" editor banner (RSM-141).
+ *
+ * Reads the editor's current post + integration store state, decides
+ * whether the banner should render, and exposes the actions the
+ * `<UpdateBannerPlugin>` component needs to drive review / apply /
+ * dismiss interactions.
+ *
+ * Owns:
+ *   - eligibility predicate (status / postId / postType / dismiss state)
+ *   - change-summary fetch + conflict derivation
+ *   - apply state machine (`idle → applying → applied|failed`)
+ *   - dirty / read-only / has_conflicts gates
+ *   - per-(postId, version_to) dedup of the `_viewed` Tracks event
+ *   - `_viewed` / `_dismissed` / `_applied` Tracks event firing
+ */
+export function useUpdateBanner(): UseUpdateBannerResult {
+	// One `useSelect` lambda computes everything that depends on store
+	// reads. Keeping the shape flat (and the property names matching the
+	// test mock) means the lambda's contract is obvious from one
+	// glance — and the test can bypass the lambda entirely.
+	const { postId, postType, isDirty, canUserUpdate, isDismissed } = useSelect(
+		( selectFn ) => {
+			// We pass string store keys here because importing `store as
+			// editorStore` from `@wordpress/editor` pulls in a transitive
+			// module that Jest can't resolve in this package's test setup.
+			// Stable WP store keys: `core/editor`, `core` (core-data).
+			const { getCurrentPostId, getCurrentPostType, isEditedPostDirty } =
+				selectFn( 'core/editor' );
+			const { canUser } = selectFn( 'core' );
+			const { isUpdateBannerDismissedFor } = selectFn( STORE_NAME );
+
+			const rawId = getCurrentPostId();
+			const id = typeof rawId === 'number' ? rawId : null;
+			const type = getCurrentPostType() ?? null;
+			const dirty = Boolean( isEditedPostDirty() );
+
+			// `canUser` is `undefined` while the resolver is in flight; treat
+			// undefined as permissive so we don't flicker the banner away
+			// during the initial load. Only an explicit `false` denies.
+			const canUpdateRaw =
+				id !== null
+					? canUser( 'update', {
+							kind: 'postType',
+							name: 'woo_email',
+							id,
+					  } )
+					: undefined;
+			const canUpdate = canUpdateRaw === false ? false : true;
+
+			const dismissed =
+				id !== null
+					? Boolean( isUpdateBannerDismissedFor( id ) )
+					: false;
+
+			return {
+				postId: id,
+				postType: type,
+				isDirty: dirty,
+				canUserUpdate: canUpdate,
+				isDismissed: dismissed,
+			};
+		},
+		[]
+	);
+
+	// `useEntityRecord` always wants a non-null id; pass a harmless `0`
+	// when there's no post yet and ignore the result in that case.
+	const { record } = useEntityRecord(
+		'postType',
+		'woo_email',
+		postId ?? 0
+	) as {
+		record: {
+			slug?: unknown;
+			meta?: Record< string, unknown >;
+			content?: { raw?: string };
+		} | null;
+	};
+
+	const meta = postId !== null ? record?.meta ?? null : null;
+	const status =
+		meta && typeof meta._wc_email_template_status === 'string'
+			? ( meta._wc_email_template_status as string )
+			: null;
+
+	const isEligibleByStatus = status === VALID_STATUS;
+	const isCorrectPostType = postType === 'woo_email';
+	const shouldRender =
+		postId !== null &&
+		isCorrectPostType &&
+		isEligibleByStatus &&
+		! isDismissed;
+
+	// Fetch the change-summary only once we've decided the banner is
+	// eligible to render — avoids a needless network call on every
+	// editor load.
+	const {
+		summary: rawSummary,
+		isLoading: isLoadingSummary,
+		error: summaryError,
+		refetch: refetchSummary,
+	} = useChangeSummary( postId, shouldRender );
+
+	// When the review drawer closes — typically right after a drawer-driven
+	// `/apply` succeeds — refresh the change-summary so its `version_from`
+	// reflects the merchant's now-bumped meta. The canonical
+	// `summaryShowsReviewed` check below then sees `version_from >= version_to`
+	// and unmounts the banner. A merchant who just opens and closes the
+	// drawer without applying triggers an extra fetch — acceptable cost.
+	const isReviewDrawerOpen = useSelect(
+		( selectFn ) => selectFn( STORE_NAME ).isReviewDrawerOpen(),
+		[]
+	);
+	const prevDrawerOpenRef = useRef< boolean >( false );
+	useEffect( () => {
+		if ( prevDrawerOpenRef.current && ! isReviewDrawerOpen ) {
+			refetchSummary();
+		}
+		prevDrawerOpenRef.current = isReviewDrawerOpen;
+	}, [ isReviewDrawerOpen, refetchSummary ] );
+
+	// Cache the most recent non-null summary so the banner doesn't briefly
+	// flip variants while a refetch is in flight (which clears `rawSummary`
+	// to null inside `useChangeSummary` before the new response arrives).
+	const lastNonNullSummaryRef = useRef< ChangeSummary | null >( null );
+	if ( rawSummary !== null ) {
+		lastNonNullSummaryRef.current = rawSummary;
+	}
+	const effectiveSummary: ChangeSummary | null =
+		rawSummary ?? lastNonNullSummaryRef.current;
+
+	// Canonical "has the merchant reviewed this version?" check, mirroring
+	// the detector docblock's `version_compare( $reviewed, $current, '<' )`
+	// formula. When the change-summary reports `version_from >= version_to`
+	// the merchant's stored version is at-or-above the registry's current,
+	// so they've reviewed this release — even if status stays
+	// `core_updated_customized` because they kept some customizations on
+	// purpose during a drawer apply. Hide the indicator; the cell uses
+	// the same check via `currentVersion` on the slotfill payload.
+	const summaryShowsReviewed =
+		effectiveSummary !== null &&
+		effectiveSummary.version_from !== '' &&
+		effectiveSummary.version_to !== '' &&
+		compareTemplateVersions(
+			effectiveSummary.version_from,
+			effectiveSummary.version_to
+		) >= 0;
+
+	// Defensive: even when version-compare says the merchant hasn't reviewed
+	// yet, hide the banner if there's nothing actually different — sending
+	// the merchant into a drawer that says `Apply (0)` is a dead end. This
+	// catches stale-status scenarios where meta says `core_updated_customized`
+	// but the post content matches canonical core (test fixtures, race
+	// conditions during core upgrade, manual meta edits).
+	const summaryShowsNoChanges =
+		effectiveSummary !== null &&
+		! effectiveSummary.is_fallback &&
+		effectiveSummary.summary_lines.length === 0 &&
+		effectiveSummary.added_blocks.length === 0 &&
+		effectiveSummary.removed_blocks.length === 0 &&
+		effectiveSummary.copy_changes.length === 0 &&
+		effectiveSummary.structural_changes.length === 0;
+
+	const finalShouldRender =
+		shouldRender && ! summaryShowsReviewed && ! summaryShowsNoChanges;
+	const summary: ChangeSummary | null = finalShouldRender
+		? effectiveSummary
+		: null;
+	const hasConflicts = summary !== null && summary.copy_changes.length > 0;
+
+	// `@wordpress/data`'s typed dispatch surface isn't exhaustive for
+	// custom stores; cast loosely to grab our integration-store actions.
+	const integrationDispatch = useDispatch( STORE_NAME ) as unknown as {
+		dismissUpdateBanner: ( id: number ) => void;
+		clearDismissedForPost: ( id: number ) => void;
+		markUpdateBannerViewed: ( id: number, versionTo: string ) => void;
+		openReviewDrawer: () => void;
+	};
+	const {
+		dismissUpdateBanner,
+		clearDismissedForPost,
+		markUpdateBannerViewed,
+		openReviewDrawer,
+	} = integrationDispatch;
+
+	// Build the shared Tracks payload once per render of the eligible
+	// banner; reused by `_viewed`, `_dismissed`, `_applied`.
+	const sharedPayload = useMemo< SharedTracksPayload | null >(
+		() =>
+			finalShouldRender
+				? buildSharedTracksPayload( { record, summary } )
+				: null,
+		// `record` is the upstream entity reference; `summary` is the
+		// fetched change-summary. Both are stable across renders unless
+		// the underlying data actually changed.
+		[ finalShouldRender, record, summary ]
+	);
+
+	const { apply: doApply } = useApplyUpdate( postId, {
+		// The banner surfaces its own failure state via `applyState`;
+		// suppress the global snackbar to avoid double-error UI.
+		suppressSnackbarOnError: true,
+	} );
+
+	const [ applyState, setApplyState ] = useState< ApplyState >( 'idle' );
+
+	const canApply =
+		finalShouldRender &&
+		canUserUpdate &&
+		! isDirty &&
+		! hasConflicts &&
+		applyState === 'idle';
+
+	const canReview =
+		finalShouldRender && canUserUpdate && applyState === 'idle';
+
+	let disabledReason: UseUpdateBannerResult[ 'disabledReason' ] = null;
+	if ( ! canUserUpdate ) {
+		disabledReason = 'read_only';
+	} else if ( isDirty ) {
+		disabledReason = 'dirty';
+	} else if ( hasConflicts ) {
+		disabledReason = 'has_conflicts';
+	}
+
+	const [ expanded, setExpanded ] = useState< boolean >( false );
+	const toggleExpanded = useCallback( () => setExpanded( ( v ) => ! v ), [] );
+
+	// Clear the previous post's dismiss flag when the editor swaps to a
+	// different `woo_email` post, so the user dismissing the banner on
+	// post A doesn't keep it suppressed on post B.
+	const prevPostIdRef = useRef< number | null >( null );
+	useEffect( () => {
+		const prev = prevPostIdRef.current;
+		if ( prev !== null && prev !== postId ) {
+			clearDismissedForPost( prev );
+		}
+		prevPostIdRef.current = postId;
+	}, [ postId, clearDismissedForPost ] );
+
+	// Fire the `_viewed` Tracks event exactly once per
+	// (postId, version_to) pair — store-backed dedup survives
+	// re-renders and unmount/remount cycles within a session.
+	useEffect( () => {
+		if (
+			! finalShouldRender ||
+			! sharedPayload ||
+			postId === null ||
+			sharedPayload.template_version_to === null
+		) {
+			return;
+		}
+		// Imperative consultation (not a `useSelect` lambda dep) so the
+		// effect doesn't re-run just because the dedup set mutates.
+		const integration = select( STORE_NAME ) as unknown as {
+			wasUpdateBannerViewedFor: ( id: number, v: string ) => boolean;
+		};
+		if (
+			integration.wasUpdateBannerViewedFor(
+				postId,
+				sharedPayload.template_version_to
+			)
+		) {
+			return;
+		}
+		markUpdateBannerViewed( postId, sharedPayload.template_version_to );
+		recordEvent( 'woocommerce_block_email_update_viewed', {
+			...sharedPayload,
+			viewed_from: 'editor_banner',
+		} );
+	}, [ finalShouldRender, postId, sharedPayload, markUpdateBannerViewed ] );
+
+	const apply = useCallback( async () => {
+		if ( ! sharedPayload || ! record ) {
+			setApplyState( 'failed' );
+			return;
+		}
+		// Flip to `applying` synchronously so the UI reflects the click
+		// even before the (fast) sha1 microtask + the apply round-trip.
+		setApplyState( 'applying' );
+		try {
+			// Compute `had_customizations` BEFORE the apply round-trip so
+			// the comparison is against the pre-apply content, not the
+			// merged content that core-data caches mid-flight.
+			const contentRaw =
+				( record as { content?: { raw?: string } } ).content?.raw ?? '';
+			const hadCustomizations = sharedPayload.source_hash_from
+				? ( await sha1Hex( contentRaw ) ) !==
+				  sharedPayload.source_hash_from
+				: false;
+
+			const res = await doApply( [] );
+			if ( res ) {
+				setApplyState( 'applied' );
+				recordEvent( 'woocommerce_block_email_update_applied', {
+					...sharedPayload,
+					applied_from: 'editor_banner',
+					auto_resolved: true,
+					had_customizations: hadCustomizations,
+				} );
+			} else {
+				setApplyState( 'failed' );
+			}
+		} catch {
+			// `doApply` already swallows fetch errors, but `sha1Hex` (Web
+			// Crypto) and any future async work in here can throw — make
+			// sure the banner can recover instead of getting stuck in
+			// `applying`.
+			setApplyState( 'failed' );
+		}
+	}, [ doApply, sharedPayload, record ] );
+
+	const openReview = useCallback( () => {
+		openReviewDrawer();
+	}, [ openReviewDrawer ] );
+
+	const dismiss = useCallback( () => {
+		if ( postId === null || ! sharedPayload ) {
+			return;
+		}
+		dismissUpdateBanner( postId );
+		recordEvent(
+			'woocommerce_block_email_update_dismissed',
+			sharedPayload
+		);
+	}, [ postId, sharedPayload, dismissUpdateBanner ] );
+
+	// Auto-dismiss path — used by the success morph (timer + ×). Mirrors
+	// `dismiss` minus the Tracks event; spec §9.2 excludes the success
+	// auto-dismiss from the `_dismissed` event surface.
+	const autoDismiss = useCallback( () => {
+		if ( postId === null ) {
+			return;
+		}
+		dismissUpdateBanner( postId );
+	}, [ postId, dismissUpdateBanner ] );
+
+	return {
+		shouldRender: finalShouldRender,
+		summary,
+		isLoadingSummary,
+		summaryError,
+		applyState,
+		canApply,
+		canReview,
+		disabledReason,
+		hasConflicts,
+		expanded,
+		toggleExpanded,
+		apply,
+		openReview,
+		dismiss,
+		autoDismiss,
+	};
+}
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 ecdfd872f00..84255c74fcb 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
@@ -21,12 +21,14 @@ import { modifySidebar } from './sidebar_settings';
 import { registerEmailValidationRules } from './email-validation';
 import getResetNotificationEmailContentAction from './reset-notification-email-content';
 import { ReviewUpdatePlugin } from './review-update-plugin';
+import { UpdateBannerPlugin } from './update-banner-plugin';
 import {
 	registerStore as registerIntegrationStore,
 	STORE_NAME as INTEGRATION_STORE_NAME,
 } from './store';

 import './style.scss';
+import './update-banner.scss';

 addFilter( 'woocommerce_email_editor_send_button_label', NAME_SPACE, () =>
 	__( 'Save email', 'woocommerce' )
@@ -96,6 +98,16 @@ registerPlugin( 'woocommerce-email-editor-review-update', {
 	render: ReviewUpdatePlugin,
 } );

+// Register the update banner plugin (RSM-141). Mounts a floating banner
+// over the editor canvas when the open email post is classified
+// `core_updated_customized`. Reads dismiss + viewed-dedup state from the
+// integration store; consumes useChangeSummary (RSM-142) and
+// useApplyUpdate (RSM-143) for content + apply.
+registerPlugin( 'woocommerce-email-editor-update-banner', {
+	scope: 'woocommerce-email-editor',
+	render: UpdateBannerPlugin,
+} );
+
 // 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 (
@@ -104,6 +116,12 @@ if (
 	) === '1'
 ) {
 	dispatch( INTEGRATION_STORE_NAME ).openReviewDrawer();
+
+	// Strip the param from the URL so a refresh doesn't re-trigger the
+	// drawer auto-open. RSM-141 §5.2.
+	const url = new URL( window.location.href );
+	url.searchParams.delete( 'wc_email_review_drawer' );
+	window.history.replaceState( {}, '', url.pathname + url.search + url.hash );
 }

 /**
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
index b8a16f23b6e..2258c39408d 100644
--- 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
@@ -15,24 +15,63 @@ import type {
  * than passing props across siblings, since the trigger and the drawer
  * live in different React subtrees mounted by `registerPlugin`.
  *
+ * RSM-141 extends this store with two additional pieces of session-only
+ * state for the "update available" banner:
+ *   - `dismissedPostIds`: which posts the user has dismissed the banner
+ *     for during this editor session, so we don't re-show it after they
+ *     explicitly close it.
+ *   - `viewedPostVersionPairs`: which (postId, versionTo) pairs have
+ *     already fired the "viewed" Tracks event, so re-renders don't
+ *     double-count impressions.
+ *
  * @internal
  */
 export const STORE_NAME = 'woocommerce/email-editor-integration';

 interface State {
 	isReviewDrawerOpen: boolean;
+	dismissedPostIds: Set< number >;
+	viewedPostVersionPairs: Set< string >;
 }

 const initialState: State = {
 	isReviewDrawerOpen: false,
+	dismissedPostIds: new Set< number >(),
+	viewedPostVersionPairs: new Set< string >(),
 };

-type Action = { type: 'SET_REVIEW_DRAWER_OPEN'; open: boolean };
+type Action =
+	| { type: 'SET_REVIEW_DRAWER_OPEN'; open: boolean }
+	| { type: 'DISMISS_UPDATE_BANNER'; postId: number }
+	| { type: 'CLEAR_DISMISSED_FOR_POST'; postId: number }
+	| { type: 'MARK_UPDATE_BANNER_VIEWED'; postId: number; versionTo: string };

 const reducer = ( state: State = initialState, action: Action ): State => {
 	switch ( action.type ) {
 		case 'SET_REVIEW_DRAWER_OPEN':
 			return { ...state, isReviewDrawerOpen: action.open };
+		case 'DISMISS_UPDATE_BANNER': {
+			const nextDismissed = new Set( state.dismissedPostIds );
+			nextDismissed.add( action.postId );
+			return { ...state, dismissedPostIds: nextDismissed };
+		}
+		case 'CLEAR_DISMISSED_FOR_POST': {
+			if ( ! state.dismissedPostIds.has( action.postId ) ) {
+				return state;
+			}
+			const nextDismissed = new Set( state.dismissedPostIds );
+			nextDismissed.delete( action.postId );
+			return { ...state, dismissedPostIds: nextDismissed };
+		}
+		case 'MARK_UPDATE_BANNER_VIEWED': {
+			const key = `${ action.postId }:${ action.versionTo }`;
+			if ( state.viewedPostVersionPairs.has( key ) ) {
+				return state;
+			}
+			const nextViewed = new Set( state.viewedPostVersionPairs );
+			nextViewed.add( key );
+			return { ...state, viewedPostVersionPairs: nextViewed };
+		}
 		default:
 			return state;
 	}
@@ -45,10 +84,24 @@ const actions = {
 		( { type: 'SET_REVIEW_DRAWER_OPEN', open: true } as const ),
 	closeReviewDrawer: () =>
 		( { type: 'SET_REVIEW_DRAWER_OPEN', open: false } as const ),
+	dismissUpdateBanner: ( postId: number ) =>
+		( { type: 'DISMISS_UPDATE_BANNER', postId } as const ),
+	clearDismissedForPost: ( postId: number ) =>
+		( { type: 'CLEAR_DISMISSED_FOR_POST', postId } as const ),
+	markUpdateBannerViewed: ( postId: number, versionTo: string ) =>
+		( { type: 'MARK_UPDATE_BANNER_VIEWED', postId, versionTo } as const ),
 };

 const selectors = {
 	isReviewDrawerOpen: ( state: State ): boolean => state.isReviewDrawerOpen,
+	isUpdateBannerDismissedFor: ( state: State, postId: number ): boolean =>
+		state.dismissedPostIds.has( postId ),
+	wasUpdateBannerViewedFor: (
+		state: State,
+		postId: number,
+		versionTo: string
+	): boolean =>
+		state.viewedPostVersionPairs.has( `${ postId }:${ versionTo }` ),
 };

 const config = { reducer, actions, selectors };
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner-plugin.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner-plugin.tsx
new file mode 100644
index 00000000000..df2aa71cd1b
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner-plugin.tsx
@@ -0,0 +1,85 @@
+/**
+ * External dependencies
+ */
+import { createPortal, useEffect, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { UpdateBanner } from './update-banner';
+import { useUpdateBanner } from './hooks/use-update-banner';
+
+/**
+ * DOM selectors we try in order to find the editor canvas to portal
+ * the banner into. The first match wins; if none match we fall back
+ * to `document.body` so the banner never silently disappears.
+ */
+const PORTAL_SELECTORS = [
+	'.edit-post-visual-editor',
+	'.editor-canvas-container',
+	'.wp-block-post-content',
+];
+
+/**
+ * Walk the selector list and return the first matching element, or
+ * `null` if nothing matches. Pure DOM lookup — no React state.
+ */
+function resolvePortalTarget(): HTMLElement | null {
+	for ( const selector of PORTAL_SELECTORS ) {
+		const node = document.querySelector( selector );
+		if ( node instanceof HTMLElement ) {
+			return node;
+		}
+	}
+	return null;
+}
+
+/**
+ * Mounts the `<UpdateBanner>` into the email editor's canvas via a
+ * portal (RSM-141). Pure orchestration: reads the banner state from
+ * `useUpdateBanner` and forwards it to the presentational component.
+ *
+ * Resolves the portal target on mount AND once more on the next
+ * animation frame, since the canvas can mount on a later paint than
+ * the plugin itself. Falls back to `document.body` if no canvas
+ * selector matches — the banner never silently disappears.
+ */
+export function UpdateBannerPlugin(): JSX.Element | null {
+	const banner = useUpdateBanner();
+	const [ portalTarget, setPortalTarget ] = useState< HTMLElement | null >(
+		() => resolvePortalTarget() ?? document.body
+	);
+
+	useEffect( () => {
+		// First-paint resolution may have run before the canvas
+		// mounted; re-resolve once on the next frame to catch up.
+		const handle = window.requestAnimationFrame( () => {
+			const next = resolvePortalTarget() ?? document.body;
+			setPortalTarget( ( prev ) => ( prev === next ? prev : next ) );
+		} );
+		return () => {
+			window.cancelAnimationFrame( handle );
+		};
+	}, [] );
+
+	if ( ! banner.shouldRender || ! portalTarget ) {
+		return null;
+	}
+
+	return createPortal(
+		<UpdateBanner
+			summary={ banner.summary }
+			applyState={ banner.applyState }
+			canApply={ banner.canApply }
+			canReview={ banner.canReview }
+			disabledReason={ banner.disabledReason }
+			expanded={ banner.expanded }
+			onApply={ banner.apply }
+			onReview={ banner.openReview }
+			onDismiss={ banner.dismiss }
+			onAutoDismiss={ banner.autoDismiss }
+			onToggleExpanded={ banner.toggleExpanded }
+		/>,
+		portalTarget
+	);
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.scss b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.scss
new file mode 100644
index 00000000000..ea82bd90f90
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.scss
@@ -0,0 +1,98 @@
+/**
+ * <UpdateBanner> — RSM-141.
+ *
+ * Floating card pinned 16px from the canvas top, centered horizontally.
+ * Uses WPDS tokens via CSS custom properties with sensible WP-admin
+ * fallbacks; no custom design tokens introduced.
+ */
+
+.wc-update-banner {
+	position: absolute;
+	top: 16px;
+	left: 50%;
+	transform: translateX(-50%);
+	width: min(720px, calc(100% - 32px));
+	background: var(--wpds-color-bg-surface, #fff);
+	border-radius: var(--wpds-border-radius-sm, 2px);
+	box-shadow:
+		var(--wpds-elevation-md, 0 1px 2px rgba(0, 0, 0, 0.06)),
+		0 4px 12px rgba(0, 0, 0, 0.08);
+	border-left: 3px solid var(--wpds-color-bg-interactive-brand-strong, #3858e9);
+	padding: 12px 16px;
+	display: flex;
+	gap: 16px;
+	align-items: flex-start;
+	z-index: 100;
+	pointer-events: auto;
+
+	&__body {
+		flex: 1;
+		min-width: 0;
+	}
+
+	&__title {
+		font-weight: 600;
+		font-size: 13px;
+		line-height: 1.4;
+	}
+
+	&__subtitle {
+		margin-top: 4px;
+		font-size: 12px;
+		color: var(--wpds-color-text-secondary, #757575);
+	}
+
+	&__expand {
+		appearance: none;
+		background: none;
+		border: 0;
+		padding: 4px 0 0 0;
+		margin-top: 6px;
+		color: var(--wpds-color-bg-interactive-brand-strong, #3858e9);
+		cursor: pointer;
+		font-size: 12px;
+		font-weight: 500;
+	}
+
+	&__changes {
+		margin: 8px 0 0 16px;
+		padding: 0;
+		font-size: 12px;
+		line-height: 1.5;
+	}
+
+	&__actions {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		flex-shrink: 0;
+	}
+
+	&__dismiss {
+		appearance: none;
+		background: none;
+		border: 0;
+		padding: 4px 8px;
+		font-size: 16px;
+		line-height: 1;
+		cursor: pointer;
+		color: var(--wpds-color-text-secondary, #757575);
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+	}
+
+	/* Success morph */
+	&--success {
+		background: var(--wpds-color-bg-surface-success-weak, #ebffed);
+		border-left-color: var(--wpds-color-bg-interactive-success-strong, #00ba37);
+	}
+
+	/* Failure morph */
+	&--failure {
+		background: var(--wpds-color-bg-surface-warning, #fde6be);
+		border-left-color: #b26200;
+	}
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.tsx
new file mode 100644
index 00000000000..db4659793de
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/update-banner.tsx
@@ -0,0 +1,343 @@
+/**
+ * External dependencies
+ */
+import { useEffect } from '@wordpress/element';
+import { Button, Tooltip } from '@wordpress/components';
+import { __, _n, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ChangeSummary } from './hooks/use-change-summary';
+import type { ApplyState } from './hooks/use-update-banner';
+
+/**
+ * Auto-dismiss the success morph this many milliseconds after it
+ * appears. Long enough for the merchant to see the confirmation, short
+ * enough that the banner doesn't linger after the work is done.
+ */
+const SUCCESS_AUTODISMISS_MS = 2000;
+
+/**
+ * Stable id used to wire `aria-controls` from the expand toggle to the
+ * `<ul>` of summary lines.
+ */
+const CHANGES_LIST_ID = 'wc-update-banner-changes';
+
+interface UpdateBannerProps {
+	summary: ChangeSummary | null;
+	applyState: ApplyState;
+	canApply: boolean;
+	canReview: boolean;
+	disabledReason: 'dirty' | 'read_only' | 'has_conflicts' | null;
+	expanded: boolean;
+	onApply: () => void;
+	onReview: () => void;
+	/**
+	 * User-initiated dismiss (× click in the default or failure morph).
+	 * The hook fires the `_dismissed` Tracks event from this path —
+	 * spec §9.2 scopes the event to the user-initiated paths only.
+	 */
+	onDismiss: () => void;
+	/**
+	 * Non-user dismiss path used by the success morph: both the 2s
+	 * auto-dismiss timer and the success-morph × click route here so
+	 * the `_dismissed` Tracks event does NOT fire (per spec §9.2 —
+	 * "does NOT fire on auto-dismiss after success").
+	 */
+	onAutoDismiss: () => void;
+	onToggleExpanded: () => void;
+}
+
+/**
+ * Tooltip text for a guard reason. Returns `null` when no tooltip
+ * should be surfaced — for `has_conflicts` the Apply slot already
+ * morphs into a primary "Review changes" button so no tooltip is
+ * needed; for `null` (no guard) the buttons are enabled.
+ */
+function tooltipText(
+	reason: UpdateBannerProps[ 'disabledReason' ]
+): string | null {
+	switch ( reason ) {
+		case 'dirty':
+			return __( 'Save your changes first.', 'woocommerce' );
+		case 'read_only':
+			return __(
+				"You don't have permission to update this email.",
+				'woocommerce'
+			);
+		case 'has_conflicts':
+		case null:
+		default:
+			return null;
+	}
+}
+
+/**
+ * Compute the subtitle for the default banner branch.
+ *
+ * Falls back to a generic message when the summary is missing, the
+ * change-summary detector flagged the diff as a fallback, or the
+ * detector returned an empty `summary_lines`.
+ */
+function defaultSubtitle( summary: ChangeSummary | null ): string {
+	if ( summary === null || summary.is_fallback ) {
+		return __( 'WooCommerce updated this template.', 'woocommerce' );
+	}
+	if ( summary.summary_lines.length === 0 ) {
+		return sprintf(
+			// translators: %s is a WooCommerce version number, e.g. "1.2.3".
+			__( 'WooCommerce %s refreshed this template.', 'woocommerce' ),
+			summary.version_to
+		);
+	}
+	return summary.summary_lines[ 0 ];
+}
+
+/**
+ * Wrap a disabled button in a Tooltip when there's a guard reason that
+ * carries help text. The wrapping `<span>` is required: a disabled
+ * native button doesn't fire pointer events on its own, so Tooltip
+ * needs an enabled element to attach hover/focus listeners to.
+ *
+ * The tip is also rendered inline as visually-hidden screen-reader
+ * text, so assistive tech announces the guard reason even when the
+ * tooltip's hover/focus heuristics don't fire (and so tests can query
+ * the help text without simulating hover delays).
+ */
+function MaybeTooltip( {
+	tip,
+	children,
+}: {
+	tip: string | null;
+	children: JSX.Element;
+} ): JSX.Element {
+	if ( ! tip ) {
+		return children;
+	}
+	return (
+		<Tooltip text={ tip }>
+			<span className="wc-update-banner__tooltip-wrap">
+				{ children }
+				<span className="screen-reader-text">{ tip }</span>
+			</span>
+		</Tooltip>
+	);
+}
+
+/**
+ * Presentational floating banner that surfaces template-divergence
+ * info to the merchant inside the email editor (RSM-141).
+ *
+ * Pure: receives every piece of state via props from `useUpdateBanner`
+ * and never reads from the data layer itself. Three render branches
+ * keyed off `applyState`:
+ *
+ *   - `applied` → success morph, auto-dismisses after 2s
+ *   - `failed`  → failure morph, manual recovery only
+ *   - default   → idle / applying — the actionable banner
+ */
+export function UpdateBanner( {
+	summary,
+	applyState,
+	canApply,
+	canReview,
+	disabledReason,
+	expanded,
+	onApply,
+	onReview,
+	onDismiss,
+	onAutoDismiss,
+	onToggleExpanded,
+}: UpdateBannerProps ): JSX.Element {
+	// ---- Success morph ---------------------------------------------------
+	// Schedule the auto-dismiss timer once the morph mounts; clean it up
+	// on unmount or if `onAutoDismiss` changes mid-lifecycle.
+	useEffect( () => {
+		if ( applyState !== 'applied' ) {
+			return;
+		}
+		const handle = setTimeout( () => {
+			onAutoDismiss();
+		}, SUCCESS_AUTODISMISS_MS );
+		return () => {
+			clearTimeout( handle );
+		};
+	}, [ applyState, onAutoDismiss ] );
+
+	if ( applyState === 'applied' ) {
+		return (
+			<div
+				className="wc-update-banner wc-update-banner--success"
+				role="status"
+				aria-live="polite"
+			>
+				<div className="wc-update-banner__body">
+					<div className="wc-update-banner__title">
+						{ __( 'Template updated', 'woocommerce' ) }
+					</div>
+					<div className="wc-update-banner__subtitle">
+						{ __(
+							'Your customizations were preserved.',
+							'woocommerce'
+						) }
+					</div>
+				</div>
+				<button
+					type="button"
+					className="wc-update-banner__dismiss"
+					aria-label={ __(
+						'Dismiss for this session',
+						'woocommerce'
+					) }
+					onClick={ onAutoDismiss }
+				>
+					{ '×' }
+				</button>
+			</div>
+		);
+	}
+
+	// ---- Failure morph ---------------------------------------------------
+	if ( applyState === 'failed' ) {
+		return (
+			<div
+				className="wc-update-banner wc-update-banner--failure"
+				role="alert"
+			>
+				<div className="wc-update-banner__body">
+					<div className="wc-update-banner__title">
+						{ __( "Couldn't apply", 'woocommerce' ) }
+					</div>
+				</div>
+				<div className="wc-update-banner__actions">
+					<Button variant="primary" onClick={ onApply }>
+						{ __( 'Try again', 'woocommerce' ) }
+					</Button>
+					<Button variant="tertiary" onClick={ onReview }>
+						{ __( 'Review changes', 'woocommerce' ) }
+					</Button>
+				</div>
+				<button
+					type="button"
+					className="wc-update-banner__dismiss"
+					aria-label={ __(
+						'Dismiss for this session',
+						'woocommerce'
+					) }
+					onClick={ onDismiss }
+				>
+					{ '×' }
+				</button>
+			</div>
+		);
+	}
+
+	// ---- Default / applying branch --------------------------------------
+	const isApplying = applyState === 'applying';
+	const isConflict = disabledReason === 'has_conflicts';
+	// Pull out the lines once so the JSX below doesn't need non-null
+	// assertions on `summary` — the local is narrowed to `string[]`.
+	const expandableLines: string[] =
+		summary !== null && ! summary.is_fallback ? summary.summary_lines : [];
+	const hasExpandableChanges = expandableLines.length > 0;
+	const subtitle = defaultSubtitle( summary );
+	const tip = tooltipText( disabledReason );
+
+	const applyLabel = isApplying
+		? __( 'Applying…', 'woocommerce' )
+		: __( 'Apply', 'woocommerce' );
+
+	// In the conflict variant the Apply slot becomes the primary
+	// "Review changes" CTA — a disabled Apply button would be the wrong
+	// affordance, since the user can't apply until they resolve the
+	// conflicts in the review drawer.
+	const applySlot = isConflict ? (
+		<Button variant="primary" onClick={ onReview }>
+			{ __( 'Review changes', 'woocommerce' ) }
+		</Button>
+	) : (
+		<MaybeTooltip tip={ tip }>
+			<Button
+				variant="primary"
+				onClick={ onApply }
+				disabled={ ! canApply || isApplying }
+				aria-disabled={ ! canApply ? 'true' : undefined }
+				isBusy={ isApplying }
+			>
+				{ applyLabel }
+			</Button>
+		</MaybeTooltip>
+	);
+
+	const reviewSlot = isConflict ? null : (
+		<MaybeTooltip tip={ tip }>
+			<Button
+				variant="tertiary"
+				onClick={ onReview }
+				disabled={ ! canReview || isApplying }
+				aria-disabled={ ! canReview ? 'true' : undefined }
+			>
+				{ __( 'Review', 'woocommerce' ) }
+			</Button>
+		</MaybeTooltip>
+	);
+
+	return (
+		<div className="wc-update-banner" role="status" aria-live="polite">
+			<div className="wc-update-banner__body">
+				<div className="wc-update-banner__title">
+					{ __( 'Template update available', 'woocommerce' ) }
+				</div>
+				<div className="wc-update-banner__subtitle">{ subtitle }</div>
+				{ hasExpandableChanges && (
+					<>
+						<button
+							type="button"
+							className="wc-update-banner__expand"
+							aria-expanded={ expanded }
+							aria-controls={ CHANGES_LIST_ID }
+							onClick={ onToggleExpanded }
+						>
+							{ expanded
+								? __( 'Hide changes', 'woocommerce' )
+								: sprintf(
+										/* translators: %d is the number of summary changes. */
+										_n(
+											'Show %d change',
+											'Show %d changes',
+											expandableLines.length,
+											'woocommerce'
+										),
+										expandableLines.length
+								  ) }
+						</button>
+						{ expanded && (
+							<ul
+								id={ CHANGES_LIST_ID }
+								className="wc-update-banner__changes"
+							>
+								{ expandableLines.map( ( line, i ) => (
+									<li key={ i }>{ line }</li>
+								) ) }
+							</ul>
+						) }
+					</>
+				) }
+			</div>
+			<div className="wc-update-banner__actions">
+				{ applySlot }
+				{ reviewSlot }
+			</div>
+			<button
+				type="button"
+				className="wc-update-banner__dismiss"
+				aria-label={ __( 'Dismiss for this session', 'woocommerce' ) }
+				onClick={ onDismiss }
+				disabled={ isApplying }
+			>
+				{ '×' }
+			</button>
+		</div>
+	);
+}
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
index 493b9d2c680..0c7011293f3 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Internal\Email\EmailFont;
 use Automattic\WooCommerce\Internal\Email\EmailStyleSync;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
 use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;

@@ -600,16 +601,24 @@ class WC_Settings_Emails extends WC_Settings_Page {
 		$email_types          = array();
 		$post_id_for_template = null;
 		foreach ( $emails as $email_key => $email ) {
-			$post_id       = $email_post_manager->get_email_template_post_id( $email->id );
-			$email_types[] = array(
-				'title'       => $email->get_title(),
-				'description' => $email->get_description(),
-				'id'          => $email->id,
-				'email_key'   => strtolower( $email_key ),
-				'post_id'     => $post_id,
-				'enabled'     => $email->is_enabled(),
-				'manual'      => $email->is_manual(),
-				'recipients'  => array(
+			$post_id     = $email_post_manager->get_email_template_post_id( $email->id );
+			$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email->id );
+			// `current_version` is the canonical version core ships right now;
+			// the list view's "Review update" cell and RSM-141's editor banner
+			// gate on `merchant_reviewed_version < current_version` so a row
+			// stays customized but stops showing the indicator once the
+			// merchant has reviewed this release.
+			$current_version = is_array( $sync_config ) ? (string) ( $sync_config['version'] ?? '' ) : '';
+			$email_types[]   = array(
+				'title'           => $email->get_title(),
+				'description'     => $email->get_description(),
+				'id'              => $email->id,
+				'email_key'       => strtolower( $email_key ),
+				'post_id'         => $post_id,
+				'enabled'         => $email->is_enabled(),
+				'manual'          => $email->is_manual(),
+				'current_version' => '' !== $current_version ? $current_version : null,
+				'recipients'      => array(
 					'to'  => $email->is_customer_email() ? __( 'Customers', 'woocommerce' ) : $email->get_recipient(),
 					'cc'  => $email->get_cc_recipient(),
 					'bcc' => $email->get_bcc_recipient(),
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
index c8d91defad3..a2fe23f95ea 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
@@ -99,6 +99,7 @@ class WCEmailTemplateChangeSummary {
 	 *
 	 * - `version_from`        — `string`     — `_wc_email_template_version` meta on the post (may be empty).
 	 * - `version_to`          — `string`     — registry-side current version.
+	 * - `source_hash_to`      — `string`     — sha1 of the canonical core content for this email type. Mirrors the post's `_wc_email_template_source_hash` meta. Empty string in fallback / no-config paths where the core content can't be computed.
 	 * - `added_blocks`        — `array<int, array{name:string, label:string, path:array<int|string>}>` — blocks that would be added to the post by applying (in core, not in post). `name` is the post-alias-normalized block name (e.g. `core/heading`); `label` is its humanized form for display; `path` is the core-side index path.
 	 * - `removed_blocks`      — `array<int, array{name:string, label:string, path:array<int|string>}>` — blocks that would be removed from the post by applying (in post, not in core). Same field semantics as `added_blocks`; `path` is the post-side index path.
 	 * - `copy_changes`        — `array<int, array{block:string, before:string, after:string, occurrence:int, total:int, path:array<int|string>}>`.
@@ -180,9 +181,10 @@ class WCEmailTemplateChangeSummary {
 		// by emptiness alone — they construct any "you're up to date" copy
 		// themselves.
 		if ( $post_hash === $core_hash ) {
-			$payload                 = self::empty_payload();
-			$payload['version_from'] = $version_from;
-			$payload['version_to']   = $version_to;
+			$payload                   = self::empty_payload();
+			$payload['version_from']   = $version_from;
+			$payload['version_to']     = $version_to;
+			$payload['source_hash_to'] = $core_hash;
 			self::write_cache( $cache_key, $payload );
 			return $payload;
 		}
@@ -220,6 +222,7 @@ class WCEmailTemplateChangeSummary {
 		$payload = array(
 			'version_from'       => $version_from,
 			'version_to'         => $version_to,
+			'source_hash_to'     => $core_hash,
 			'added_blocks'       => $structured['added_blocks'],
 			'removed_blocks'     => $structured['removed_blocks'],
 			'copy_changes'       => $structured['copy_changes'],
@@ -274,6 +277,7 @@ class WCEmailTemplateChangeSummary {
 		return array(
 			'version_from'       => '',
 			'version_to'         => '',
+			'source_hash_to'     => '',
 			'added_blocks'       => array(),
 			'removed_blocks'     => array(),
 			'copy_changes'       => array(),
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index 85006ec1d5e..5504eaedf0c 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -105,6 +105,20 @@ class WCEmailTemplateDivergenceDetector {
 	 */
 	public const LAST_SYNCED_AT_META_KEY = '_wc_email_last_synced_at';

+	/**
+	 * Informational flag intended to be set to `true` by the RSM-149 backfill
+	 * on every pre-existing `woo_email` post it stamps. Registered + surfaced
+	 * read-only over REST here so RSM-145 Tracks instrumentation can distinguish
+	 * backfilled posts from natively generated ones, but the writer is staged
+	 * in a separate follow-up PR after the 10.8 feature freeze for the backfill
+	 * class. Until then, the field defaults to `false` everywhere — Tracks will
+	 * report `was_backfilled: false` for all posts. Safe default; no behavior
+	 * depends on it being `true`.
+	 *
+	 * @var string
+	 */
+	public const BACKFILLED_META_KEY = '_wc_email_backfilled';
+
 	/**
 	 * Classification outcomes.
 	 */
@@ -120,18 +134,24 @@ class WCEmailTemplateDivergenceDetector {
 	private static $logger = null;

 	/**
-	 * Register `_wc_email_template_status` and `_wc_email_template_version` post meta on
-	 * the `woo_email` post type as REST-readable, server-write-only meta.
+	 * Register the four sync-related post meta keys on the `woo_email` post type as
+	 * REST-readable, server-write-only meta:
+	 *
+	 * - {@see self::STATUS_META_KEY} (`_wc_email_template_status`)
+	 * - {@see self::VERSION_META_KEY} (`_wc_email_template_version`)
+	 * - {@see self::SOURCE_HASH_META_KEY} (`_wc_email_template_source_hash`)
+	 * - {@see self::BACKFILLED_META_KEY} (`_wc_email_backfilled`)
 	 *
 	 * Because the `woo_email` post type declares `'custom-fields'` support (see
 	 * {@see Integration::add_email_post_type()}), WP core auto-surfaces every
 	 * `show_in_rest = true` meta key under the standard `meta` property of the
 	 * `wp/v2/woo_email` response — no custom REST field registration is needed.
 	 *
-	 * This is a stable read contract for the email list UI and any downstream consumer
-	 * (extensions, headless admins). Renaming or removing either meta key, or changing
-	 * the meaning of an existing status string value, is a breaking change. Vocabulary
-	 * expansion (adding new status values) is fine.
+	 * This is a stable read contract for the email list UI, the RSM-141 editor
+	 * "update available" banner, and the RSM-145 Tracks instrumentation. Renaming
+	 * or removing any of these meta keys, or changing the meaning of an existing
+	 * status string value, is a breaking change. Vocabulary expansion (adding new
+	 * status values) is fine.
 	 *
 	 * Hook: `init`.
 	 *
@@ -165,6 +185,32 @@ class WCEmailTemplateDivergenceDetector {
 				'sanitize_callback' => 'sanitize_text_field',
 			)
 		);
+
+		register_post_meta(
+			'woo_email',
+			self::SOURCE_HASH_META_KEY,
+			array(
+				'type'              => 'string',
+				'description'       => 'SHA-1 stamp of the canonical core post content recorded the last time this email post was generated, applied, or reset. Consumed by RSM-145 Tracks instrumentation to fingerprint the baseline the merchant was reviewing. Read-only over REST.',
+				'single'            => true,
+				'show_in_rest'      => true,
+				'auth_callback'     => array( self::class, 'rest_meta_auth_read_only' ),
+				'sanitize_callback' => 'sanitize_text_field',
+			)
+		);
+
+		register_post_meta(
+			'woo_email',
+			self::BACKFILLED_META_KEY,
+			array(
+				'type'              => 'boolean',
+				'description'       => 'True when the post was stamped by the RSM-149 backfill rather than created natively by the modern generator. Consumed by RSM-145 Tracks instrumentation to segment update-banner interactions on backfilled posts. Read-only over REST.',
+				'single'            => true,
+				'show_in_rest'      => true,
+				'auth_callback'     => array( self::class, 'rest_meta_auth_read_only' ),
+				'sanitize_callback' => 'rest_sanitize_boolean',
+			)
+		);
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
index 72908eb928d..5a0031572cb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummaryTest.php
@@ -480,6 +480,40 @@ class WCEmailTemplateChangeSummaryTest extends \WC_Unit_Test_Case {
 		$this->assertSame( array(), $second['summary_lines'] );
 	}

+	/**
+	 * `source_hash_to` is the sha1 of the canonical core content for the
+	 * email type. It mirrors the post's `_wc_email_template_source_hash`
+	 * meta and is consumed by the RSM-145 Tracks instrumentation to identify
+	 * which canonical revision a merchant is comparing against.
+	 *
+	 * @testdox Should expose source_hash_to as a non-empty sha1 hex string on the success-path payload.
+	 */
+	public function test_summarize_includes_source_hash_to_for_customized_post(): void {
+		$email_id = 'change_summary_source_hash';
+		$this->register_fixture_email( $email_id );
+
+		$core_content = "<!-- wp:heading -->\n<h2>Welcome</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Original line.</p>\n<!-- /wp:paragraph -->";
+
+		// Merchant edited the paragraph copy.
+		$post_content = "<!-- wp:heading -->\n<h2>Welcome</h2>\n<!-- /wp:heading -->\n\n"
+			. "<!-- wp:paragraph -->\n<p>Edited line.</p>\n<!-- /wp:paragraph -->";
+
+		$this->use_canonical_content( $email_id, $core_content );
+		$post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+		$summary = WCEmailTemplateChangeSummary::summarize( $post_id );
+
+		$this->assertArrayHasKey( 'source_hash_to', $summary );
+		$this->assertIsString( $summary['source_hash_to'] );
+		$this->assertNotEmpty( $summary['source_hash_to'] );
+		$this->assertSame( 40, strlen( $summary['source_hash_to'] ) );
+		$this->assertTrue(
+			ctype_xdigit( $summary['source_hash_to'] ),
+			'source_hash_to must be a hex-only sha1 (40 hex chars).'
+		);
+	}
+
 	/**
 	 * Cache: first call computes and stores; second call with same inputs hits
 	 * the cache; mutating the post invalidates by content hash.
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 29f486575e6..c81c1f29c38 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -93,6 +93,44 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 		$this->assertSame( 'string', $args['type'] );
 	}

+	/**
+	 * @testdox Should register _wc_email_template_source_hash post meta on woo_email with show_in_rest.
+	 */
+	public function test_registers_template_source_hash_meta_with_show_in_rest(): void {
+		$this->initialize_email_editor_integration();
+
+		$this->assertTrue(
+			registered_meta_key_exists( 'post', WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, 'woo_email' ),
+			'Expected _wc_email_template_source_hash to be registered for woo_email.'
+		);
+
+		$args = get_registered_meta_keys( 'post', 'woo_email' )[ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY ];
+
+		$this->assertTrue( $args['show_in_rest'], 'Expected show_in_rest = true.' );
+		$this->assertTrue( $args['single'], 'Expected single = true.' );
+		$this->assertSame( 'string', $args['type'] );
+		$this->assertIsCallable( $args['auth_callback'] );
+	}
+
+	/**
+	 * @testdox Should register _wc_email_backfilled post meta on woo_email with show_in_rest.
+	 */
+	public function test_registers_email_backfilled_meta_with_show_in_rest(): void {
+		$this->initialize_email_editor_integration();
+
+		$this->assertTrue(
+			registered_meta_key_exists( 'post', WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, 'woo_email' ),
+			'Expected _wc_email_backfilled to be registered for woo_email.'
+		);
+
+		$args = get_registered_meta_keys( 'post', 'woo_email' )[ WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY ];
+
+		$this->assertTrue( $args['show_in_rest'], 'Expected show_in_rest = true.' );
+		$this->assertTrue( $args['single'], 'Expected single = true.' );
+		$this->assertSame( 'boolean', $args['type'] );
+		$this->assertIsCallable( $args['auth_callback'] );
+	}
+
 	/**
 	 * @testdox Should deny REST writes to template meta even for administrators.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php
index e273e05ec8f..0e98ab34ad5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php
@@ -100,6 +100,16 @@ class WCEmailTemplateMetaRestExposureTest extends \WC_REST_Unit_Test_Case {
 			WCEmailTemplateDivergenceDetector::VERSION_META_KEY,
 			'9.4.0'
 		);
+		update_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+			'abc123def456'
+		);
+		update_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY,
+			true
+		);

 		$request  = new WP_REST_Request( 'GET', "/wp/v2/woo_email/{$post_id}" );
 		$response = rest_do_request( $request );
@@ -131,6 +141,27 @@ class WCEmailTemplateMetaRestExposureTest extends \WC_REST_Unit_Test_Case {
 			$data['meta'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ],
 			'Version meta value must reflect the stamped post meta.'
 		);
+
+		$this->assertArrayHasKey(
+			WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+			$data['meta'],
+			'Source hash meta must be auto-surfaced under the meta property of the wp/v2/woo_email response.'
+		);
+		$this->assertSame(
+			'abc123def456',
+			$data['meta'][ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY ] ?? null,
+			'Source hash meta value must reflect the stamped post meta.'
+		);
+
+		$this->assertArrayHasKey(
+			WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY,
+			$data['meta'],
+			'Backfilled meta must be auto-surfaced under the meta property of the wp/v2/woo_email response.'
+		);
+		$this->assertTrue(
+			$data['meta'][ WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY ] ?? null,
+			'Backfilled meta value must reflect the stamped post meta.'
+		);
 	}

 	/**
@@ -172,5 +203,20 @@ class WCEmailTemplateMetaRestExposureTest extends \WC_REST_Unit_Test_Case {
 			$data['meta'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ],
 			'Unstamped posts must surface an empty version; the JS data hook normalises this to null.'
 		);
+
+		// WP core surfaces registered string meta with an empty-string default when no value is stored.
+		$this->assertArrayHasKey( WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $data['meta'] );
+		$this->assertSame(
+			'',
+			$data['meta'][ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY ],
+			'Unstamped posts must surface an empty source hash.'
+		);
+
+		// WP core surfaces registered boolean meta as `false` when no value is stored.
+		$this->assertArrayHasKey( WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, $data['meta'] );
+		$this->assertFalse(
+			$data['meta'][ WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY ],
+			'Unstamped posts must surface false for the backfilled flag.'
+		);
 	}
 }