Commit d916c754eb3 for woocommerce

commit d916c754eb370f04e90fecc45e82a06ced2a3a14
Author: Patrick Zielinski <patrick.zielinski@a8c.com>
Date:   Tue May 5 13:58:05 2026 -0400

    [Email Editor] Surface update-available indicator on the email list (RSM-140) (#64547)

    * Expose woo_email template status and version meta over REST

    * Add buildEmailEditorReviewUrl helper for review-drawer URL contract

    * Add UpdatesCell and template-status fields for email list updates column

    * Add Updates field to email listing DataView with filter chip

    * Add Storybook story for UpdatesCell meta states

    * Add changelog entry for RSM-140 update-available indicator

    * Apply Updates view filter in email list data hook

    * Replace DataView auto-chip with custom Update available chip

    * Style Review update cell button to match design handoff

    * Tune Update available chip vertical alignment in toolbar

    * Drop custom chip and URL param per PR review

    * Restore wc_email_review_drawer URL param helper for review-update click

    * Surface template meta via custom-fields support; refactor status allowlist

    * Guard Review update click against empty post_id

    * Restore feature-flag state in REST exposure test lifecycle

    * Document init action firing in test setUp

    ---------

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

diff --git a/plugins/woocommerce/changelog/rsm-140-add-update-available-indicator-email-list b/plugins/woocommerce/changelog/rsm-140-add-update-available-indicator-email-list
new file mode 100644
index 00000000000..dfbabe6e263
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-140-add-update-available-indicator-email-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Surface an "Update available" indicator on the email list page that links into the editor with a deep-link review parameter so merchants can see which transactional emails have customizations diverging from the current core template.
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/build-email-editor-review-url.test.ts b/plugins/woocommerce/client/admin/client/settings-email/__tests__/build-email-editor-review-url.test.ts
new file mode 100644
index 00000000000..d7087868363
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/build-email-editor-review-url.test.ts
@@ -0,0 +1,40 @@
+/**
+ * Unit tests for the wc_email_review_drawer URL helper. RSM-140.
+ */
+
+/**
+ * Internal dependencies
+ */
+import {
+	buildEmailEditorReviewUrl,
+	REVIEW_DRAWER_PARAM,
+} from '../build-email-editor-review-url';
+
+describe( 'buildEmailEditorReviewUrl', () => {
+	it( 'returns a relative post.php URL with post, action, and the review-drawer param', () => {
+		const url = buildEmailEditorReviewUrl( 123 );
+
+		expect( url ).toContain( 'post.php?' );
+		expect( url ).toContain( 'post=123' );
+		expect( url ).toContain( 'action=edit' );
+		expect( url ).toContain( `${ REVIEW_DRAWER_PARAM }=1` );
+	} );
+
+	it( 'exports the param name as a stable constant', () => {
+		expect( REVIEW_DRAWER_PARAM ).toBe( 'wc_email_review_drawer' );
+	} );
+
+	it( 'throws on a non-positive id', () => {
+		expect( () => buildEmailEditorReviewUrl( 0 ) ).toThrow();
+		expect( () => buildEmailEditorReviewUrl( -1 ) ).toThrow();
+		// @ts-expect-error: deliberately wrong type to verify guard.
+		expect( () => buildEmailEditorReviewUrl( undefined ) ).toThrow();
+	} );
+
+	it( 'encodes via URLSearchParams (no hand-rolled concat)', () => {
+		const url = buildEmailEditorReviewUrl( 9999 );
+		expect( url ).toMatch(
+			/post=9999&action=edit&wc_email_review_drawer=1$/
+		);
+	} );
+} );
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
new file mode 100644
index 00000000000..2a2a02e5875
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
@@ -0,0 +1,160 @@
+/**
+ * Component tests for <UpdatesCell> — RSM-140 acceptance criteria.
+ */
+
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import type { EmailType } from '../settings-email-listing-slotfill';
+import { UpdatesCell } from '../settings-email-listing-update-cell';
+
+jest.mock( '@woocommerce/settings', () => ( {
+	getAdminLink: ( path: string ) => `https://example.test/wp-admin/${ path }`,
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+	Button: ( {
+		children,
+		onClick,
+		...rest
+	}: {
+		children: React.ReactNode;
+		onClick?: () => void;
+	} & Record< string, unknown > ) => (
+		<button onClick={ onClick } { ...rest }>
+			{ children }
+		</button>
+	),
+} ) );
+
+const baseEmail: EmailType = {
+	id: 'new-order',
+	post_id: '123',
+	title: 'New order',
+	description: 'Notifies admins when a new order is placed.',
+	enabled: true,
+	manual: false,
+	email_key: 'new_order',
+	recipients: { to: '', cc: '', bcc: '' },
+	status: 'enabled',
+	templateStatus: null,
+	templateVersion: null,
+};
+
+describe( '<UpdatesCell>', () => {
+	let originalLocation: Location;
+
+	beforeEach( () => {
+		originalLocation = window.location;
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		delete ( window as any ).location;
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		( window as any ).location = {
+			...originalLocation,
+			href: '',
+			assign: jest.fn(),
+		};
+	} );
+
+	afterEach( () => {
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		( window as any ).location = originalLocation;
+	} );
+
+	it( 'renders a Review update button when status is core_updated_customized', () => {
+		render(
+			<UpdatesCell
+				post={ {
+					...baseEmail,
+					templateStatus: 'core_updated_customized',
+				} }
+			/>
+		);
+
+		expect(
+			screen.getByRole( 'button', { name: /review update/i } )
+		).toBeInTheDocument();
+	} );
+
+	it.each( [ [ 'in_sync' ], [ 'core_updated_uncustomized' ], [ null ] ] )(
+		'renders an em-dash with Up to date label when status is %s',
+		( status ) => {
+			render(
+				<UpdatesCell
+					post={ {
+						...baseEmail,
+						templateStatus: status as EmailType[ 'templateStatus' ],
+					} }
+				/>
+			);
+
+			expect(
+				screen.queryByRole( 'button', { name: /review update/i } )
+			).not.toBeInTheDocument();
+
+			expect( screen.getByLabelText( /up to date/i ) ).toHaveTextContent(
+				'—'
+			);
+		}
+	);
+
+	it( 'falls through to em-dash for an unexpected status string', () => {
+		render(
+			<UpdatesCell
+				post={ {
+					...baseEmail,
+					// Cast to bypass the union for the defensive-default test.
+					templateStatus:
+						'something_unexpected' as unknown as EmailType[ 'templateStatus' ],
+				} }
+			/>
+		);
+
+		expect(
+			screen.queryByRole( 'button', { name: /review update/i } )
+		).not.toBeInTheDocument();
+		expect( screen.getByLabelText( /up to date/i ) ).toBeInTheDocument();
+	} );
+
+	it( 'navigates to the editor with wc_email_review_drawer=1 on click', () => {
+		render(
+			<UpdatesCell
+				post={ {
+					...baseEmail,
+					templateStatus: 'core_updated_customized',
+				} }
+			/>
+		);
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: /review update/i } )
+		);
+
+		expect( window.location.href ).toMatch(
+			/\/wp-admin\/post\.php\?post=123&action=edit&wc_email_review_drawer=1$/
+		);
+	} );
+
+	it( 'does nothing on click when post_id is empty', () => {
+		render(
+			<UpdatesCell
+				post={ {
+					...baseEmail,
+					post_id: '',
+					templateStatus: 'core_updated_customized',
+				} }
+			/>
+		);
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: /review update/i } )
+		);
+
+		expect( window.location.href ).toBe( '' );
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/build-email-editor-review-url.ts b/plugins/woocommerce/client/admin/client/settings-email/build-email-editor-review-url.ts
new file mode 100644
index 00000000000..906e7c289ba
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/build-email-editor-review-url.ts
@@ -0,0 +1,37 @@
+/**
+ * Build the relative editor URL fragment that opens an email post's editor with
+ * the review drawer auto-opened.
+ *
+ * Pair with `getAdminLink` from `@woocommerce/settings` to produce a full admin URL.
+ *
+ *   import { getAdminLink } from '@woocommerce/settings';
+ *   import { buildEmailEditorReviewUrl } from './build-email-editor-review-url';
+ *
+ *   window.location.href = getAdminLink( buildEmailEditorReviewUrl( postId ) );
+ *
+ * Public param contract — see RSM-140 spec § 5.4 (Linear: RSM-140, RSM-141; PR #64497).
+ * The consumer side that reads the param and dispatches `openReviewDrawer()` is
+ * owned by RSM-141 (editor banner) — out of scope for this PR.
+ *
+ * @param postId Positive integer post id of the woo_email post to open.
+ * @throws Error when postId is not a positive integer.
+ */
+export const REVIEW_DRAWER_PARAM = 'wc_email_review_drawer';
+
+export function buildEmailEditorReviewUrl( postId: number ): string {
+	if ( ! Number.isInteger( postId ) || postId <= 0 ) {
+		throw new Error(
+			`buildEmailEditorReviewUrl: postId must be a positive integer (got ${ String(
+				postId
+			) }).`
+		);
+	}
+
+	const params = new URLSearchParams( {
+		post: String( postId ),
+		action: 'edit',
+		[ REVIEW_DRAWER_PARAM ]: '1',
+	} );
+
+	return `post.php?${ params.toString() }`;
+}
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 7d1762c6449..9afc2a4f3f4 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
@@ -12,9 +12,23 @@ import { View } from '@wordpress/dataviews/wp'; // eslint-disable-line @woocomme
 /**
  * Internal dependencies
  */
