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.'
+ );
+ }
+}