-import { EmailType, EmailStatus } from './settings-email-listing-slotfill';
+import {
+	EmailType,
+	EmailStatus,
+	TemplateStatus,
+} from './settings-email-listing-slotfill';
 import { getAdminSetting } from '~/utils/admin-settings';

+/**
+ * Allowlist of valid template status values. Defined once at module scope so a
+ * future status addition only requires one update site.
+ */
+const VALID_TEMPLATE_STATUSES: readonly TemplateStatus[] = [
+	'in_sync',
+	'core_updated_uncustomized',
+	'core_updated_customized',
+] as const;
+
 type EmailListingRecreateEmailPostResponse = {
 	message: string;
 	post_id: string;
@@ -69,10 +83,34 @@ export const useTransactionalEmails = (
 				if ( emailType.manual ) {
 					status = 'manual';
 				}
+
+				// RSM-140: project template-status and template-version meta auto-
+				// surfaced under `meta` in the wp/v2/woo_email REST response (the
+				// post type declares 'custom-fields' support). Read-only.
+				const meta = (
+					post as { meta?: Record< string, unknown > } | null
+				 )?.meta;
+				const rawStatus = meta?._wc_email_template_status;
+				const templateStatus: TemplateStatus | null =
+					typeof rawStatus === 'string' &&
+					( VALID_TEMPLATE_STATUSES as readonly string[] ).includes(
+						rawStatus
+					)
+						? ( rawStatus as TemplateStatus )
+						: null;
+
+				const rawVersion = meta?._wc_email_template_version;
+				const templateVersion: string | null =
+					typeof rawVersion === 'string' && rawVersion.length > 0
+						? rawVersion
+						: null;
+
 				return {
 					...emailType,
 					link: post?.link || '',
 					status: status as EmailStatus,
+					templateStatus,
+					templateVersion,
 				};
 			} ),
 		[ emailTypesData, emailPosts, postIdsMap ]
@@ -163,6 +201,24 @@ export const useTransactionalEmails = (
 		);
 	} );

+	// Apply Updates Filter (RSM-140)
+	filteredEmails = filteredEmails.filter( ( email ) => {
+		const updatesFilter = view.filters.find(
+			( filter: View.Filter ) => filter.field === 'updates'
+		);
+		if ( ! updatesFilter || ! updatesFilter.value ) {
+			return true;
+		}
+		const selected = Array.isArray( updatesFilter.value )
+			? ( updatesFilter.value as string[] )
+			: [ updatesFilter.value as string ];
+		const emailValue =
+			email.templateStatus === 'core_updated_customized'
+				? 'available'
+				: 'none';
+		return selected.includes( emailValue );
+	} );
+
 	// Apply pagination
 	const startIndex = ( view.page - 1 ) * view.perPage;
 	const endIndex = startIndex + view.perPage;
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 e7dc7176492..8d1c54162a0 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
@@ -16,12 +16,13 @@ import { EmailType } from './settings-email-listing-slotfill';
 import { useTransactionalEmails } from './settings-email-listing-data';
 import { Status, EMAIL_STATUSES } from './settings-email-listing-status';
 import { RecipientsList } from './settings-email-listing-recipients';
+import { UpdatesCell } from './settings-email-listing-update-cell';

 export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 	const [ view, setView ] = useState< View >( {
 		type: 'table',
 		search: '',
-		fields: [ 'recipients', 'status' ],
+		fields: [ 'recipients', 'status', 'updates' ],
 		filters: [],
 		page: 1,
 		perPage: 20,
@@ -104,6 +105,33 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 				},
 				elements: EMAIL_STATUSES,
 			},
+			{
+				id: 'updates',
+				label: __( 'Updates', 'woocommerce' ),
+				enableHiding: true,
+				enableSorting: false,
+				getValue: ( { item }: { item: EmailType } ) =>
+					item.templateStatus === 'core_updated_customized'
+						? 'available'
+						: 'none',
+				elements: [
+					{
+						value: 'available',
+						label: __( 'Update available', 'woocommerce' ),
+					},
+					{
+						value: 'none',
+						label: __( 'Up to date', 'woocommerce' ),
+					},
+				],
+				filterBy: {
+					operators: [ 'is' ],
+					isPrimary: true,
+				},
+				render: ( { item }: { item: EmailType } ) => (
+					<UpdatesCell post={ item } />
+				),
+			},
 		];
 	}, [ emailTypes ] );

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 efd790085a0..8df8a061b7a 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
@@ -19,6 +19,18 @@ export type Recipients = {

 export type EmailStatus = 'enabled' | 'disabled' | 'manual';

+/**
+ * Classification of an email post relative to the current core template.
+ *
+ * Sourced from `_wc_email_template_status` post meta (RSM-138), auto-surfaced
+ * under `meta` in the `wp/v2/woo_email` REST response. Read-only client-side.
+ * Public REST API contract — see RSM-140 spec § 4.3.
+ */
+export type TemplateStatus =
+	| 'in_sync'
+	| 'core_updated_uncustomized'
+	| 'core_updated_customized';
+
 export type EmailType = {
 	title: string;
 	description: string;
@@ -30,6 +42,8 @@ export type EmailType = {
 	manual: boolean;
 	link?: string;
 	status?: EmailStatus;
+	templateStatus: TemplateStatus | null;
+	templateVersion: 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
new file mode 100644
index 00000000000..1a5b53d04cf
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
@@ -0,0 +1,49 @@
+/**
+ * Internal dependencies
+ */
+import type { EmailType } from './settings-email-listing-slotfill';
+import { UpdatesCell } from './settings-email-listing-update-cell';
+
+const baseEmail: EmailType = {
+	id: 'new-order',
+	post_id: '1',
+	title: 'New order',
+	description: 'Notifies admins when a new order is placed.',
+	enabled: true,
+	manual: false,
+	email_key: 'new_order',
+	recipients: { to: '', cc: '', bcc: '' },
+	templateStatus: null,
+	templateVersion: null,
+};
+
+export const CoreUpdatedCustomized = () => (
+	<UpdatesCell
+		post={ {
+			...baseEmail,
+			templateStatus: 'core_updated_customized',
+		} }
+	/>
+);
+
+export const InSync = () => (
+	<UpdatesCell post={ { ...baseEmail, templateStatus: 'in_sync' } } />
+);
+
+export const CoreUpdatedUncustomized = () => (
+	<UpdatesCell
+		post={ {
+			...baseEmail,
+			templateStatus: 'core_updated_uncustomized',
+		} }
+	/>
+);
+
+export const ThirdPartyNotOptedIn = () => (
+	<UpdatesCell post={ { ...baseEmail, templateStatus: null } } />
+);
+
+export default {
+	title: 'WooCommerce Admin/Settings · Email · Update indicator',
+	component: UpdatesCell,
+};
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
new file mode 100644
index 00000000000..7dfdababc6c
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
@@ -0,0 +1,59 @@
+/**
+ * <UpdatesCell> — RSM-140
+ *
+ * Renders one of two visual states inside the email list's "Updates" column:
+ *
+ *  - core_updated_customized → "Review update" Button (variant="secondary").
+ *    Click navigates to the email post's editor with `?wc_email_review_drawer=1`,
+ *    a stable param contract consumed by RSM-141 (editor banner) to auto-open
+ *    the review drawer.
+ *
+ *  - any other status (in_sync, core_updated_uncustomized, null/missing meta,
+ *    unexpected string) → em-dash placeholder with `aria-label="Up to date"`.
+ *
+ * The click handler is intentionally minimal and self-contained. RSM-144
+ * may layer a no-conflict /apply fast-path on top later.
+ */
+
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { getAdminLink } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import type { EmailType } from './settings-email-listing-slotfill';
+import { buildEmailEditorReviewUrl } from './build-email-editor-review-url';
+
+interface UpdatesCellProps {
+	post: EmailType;
+}
+
+export const UpdatesCell = ( { post }: UpdatesCellProps ) => {
+	if ( post.templateStatus !== 'core_updated_customized' ) {
+		return <span aria-label={ __( 'Up to date', 'woocommerce' ) }>—</span>;
+	}
+
+	const onReviewUpdate = () => {
+		// Defensive guard: EmailType.post_id is typed as string and may be
+		// empty for third-party emails without a generated woo_email post.
+		// The detector should never stamp _wc_email_template_status on such
+		// rows, but mirror the existing `edit` row-action pattern in the
+		// listview rather than rely on that invariant.
+		if ( ! post.post_id ) {
+			return;
+		}
+		window.location.href = getAdminLink(
+			buildEmailEditorReviewUrl( parseInt( post.post_id, 10 ) )
+		);
+	};
+
+	return (
+		<Button variant="secondary" onClick={ onReviewUpdate }>
+			{ __( 'Review update', 'woocommerce' ) }
+		</Button>
+	);
+};
diff --git a/plugins/woocommerce/client/admin/client/settings-email/style.scss b/plugins/woocommerce/client/admin/client/settings-email/style.scss
index 3d1d2161a70..6bf88c67b84 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/style.scss
+++ b/plugins/woocommerce/client/admin/client/settings-email/style.scss
@@ -359,3 +359,4 @@ $wc-setting-email-width: 634px;
 		margin-bottom: 0;
 	}
 }
+
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index 7410c5742f1..f71e6bfc2c0 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -148,6 +148,9 @@ class Integration {
 		add_action( 'woocommerce_email_editor_send_preview_email_after_wp_mail', array( $this, 'send_preview_email_after_wp_mail' ), 10 );
 		add_filter( 'woocommerce_email_editor_send_preview_email_subject', array( $this, 'update_email_subject_for_send_preview_email' ), 10, 2 );
 		add_action( 'rest_api_init', array( $this->email_api_controller, 'register_routes' ) );
+		// Priority 11 ensures the email editor's `init` bootstrap (default priority 10)
+		// has registered the `woo_email` post type before we register meta against it.
+		add_action( 'init', array( WCEmailTemplateDivergenceDetector::class, 'register_meta' ), 11 );
 		add_action( 'woocommerce_updated', array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 20 );
 		add_action( WCEmailTemplateSyncBackfill::BACKFILL_COMPLETE_ACTION, array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 10 );
 		add_action( WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK, array( WCEmailTemplateAutoApplier::class, 'run' ), 10 );
@@ -180,6 +183,7 @@ class Integration {
 						'default-mode' => 'template-locked',
 					),
 					'excerpt',
+					'custom-fields',
 				),
 				'capability_type' => self::EMAIL_POST_TYPE,
 				'capabilities'    => array(
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index 02d438eb93d..00e1da46701 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -85,6 +85,83 @@ 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.
+	 *
+	 * 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.
+	 *
+	 * Hook: `init`.
+	 *
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public static function register_meta(): void {
+		register_post_meta(
+			'woo_email',
+			self::STATUS_META_KEY,
+			array(
+				'type'              => 'string',
+				'description'       => 'Classification of this email post relative to the current core template ("in_sync", "core_updated_uncustomized", or "core_updated_customized"). Written server-side by the divergence detector and apply / reset flows; 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::VERSION_META_KEY,
+			array(
+				'type'              => 'string',
+				'description'       => 'Core template version stamp recorded the last time this email post was generated, applied, or reset. 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',
+			)
+		);
+	}
+
+	/**
+	 * REST auth gate for `_wc_email_template_*` meta.
+	 *
+	 * - Read: allowed for users who can edit `woo_email` posts (matches the email-list capability).
+	 * - Write: never allowed via REST. Meta is owned by server-side detection, apply, and reset flows.
+	 *
+	 * Signature follows the `auth_{$object_type}_meta_{$meta_key}` filter contract.
+	 *
+	 * @param bool   $allowed   Whether the request is allowed (current state).
+	 * @param string $meta_key  The meta key in question.
+	 * @param int    $object_id The post ID.
+	 * @param int    $user_id   The current user ID.
+	 * @param string $cap       The capability being requested.
+	 * @param array  $caps      The full set of caps the user must have.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public static function rest_meta_auth_read_only( $allowed, $meta_key, $object_id, $user_id, $cap, $caps ): bool {
+		unset( $allowed, $meta_key, $caps );
+
+		// Block all writes regardless of caller.
+		if ( in_array( $cap, array( 'edit_post_meta', 'add_post_meta', 'delete_post_meta' ), true ) ) {
+			return false;
+		}
+		// For reads, defer to whether the user can edit the post.
+		return user_can( $user_id, 'edit_post', $object_id );
+	}
+
 	/**
 	 * Run the post-upgrade divergence sweep.
 	 *
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 e6e6b764037..ecb02832632 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -4,6 +4,10 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;

+use Automattic\WooCommerce\EmailEditor\Bootstrap;
+use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\Package;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
@@ -52,6 +56,89 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 		WCEmailTemplateSyncRegistry::reset_cache();
 	}

+	/**
+	 * @testdox Should register _wc_email_template_status post meta on woo_email with show_in_rest.
+	 */
+	public function test_registers_template_status_meta_with_show_in_rest(): void {
+		$this->initialize_email_editor_integration();
+
+		$this->assertTrue(
+			registered_meta_key_exists( 'post', WCEmailTemplateDivergenceDetector::STATUS_META_KEY, 'woo_email' ),
+			'Expected _wc_email_template_status to be registered for woo_email.'
+		);
+
+		$args = get_registered_meta_keys( 'post', 'woo_email' )[ WCEmailTemplateDivergenceDetector::STATUS_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_template_version post meta on woo_email with show_in_rest.
+	 */
+	public function test_registers_template_version_meta_with_show_in_rest(): void {
+		$this->initialize_email_editor_integration();
+
+		$this->assertTrue(
+			registered_meta_key_exists( 'post', WCEmailTemplateDivergenceDetector::VERSION_META_KEY, 'woo_email' ),
+			'Expected _wc_email_template_version to be registered for woo_email.'
+		);
+
+		$args = get_registered_meta_keys( 'post', 'woo_email' )[ WCEmailTemplateDivergenceDetector::VERSION_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'] );
+	}
+
+	/**
+	 * @testdox Should deny REST writes to template meta even for administrators.
+	 */
+	public function test_meta_auth_callback_denies_write_via_rest(): void {
+		$admin_user = self::factory()->user->create( array( 'role' => 'administrator' ) );
+
+		$this->assertFalse(
+			WCEmailTemplateDivergenceDetector::rest_meta_auth_read_only( true, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, 0, $admin_user, 'edit_post_meta', array() ),
+			'Even an administrator must not be able to write _wc_email_template_status via REST.'
+		);
+
+		$this->assertFalse(
+			WCEmailTemplateDivergenceDetector::rest_meta_auth_read_only( true, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, 0, $admin_user, 'add_post_meta', array() ),
+			'add_post_meta must be denied via REST.'
+		);
+
+		$this->assertFalse(
+			WCEmailTemplateDivergenceDetector::rest_meta_auth_read_only( true, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, 0, $admin_user, 'delete_post_meta', array() ),
+			'delete_post_meta must be denied via REST.'
+		);
+	}
+
+	/**
+	 * @testdox Should allow REST reads of template meta for users who can edit the post.
+	 */
+	public function test_meta_auth_callback_allows_read_for_capable_user(): void {
+		// Ensure the woo_email post type is registered so user_can( 'edit_post' ) does not
+		// trip a doing-it-wrong notice about the post type being unregistered.
+		$this->initialize_email_editor_integration();
+
+		$admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+
+		$post_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'woo_email',
+				'post_status' => 'publish',
+				'post_author' => $admin_user_id,
+			)
+		);
+
+		$this->assertTrue(
+			WCEmailTemplateDivergenceDetector::rest_meta_auth_read_only( false, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, $post_id, $admin_user_id, 'read_post', array() ),
+			'Capable user must be able to read _wc_email_template_status via REST.'
+		);
+	}
+
 	/**
 	 * Cleanup after test.
 	 */
@@ -238,6 +325,32 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 		$this->assertSame( 1, $fired, 'Completion action must fire exactly once per sweep.' );
 	}

+	/**
+	 * Force-initialize the EmailEditor Integration and Bootstrap so the production
+	 * `init`-time hooks (notably `WCEmailTemplateDivergenceDetector::register_meta`)
+	 * register on the global hook table, the `woo_email` post type is registered, and
+	 * `init` fires so the meta-registration callback runs. Swallows the doing-it-wrong
+	 * notices that the full chain triggers when re-registering already-registered
+	 * blocks / integrations during a unit-test process; those notices are unrelated
+	 * to the meta-registration wiring under test.
+	 */
+	private function initialize_email_editor_integration(): void {
+		$this->setExpectedIncorrectUsage( 'WP_Block_Type_Registry::register' );
+		$this->setExpectedIncorrectUsage( 'Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry::register' );
+
+		add_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+		wc_get_container()->get( Package::class )->init();
+		wc_get_container()->get( Integration::class )->initialize();
+		Email_Editor_Container::container()->get( Bootstrap::class )->initialize();
+
+		/**
+		 * Fires once WordPress, all plugins, and the theme are fully loaded and instantiated.
+		 *
+		 * @since 1.5.0
+		 */
+		do_action( 'init' );
+	}
+
 	/**
 	 * Build a WC_Email stub backed by the third-party-with-version.php fixture, inject it
 	 * into WC_Emails::$emails, and opt the email ID into the block-editor filter so the
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php
new file mode 100644
index 00000000000..e273e05ec8f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateMetaRestExposureTest.php
@@ -0,0 +1,176 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\EmailEditor\Bootstrap;
+use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\Package;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use WP_REST_Request;
+
+/**
+ * REST integration coverage for `_wc_email_template_status` and `_wc_email_template_version`
+ * exposure on the `woo_email` post type.
+ *
+ * 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 registered via {@see WCEmailTemplateDivergenceDetector::register_meta()}
+ * under the standard `meta` property of the `wp/v2/woo_email` response. This
+ * test pins that exposure contract for the email list UI.
+ *
+ * Lives in a sibling class (rather than alongside the unit-level detector tests) so
+ * we can extend `WC_REST_Unit_Test_Case` and use the real REST stack.
+ *
+ * @group rest
+ * @group email-editor
+ */
+class WCEmailTemplateMetaRestExposureTest extends \WC_REST_Unit_Test_Case {
+
+	/**
+	 * Previous value of the email editor feature flag, captured in setUp() so we
+	 * can deterministically restore the original state in tearDown() and avoid
+	 * order-dependent failures when other tests touch the same option.
+	 *
+	 * @var string|false|null Either the previous option value (string), `false`
+	 *   when the option did not exist, or `null` before setUp() runs.
+	 */
+	private $previous_feature_flag_value = null;
+
+	/**
+	 * Setup test case.
+	 *
+	 * The bootstrap order matters: WC_REST_Unit_Test_Case::setUp() fires
+	 * `rest_api_init` after `parent::setUp()`, so the woo_email post type and its
+	 * meta must be registered before the parent setup runs — otherwise the post
+	 * type's REST routes never get registered and GET wp/v2/woo_email returns 404.
+	 */
+	public function setUp(): void {
+		$this->previous_feature_flag_value = get_option( 'woocommerce_feature_block_email_editor_enabled', false );
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+		wc_get_container()->get( Package::class )->init();
+		wc_get_container()->get( Integration::class )->initialize();
+		Email_Editor_Container::container()->get( Bootstrap::class )->initialize();
+
+		/**
+		 * Fires once WordPress, all plugins, and the theme are fully loaded and instantiated.
+		 *
+		 * @since 1.5.0
+		 */
+		do_action( 'init' );
+
+		parent::setUp();
+	}
+
+	/**
+	 * Cleanup after test.
+	 */
+	public function tearDown(): void {
+		if ( false === $this->previous_feature_flag_value ) {
+			delete_option( 'woocommerce_feature_block_email_editor_enabled' );
+		} else {
+			update_option( 'woocommerce_feature_block_email_editor_enabled', $this->previous_feature_flag_value );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should expose template status and version under `meta` in wp/v2/woo_email GET response.
+	 */
+	public function test_template_status_meta_visible_via_rest_get_post(): void {
+		$admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin_user_id );
+
+		$post_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'woo_email',
+				'post_status' => 'publish',
+				'post_author' => $admin_user_id,
+			)
+		);
+		update_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED
+		);
+		update_post_meta(
+			$post_id,
+			WCEmailTemplateDivergenceDetector::VERSION_META_KEY,
+			'9.4.0'
+		);
+
+		$request  = new WP_REST_Request( 'GET', "/wp/v2/woo_email/{$post_id}" );
+		$response = rest_do_request( $request );
+
+		$this->assertSame( 200, $response->get_status(), 'GET wp/v2/woo_email/{id} must succeed for an authenticated administrator.' );
+		$data = $response->get_data();
+
+		$this->assertArrayHasKey( 'meta', $data, 'wp/v2/woo_email response must include a meta property when the post type supports custom-fields.' );
+		$this->assertIsArray( $data['meta'], 'meta property must be an array.' );
+
+		$this->assertArrayHasKey(
+			WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+			$data['meta'],
+			'Status meta must be auto-surfaced under the meta property of the wp/v2/woo_email response.'
+		);
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+			$data['meta'][ WCEmailTemplateDivergenceDetector::STATUS_META_KEY ],
+			'Status meta value must reflect the stamped post meta.'
+		);
+
+		$this->assertArrayHasKey(
+			WCEmailTemplateDivergenceDetector::VERSION_META_KEY,
+			$data['meta'],
+			'Version meta must be auto-surfaced under the meta property of the wp/v2/woo_email response.'
+		);
+		$this->assertSame(
+			'9.4.0',
+			$data['meta'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ],
+			'Version meta value must reflect the stamped post meta.'
+		);
+	}
+
+	/**
+	 * @testdox Should return empty-string meta values when no meta is stamped.
+	 */
+	public function test_template_status_meta_returns_empty_when_unstamped(): void {
+		$admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin_user_id );
+
+		$post_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'woo_email',
+				'post_status' => 'publish',
+				'post_author' => $admin_user_id,
+			)
+		);
+
+		$request  = new WP_REST_Request( 'GET', "/wp/v2/woo_email/{$post_id}" );
+		$response = rest_do_request( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+		$data = $response->get_data();
+
+		$this->assertArrayHasKey( 'meta', $data );
+		$this->assertIsArray( $data['meta'] );
+
+		// WP core surfaces registered single-string meta with a default empty string when no value is stored.
+		// The JS data hook treats empty/missing/non-matching values as `null` — see VALID_TEMPLATE_STATUSES allowlist.
+		$this->assertArrayHasKey( WCEmailTemplateDivergenceDetector::STATUS_META_KEY, $data['meta'] );
+		$this->assertSame(
+			'',
+			$data['meta'][ WCEmailTemplateDivergenceDetector::STATUS_META_KEY ],
+			'Unstamped posts must surface an empty status (e.g. third-party emails not in the sync registry); the JS data hook normalises this to null.'
+		);
+
+		$this->assertArrayHasKey( WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $data['meta'] );
+		$this->assertSame(
+			'',
+			$data['meta'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ],
+			'Unstamped posts must surface an empty version; the JS data hook normalises this to null.'
+		);
+	}
+}