Commit a5bbf97e4b1 for woocommerce
commit a5bbf97e4b1b71c9f62b4e590e9a486808331efd
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date: Tue May 5 20:45:50 2026 +0100
[Email Editor] Add selective apply for block email template updates (#64497)
* [Email Editor] Add selective apply + undo backend for template updates (RSM-143)
Backend foundation for the Review drawer's "Keep yours / Use core"
workflow. Lands the merger, the apply / undo REST surface, and the
deprecated-namespace migration — frontend (Review drawer component)
follows in a separate PR per the RSM-143 plan.
WCEmailTemplateSelectiveApplier:
- apply_selectively($post_id, $choices) — produces a merged post_content
that preserves the merchant's spine and layers core changes on top
per the v1 algorithm:
- copy_changes default keep_yours; merchant must explicitly opt
into use_core to discard their edit (matches the design's
"Default is always Keep yours").
- added_blocks always applied (auto-resolved Apply core); inserted
via core's path with best-effort fallback to closest-level append.
- removed_blocks always preserved (auto-resolved Keep yours).
- structural_changes (nest / reorder) punted in v1; surfaced as
structural_skipped: true so consumers can mention it. Conservative
— reorder transformations on customised trees risk surprising
layouts; revisit when telemetry shows demand.
- undo($post_id, $revision_id) — restores the pre-apply snapshot via
meta key _wc_email_template_pre_apply_snapshot. Single-step (each
apply overwrites the prior snapshot); a stale or missing revision_id
returns 410 Gone. Matches the design's toast Undo + footer "Revision
recorded for rollback."
- is_applying() flag mirrors WCEmailTemplateAutoApplier's so future
save_post listeners can ignore system-initiated writes.
- On success, stamps the four sync meta keys (version, source_hash,
synced_at, status=in_sync) — same as the auto-applier's wholesale
path. Invalidates WCEmailTemplateChangeSummary's transient cache so
the next read reflects the merged state.
Deprecated-namespace migration: an unconditional final pass rewrites
wp:woo/email-content to the canonical wp:woocommerce/email-content
(blockName + the wp-block-woo-email-content CSS class on the inner
div). Targeted to this single rename by design — woo/email-content is
a known alias of the canonical block, not a customisation worth
preserving. Surfaced on the response via aliases_migrated so consumers
can mention it in success copy. Migration is reversible — undo
restores the original namespace from the snapshot.
REST routes on EmailApiController:
- POST /woocommerce-email-editor/v1/emails/{id}/apply with
body { choices: [{path, decision}, ...] }. 404 path mirrors the
sibling routes; 422 fires when summarize() returns is_fallback (post
outside the sync registry, or the inversion guard tripped).
- POST /woocommerce-email-editor/v1/emails/{id}/undo with
body { revision_id }. 410 fires for missing or stale snapshots.
WCEmailTemplateChangeSummary::flatten_blocks() and lcs_matches()
promoted from private to public (still @internal-marked) so the
SelectiveApplier reuses the same matched-pair alignment without
re-implementing the algorithm. Internal-namespace-bounded; not part
of any external contract.
Tests: 14 service tests (keep_yours default, use_core text replace,
added_blocks insert, removed_blocks preserve, structural punt,
snapshot write, sync meta stamp, undo round-trip, undo 410 paths,
inversion-guard refusal, second-apply overwrites snapshot,
woo→woocommerce namespace migration, 404 for posts with no email
type) + 4 controller tests for the apply / undo routes. Total 35
tests / 218 assertions pass. Lint clean. PHPStan clean (no baseline
additions).
Verified end-to-end on a divergent wp-env post: per-conflict use_core
swaps the merchant's text for core's; auto-resolved Keep yours
preserves the merchant's custom blocks (gallery, images, group);
structural_skipped flagged when a Group wrap was punted; undo
round-trip is byte-identical; second undo with the same revision_id
returns 410.
Frontend (RSM-143 Part B — Review drawer component) lands separately.
* [Email Editor] Add Review drawer UI for selective template updates (RSM-143)
Frontend half of RSM-143. Adds an interim "Review template update" button
in the email actions slot that opens a 480px right-side drawer with the
change-summary diff and per-conflict Keep yours / Use core radios. Apply
calls POST /apply (backend in 3563f1451b), syncs the editor entity to
the merged content, and offers Undo via POST /undo from a snackbar.
The drawer renders via React portal to document.body because PluginArea
(scope: woocommerce-email-editor) mounts non-Slot output inside a hidden
wrapper, which would otherwise trap the fixed-position panel.
The button is interim — RSM-141 will replace it with the editor banner.
* Add @wordpress/ui@0.12.0 to client/admin
* [Email Editor] Rebuild Review drawer on @wordpress/ui Drawer + match design
Replace the hand-rolled drawer (portal, focus trap, key handlers, slide
animation, ~200 lines of skeleton CSS) with `@wordpress/ui`'s `Drawer`
primitive — Root, Popup, Header, Title, Description, Content, Footer,
CloseIcon, Action. Switch the trigger button to `@wordpress/ui` Button
(outline / neutral).
Restyle the body to match the handoff: dotted section heading (amber for
conflicts, brand blue for auto-resolved), per-row title + subtitle + tag
pill (Conflict amber / Apply core blueberry / Keep yours gray), the
two-up choice grid with label + hint cards instead of flat radios, and
the small "Revision recorded for rollback." note in the footer.
`Spinner` is the only `@wordpress/components` import that remains —
`@wordpress/ui` doesn't ship one yet.
* [Email Editor] Adopt @wordpress/ui Drawer compound + design polish
Drop the bespoke conflict tag span, item title/sub divs, and most of the
drawer chrome CSS in favour of `@wordpress/ui` `Badge`, `Text`, and
`Drawer.Action`. Apply uses `Drawer.Action loading={isApplying}` so the
compound's dismiss + aria-busy semantics stay consistent. Keep the
two-up choice cards with label + hint per the design — the picker's
two-line treatment doesn't fit `ToggleGroupControl`'s single-label API.
Pin the footer buttons to their natural width (`flex-shrink: 0` +
`white-space: nowrap`) so labels like "Apply (11)" stay on a single
line; the note absorbs the shrinkage instead.
* [Email Editor] Drive review drawer via integration store + drop trigger button
Add a small `@wordpress/data` store at `woocommerce/email-editor-integration`
that owns the review drawer's open / close state. Other surfaces — the
RSM-141 banner, future list-page row actions, browser console — open
the drawer with:
wp.data.dispatch( 'woocommerce/email-editor-integration' )
.openReviewDrawer();
This unblocks RSM-141, whose trigger button lives in a different React
subtree from the drawer.
The interim "Review template update" button + its `EmailActionsFill`
slotfill, change-summary prefetch, and the imports that supported them
are removed from `review-update-plugin.tsx`. The component now exists
only to mount the drawer at plugin scope and feed it the current post
ID + title; `isOpen` and `onOpenChange` come from the store.
* Add @wordpress/ui to wp-min catalog and use catalog ref
The package was previously pinned at `0.12.0` directly in
`client/admin/package.json`, which the syncpack pre-push hook flags as
inconsistent with the catalog pattern used for all other `@wordpress/*`
deps. Add the version to the `wp-min` catalog and switch the dependency
to `catalog:wp-min` so the rule passes.
* [Email Editor] Sync editor via receiveEntityRecords after server-side write
After both `/apply` and `/reset`, the server has already persisted the
new post_content. The previous flow then called `editEntityRecord` plus
`saveEditedEntityRecord({})` from the client — the only purpose of the
trailing save was to settle core-data's dirty state, which fires a
PUT/POST that re-uploads content the server already has.
Replace it with `receiveEntityRecords`. The action pushes the new
canonical content into core-data's cache directly, and its reducer
(`packages/core-data/src/reducer.js`, `RECEIVE_ITEMS` case in the
`edits` slice) auto-clears any pending edit whose value matches the new
canonical — so the post stays consistent without an extra REST round
trip. `useEntityBlockEditor` re-reads from the updated record on the
next render, so the canvas refreshes without a page reload.
Per-file:
- `hooks/use-apply-update.ts` — drop `parse`/`serialize`, drop
`editEntityRecord`/`saveEditedEntityRecord`. Read the current record
via `select(coreStore).getEntityRecord` and dispatch
`receiveEntityRecords` with `{ ...current, content: { ...content, raw } }`.
`syncEditorState` is no longer async; both call sites drop their
awaits.
- `reset-notification-email-content.tsx` — same swap inside the action's
Reset confirm handler.
* [Email Editor] Fix STATUS_META_KEY drift after selective apply
Reviewer flagged on PR #64497 that `apply_selectively()` stamped
STATUS_IN_SYNC even when the merchant kept their own version (any
keep_yours choice or any preserved removed_block produces merged
content that still differs from canonical). Verified end-to-end: post
still had merchant text, but `_wc_email_template_status` ended up
in_sync — misleading until the next divergence sweep overwrites it.
Underlying cause: three writers (auto-applier, selective applier, undo)
each duplicated their own version of the classification rules, so any
time the rules diverged between writers, the meta drifted from reality.
The selective applier hard-coded STATUS_IN_SYNC; the undo path blindly
restored the snapshot's prior_status (which goes stale if core moves
between apply and undo); the auto-applier hard-coded STATUS_IN_SYNC
too (correct in practice today, but fragile if a partial-apply path is
ever added).
Fix:
- Extract `WCEmailTemplateDivergenceDetector::reclassify( int \$post_id )`
as the single owner of STATUS_META_KEY writes. Runs the existing
classifier against current post / canonical / source-hash state and
stamps the truthful status. \`run_sweep()\` delegates to it per post.
- Selective applier \`apply_selectively()\` and \`undo()\` route their
status writes through \`reclassify()\` instead of literals; auto-applier
follows the same pattern for consistency. \`prior_status\` drops out
of the snapshot — recomputing on undo is more correct than restoring
a stale value.
- Document STATUS_META_KEY and VERSION_META_KEY as orthogonal signals.
STATUS_META_KEY answers "does the post differ from canonical right
now?". VERSION_META_KEY answers "has the merchant reviewed the
current core update?". RSM-141's banner reads VERSION_META_KEY, which
closes the related concern that a deliberate keep-yours apply would
otherwise leave the indicator on forever (post still differs from
canonical → STATUS = core_updated_customized, but VERSION = current,
so no banner).
Tests pin: extracted classifier helper (in_sync + customized paths),
reviewer's empty-choices keep-yours scenario, undo-after-core-release,
and auto-applier-via-classifier contract.
* [Email Editor] Add changelog entry for status-meta classifier fix
* [Email Editor] Note alias migration in merged_content schema doc
The namespace-alias rewrite inside `apply_selectively()` runs
unconditionally — even when every choice was `keep_yours` and no other
change applied. Without a note in the schema, a downstream consumer
reading `get_apply_schema()` could assume all keep_yours = no-op and be
wrong on posts with the legacy alias.
Add one sentence to `merged_content`'s description pointing at
`aliases_migrated` so consumers know when this can happen.
Reviewer feedback: PR #64497 r3169878311.
* [Email Editor] Pin the closest-level fallback in insert_block_at_path
The "best-effort, append at closest level" fallback in
`insert_block_at_path()` was verified by docblock only — every existing
`added_blocks` test case has the post and core trees aligned up to the
insertion point, so the fallback never executed.
The fallback also isn't reachable through `apply_selectively()` end-to-end:
the public flow's LCS + structural-wrapper skip consume the only
realistic deep-path scenarios before the fallback can fire. The new test
reaches into the private helper via reflection — same pattern the
PHP-tests CLAUDE.md allows when the public API can't reach a contract
worth pinning.
Asserts: when an intermediate path index doesn't exist on the post side,
the new block is appended to the closest reachable level, not dropped.
A future refactor that turned the append into a skip would fail loudly.
Reviewer feedback: PR #64497 r3169878318.
* [Email Editor] Use sentence case for the Conflict badge label
Per the WooCommerce copy guidelines, source i18n strings should be
sentence case — translators see the source, and many target locales
don't have a meaningful all-caps form. Sibling Badge labels in the
same row (`Apply core`, `Keep yours`) are already sentence case;
`CONFLICT` was the lone outlier.
Visual stays as it was (`Badge` from `@wordpress/ui` does not
auto-uppercase), so this is a pure i18n fix with no design impact.
Reviewer feedback: PR #64497 r3169878324.
* [Email Editor] Stamp source_hash from saved post_content
Classifier hashes the persisted post_content, but writers stamped the
in-memory string before WordPress ran content_save_pre filters. The
mismatch caused false drift on every classifier read.
Now hash get_post()->post_content after wp_update_post / wp_insert_post
returns at three sites: selective applier, auto-applier, and generator.
Adds one regression test in the selective applier suite that installs
a deliberately-mutating content_save_pre filter and asserts the stored
hash equals sha1(saved post_content).
Backfill is intentionally skipped. Case C must keep stamping
sha1(canonical) so the next core bump correctly surfaces customized
posts as core_updated_customized; sha1(saved) would silently downgrade
them to core_updated_uncustomized and erase the customization signal.
* [Email Editor] Drop @wordpress/ui, restore @wordpress/components drawer
@wordpress/ui@0.12.0 pulls in @wordpress/theme, which calls
__dangerouslyOptInToUnstableAPIsOnlyForCoreModules at module load.
WordPress 6.8.x's private-apis registry doesn't list @wordpress/theme,
so the opt-in throws and the email editor never mounts.
Drop the @wordpress/ui dep and rebuild the Review drawer's chrome with
a hand-rolled portal, semantic HTML pills, and @wordpress/components
Button. Keep the integration store, ChoiceCard 2-up grid, and entity
sync untouched. Also remove a stale changelog file that the new
classifier routing supersedes.
* [Email Editor] Prevent auto-overwrite after keep_yours apply
Two audit-driven fixes for the email-template sync subsystem.
Selective applier keep_yours: stamping sha1(saved post_content) made
the next divergence sweep see "no merchant edits since sync" →
STATUS_CORE_UPDATED_UNCUSTOMIZED → auto-applier silently overwrote
the merchant's customisation on the next core bump. Now branch the
stamp: aligned with canonical → sha1(saved); diverges → sha1(canonical)
and hard-stamp STATUS_CORE_UPDATED_CUSTOMIZED so the auto-applier
(filtered to UNCUSTOMIZED) skips the post.
Generator: stamp STATUS_META_KEY = STATUS_IN_SYNC at insert. Posts
previously had no status until the first sweep, breaking UI surfaces
that expected a populated value.
Adds a regression test for the keep_yours path and updates existing
tests whose preconditions shifted.
* [Email Editor] Auto-open review drawer from URL param
* [Email Editor] Clear stale change-summary state on disable or postId change
* [Email Editor] Return persisted post_content from apply_to_post
diff --git a/plugins/woocommerce/changelog/rsm-143-selective-apply b/plugins/woocommerce/changelog/rsm-143-selective-apply
new file mode 100644
index 00000000000..caed2ca2cc0
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-143-selective-apply
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add selective apply + undo REST endpoints for block email template updates.
diff --git a/plugins/woocommerce/changelog/rsm-143-selective-apply-drawer b/plugins/woocommerce/changelog/rsm-143-selective-apply-drawer
new file mode 100644
index 00000000000..3d2116109d7
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-143-selective-apply-drawer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add Review drawer UI for selectively applying block email template updates.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts
new file mode 100644
index 00000000000..aa3edc2eb63
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-apply-update.ts
@@ -0,0 +1,178 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import apiFetch from '@wordpress/api-fetch';
+import { select, useDispatch } from '@wordpress/data';
+import { store as noticesStore } from '@wordpress/notices';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Shape of an entry on the `choices` array sent to /apply.
+ */
+export interface ApplyChoice {
+ /** Path of a `copy_changes` entry from the change-summary. */
+ path: Array< number | string >;
+ /** `keep_yours` (default) or `use_core`. */
+ decision: 'keep_yours' | 'use_core';
+}
+
+/**
+ * Shape of the /apply response.
+ */
+interface ApplyResponse {
+ merged_content: string;
+ revision_id: string;
+ version_to: string;
+ status: 'applied';
+ structural_skipped: boolean;
+ aliases_migrated: string[];
+}
+
+/**
+ * Shape of the /undo response.
+ */
+interface UndoResponse {
+ restored_content: string;
+ status: 'restored';
+}
+
+interface UseApplyUpdateResult {
+ apply: ( choices: ApplyChoice[] ) => Promise< ApplyResponse | null >;
+ isApplying: boolean;
+}
+
+/**
+ * Drive the apply + undo flow for a `woo_email` post.
+ *
+ * On apply success: shows a snackbar with an Undo action wired to the
+ * `/undo` endpoint, syncs the editor's in-memory entity to the merged
+ * content (so the canvas reflects the apply without a page reload), and
+ * surfaces the migrated alias list in the snackbar copy when applicable.
+ *
+ * Sync uses `receiveEntityRecords` to push the server's freshly-saved
+ * content straight into core-data's cache. The reducer auto-clears any
+ * matching pending edits, so no follow-up `saveEditedEntityRecord`
+ * round-trip is needed.
+ *
+ * @param postId The `woo_email` post ID.
+ */
+export function useApplyUpdate( postId: number | null ): UseApplyUpdateResult {
+ const { createSuccessNotice, createErrorNotice } =
+ useDispatch( noticesStore );
+ const { receiveEntityRecords } = useDispatch( coreStore );
+ const [ isApplying, setIsApplying ] = useState< boolean >( false );
+
+ const syncEditorState = useCallback(
+ ( content: string ) => {
+ if ( ! postId ) {
+ return;
+ }
+ // Read the current canonical record so the patched record we
+ // hand to `receiveEntityRecords` keeps every other field
+ // (title, status, meta, …) intact. Only `content.raw` changes.
+ const current = select( coreStore ).getEntityRecord(
+ 'postType',
+ 'woo_email',
+ postId
+ ) as { content?: { raw?: string } } | undefined;
+ if ( ! current ) {
+ return;
+ }
+ receiveEntityRecords(
+ 'postType',
+ 'woo_email',
+ [
+ {
+ ...current,
+ content: { ...current.content, raw: content },
+ },
+ ],
+ undefined,
+ false,
+ undefined,
+ undefined
+ );
+ },
+ [ postId, receiveEntityRecords ]
+ );
+
+ const undo = useCallback(
+ async ( revisionId: string ) => {
+ if ( ! postId ) {
+ return;
+ }
+ try {
+ const res = ( await apiFetch( {
+ path: `/woocommerce-email-editor/v1/emails/${ postId }/undo`,
+ method: 'POST',
+ data: { revision_id: revisionId },
+ } ) ) as UndoResponse;
+
+ syncEditorState( res.restored_content );
+
+ createSuccessNotice( __( 'Update reverted.', 'woocommerce' ), {
+ type: 'snackbar',
+ } );
+ } catch ( err: unknown ) {
+ const message =
+ err && typeof err === 'object' && 'message' in err
+ ? String( err.message )
+ : __( 'Could not revert the update.', 'woocommerce' );
+ createErrorNotice( message, { type: 'snackbar' } );
+ }
+ },
+ [ postId, createSuccessNotice, createErrorNotice, syncEditorState ]
+ );
+
+ const apply = useCallback(
+ async ( choices: ApplyChoice[] ): Promise< ApplyResponse | null > => {
+ if ( ! postId ) {
+ return null;
+ }
+ setIsApplying( true );
+ try {
+ const res = ( await apiFetch( {
+ path: `/woocommerce-email-editor/v1/emails/${ postId }/apply`,
+ method: 'POST',
+ data: { choices },
+ } ) ) as ApplyResponse;
+
+ syncEditorState( res.merged_content );
+
+ createSuccessNotice( __( 'Update applied.', 'woocommerce' ), {
+ type: 'snackbar',
+ actions: [
+ {
+ label: __( 'Undo', 'woocommerce' ),
+ onClick: () => {
+ void undo( res.revision_id );
+ },
+ },
+ ],
+ } );
+
+ return res;
+ } catch ( err: unknown ) {
+ const message =
+ err && typeof err === 'object' && 'message' in err
+ ? String( err.message )
+ : __( 'Could not apply the update.', 'woocommerce' );
+ createErrorNotice( message, { type: 'snackbar' } );
+ return null;
+ } finally {
+ setIsApplying( false );
+ }
+ },
+ [
+ postId,
+ createSuccessNotice,
+ createErrorNotice,
+ undo,
+ syncEditorState,
+ ]
+ );
+
+ return { apply, isApplying };
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
new file mode 100644
index 00000000000..93e4b73ac19
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-change-summary.ts
@@ -0,0 +1,137 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useEffect, useState } from '@wordpress/element';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Shape of a single block-level entry on the `added_blocks` /
+ * `removed_blocks` arrays returned by the change-summary REST endpoint.
+ */
+export interface ChangeSummaryBlockEntry {
+ /** Post-alias-normalized block name, e.g. `core/heading`. */
+ name: string;
+ /** Humanized label for display, e.g. `Heading`. */
+ label: string;
+ /** Index path through the parsed block tree on the relevant side. */
+ path: Array< number | string >;
+}
+
+/**
+ * Shape of a single text-conflict entry on the `copy_changes` array.
+ */
+export interface ChangeSummaryCopyChange {
+ /** Humanized block label, e.g. `Paragraph`. */
+ block: string;
+ /** Merchant's current text, truncated to 120 chars. */
+ before: string;
+ /** Canonical core text, truncated to 120 chars. */
+ after: string;
+ /** 1-indexed position among matched blocks of this type. */
+ occurrence: number;
+ /** Total matched blocks of this type on the core side. */
+ total: number;
+ /** Post-side index path of the conflicting block. */
+ path: Array< number | string >;
+}
+
+/**
+ * Shape of a single structural-change entry.
+ */
+export interface ChangeSummaryStructuralChange {
+ /** `nest` or `reorder`. */
+ kind: string;
+ /** Pre-localized one-line description. */
+ description: string;
+ /** Index path of the affected block; absent for `kind: 'reorder'`. */
+ path?: Array< number | string >;
+}
+
+/**
+ * Full change-summary payload returned by
+ * `GET /woocommerce-email-editor/v1/emails/{id}/change-summary`.
+ */
+export interface ChangeSummary {
+ version_from: string;
+ version_to: string;
+ added_blocks: ChangeSummaryBlockEntry[];
+ removed_blocks: ChangeSummaryBlockEntry[];
+ copy_changes: ChangeSummaryCopyChange[];
+ structural_changes: ChangeSummaryStructuralChange[];
+ summary_lines: string[];
+ is_fallback: boolean;
+ cache_hit: boolean;
+}
+
+interface UseChangeSummaryResult {
+ summary: ChangeSummary | null;
+ isLoading: boolean;
+ error: Error | null;
+ refetch: () => void;
+}
+
+/**
+ * Fetch the change-summary for a `woo_email` post and re-render when it
+ * arrives. Aborts the in-flight request on unmount or post-id change.
+ *
+ * @param postId The `woo_email` post ID.
+ * @param enabled When false, no fetch is issued and `summary` stays null.
+ * Useful when the drawer is closed.
+ */
+export function useChangeSummary(
+ postId: number | null,
+ enabled: boolean
+): UseChangeSummaryResult {
+ const [ summary, setSummary ] = useState< ChangeSummary | null >( null );
+ const [ isLoading, setIsLoading ] = useState< boolean >( false );
+ const [ error, setError ] = useState< Error | null >( null );
+ const [ refreshKey, setRefreshKey ] = useState< number >( 0 );
+
+ const refetch = useCallback( () => {
+ setRefreshKey( ( k ) => k + 1 );
+ }, [] );
+
+ useEffect( () => {
+ if ( ! enabled || ! postId ) {
+ // Reset state so the drawer never renders a previous template's
+ // summary (or keeps Apply enabled) after the post-id changes or
+ // the drawer is re-disabled.
+ setSummary( null );
+ setError( null );
+ setIsLoading( false );
+ return;
+ }
+
+ let cancelled = false;
+ setSummary( null );
+ setIsLoading( true );
+ setError( null );
+
+ apiFetch< ChangeSummary >( {
+ path: `/woocommerce-email-editor/v1/emails/${ postId }/change-summary`,
+ } )
+ .then( ( res ) => {
+ if ( ! cancelled ) {
+ setSummary( res );
+ }
+ } )
+ .catch( ( err: unknown ) => {
+ if ( ! cancelled ) {
+ setError(
+ err instanceof Error ? err : new Error( String( err ) )
+ );
+ }
+ } )
+ .finally( () => {
+ if ( ! cancelled ) {
+ setIsLoading( false );
+ }
+ } );
+
+ return () => {
+ cancelled = true;
+ };
+ }, [ postId, enabled, refreshKey ] );
+
+ return { summary, isLoading, error, refetch };
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
index 87f5aedd04d..ecdfd872f00 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
@@ -3,8 +3,10 @@
/**
* External dependencies
*/
+import { dispatch } from '@wordpress/data';
import { addFilter, addAction } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
+import { registerPlugin } from '@wordpress/plugins';
import {
initializeEditor,
registerEntityAction,
@@ -18,6 +20,11 @@ import { modifyTemplateSidebar } from './templates';
import { modifySidebar } from './sidebar_settings';
import { registerEmailValidationRules } from './email-validation';
import getResetNotificationEmailContentAction from './reset-notification-email-content';
+import { ReviewUpdatePlugin } from './review-update-plugin';
+import {
+ registerStore as registerIntegrationStore,
+ STORE_NAME as INTEGRATION_STORE_NAME,
+} from './store';
import './style.scss';
@@ -69,10 +76,36 @@ addFilter( 'woocommerce_email_editor_create_coupon_handler', NAME_SPACE, () => {
};
} );
+// Register the integration's @wordpress/data store before any plugin
+// renders, so consumers (review drawer, future RSM-141 banner, etc.)
+// can dispatch into it from anywhere.
+registerIntegrationStore();
+
modifySidebar();
modifyTemplateSidebar();
registerEmailValidationRules();
+// Register the review-update plugin (RSM-143). Mounts the review drawer
+// into the email editor — its open / close state is driven by the
+// `woocommerce/email-editor-integration` store, so any other surface
+// (RSM-141 banner, list-page row action, browser console) can open it
+// via `wp.data.dispatch( 'woocommerce/email-editor-integration' )
+// .openReviewDrawer()`.
+registerPlugin( 'woocommerce-email-editor-review-update', {
+ scope: 'woocommerce-email-editor',
+ render: ReviewUpdatePlugin,
+} );
+
+// Deep-link contract: opens the review drawer when arriving with
+// `?wc_email_review_drawer=1` (set by the email list page's update indicator).
+if (
+ new URLSearchParams( window.location.search ).get(
+ 'wc_email_review_drawer'
+ ) === '1'
+) {
+ dispatch( INTEGRATION_STORE_NAME ).openReviewDrawer();
+}
+
/**
* Register the reset notification email content entity action for the woo_email post type.
* This action allows users to reset the email content to the original state as distributed by the plugin.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
index 2a4682f46dd..884e8eceafa 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/reset-notification-email-content.tsx
@@ -6,7 +6,7 @@ import { store as noticesStore } from '@wordpress/notices';
import { store as coreStore } from '@wordpress/core-data';
import { backup } from '@wordpress/icons';
import { useState } from '@wordpress/element';
-import { useDispatch } from '@wordpress/data';
+import { select, useDispatch } from '@wordpress/data';
import {
Button,
__experimentalText as Text,
@@ -14,7 +14,6 @@ import {
__experimentalVStack as VStack,
} from '@wordpress/components';
import { decodeEntities } from '@wordpress/html-entities';
-import { parse, serialize } from '@wordpress/blocks';
import apiFetch from '@wordpress/api-fetch';
// eslint-disable-next-line @woocommerce/dependency-group
@@ -70,8 +69,7 @@ const getResetNotificationEmailContentAction = () => {
const [ isBusy, setIsBusy ] = useState( false );
const { createSuccessNotice, createErrorNotice } =
useDispatch( noticesStore );
- const { editEntityRecord, saveEditedEntityRecord } =
- useDispatch( coreStore );
+ const { receiveEntityRecords } = useDispatch( coreStore );
const item = items[ 0 ];
const modalTitle = sprintf(
@@ -108,32 +106,40 @@ const getResetNotificationEmailContentAction = () => {
method: 'POST',
} ) ) as { content: string };
- // Server has already persisted post_content + sync meta.
- // Sync the editor's in-memory state so the user sees the
- // reset content without a page reload. The trailing
- // saveEditedEntityRecord is a content no-op (matches what
- // the server just wrote) but keeps core-data's dirty
- // tracking in a consistent state.
- const blocks = parse(
- response.content || ''
- );
-
- await editEntityRecord(
- 'postType',
- item.type,
- item.id,
- {
- blocks,
- content: serialize( blocks ),
- }
- );
-
- await saveEditedEntityRecord(
+ // Server has already persisted post_content + sync
+ // meta. Push the new canonical content into core-data
+ // via `receiveEntityRecords` so the editor refreshes
+ // without a page reload — the reducer auto-clears any
+ // matching pending edits, so no extra REST round-trip
+ // is needed.
+ const current = select(
+ coreStore
+ ).getEntityRecord(
'postType',
item.type,
- item.id,
- {}
- );
+ item.id
+ ) as
+ | { content?: { raw?: string } }
+ | undefined;
+ if ( current ) {
+ receiveEntityRecords(
+ 'postType',
+ item.type,
+ [
+ {
+ ...current,
+ content: {
+ ...current.content,
+ raw: response.content,
+ },
+ },
+ ],
+ undefined,
+ false,
+ undefined,
+ undefined
+ );
+ }
const successMessage = sprintf(
/* translators: The email's title. */
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx
new file mode 100644
index 00000000000..768d2735b74
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-drawer.tsx
@@ -0,0 +1,588 @@
+/**
+ * External dependencies
+ */
+import { createPortal, useEffect, useRef, useState } from '@wordpress/element';
+import { __, sprintf, _n } from '@wordpress/i18n';
+import { Button, Spinner } from '@wordpress/components';
+import { closeSmall } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import {
+ useChangeSummary,
+ type ChangeSummary,
+ type ChangeSummaryCopyChange,
+ type ChangeSummaryStructuralChange,
+} from './hooks/use-change-summary';
+import { useApplyUpdate, type ApplyChoice } from './hooks/use-apply-update';
+
+interface Props {
+ postId: number | null;
+ emailTitle: string;
+ isOpen: boolean;
+ onOpenChange: ( open: boolean ) => void;
+}
+
+type ChoiceMap = Record< string, 'keep_yours' | 'use_core' >;
+type AutoTag = 'apply_core' | 'keep_yours';
+
+/** Stable string key for a path array, used as the choice-map key. */
+function pathKey( path: Array< number | string > ): string {
+ return JSON.stringify( path );
+}
+
+/** Decorative leading dot for section headings (color-coded). */
+const SectionDot = ( { tone }: { tone: 'warning' | 'brand' } ) => (
+ <span
+ aria-hidden="true"
+ className={ `woocommerce-review-drawer__dot woocommerce-review-drawer__dot--${ tone }` }
+ />
+);
+
+/**
+ * Per-conflict choice card. Two cards live side-by-side in a 2-column
+ * grid; selecting one toggles the merchant's decision for that block.
+ * The label + hint sublabel comes from the design handoff —
+ * `ToggleGroupControl` only fits a single label, so we keep bespoke
+ * buttons with `role="radio"` for the same a11y semantics.
+ */
+const ChoiceCard = ( {
+ label,
+ hint,
+ active,
+ onClick,
+}: {
+ label: string;
+ hint: string;
+ active: boolean;
+ onClick: () => void;
+} ) => (
+ <button
+ type="button"
+ role="radio"
+ aria-checked={ active }
+ onClick={ onClick }
+ className={ [
+ 'woocommerce-review-drawer__choice-card',
+ active && 'is-active',
+ ]
+ .filter( Boolean )
+ .join( ' ' ) }
+ >
+ <span className="woocommerce-review-drawer__choice-label">
+ { label }
+ </span>
+ <span className="woocommerce-review-drawer__choice-hint">{ hint }</span>
+ </button>
+);
+
+const ConflictsGroup = ( {
+ conflicts,
+ choices,
+ onChoose,
+}: {
+ conflicts: ChangeSummaryCopyChange[];
+ choices: ChoiceMap;
+ onChoose: (
+ path: Array< number | string >,
+ decision: 'keep_yours' | 'use_core'
+ ) => void;
+} ) => {
+ if ( conflicts.length === 0 ) {
+ return null;
+ }
+
+ const heading = sprintf(
+ /* translators: %d: number of conflicts. */
+ _n(
+ 'Needs your attention · %d conflict',
+ 'Needs your attention · %d conflicts',
+ conflicts.length,
+ 'woocommerce'
+ ),
+ conflicts.length
+ );
+
+ return (
+ <section
+ className="woocommerce-review-drawer__group"
+ aria-labelledby="woocommerce-review-drawer-conflicts-heading"
+ >
+ <h3
+ id="woocommerce-review-drawer-conflicts-heading"
+ className="woocommerce-review-drawer__group-h"
+ >
+ <SectionDot tone="warning" />
+ { heading }
+ </h3>
+ { conflicts.map( ( conflict ) => {
+ const key = pathKey( conflict.path );
+ const decision = choices[ key ] ?? 'keep_yours';
+ const blockTitle =
+ conflict.total > 1
+ ? sprintf(
+ /* translators: 1: block name; 2: occurrence; 3: total. */
+ __( '%1$s %2$d of %3$d', 'woocommerce' ),
+ conflict.block,
+ conflict.occurrence,
+ conflict.total
+ )
+ : conflict.block;
+
+ return (
+ <div
+ key={ key }
+ className="woocommerce-review-drawer__item"
+ >
+ <div className="woocommerce-review-drawer__item-h">
+ <h4 className="woocommerce-review-drawer__item-title">
+ { blockTitle }
+ </h4>
+ <span className="woocommerce-review-drawer__tag woocommerce-review-drawer__tag--conflict">
+ { __( 'Conflict', 'woocommerce' ) }
+ </span>
+ </div>
+ <p className="woocommerce-review-drawer__item-sub">
+ { __(
+ 'Core changed this text. Pick which version to keep.',
+ 'woocommerce'
+ ) }
+ </p>
+ <div
+ className="woocommerce-review-drawer__diff"
+ role="group"
+ aria-label={ __( 'Diff', 'woocommerce' ) }
+ >
+ <div className="woocommerce-review-drawer__diff-row woocommerce-review-drawer__diff-row--minus">
+ { conflict.before }
+ </div>
+ <div className="woocommerce-review-drawer__diff-row woocommerce-review-drawer__diff-row--plus">
+ { conflict.after }
+ </div>
+ </div>
+ <div
+ className="woocommerce-review-drawer__choice"
+ role="radiogroup"
+ aria-label={ __(
+ 'Choose which version to apply',
+ 'woocommerce'
+ ) }
+ >
+ <ChoiceCard
+ label={ __( 'Keep yours', 'woocommerce' ) }
+ hint={ __( 'Default · safe', 'woocommerce' ) }
+ active={ decision === 'keep_yours' }
+ onClick={ () =>
+ onChoose( conflict.path, 'keep_yours' )
+ }
+ />
+ <ChoiceCard
+ label={ __( 'Use core', 'woocommerce' ) }
+ hint={ __(
+ 'Discard your edit',
+ 'woocommerce'
+ ) }
+ active={ decision === 'use_core' }
+ onClick={ () =>
+ onChoose( conflict.path, 'use_core' )
+ }
+ />
+ </div>
+ </div>
+ );
+ } ) }
+ </section>
+ );
+};
+
+const AutoResolvedItem = ( {
+ title,
+ sub,
+ tag,
+}: {
+ title: string;
+ sub: string;
+ tag: AutoTag;
+} ) => (
+ <div className="woocommerce-review-drawer__item">
+ <div className="woocommerce-review-drawer__item-h">
+ <h4 className="woocommerce-review-drawer__item-title">{ title }</h4>
+ <span
+ className={ [
+ 'woocommerce-review-drawer__tag',
+ `woocommerce-review-drawer__tag--${
+ tag === 'apply_core' ? 'apply-core' : 'keep-yours'
+ }`,
+ ].join( ' ' ) }
+ >
+ { tag === 'apply_core'
+ ? __( 'Apply core', 'woocommerce' )
+ : __( 'Keep yours', 'woocommerce' ) }
+ </span>
+ </div>
+ <p className="woocommerce-review-drawer__item-sub">{ sub }</p>
+ </div>
+);
+
+const AutoResolvedGroup = ( { summary }: { summary: ChangeSummary } ) => {
+ const total =
+ summary.added_blocks.length +
+ summary.removed_blocks.length +
+ summary.structural_changes.length;
+
+ if ( total === 0 ) {
+ return null;
+ }
+
+ const heading = sprintf(
+ /* translators: %d: number of auto-resolved blocks. */
+ _n(
+ 'Auto-resolved · %d block',
+ 'Auto-resolved · %d blocks',
+ total,
+ 'woocommerce'
+ ),
+ total
+ );
+
+ return (
+ <section
+ className="woocommerce-review-drawer__group"
+ aria-labelledby="woocommerce-review-drawer-auto-heading"
+ >
+ <h3
+ id="woocommerce-review-drawer-auto-heading"
+ className="woocommerce-review-drawer__group-h"
+ >
+ <SectionDot tone="brand" />
+ { heading }
+ </h3>
+
+ { summary.added_blocks.map( ( entry ) => (
+ <AutoResolvedItem
+ key={ `added-${ pathKey( entry.path ) }` }
+ title={ entry.label }
+ sub={ __(
+ 'Added by core. Will appear in your email.',
+ 'woocommerce'
+ ) }
+ tag="apply_core"
+ />
+ ) ) }
+ { summary.removed_blocks.map( ( entry ) => (
+ <AutoResolvedItem
+ key={ `removed-${ pathKey( entry.path ) }` }
+ title={ entry.label }
+ sub={ __(
+ 'Not in core. Your block is preserved.',
+ 'woocommerce'
+ ) }
+ tag="keep_yours"
+ />
+ ) ) }
+ { summary.structural_changes.map(
+ ( change: ChangeSummaryStructuralChange, idx: number ) => (
+ <AutoResolvedItem
+ key={ `structural-${ idx }` }
+ title={ change.description }
+ sub={ __(
+ 'Structural change applied automatically.',
+ 'woocommerce'
+ ) }
+ tag="apply_core"
+ />
+ )
+ ) }
+ </section>
+ );
+};
+
+/**
+ * Review drawer — surfaces the change-summary diff and lets the merchant
+ * pick per-conflict "Keep yours / Use core" choices, then commits via the
+ * /apply endpoint.
+ *
+ * Hand-rolled drawer (right-side, 480px, scrim, slide animation, focus
+ * trap, Escape close) rendered via `createPortal` to `document.body` so
+ * the fixed-position panel isn't trapped inside the `display: none`
+ * `<PluginArea scope="woocommerce-email-editor">` wrapper. The choice
+ * picker is the bespoke `ChoiceCard` two-up grid (the design's two-line
+ * label + hint doesn't fit `ToggleGroupControl`'s single-label API);
+ * tag pills and typography are plain `<span>` / `<h*>` / `<p>` styled
+ * via SCSS.
+ */
+export const ReviewDrawer = ( {
+ postId,
+ emailTitle,
+ isOpen,
+ onOpenChange,
+}: Props ) => {
+ const drawerRef = useRef< HTMLDivElement >( null );
+ const previousFocusRef = useRef< HTMLElement | null >( null );
+
+ const [ choices, setChoices ] = useState< ChoiceMap >( {} );
+ const { summary, isLoading, error } = useChangeSummary( postId, isOpen );
+ const { apply, isApplying } = useApplyUpdate( postId );
+
+ // Reset choices whenever a new diff is loaded.
+ useEffect( () => {
+ if ( summary ) {
+ setChoices( {} );
+ }
+ }, [ summary ] );
+
+ // Focus management — save the previously focused element on open,
+ // move focus into the panel, restore on close.
+ useEffect( () => {
+ let rafId1: number;
+ let rafId2: number;
+ if ( isOpen ) {
+ const drawerElement = drawerRef.current;
+ if ( drawerElement ) {
+ previousFocusRef.current = drawerElement.ownerDocument
+ .activeElement as HTMLElement;
+ rafId1 = requestAnimationFrame( () => {
+ rafId2 = requestAnimationFrame( () => {
+ drawerElement.focus();
+ } );
+ } );
+ }
+ } else if ( previousFocusRef.current?.isConnected ) {
+ previousFocusRef.current.focus();
+ }
+ return () => {
+ cancelAnimationFrame( rafId1 );
+ cancelAnimationFrame( rafId2 );
+ };
+ }, [ isOpen ] );
+
+ // Escape closes; Tab/Shift+Tab traps inside the drawer.
+ useEffect( () => {
+ const handleKeyDown = ( event: KeyboardEvent ) => {
+ if ( ! isOpen ) {
+ return;
+ }
+ if ( event.key === 'Escape' ) {
+ onOpenChange( false );
+ return;
+ }
+ if ( event.key === 'Tab' ) {
+ const drawerElement = drawerRef.current;
+ if ( ! drawerElement ) {
+ return;
+ }
+ const focusable = drawerElement.querySelectorAll(
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])'
+ );
+ if ( focusable.length === 0 ) {
+ return;
+ }
+ const first = focusable[ 0 ] as HTMLElement;
+ const last = focusable[ focusable.length - 1 ] as HTMLElement;
+ const active = drawerElement.ownerDocument
+ .activeElement as HTMLElement;
+ if ( event.shiftKey ) {
+ if ( active === first || active === drawerElement ) {
+ event.preventDefault();
+ last?.focus();
+ }
+ } else if ( active === last ) {
+ event.preventDefault();
+ first?.focus();
+ }
+ }
+ };
+ if ( isOpen ) {
+ document.addEventListener( 'keydown', handleKeyDown );
+ }
+ return () => {
+ document.removeEventListener( 'keydown', handleKeyDown );
+ };
+ }, [ isOpen, onOpenChange ] );
+
+ const setChoice = (
+ path: Array< number | string >,
+ decision: 'keep_yours' | 'use_core'
+ ) => {
+ setChoices( ( prev ) => ( {
+ ...prev,
+ [ pathKey( path ) ]: decision,
+ } ) );
+ };
+
+ const handleApply = async () => {
+ const choiceList: ApplyChoice[] = Object.entries( choices ).map(
+ ( [ key, decision ] ) => ( {
+ path: JSON.parse( key ) as Array< number | string >,
+ decision,
+ } )
+ );
+ const res = await apply( choiceList );
+ if ( res ) {
+ onOpenChange( false );
+ }
+ };
+
+ const totalChanges = summary
+ ? summary.copy_changes.length +
+ summary.added_blocks.length +
+ summary.removed_blocks.length +
+ summary.structural_changes.length
+ : 0;
+
+ const subtitle = sprintf(
+ /* translators: 1: email name; 2: WooCommerce version; 3: number of changes. */
+ _n(
+ '%1$s · WooCommerce %2$s · %3$d change',
+ '%1$s · WooCommerce %2$s · %3$d changes',
+ totalChanges,
+ 'woocommerce'
+ ),
+ emailTitle,
+ summary?.version_to ?? '',
+ totalChanges
+ );
+
+ const applyLabel = sprintf(
+ /* translators: %d: total number of changes that will be applied. */
+ __( 'Apply (%d)', 'woocommerce' ),
+ totalChanges
+ );
+
+ const applyDisabled =
+ isApplying ||
+ isLoading ||
+ ! summary ||
+ summary.is_fallback ||
+ totalChanges === 0;
+
+ return createPortal(
+ <>
+ <div
+ className="woocommerce-review-drawer__overlay"
+ onClick={ () => onOpenChange( false ) }
+ role="presentation"
+ style={ { display: isOpen ? 'block' : 'none' } }
+ aria-hidden={ ! isOpen }
+ />
+ <div className="woocommerce-review-drawer">
+ <aside
+ ref={ drawerRef }
+ className={ [
+ 'woocommerce-review-drawer__panel',
+ isOpen ? 'is-open' : 'is-closed',
+ ].join( ' ' ) }
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="woocommerce-review-drawer-title"
+ aria-hidden={ ! isOpen }
+ tabIndex={ -1 }
+ >
+ <header className="woocommerce-review-drawer__header">
+ <div className="woocommerce-review-drawer__h-stack">
+ <h2
+ id="woocommerce-review-drawer-title"
+ className="woocommerce-review-drawer__title"
+ >
+ { __(
+ 'Review template update',
+ 'woocommerce'
+ ) }
+ </h2>
+ <p className="woocommerce-review-drawer__subtitle">
+ { subtitle }
+ </p>
+ </div>
+ <Button
+ icon={ closeSmall }
+ label={ __( 'Close', 'woocommerce' ) }
+ onClick={ () => onOpenChange( false ) }
+ className="woocommerce-review-drawer__close"
+ />
+ </header>
+
+ <div className="woocommerce-review-drawer__body">
+ { isLoading && (
+ <div
+ role="status"
+ aria-live="polite"
+ aria-label={ __(
+ 'Loading diff',
+ 'woocommerce'
+ ) }
+ className="woocommerce-review-drawer__status"
+ >
+ <Spinner />
+ </div>
+ ) }
+
+ { error && (
+ <div
+ role="alert"
+ className="woocommerce-review-drawer__status"
+ >
+ { __(
+ 'Could not load the change summary.',
+ 'woocommerce'
+ ) }
+ </div>
+ ) }
+
+ { summary && summary.is_fallback && (
+ <div className="woocommerce-review-drawer__status">
+ { summary.summary_lines[ 0 ] ??
+ __(
+ 'Template updated — see release notes.',
+ 'woocommerce'
+ ) }
+ </div>
+ ) }
+
+ { summary && ! summary.is_fallback && (
+ <>
+ <ConflictsGroup
+ conflicts={ summary.copy_changes }
+ choices={ choices }
+ onChoose={ setChoice }
+ />
+ <AutoResolvedGroup summary={ summary } />
+ </>
+ ) }
+ </div>
+
+ <footer className="woocommerce-review-drawer__footer">
+ <p className="woocommerce-review-drawer__foot-note">
+ { __(
+ 'Revision recorded for rollback.',
+ 'woocommerce'
+ ) }
+ </p>
+ <div className="woocommerce-review-drawer__footer-actions">
+ <Button
+ variant="tertiary"
+ onClick={ () => onOpenChange( false ) }
+ disabled={ isApplying }
+ __next40pxDefaultSize
+ >
+ { __( 'Cancel', 'woocommerce' ) }
+ </Button>
+ <Button
+ variant="primary"
+ onClick={ () => {
+ void handleApply();
+ } }
+ disabled={ applyDisabled }
+ isBusy={ isApplying }
+ __next40pxDefaultSize
+ >
+ { applyLabel }
+ </Button>
+ </div>
+ </footer>
+ </aside>
+ </div>
+ </>,
+ document.body
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-update-plugin.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-update-plugin.tsx
new file mode 100644
index 00000000000..e130dd6e00b
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/review-update-plugin.tsx
@@ -0,0 +1,93 @@
+/**
+ * External dependencies
+ */
+import { useDispatch, useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import { ReviewDrawer } from './review-drawer';
+import { STORE_NAME } from './store';
+
+/** Extract a human-readable title from a core-data post entity. */
+function extractTitle( post: { title: unknown } ): string {
+ const { title } = post;
+ if ( typeof title === 'string' ) {
+ return title;
+ }
+ if ( title && typeof title === 'object' ) {
+ if ( 'rendered' in title && typeof title.rendered === 'string' ) {
+ return title.rendered;
+ }
+ if ( 'raw' in title && typeof title.raw === 'string' ) {
+ return title.raw;
+ }
+ }
+ return '';
+}
+
+/**
+ * Mounts the review drawer into the WooCommerce email editor's plugin
+ * scope. The drawer's open / close state lives in the
+ * `woocommerce/email-editor-integration` store, so any other surface
+ * can open it via:
+ *
+ * ```
+ * wp.data.dispatch( 'woocommerce/email-editor-integration' )
+ * .openReviewDrawer();
+ * ```
+ *
+ * RSM-141 will wire the dispatch to the design's floating editor
+ * banner. Until then, opening the drawer for testing happens from the
+ * browser console using the same dispatch call.
+ */
+export const ReviewUpdatePlugin = () => {
+ const { setReviewDrawerOpen } = useDispatch( STORE_NAME );
+ const isDrawerOpen = useSelect(
+ ( select ) => select( STORE_NAME ).isReviewDrawerOpen(),
+ []
+ );
+
+ // Resolve the current woo_email post ID. The block editor's core/editor
+ // store exposes it via getCurrentPostId(); we typecheck loosely because
+ // the global is typed as `any` upstream.
+ const postId = useSelect( ( select ) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const editorStore = ( select as any )( 'core/editor' );
+ const id = editorStore?.getCurrentPostId?.();
+ return typeof id === 'number' ? id : null;
+ }, [] );
+
+ const post = useSelect(
+ ( select ) => {
+ if ( ! postId ) {
+ return null;
+ }
+ return select( coreStore ).getEntityRecord(
+ 'postType',
+ 'woo_email',
+ postId
+ );
+ },
+ [ postId ]
+ );
+
+ const emailTitle =
+ post && typeof post === 'object' && 'title' in post
+ ? extractTitle( post as { title: unknown } )
+ : '';
+
+ if ( ! postId ) {
+ return null;
+ }
+
+ return (
+ <ReviewDrawer
+ postId={ postId }
+ emailTitle={ emailTitle }
+ isOpen={ isDrawerOpen }
+ onOpenChange={ setReviewDrawerOpen }
+ />
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts
new file mode 100644
index 00000000000..b8a16f23b6e
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/store.ts
@@ -0,0 +1,75 @@
+/**
+ * External dependencies
+ */
+import { createReduxStore, register, select } from '@wordpress/data';
+import type {
+ ReduxStoreConfig,
+ StoreDescriptor as GenericStoreDescriptor,
+} from '@wordpress/data/build-types/types';
+
+/**
+ * Integration-layer UI state for the WooCommerce email editor.
+ *
+ * Internal — consumers in this directory open/close the review drawer
+ * (and any future integration-level UI state) via this store rather
+ * than passing props across siblings, since the trigger and the drawer
+ * live in different React subtrees mounted by `registerPlugin`.
+ *
+ * @internal
+ */
+export const STORE_NAME = 'woocommerce/email-editor-integration';
+
+interface State {
+ isReviewDrawerOpen: boolean;
+}
+
+const initialState: State = {
+ isReviewDrawerOpen: false,
+};
+
+type Action = { type: 'SET_REVIEW_DRAWER_OPEN'; open: boolean };
+
+const reducer = ( state: State = initialState, action: Action ): State => {
+ switch ( action.type ) {
+ case 'SET_REVIEW_DRAWER_OPEN':
+ return { ...state, isReviewDrawerOpen: action.open };
+ default:
+ return state;
+ }
+};
+
+const actions = {
+ setReviewDrawerOpen: ( open: boolean ) =>
+ ( { type: 'SET_REVIEW_DRAWER_OPEN', open } as const ),
+ openReviewDrawer: () =>
+ ( { type: 'SET_REVIEW_DRAWER_OPEN', open: true } as const ),
+ closeReviewDrawer: () =>
+ ( { type: 'SET_REVIEW_DRAWER_OPEN', open: false } as const ),
+};
+
+const selectors = {
+ isReviewDrawerOpen: ( state: State ): boolean => state.isReviewDrawerOpen,
+};
+
+const config = { reducer, actions, selectors };
+
+/**
+ * Register the integration store. Called once from the entrypoint;
+ * guarded against double-registration so HMR / repeated boots are
+ * safe (mirrors the pattern in the upstream `@woocommerce/email-editor`
+ * store).
+ */
+export function registerStore(): void {
+ if ( select( STORE_NAME ) !== undefined ) {
+ return;
+ }
+ register( createReduxStore( STORE_NAME, config ) );
+}
+
+declare module '@wordpress/data' {
+ interface StoreRegistry {
+ [ STORE_NAME ]: GenericStoreDescriptor<
+ ReduxStoreConfig< State, typeof actions, typeof selectors >
+ >;
+ }
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/style.scss b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/style.scss
index d6e7dbd264a..08004095bd1 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/style.scss
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/style.scss
@@ -36,3 +36,308 @@
.editor-post-excerpt__dropdown button {
display: none;
}
+
+/*
+ Review drawer (RSM-143). Hand-rolled right-side drawer rendered via
+ `createPortal` to `document.body` (the `<PluginArea>` wrapper is
+ `display: none`, which would trap a fixed-position panel). Chrome,
+ tag pills, and typography are all styled here — no `@wordpress/ui`
+ dependency.
+ */
+
+/* ---------- Chrome (overlay + panel + header + body + footer) ---------- */
+
+.woocommerce-review-drawer__overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: 100000;
+}
+
+.woocommerce-review-drawer {
+ position: fixed;
+ inset: 0;
+ z-index: 100001;
+ pointer-events: none;
+}
+
+.woocommerce-review-drawer__panel {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 480px;
+ max-width: 100vw;
+ background: #fff;
+ box-shadow: -2px 0 16px rgba(0, 0, 0, 0.12);
+ display: flex;
+ flex-direction: column;
+ pointer-events: auto;
+ transform: translateX(100%);
+ transition: transform 240ms cubic-bezier(0.08, 0.52, 0.52, 1);
+
+ &.is-open {
+ transform: translateX(0);
+ }
+
+ &:focus-visible {
+ outline: none;
+ }
+}
+
+.woocommerce-review-drawer__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 20px;
+ border-bottom: 1px solid #f0f0f1;
+}
+
+.woocommerce-review-drawer__h-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+.woocommerce-review-drawer__title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: #1d2327;
+}
+
+.woocommerce-review-drawer__subtitle {
+ margin: 0;
+ font-size: 12px;
+ color: #50575e;
+ line-height: 1.4;
+}
+
+.woocommerce-review-drawer__close {
+ flex: 0 0 auto;
+}
+
+.woocommerce-review-drawer__body {
+ flex: 1 1 auto;
+ overflow-y: auto;
+ padding: 0;
+}
+
+.woocommerce-review-drawer__footer {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 20px;
+ border-top: 1px solid #f0f0f1;
+}
+
+.woocommerce-review-drawer__foot-note {
+ margin: 0;
+ flex: 1 1 auto;
+ font-size: 12px;
+ color: #6b7280;
+}
+
+.woocommerce-review-drawer__footer-actions {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+
+ /*
+ Keep Cancel / Apply at their natural width so the label never
+ wraps onto two lines when the count grows (e.g. "Apply (11)").
+ The footer note absorbs the shrinkage instead.
+ */
+ > button {
+ flex-shrink: 0;
+ white-space: nowrap;
+ }
+}
+
+/* ---------- Body (status, groups, items, diff, choice grid, tags) ---------- */
+
+.woocommerce-review-drawer__status {
+ padding: 24px 20px;
+ color: #50575e;
+}
+
+.woocommerce-review-drawer__group {
+ padding: 12px 0;
+ border-bottom: 1px solid #f0f0f1;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+}
+
+.woocommerce-review-drawer__group-h {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding: 4px 20px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #50575e;
+}
+
+.woocommerce-review-drawer__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+ flex: 0 0 auto;
+}
+
+.woocommerce-review-drawer__dot--warning {
+ background: #b26200;
+}
+
+.woocommerce-review-drawer__dot--brand {
+ background: var(--wp-admin-theme-color, #3858e9);
+}
+
+.woocommerce-review-drawer__item {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 12px 20px;
+ border-top: 1px solid #f0f0f1;
+
+ &:first-of-type {
+ border-top: 0;
+ }
+}
+
+.woocommerce-review-drawer__item-h {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.woocommerce-review-drawer__item-title {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.3;
+ color: #1d2327;
+}
+
+.woocommerce-review-drawer__item-sub {
+ margin: 0;
+ font-size: 13px;
+ color: #4b5563;
+ line-height: 1.4;
+}
+
+.woocommerce-review-drawer__tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 9999px;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.4;
+ white-space: nowrap;
+ flex: 0 0 auto;
+}
+
+.woocommerce-review-drawer__tag--conflict {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.woocommerce-review-drawer__tag--apply-core {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+.woocommerce-review-drawer__tag--keep-yours {
+ background: #f3f4f6;
+ color: #374151;
+}
+
+.woocommerce-review-drawer__diff {
+ border: 1px solid #f0f0f1;
+ border-radius: 2px;
+ overflow: hidden;
+ font-family: Menlo, Consolas, monospace;
+ font-size: 11px;
+ line-height: 1.6;
+}
+
+.woocommerce-review-drawer__diff-row {
+ padding: 6px 10px;
+}
+
+.woocommerce-review-drawer__diff-row--minus {
+ background: #fff6f5;
+ color: #6b1b1b;
+
+ &::before {
+ content: "− ";
+ color: #cc1818;
+ }
+}
+
+.woocommerce-review-drawer__diff-row--plus {
+ background: #ebffed;
+ color: #003814;
+
+ &::before {
+ content: "+ ";
+ color: #008030;
+ }
+}
+
+.woocommerce-review-drawer__choice {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+.woocommerce-review-drawer__choice-card {
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 2px;
+ padding: 10px 12px;
+ text-align: left;
+ cursor: pointer;
+ display: grid;
+ gap: 2px;
+ font: inherit;
+ color: inherit;
+
+ &:hover {
+ border-color: #999;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--wp-admin-theme-color, #3858e9);
+ outline-offset: 1px;
+ }
+
+ &.is-active {
+ background: #f5f8ff;
+ border-color: var(--wp-admin-theme-color, #3858e9);
+ box-shadow: inset 0 0 0 1px var(--wp-admin-theme-color, #3858e9);
+ }
+}
+
+.woocommerce-review-drawer__choice-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: #1d2327;
+}
+
+.woocommerce-review-drawer__choice-hint {
+ font-size: 11px;
+ color: #50575e;
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
index 2f543779d86..d1a0664f5bb 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Internal\EmailEditor;
use Automattic\WooCommerce\EmailEditor\Validator\Builder;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSelectiveApplier;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use WC_Email;
@@ -327,6 +328,59 @@ class EmailApiController {
'schema' => array( $this, 'get_change_summary_schema' ),
)
);
+
+ register_rest_route(
+ 'woocommerce-email-editor/v1',
+ '/emails/(?P<id>\d+)/apply',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'apply_response' ),
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_woocommerce' );
+ },
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The ID of the woo_email post.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'choices' => array(
+ 'description' => __( 'Per-conflict apply decisions: an array of {path, decision} entries. `decision` is `keep_yours` or `use_core`.', 'woocommerce' ),
+ 'type' => 'array',
+ 'required' => false,
+ 'default' => array(),
+ ),
+ ),
+ 'schema' => array( $this, 'get_apply_schema' ),
+ )
+ );
+
+ register_rest_route(
+ 'woocommerce-email-editor/v1',
+ '/emails/(?P<id>\d+)/undo',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'undo_response' ),
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_woocommerce' );
+ },
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The ID of the woo_email post.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'revision_id' => array(
+ 'description' => __( 'The revision_id returned by the prior /apply call.', 'woocommerce' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ ),
+ 'schema' => array( $this, 'get_undo_schema' ),
+ )
+ );
}
/**
@@ -644,4 +698,173 @@ class EmailApiController {
200
);
}
+
+ /**
+ * Get the schema for the apply endpoint response.
+ *
+ * @return array
+ */
+ public function get_apply_schema(): array {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'woo_email_apply',
+ 'type' => 'object',
+ 'properties' => array(
+ 'merged_content' => array(
+ 'description' => __( 'The merged block content written to the post. May differ from the input `post_content` even when every choice was `keep_yours` — the namespace-alias migration (see `aliases_migrated`) rewrites legacy block names unconditionally.', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'revision_id' => array(
+ 'description' => __( 'A UUID identifying the pre-apply snapshot. Use as the revision_id on a subsequent /undo call.', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'version_to' => array(
+ 'description' => __( 'The core template version stamped on the post after applying.', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'status' => array(
+ 'description' => __( 'The post-apply status (always `applied` on success).', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'structural_skipped' => array(
+ 'description' => __( 'True when one or more structural deltas (nest / reorder) existed in the diff but were not applied. v1 punts structural changes; the merchant\'s structure is preserved.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'readonly' => true,
+ ),
+ 'aliases_migrated' => array(
+ 'description' => __( 'List of deprecated block-name aliases rewritten to their canonical form during the apply (e.g. `["woo/email-content"]`). Empty when no migration was needed. Targeted to known deprecated aliases only.', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Get the schema for the undo endpoint response.
+ *
+ * @return array
+ */
+ public function get_undo_schema(): array {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'woo_email_undo',
+ 'type' => 'object',
+ 'properties' => array(
+ 'restored_content' => array(
+ 'description' => __( 'The pre-apply post content that was restored.', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'status' => array(
+ 'description' => __( 'The post-undo status (always `restored` on success).', 'woocommerce' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Apply a partial set of core template changes to a `woo_email` post,
+ * driven by per-conflict merchant choices. Thin wrapper over
+ * {@see WCEmailTemplateSelectiveApplier::apply_selectively()}.
+ *
+ * The 404 path mirrors {@see self::get_change_summary_response()} — when
+ * the email type cannot be resolved from the post ID, the post is either
+ * non-existent or not a `woo_email`. 422 fires when the change-summary
+ * has no actionable diff (e.g. post outside the sync registry, or the
+ * inversion guard tripped).
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response|WP_Error
+ *
+ * @since 10.9.0
+ */
+ public function apply_response( WP_REST_Request $request ) {
+ if ( ! ( $this->post_manager && $this->posts_generator ) ) {
+ return new WP_Error(
+ 'woocommerce_email_editor_not_initialized',
+ __( 'Email editor is not initialized.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $post_id = (int) $request->get_param( 'id' );
+ $email_type = $this->post_manager->get_email_type_from_post_id( $post_id );
+ $email = $this->get_email_by_type( $email_type ?? '' );
+
+ if ( ! $email ) {
+ return new WP_Error(
+ 'woocommerce_email_not_found',
+ __( 'No email found for the given post ID.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $choices = $request->get_param( 'choices' );
+ if ( ! is_array( $choices ) ) {
+ $choices = array();
+ }
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, $choices );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ return new WP_REST_Response( $result, 200 );
+ }
+
+ /**
+ * Restore the pre-apply snapshot for a `woo_email` post. Thin wrapper
+ * over {@see WCEmailTemplateSelectiveApplier::undo()}.
+ *
+ * Returns 410 Gone when no snapshot exists for the given post or when
+ * the supplied `revision_id` doesn't match the latest snapshot —
+ * matches the design's single-step undo model.
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response|WP_Error
+ *
+ * @since 10.9.0
+ */
+ public function undo_response( WP_REST_Request $request ) {
+ if ( ! ( $this->post_manager && $this->posts_generator ) ) {
+ return new WP_Error(
+ 'woocommerce_email_editor_not_initialized',
+ __( 'Email editor is not initialized.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $post_id = (int) $request->get_param( 'id' );
+ $email_type = $this->post_manager->get_email_type_from_post_id( $post_id );
+ $email = $this->get_email_by_type( $email_type ?? '' );
+
+ if ( ! $email ) {
+ return new WP_Error(
+ 'woocommerce_email_not_found',
+ __( 'No email found for the given post ID.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $revision_id = (string) $request->get_param( 'revision_id' );
+
+ $result = WCEmailTemplateSelectiveApplier::undo( $post_id, $revision_id );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ return new WP_REST_Response( $result, 200 );
+ }
}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
index b061e9abf47..600bee39057 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
@@ -174,17 +174,30 @@ class WCEmailTemplateAutoApplier {
return $updated;
}
+ // Read back the persisted post_content. The `content_save_pre` filter
+ // chain can mutate `$canonical` between the in-memory string and what
+ // lands in the DB, so both the returned `content` field and the
+ // stamped source hash must reflect what the database actually holds.
+ // See the same note in `WCEmailTemplateSelectiveApplier::apply_selectively()`.
+ $saved_post = get_post( $post_id );
+ $saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : $canonical;
+ $canonical = $saved_body;
+
if ( null !== $sync_config ) {
$source_hash = sha1( $canonical );
$synced_at = gmdate( 'Y-m-d H:i:s' );
- $status = WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC;
$version = (string) $sync_config['version'];
update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $version );
update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
- update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, $status );
- }
+
+ // Status comes from the classifier so all writers stay consistent.
+ // In this path we always write canonical, so the classifier returns
+ // IN_SYNC, but going through the same helper as the selective applier
+ // avoids drift if a future partial-apply path is added here.
+ $status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+ }//end if
} finally {
self::$is_auto_applying = false;
}//end try
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
index 3fbda035e44..c8d91defad3 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateChangeSummary.php
@@ -350,13 +350,20 @@ class WCEmailTemplateChangeSummary {
* {@see self::STRUCTURAL_BLOCK_NAMES}. Null-name entries (raw HTML wrappers
* between blocks) are skipped.
*
+ * Public so the RSM-143 selective-merge engine
+ * ({@see WCEmailTemplateSelectiveApplier}) can reuse this and
+ * {@see self::lcs_matches()} to align matched pairs without duplicating the
+ * algorithm. Internal namespace-bounded; not part of any external contract.
+ *
+ * @internal
+ *
* @param array<int|string, array<string, mixed>> $blocks Output of `parse_blocks()`.
* @param array<int|string> $path Current index path from root.
* @param string|null $parent_name Normalized parent block name, null at root.
*
* @return array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}>
*/
- private static function flatten_blocks( array $blocks, array $path = array(), ?string $parent_name = null ): array {
+ public static function flatten_blocks( array $blocks, array $path = array(), ?string $parent_name = null ): array {
$records = array();
foreach ( $blocks as $idx => $block ) {
if ( ! is_array( $block ) || null === ( $block['blockName'] ?? null ) ) {
@@ -657,12 +664,18 @@ class WCEmailTemplateChangeSummary {
* their edited version with core's original (high word overlap) instead of
* with an unrelated paragraph that happens to be in the right position.
*
+ * Public so {@see WCEmailTemplateSelectiveApplier} can reuse the same
+ * matched-pair alignment when applying merchant choices. Internal-namespace
+ * bounded; not part of any external contract.
+ *
+ * @internal
+ *
* @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $a Core records.
* @param array<int, array{path:array<int|string>, parent_name:?string, name:string, inner_text:string}> $b Post records.
*
* @return array<int, array{0:int, 1:int}>
*/
- private static function lcs_matches( array $a, array $b ): array {
+ public static function lcs_matches( array $a, array $b ): array {
$n = count( $a );
$m = count( $b );
if ( 0 === $n || 0 === $m ) {
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index 00e1da46701..85006ec1d5e 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -5,6 +5,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
use Automattic\WooCommerce\Internal\EmailEditor\Logger;
/**
@@ -42,7 +43,20 @@ class WCEmailTemplateDivergenceDetector {
public const BACKFILL_COMPLETE_OPTION = 'woocommerce_email_template_sync_backfill_complete';
/**
- * Post meta key the detector writes.
+ * Cached classification of "does the post currently differ from the
+ * canonical core render?". Computed by {@see self::classify_post()}
+ * and written by {@see self::reclassify()}; read by surfaces that
+ * filter posts by state (auto-applier targeting, list-page status
+ * column, sweep optimisation).
+ *
+ * **Single writer.** All code paths that mutate `post_content` for a
+ * `woo_email` post — auto-applier, selective applier, undo — call
+ * `reclassify()` after the write. Direct `update_post_meta()` against
+ * this key from any other call site will desync the cache.
+ *
+ * **Not the banner / indicator signal.** Whether the merchant has
+ * reviewed the latest core update is a separate question; that
+ * answer lives on {@see self::VERSION_META_KEY}.
*
* @var string
*/
@@ -56,8 +70,28 @@ class WCEmailTemplateDivergenceDetector {
public const SOURCE_HASH_META_KEY = '_wc_email_template_source_hash';
/**
- * Post meta key storing the version of the block template the post was stamped
- * against. Written by the generator and by the RSM-149 backfill.
+ * The canonical core version the merchant most recently reviewed for
+ * this post — written by every applier path that runs as a deliberate
+ * merchant action (auto-apply, selective apply with any choices,
+ * reset-to-default).
+ *
+ * **Load-bearing for the "update available" indicator.** RSM-141's
+ * editor banner and list-page filter chip compare this value against
+ * {@see WCEmailTemplateSyncRegistry}'s current canonical version for
+ * the email type:
+ *
+ * ```
+ * $reviewed = (string) get_post_meta( $post_id, self::VERSION_META_KEY, true );
+ * $current = (string) ( $sync_registry[ $email_id ]['version'] ?? '' );
+ * $show_indicator = $current !== '' && version_compare( $reviewed, $current, '<' );
+ * ```
+ *
+ * Distinct from {@see self::STATUS_META_KEY}: the merchant can
+ * deliberately choose `keep_yours` for every conflict (post still
+ * differs from canonical → STATUS = core_updated_customized) and
+ * still have addressed the update for this version (VERSION = current
+ * → no indicator). Direct merchant edits to post_content do not
+ * advance this stamp; only review-driven applies do.
*
* @var string
*/
@@ -187,62 +221,16 @@ class WCEmailTemplateDivergenceDetector {
return;
}
- $posts_manager = WCTransactionalEmailPostsManager::get_instance();
- $canonical_emails = $posts_manager->get_emails_by_id();
+ $posts_manager = WCTransactionalEmailPostsManager::get_instance();
foreach ( $registry as $email_id => $_config ) {
try {
- $email = $canonical_emails[ $email_id ] ?? null;
- if ( ! $email instanceof \WC_Email ) {
- // Extension providing the email was deactivated; nothing to classify.
- continue;
- }
-
$post = $posts_manager->get_email_post( (string) $email_id );
if ( ! $post instanceof \WP_Post ) {
continue;
}
- $stored_source_hash = (string) get_post_meta( (int) $post->ID, self::SOURCE_HASH_META_KEY, true );
- if ( '' === $stored_source_hash ) {
- // This should not normally occur post-backfill: the generator always stamps
- // this meta and RSM-149 is supposed to have backfilled pre-existing posts.
- // Surface at warning so it's visible in the default WC log UI without
- // requiring operators to lower the email-editor logging threshold.
- self::get_logger()->warning(
- sprintf(
- 'Email template divergence sweep skipped post %d for email "%s": no stored source hash.',
- (int) $post->ID,
- (string) $email_id
- ),
- array(
- 'email_id' => (string) $email_id,
- 'post_id' => (int) $post->ID,
- 'context' => 'email_template_divergence_detector',
- )
- );
- continue;
- }
-
- $status = self::classify_post(
- (int) $post->ID,
- $email,
- array(
- 'post_content' => (string) $post->post_content,
- 'stored_source_hash' => $stored_source_hash,
- )
- );
-
- if ( null === $status ) {
- continue;
- }
-
- $existing_status = (string) get_post_meta( (int) $post->ID, self::STATUS_META_KEY, true );
- if ( $existing_status === $status ) {
- continue;
- }
-
- update_post_meta( (int) $post->ID, self::STATUS_META_KEY, $status );
+ self::reclassify( (int) $post->ID );
} catch ( \Throwable $e ) {
self::get_logger()->error(
sprintf(
@@ -323,6 +311,89 @@ class WCEmailTemplateDivergenceDetector {
: self::STATUS_CORE_UPDATED_CUSTOMIZED;
}
+ /**
+ * Run the divergence classifier on a single post and stamp the resulting
+ * status. Used both by {@see self::run_sweep()} and by every code path
+ * that mutates `post_content` for a `woo_email` post (auto-applier,
+ * selective applier, undo). Centralising the write here keeps
+ * {@see self::STATUS_META_KEY} consistent regardless of which writer
+ * triggered the change.
+ *
+ * Returns the stamped status, or `null` when the classifier cannot
+ * decide (no stored source hash yet, post / email cannot be resolved).
+ * In the `null` case the meta is left untouched.
+ *
+ * @param int $post_id The `woo_email` post ID.
+ *
+ * @return string|null One of the STATUS_* constants, or null when no
+ * decision was made.
+ *
+ * @since 10.9.0
+ */
+ public static function reclassify( int $post_id ): ?string {
+ $post = get_post( $post_id );
+ if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) {
+ return null;
+ }
+
+ $posts_manager = WCTransactionalEmailPostsManager::get_instance();
+ $email_id = (string) $posts_manager->get_email_type_from_post_id( $post_id );
+ if ( '' === $email_id ) {
+ return null;
+ }
+
+ $canonical_emails = $posts_manager->get_emails_by_id();
+ $email = $canonical_emails[ $email_id ] ?? null;
+ if ( ! $email instanceof \WC_Email ) {
+ return null;
+ }
+
+ $stored_source_hash = (string) get_post_meta( $post_id, self::SOURCE_HASH_META_KEY, true );
+ if ( '' === $stored_source_hash ) {
+ // This should not normally occur post-backfill: the generator always stamps
+ // this meta and RSM-149 is supposed to have backfilled pre-existing posts.
+ // Surface at warning so it's visible in the default WC log UI without
+ // requiring operators to lower the email-editor logging threshold.
+ self::get_logger()->warning(
+ sprintf(
+ 'Email template divergence reclassify skipped post %d for email "%s": no stored source hash.',
+ $post_id,
+ $email_id
+ ),
+ array(
+ 'email_id' => $email_id,
+ 'post_id' => $post_id,
+ 'context' => 'email_template_divergence_detector',
+ )
+ );
+ return null;
+ }
+
+ $status = self::classify_post(
+ $post_id,
+ $email,
+ array(
+ 'post_content' => (string) $post->post_content,
+ 'stored_source_hash' => $stored_source_hash,
+ )
+ );
+
+ if ( null === $status ) {
+ return null;
+ }
+
+ // Idempotent write: skip the meta call entirely when the status hasn't shifted,
+ // so successive reclassifies on unchanged state produce zero DB writes (and zero
+ // `update_post_metadata` filter calls observed by tests / observers).
+ $existing_status = (string) get_post_meta( $post_id, self::STATUS_META_KEY, true );
+ if ( $existing_status === $status ) {
+ return $status;
+ }
+
+ update_post_meta( $post_id, self::STATUS_META_KEY, $status );
+ return $status;
+ }
+
/**
* Override the logger implementation. Intended for tests only.
*
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
new file mode 100644
index 00000000000..7e124ecafc3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
@@ -0,0 +1,717 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\Logger;
+
+/**
+ * Applies a partial set of core template changes to a customised `woo_email`
+ * post, driven by per-conflict merchant choices. Pairs with
+ * {@see WCEmailTemplateChangeSummary} (the diff data source) and
+ * {@see WCEmailTemplateAutoApplier} (the wholesale-apply primitive) to power
+ * the Review drawer's "Keep yours / Use core" workflow.
+ *
+ * V1 algorithm (spine = the merchant's post):
+ *
+ * - **`copy_changes`** (matched pair, different inner_text): default decision
+ * is `keep_yours`. When the merchant explicitly opts into `use_core`, the
+ * matched block's `innerHTML` / `innerContent` is replaced with core's
+ * version. Block `attrs` are preserved from the post side (no attribute
+ * diff in v1).
+ * - **`added_blocks`** (in core, not in post): always applied. Inserted at
+ * the equivalent position from core's path; if the path can't be navigated
+ * in the post tree, falls back to appending at the closest level.
+ * - **`removed_blocks`** (in post, not in core): always preserved (Keep
+ * yours).
+ * - **`structural_changes`** (`nest` / `reorder`): not applied in v1. The
+ * merchant's structure is preserved; the response carries
+ * `structural_skipped: true` if any structural delta was observed.
+ *
+ * Undo: each apply writes a single-step snapshot of the prior `post_content`
+ * to {@see self::SNAPSHOT_META_KEY}, keyed by a UUID `revision_id`. A
+ * subsequent apply overwrites the snapshot. {@see self::undo()} restores from
+ * the snapshot when the supplied `revision_id` matches.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 10.9.0
+ */
+class WCEmailTemplateSelectiveApplier {
+ /**
+ * Post meta key for the single-step pre-apply snapshot. Stores an array
+ * with `revision_id`, `content`, and `snapshot_at` (UTC `Y-m-d H:i:s`).
+ * The snapshot does **not** record the prior status — on undo the status
+ * is recomputed via
+ * {@see WCEmailTemplateDivergenceDetector::reclassify()} so it reflects
+ * the world as it stands at undo time (core may have shipped a release
+ * since the apply).
+ *
+ * @var string
+ */
+ public const SNAPSHOT_META_KEY = '_wc_email_template_pre_apply_snapshot';
+
+ /**
+ * Re-entrancy flag set while the applier rewrites a post. Mirrors
+ * {@see WCEmailTemplateAutoApplier::is_auto_applying()}; future
+ * `save_post` listeners (RSM-145 Tracks event firing) should consult both
+ * before treating a write as a merchant edit.
+ *
+ * @var bool
+ */
+ private static bool $is_applying = false;
+
+ /**
+ * Logger instance. Lazily instantiated on first use; overridable for tests.
+ *
+ * @var Email_Editor_Logger_Interface|null
+ */
+ private static ?Email_Editor_Logger_Interface $logger = null;
+
+ /**
+ * Apply the selected set of core template changes to a `woo_email` post.
+ *
+ * @param int $post_id The `woo_email` post ID.
+ * @param array<int, array{path:array<int|string>, decision:string}> $choices Per-conflict choices keyed implicitly by `path`. `decision` is `'keep_yours'` (default if absent) or `'use_core'`. Choices for paths that don't correspond to a `copy_changes` entry are ignored — auto-resolved entries are non-overridable in v1.
+ *
+ * @return array<string, mixed>|\WP_Error On success, an array with keys
+ * `merged_content`, `revision_id`,
+ * `version_to`, `status` ('applied'),
+ * `structural_skipped`, and
+ * `aliases_migrated` (a list of
+ * deprecated block-name aliases
+ * rewritten to canonical form
+ * during the apply, e.g.
+ * `['woo/email-content']`).
+ *
+ * @since 10.9.0
+ */
+ public static function apply_selectively( int $post_id, array $choices ) {
+ $post = get_post( $post_id );
+ if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) {
+ return new \WP_Error(
+ 'post_not_found',
+ sprintf(
+ /* translators: %d: post ID */
+ __( 'No woo_email post found for ID %d.', 'woocommerce' ),
+ $post_id
+ ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $posts_manager = WCTransactionalEmailPostsManager::get_instance();
+ $email_id = $posts_manager->get_email_type_from_post_id( $post_id );
+ if ( ! is_string( $email_id ) || '' === $email_id ) {
+ return new \WP_Error(
+ 'email_not_found',
+ __( 'No email type associated with the given post ID.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id );
+ if ( null === $sync_config ) {
+ return new \WP_Error(
+ 'not_sync_enabled',
+ sprintf(
+ /* translators: %s: email ID */
+ __( 'Email "%s" is not registered for template sync; selective apply is unavailable.', 'woocommerce' ),
+ $email_id
+ ),
+ array( 'status' => 422 )
+ );
+ }
+
+ $emails = $posts_manager->get_emails_by_id();
+ $email = $emails[ $email_id ] ?? null;
+ if ( ! $email instanceof \WC_Email ) {
+ return new \WP_Error(
+ 'email_not_found',
+ sprintf(
+ /* translators: %s: email ID */
+ __( 'Email instance for "%s" is unavailable.', 'woocommerce' ),
+ $email_id
+ ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $summary = WCEmailTemplateChangeSummary::summarize( $post_id );
+ if ( ! empty( $summary['is_fallback'] ) ) {
+ return new \WP_Error(
+ 'no_actionable_summary',
+ __( 'No actionable diff is available for this post; refusing to apply.', 'woocommerce' ),
+ array( 'status' => 422 )
+ );
+ }
+
+ $post_content = (string) $post->post_content;
+
+ try {
+ $core_content = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ } catch ( \Throwable $e ) {
+ self::get_logger()->error(
+ sprintf(
+ 'Selective apply failed to compute canonical content for email "%s": %s',
+ $email_id,
+ $e->getMessage()
+ ),
+ array(
+ 'email_id' => $email_id,
+ 'post_id' => $post_id,
+ 'context' => 'email_template_selective_applier',
+ )
+ );
+ return new \WP_Error(
+ 'canonical_render_failed',
+ __( 'Failed to compute the canonical core render.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }//end try
+
+ $merged_result = self::merge( $post_content, $core_content, $choices );
+ $merged_content = $merged_result['content'];
+ $structural_skipped = $merged_result['structural_skipped'];
+ $aliases_migrated = $merged_result['aliases_migrated'];
+
+ $revision_id = wp_generate_uuid4();
+ $snapshot = array(
+ 'revision_id' => $revision_id,
+ 'content' => $post_content,
+ 'snapshot_at' => gmdate( 'Y-m-d H:i:s' ),
+ );
+ update_post_meta( $post_id, self::SNAPSHOT_META_KEY, $snapshot );
+
+ self::$is_applying = true;
+ try {
+ $updated = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => $merged_content,
+ ),
+ true
+ );
+
+ if ( is_wp_error( $updated ) ) {
+ delete_post_meta( $post_id, self::SNAPSHOT_META_KEY );
+ return $updated;
+ }
+
+ $saved_post = get_post( $post_id );
+ $saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : $merged_content;
+
+ // When merged content diverges from canonical (any keep_yours or
+ // preserved removed-block), stamp sha1(canonical) and hard-stamp
+ // STATUS_CORE_UPDATED_CUSTOMIZED so the auto-applier (which only
+ // acts on STATUS_CORE_UPDATED_UNCUSTOMIZED) can't silently overwrite
+ // the merchant's choice on the next core bump.
+ $is_aligned_with_canonical = ( $merged_content === $core_content );
+ $source_hash = $is_aligned_with_canonical
+ ? sha1( $saved_body )
+ : sha1( $core_content );
+ $synced_at = gmdate( 'Y-m-d H:i:s' );
+ $version_to = (string) $sync_config['version'];
+
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $version_to );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
+
+ if ( $is_aligned_with_canonical ) {
+ WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+ } else {
+ // reclassify() returns null in this branch (current_core ===
+ // stored, current_post !== stored) and would leave prior status
+ // untouched, so stamp directly.
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED
+ );
+ }
+ } finally {
+ self::$is_applying = false;
+ }//end try
+
+ // Invalidate the change-summary cache so the next read reflects the merged state.
+ WCEmailTemplateChangeSummary::reset_cache();
+
+ return array(
+ 'merged_content' => $merged_content,
+ 'revision_id' => $revision_id,
+ 'version_to' => $version_to,
+ 'status' => 'applied',
+ 'structural_skipped' => $structural_skipped,
+ 'aliases_migrated' => $aliases_migrated,
+ );
+ }
+
+ /**
+ * Restore the pre-apply snapshot for a post. Single-step undo only: the
+ * snapshot meta is consumed (deleted) on success, so a second undo
+ * without an intervening apply returns 410 Gone.
+ *
+ * @param int $post_id The `woo_email` post ID.
+ * @param string $revision_id The UUID returned by the prior `apply_selectively()` call.
+ *
+ * @return array<string, mixed>|\WP_Error On success, an array with keys
+ * `restored_content` and `status`
+ * ('restored').
+ *
+ * @since 10.9.0
+ */
+ public static function undo( int $post_id, string $revision_id ) {
+ $post = get_post( $post_id );
+ if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) {
+ return new \WP_Error(
+ 'post_not_found',
+ sprintf(
+ /* translators: %d: post ID */
+ __( 'No woo_email post found for ID %d.', 'woocommerce' ),
+ $post_id
+ ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $snapshot = get_post_meta( $post_id, self::SNAPSHOT_META_KEY, true );
+ if ( ! is_array( $snapshot ) || ! isset( $snapshot['revision_id'], $snapshot['content'] ) ) {
+ return new \WP_Error(
+ 'undo_unavailable',
+ __( 'No pre-apply snapshot is available for this post.', 'woocommerce' ),
+ array( 'status' => 410 )
+ );
+ }
+
+ if ( (string) $snapshot['revision_id'] !== $revision_id ) {
+ return new \WP_Error(
+ 'undo_unavailable',
+ __( 'The supplied revision ID does not match the latest snapshot for this post.', 'woocommerce' ),
+ array( 'status' => 410 )
+ );
+ }
+
+ $restored_content = (string) $snapshot['content'];
+
+ self::$is_applying = true;
+ try {
+ $updated = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => $restored_content,
+ ),
+ true
+ );
+
+ if ( is_wp_error( $updated ) ) {
+ return $updated;
+ }
+
+ // The snapshot's prior_status was correct at snapshot time, but
+ // the world may have moved since (core released, canonical
+ // changed). Ask the classifier for the truth against current
+ // state instead of stamping a stale value.
+ WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+ delete_post_meta( $post_id, self::SNAPSHOT_META_KEY );
+ } finally {
+ self::$is_applying = false;
+ }//end try
+
+ WCEmailTemplateChangeSummary::reset_cache();
+
+ return array(
+ 'restored_content' => $restored_content,
+ 'status' => 'restored',
+ );
+ }
+
+ /**
+ * Whether the applier is currently rewriting a post. Mirrors the
+ * auto-applier's flag so downstream listeners can ignore system writes.
+ *
+ * @since 10.9.0
+ */
+ public static function is_applying(): bool {
+ return self::$is_applying;
+ }
+
+ /**
+ * Override the logger implementation. Intended for tests only.
+ *
+ * @internal
+ *
+ * @param Email_Editor_Logger_Interface|null $logger The logger implementation, or null to restore the default.
+ */
+ public static function set_logger( ?Email_Editor_Logger_Interface $logger ): void {
+ self::$logger = $logger;
+ }
+
+ /**
+ * Compute the merged block tree, starting from the merchant's post and
+ * layering on core's changes per the v1 algorithm.
+ *
+ * @param string $post_content Merchant's current `post_content`.
+ * @param string $core_content Canonical core render.
+ * @param array<int, array{path:array<int|string>, decision:string}> $choices Per-conflict choices.
+ *
+ * @return array{content:string, structural_skipped:bool, aliases_migrated:string[]}
+ */
+ private static function merge( string $post_content, string $core_content, array $choices ): array {
+ $post_blocks = parse_blocks( $post_content );
+ $core_blocks = parse_blocks( $core_content );
+
+ if ( empty( $post_blocks ) || empty( $core_blocks ) ) {
+ return array(
+ 'content' => $post_content,
+ 'structural_skipped' => false,
+ 'aliases_migrated' => array(),
+ );
+ }
+
+ $post_records = WCEmailTemplateChangeSummary::flatten_blocks( $post_blocks );
+ $core_records = WCEmailTemplateChangeSummary::flatten_blocks( $core_blocks );
+ $matches = WCEmailTemplateChangeSummary::lcs_matches( $core_records, $post_records );
+
+ $choice_map = array();
+ foreach ( $choices as $choice ) {
+ if ( ! is_array( $choice ) || ! isset( $choice['path'] ) || ! is_array( $choice['path'] ) ) {
+ continue;
+ }
+ $decision = (string) ( $choice['decision'] ?? 'keep_yours' );
+ if ( 'use_core' !== $decision && 'keep_yours' !== $decision ) {
+ continue;
+ }
+ $choice_map[ self::path_key( $choice['path'] ) ] = $decision;
+ }
+
+ // Pass 1: matched pairs. Apply use_core decisions on copy changes;
+ // detect parent-name diffs (structural punted, but we still flag
+ // `structural_skipped` so the caller can surface it).
+ $structural_skipped = false;
+ $matched_core_set = array();
+ $matched_post_set = array();
+ foreach ( $matches as $pair ) {
+ $matched_core_set[ $pair[0] ] = true;
+ $matched_post_set[ $pair[1] ] = true;
+
+ $core_rec = $core_records[ $pair[0] ];
+ $post_rec = $post_records[ $pair[1] ];
+
+ if ( $core_rec['parent_name'] !== $post_rec['parent_name'] ) {
+ $structural_skipped = true;
+ }
+
+ if ( $core_rec['inner_text'] === $post_rec['inner_text'] ) {
+ continue;
+ }
+
+ $decision = $choice_map[ self::path_key( $post_rec['path'] ) ] ?? 'keep_yours';
+ if ( 'use_core' !== $decision ) {
+ continue;
+ }
+
+ $core_block = self::block_at_path( $core_blocks, $core_rec['path'] );
+ if ( null === $core_block ) {
+ continue;
+ }
+ $post_blocks = self::replace_block_content_at_path( $post_blocks, $post_rec['path'], $core_block );
+ }//end foreach
+
+ // Pass 2: unmatched core records. Insert non-structural blocks at
+ // the equivalent path; flag structural wrappers as skipped.
+ $insertions = array();
+ foreach ( $core_records as $i => $rec ) {
+ if ( isset( $matched_core_set[ $i ] ) ) {
+ continue;
+ }
+ if ( self::is_structural_block( $rec['name'] ) ) {
+ $structural_skipped = true;
+ continue;
+ }
+ $core_block = self::block_at_path( $core_blocks, $rec['path'] );
+ if ( null === $core_block ) {
+ continue;
+ }
+ $insertions[] = array(
+ 'path' => $rec['path'],
+ 'block' => $core_block,
+ );
+ }
+
+ // Insert in order of decreasing path-depth+index so each insert's
+ // target index isn't shifted by a prior insert at the same level.
+ usort(
+ $insertions,
+ static function ( array $a, array $b ): int {
+ $path_a = $a['path'];
+ $path_b = $b['path'];
+ $depth_cmp = count( $path_b ) - count( $path_a );
+ if ( 0 !== $depth_cmp ) {
+ return $depth_cmp;
+ }
+ $last_a = end( $path_a );
+ $last_b = end( $path_b );
+ return ( (int) $last_b ) - ( (int) $last_a );
+ }
+ );
+ foreach ( $insertions as $insertion ) {
+ $post_blocks = self::insert_block_at_path( $post_blocks, $insertion['path'], $insertion['block'] );
+ }
+
+ // Pass 3: unmatched post records (`removed_blocks`). Auto-resolved
+ // as Keep yours — no change. Detect structural wrappers solely so
+ // we can flag `structural_skipped` honestly.
+ foreach ( $post_records as $i => $rec ) {
+ if ( isset( $matched_post_set[ $i ] ) ) {
+ continue;
+ }
+ if ( self::is_structural_block( $rec['name'] ) ) {
+ $structural_skipped = true;
+ }
+ }
+
+ // Final pass: explicit deprecated-namespace migration. Whenever a
+ // `wp:woo/email-content` block is found in the merged tree, rewrite
+ // it to the canonical `wp:woocommerce/email-content` form, including
+ // the `wp-block-{old}` CSS class on the inner div so the comment and
+ // class stay consistent. The block's `attrs` and inner content are
+ // preserved — only the namespace label changes. This is unconditional
+ // (independent of `choices`) because `woo/email-content` is a known
+ // alias of the canonical core block, not a customisation worth
+ // preserving.
+ $aliases_migrated = array();
+ $post_blocks = self::migrate_woo_email_content_namespace( $post_blocks, $aliases_migrated );
+
+ return array(
+ // $post_blocks originates from parse_blocks() and our mutations only
+ // rewrite well-typed fields; serialize_blocks accepts the same shape.
+ // PHPStan can't follow the mutation chain, so the explicit ignore.
+ // @phpstan-ignore-next-line argument.type
+ 'content' => serialize_blocks( $post_blocks ),
+ 'structural_skipped' => $structural_skipped,
+ 'aliases_migrated' => array_values( array_unique( $aliases_migrated ) ),
+ );
+ }
+
+ /**
+ * Walk the merged tree and rewrite every `wp:woo/email-content` block to
+ * the canonical `wp:woocommerce/email-content` form. Touches the
+ * `blockName` and the `wp-block-woo-email-content` CSS class in the
+ * block's `innerHTML` and each `innerContent` segment. The block's
+ * `attrs` and inner content are otherwise preserved.
+ *
+ * Targeted to a single known alias by design — this is not a general
+ * alias-migration framework. Add new entries here only when a real
+ * deprecated→canonical rename ships and we want it auto-migrated on
+ * apply.
+ *
+ * @param array<int|string, array<string, mixed>> $blocks Mutable block tree.
+ * @param string[] $migrated Names of aliases that were rewritten (out param, appended to).
+ *
+ * @return array<int|string, array<string, mixed>>
+ */
+ private static function migrate_woo_email_content_namespace( array $blocks, array &$migrated ): array {
+ $out = array();
+ foreach ( $blocks as $block ) {
+ if ( ! is_array( $block ) ) {
+ $out[] = $block;
+ continue;
+ }
+
+ if ( 'woo/email-content' === ( $block['blockName'] ?? null ) ) {
+ $block['blockName'] = 'woocommerce/email-content';
+
+ if ( isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ) {
+ $block['innerHTML'] = str_replace(
+ 'wp-block-woo-email-content',
+ 'wp-block-woocommerce-email-content',
+ $block['innerHTML']
+ );
+ }
+
+ if ( isset( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) {
+ foreach ( $block['innerContent'] as $i => $segment ) {
+ if ( is_string( $segment ) ) {
+ $block['innerContent'][ $i ] = str_replace(
+ 'wp-block-woo-email-content',
+ 'wp-block-woocommerce-email-content',
+ $segment
+ );
+ }
+ }
+ }
+
+ $migrated[] = 'woo/email-content';
+ }//end if
+
+ if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
+ $block['innerBlocks'] = self::migrate_woo_email_content_namespace( $block['innerBlocks'], $migrated );
+ }
+
+ $out[] = $block;
+ }//end foreach
+ return $out;
+ }
+
+ /**
+ * Replace the block at the given path with another block's content.
+ * Preserves the post block's `attrs` (no attribute-level apply in v1);
+ * copies the source block's `innerHTML`, `innerContent`, and
+ * `innerBlocks` over the target.
+ *
+ * @param array<int|string, array<string, mixed>> $blocks Mutable block tree.
+ * @param array<int|string> $path Index path through `parse_blocks` output.
+ * @param array<string, mixed> $source_block The block whose content to copy in.
+ *
+ * @return array<int|string, array<string, mixed>>
+ */
+ private static function replace_block_content_at_path( array $blocks, array $path, array $source_block ): array {
+ if ( empty( $path ) ) {
+ return $blocks;
+ }
+ return self::replace_recursive( $blocks, array_values( $path ), 0, $source_block );
+ }
+
+ /**
+ * Recursive worker for {@see self::replace_block_content_at_path()}.
+ *
+ * @param array<int|string, array<string, mixed>> $blocks Current level of the tree.
+ * @param array<int|string> $path Path indices.
+ * @param int $depth Current depth.
+ * @param array<string, mixed> $source_block Source block to copy content from.
+ *
+ * @return array<int|string, array<string, mixed>>
+ */
+ private static function replace_recursive( array $blocks, array $path, int $depth, array $source_block ): array {
+ $idx = (int) $path[ $depth ];
+ if ( ! isset( $blocks[ $idx ] ) ) {
+ return $blocks;
+ }
+
+ if ( count( $path ) - 1 === $depth ) {
+ $blocks[ $idx ]['innerHTML'] = $source_block['innerHTML'] ?? '';
+ $blocks[ $idx ]['innerContent'] = $source_block['innerContent'] ?? array();
+ $blocks[ $idx ]['innerBlocks'] = $source_block['innerBlocks'] ?? array();
+ return $blocks;
+ }
+
+ $inner = $blocks[ $idx ]['innerBlocks'] ?? array();
+ $blocks[ $idx ]['innerBlocks'] = self::replace_recursive( is_array( $inner ) ? $inner : array(), $path, $depth + 1, $source_block );
+ return $blocks;
+ }
+
+ /**
+ * Insert a block at the equivalent position in the merged tree using
+ * core's path as a guide. Best-effort: if a parent in the path doesn't
+ * exist in the post tree, append at the closest level.
+ *
+ * @param array<int|string, array<string, mixed>> $blocks Mutable block tree.
+ * @param array<int|string> $path Core-side index path of the block to insert.
+ * @param array<string, mixed> $new_block The block to insert.
+ *
+ * @return array<int|string, array<string, mixed>>
+ */
+ private static function insert_block_at_path( array $blocks, array $path, array $new_block ): array {
+ if ( empty( $path ) ) {
+ return $blocks;
+ }
+ return self::insert_recursive( $blocks, array_values( $path ), 0, $new_block );
+ }
+
+ /**
+ * Recursive worker for {@see self::insert_block_at_path()}.
+ *
+ * @param array<int|string, array<string, mixed>> $blocks Current level.
+ * @param array<int|string> $path Path indices.
+ * @param int $depth Current depth.
+ * @param array<string, mixed> $new_block Block to insert.
+ *
+ * @return array<int|string, array<string, mixed>>
+ */
+ private static function insert_recursive( array $blocks, array $path, int $depth, array $new_block ): array {
+ $idx = (int) $path[ $depth ];
+
+ if ( count( $path ) - 1 === $depth ) {
+ $insert_at = max( 0, min( $idx, count( $blocks ) ) );
+ array_splice( $blocks, $insert_at, 0, array( $new_block ) );
+ return $blocks;
+ }
+
+ if ( ! isset( $blocks[ $idx ] ) ) {
+ // The parent on the core side doesn't exist in the post tree —
+ // fall back to appending at this level so the block isn't lost.
+ $blocks[] = $new_block;
+ return $blocks;
+ }
+
+ $inner = $blocks[ $idx ]['innerBlocks'] ?? array();
+ $blocks[ $idx ]['innerBlocks'] = self::insert_recursive( is_array( $inner ) ? $inner : array(), $path, $depth + 1, $new_block );
+ return $blocks;
+ }
+
+ /**
+ * Walk a parsed block tree along a path and return the block at that
+ * path, or null if any segment is missing.
+ *
+ * @param array<int|string, array<string, mixed>> $blocks Parsed block tree.
+ * @param array<int|string> $path Index path.
+ *
+ * @return array<string, mixed>|null
+ */
+ private static function block_at_path( array $blocks, array $path ): ?array {
+ if ( empty( $path ) ) {
+ return null;
+ }
+ $current = $blocks;
+ $last = count( $path ) - 1;
+ foreach ( array_values( $path ) as $depth => $idx ) {
+ $idx = (int) $idx;
+ if ( ! isset( $current[ $idx ] ) || ! is_array( $current[ $idx ] ) ) {
+ return null;
+ }
+ if ( $depth === $last ) {
+ return $current[ $idx ];
+ }
+ $inner = $current[ $idx ]['innerBlocks'] ?? array();
+ $current = is_array( $inner ) ? $inner : array();
+ }
+ return null;
+ }
+
+ /**
+ * Whether the given post-alias-normalized block name is a structural
+ * wrapper (matches the same set RSM-142 uses for nest detection).
+ *
+ * @param string $name Normalized block name (e.g. `core/group`).
+ */
+ private static function is_structural_block( string $name ): bool {
+ return in_array(
+ $name,
+ array( 'core/group', 'core/columns', 'core/column', 'core/row' ),
+ true
+ );
+ }
+
+ /**
+ * Stable string key for a path array, used as the choice-map key.
+ *
+ * @param array<int|string> $path Path indices.
+ */
+ private static function path_key( array $path ): string {
+ $encoded = wp_json_encode( array_values( $path ) );
+ return false === $encoded ? '[]' : $encoded;
+ }
+
+ /**
+ * Return the logger instance, lazily creating it the first time.
+ */
+ private static function get_logger(): Email_Editor_Logger_Interface {
+ if ( null === self::$logger ) {
+ self::$logger = new Logger( wc_get_logger() );
+ }
+ return self::$logger;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
index 3bab49498ed..9ed7d696260 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
@@ -399,13 +399,16 @@ class WCTransactionalEmailPostsGenerator {
$post_data = self::build_filtered_post_data( (string) $email_type, $email_data );
// Sync meta stamp for emails participating in template update propagation.
+ // VERSION + LAST_SYNCED_AT are filter-independent and can ride on `meta_input`
+ // during the insert. SOURCE_HASH must reflect the post_content WordPress
+ // actually persisted (post-`content_save_pre` filter chain), so we stamp it
+ // after the insert returns and re-fetch the post to hash its saved content.
$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_data->id );
if ( null !== $sync_config ) {
if ( ! isset( $post_data['meta_input'] ) || ! is_array( $post_data['meta_input'] ) ) {
$post_data['meta_input'] = array();
}
$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ] = (string) $sync_config['version'];
- $post_data['meta_input'][ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY ] = sha1( (string) ( $post_data['post_content'] ?? '' ) );
$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY ] = gmdate( 'Y-m-d H:i:s' );
}
@@ -415,6 +418,22 @@ class WCTransactionalEmailPostsGenerator {
throw new \Exception( esc_html( $post_id->get_error_message() ) );
}
+ if ( null !== $sync_config ) {
+ $saved_post = get_post( $post_id );
+ $saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : (string) ( $post_data['post_content'] ?? '' );
+ update_post_meta(
+ (int) $post_id,
+ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+ sha1( $saved_body )
+ );
+ // Freshly generated posts match canonical core by construction.
+ update_post_meta(
+ (int) $post_id,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC
+ );
+ }
+
$this->template_manager->save_email_template_post_id( $email_type, $post_id );
return $post_id;
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index 081ded321ea..125d3936c8e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Internal\EmailEditor\EmailApiController;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSelectiveApplier;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
@@ -466,6 +467,7 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$baseline_version = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true );
$baseline_source_hash = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
$baseline_synced_at = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true );
+ $baseline_status = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true );
// Simulate a customised post so the content reset is observable.
wp_update_post(
@@ -521,9 +523,9 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
'_wc_email_last_synced_at must not be touched when the email is not sync-enabled.'
);
$this->assertSame(
- '',
+ $baseline_status,
(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
- '_wc_email_template_status must not be set when the email is not sync-enabled.'
+ '_wc_email_template_status must not be touched when the email is not sync-enabled.'
);
}
@@ -670,6 +672,152 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$this->assertSame( 404, $result->get_error_data()['status'] );
}
+ /**
+ * @testdox Should return merged content + revision_id from the apply route.
+ */
+ public function test_apply_route_returns_merged_content_and_revision_id(): void {
+ $email_type = 'customer_new_account';
+
+ $generator = new WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+
+ $post_manager = WCTransactionalEmailPostsManager::get_instance();
+ $post_manager->clear_caches();
+ $post_manager->delete_email_template( $email_type );
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateChangeSummary::reset_cache();
+
+ $post_id = $generator->generate_email_template_if_not_exists( $email_type );
+ $this->assertIsInt( $post_id );
+
+ // Diverge the post so the change-summary has something to apply.
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => "<!-- wp:paragraph -->\n<p>Merchant edit.</p>\n<!-- /wp:paragraph -->",
+ )
+ );
+
+ $request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/apply' );
+ $request->set_param( 'id', $post_id );
+ $request->set_param( 'choices', array() );
+
+ $result = $this->email_api_controller->apply_response( $request );
+
+ $this->assertInstanceOf( \WP_REST_Response::class, $result );
+ $this->assertSame( 200, $result->get_status() );
+
+ $data = $result->get_data();
+ $this->assertIsArray( $data );
+ $this->assertArrayHasKey( 'merged_content', $data );
+ $this->assertArrayHasKey( 'revision_id', $data );
+ $this->assertArrayHasKey( 'version_to', $data );
+ $this->assertArrayHasKey( 'status', $data );
+ $this->assertArrayHasKey( 'structural_skipped', $data );
+ $this->assertSame( 'applied', $data['status'] );
+ $this->assertMatchesRegularExpression( '/^[0-9a-f-]{36}$/', (string) $data['revision_id'] );
+ }
+
+ /**
+ * @testdox Should return 404 from the apply route when no email matches the post.
+ */
+ public function test_apply_route_returns_404_for_unknown_post(): void {
+ $unassociated_post = $this->factory()->post->create_and_get(
+ array(
+ 'post_title' => 'Unknown Email',
+ 'post_name' => 'unknown_email_for_apply',
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'draft',
+ )
+ );
+
+ $request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $unassociated_post->ID . '/apply' );
+ $request->set_param( 'id', $unassociated_post->ID );
+
+ $result = $this->email_api_controller->apply_response( $request );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'woocommerce_email_not_found', $result->get_error_code() );
+ $this->assertSame( 404, $result->get_error_data()['status'] );
+ }
+
+ /**
+ * @testdox Should restore pre-apply content via the undo route.
+ */
+ public function test_undo_route_restores_pre_apply_content(): void {
+ $email_type = 'customer_new_account';
+
+ $generator = new WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+
+ $post_manager = WCTransactionalEmailPostsManager::get_instance();
+ $post_manager->clear_caches();
+ $post_manager->delete_email_template( $email_type );
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateChangeSummary::reset_cache();
+
+ $post_id = $generator->generate_email_template_if_not_exists( $email_type );
+ $this->assertIsInt( $post_id );
+
+ $pre_apply_content = "<!-- wp:paragraph -->\n<p>Merchant edit.</p>\n<!-- /wp:paragraph -->";
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => $pre_apply_content,
+ )
+ );
+
+ $apply_request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/apply' );
+ $apply_request->set_param( 'id', $post_id );
+ $apply_request->set_param( 'choices', array() );
+
+ $apply_result = $this->email_api_controller->apply_response( $apply_request );
+ $this->assertInstanceOf( \WP_REST_Response::class, $apply_result );
+
+ $revision_id = (string) $apply_result->get_data()['revision_id'];
+
+ $undo_request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/undo' );
+ $undo_request->set_param( 'id', $post_id );
+ $undo_request->set_param( 'revision_id', $revision_id );
+
+ $undo_result = $this->email_api_controller->undo_response( $undo_request );
+ $this->assertInstanceOf( \WP_REST_Response::class, $undo_result );
+ $this->assertSame( 200, $undo_result->get_status() );
+
+ $data = $undo_result->get_data();
+ $this->assertIsArray( $data );
+ $this->assertSame( 'restored', $data['status'] );
+ $this->assertSame( $pre_apply_content, $data['restored_content'] );
+ }
+
+ /**
+ * @testdox Should return 410 from the undo route when no snapshot exists.
+ */
+ public function test_undo_route_returns_410_when_no_snapshot(): void {
+ $email_type = 'customer_new_account';
+
+ $generator = new WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+
+ $post_manager = WCTransactionalEmailPostsManager::get_instance();
+ $post_manager->clear_caches();
+ $post_manager->delete_email_template( $email_type );
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ $post_id = $generator->generate_email_template_if_not_exists( $email_type );
+ $this->assertIsInt( $post_id );
+
+ $request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/undo' );
+ $request->set_param( 'id', $post_id );
+ $request->set_param( 'revision_id', 'never-applied' );
+
+ $result = $this->email_api_controller->undo_response( $request );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'undo_unavailable', $result->get_error_code() );
+ $this->assertSame( 410, $result->get_error_data()['status'] );
+ }
+
/**
* @testdox Should register a POST /reset route alongside the existing default-content route.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
index 10700fd80bf..7ce8cb81bff 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
@@ -145,6 +145,31 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should stamp STATUS_IN_SYNC via the classifier after a successful apply_to_post.
+ *
+ * The auto-applier always writes full canonical content, so the classifier
+ * naturally returns IN_SYNC. This test pins the contract that the stamped
+ * status comes through the classifier rather than from a hard-coded literal,
+ * so a future regression that introduces a partial-apply path would trip it.
+ */
+ public function test_apply_to_post_stamps_status_via_classifier(): void {
+ $email_id = 'auto_applier_classifier_path';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+ $this->assertInstanceOf( \WC_Email::class, $email );
+
+ $result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+ $this->assertIsArray( $result );
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ }
+
/**
* apply_to_post() with require_uncustomized=true must return a WP_Error and
* leave the post untouched when the merchant has edited it since stamping.
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
index ecb02832632..29f486575e6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -228,6 +228,11 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
$email_id = 'wc_test_divergence_idempotency';
$post_id = $this->generate_stamped_post( $email_id );
+ // Clear the status the generator stamped at insert so the first
+ // sweep has classification work to do; otherwise both sweeps are
+ // no-ops and the test loses signal on first-write behaviour.
+ delete_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY );
+
$write_count = 0;
$counter = static function ( $check, int $object_id, string $meta_key ) use ( &$write_count, $post_id ) {
if ( $object_id === $post_id && WCEmailTemplateDivergenceDetector::STATUS_META_KEY === $meta_key ) {
@@ -260,8 +265,10 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
$email_id = 'wc_test_divergence_missing_hash';
$post_id = $this->generate_stamped_post( $email_id );
- // Simulate a legacy (pre-RSM-137) post by removing the source-hash stamp.
+ // Simulate a legacy (pre-RSM-137) post by removing both the
+ // source-hash and status stamps the modern generator writes.
delete_post_meta( $post_id, '_wc_email_template_source_hash' );
+ delete_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY );
WCEmailTemplateDivergenceDetector::run_sweep();
@@ -351,6 +358,50 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
do_action( 'init' );
}
+ /**
+ * @testdox Should stamp STATUS_CORE_UPDATED_CUSTOMIZED on a post that differs from canonical core after a stored stamp.
+ */
+ public function test_reclassify_stamps_customized_when_post_differs_from_canonical(): void {
+ $email_id = 'reclassify_customized';
+ $this->register_fixture_email( $email_id );
+
+ $canonical = "<!-- wp:paragraph -->\n<p>Core paragraph.</p>\n<!-- /wp:paragraph -->";
+ $post_html = "<!-- wp:paragraph -->\n<p>Merchant paragraph.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $canonical );
+ $post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+ // Pre-stamp source_hash to a prior canonical so the classifier sees "core moved".
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( "<!-- wp:paragraph -->\n<p>Old core paragraph.</p>\n<!-- /wp:paragraph -->" ) );
+
+ $status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED, $status );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ }
+
+ /**
+ * @testdox Should stamp STATUS_IN_SYNC when the post matches the canonical render at the stored stamp.
+ */
+ public function test_reclassify_stamps_in_sync_when_post_matches_canonical(): void {
+ $email_id = 'reclassify_in_sync';
+ $this->register_fixture_email( $email_id );
+
+ $canonical = "<!-- wp:paragraph -->\n<p>Same on both sides.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $canonical );
+ $post_id = $this->create_woo_email_post( $email_id, $canonical );
+
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( $canonical ) );
+
+ $status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $status );
+ }
+
/**
* Build a WC_Email stub backed by the third-party-with-version.php fixture, inject it
* into WC_Emails::$emails, and opt the email ID into the block-editor filter so the
@@ -427,6 +478,56 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
return $post_id;
}
+ /**
+ * Hook the canonical content filter so `compute_canonical_post_content()`
+ * returns the supplied string for the given email_id, bypassing the
+ * file-rendered template body. Lets tests express "what core would render"
+ * directly inline.
+ *
+ * @param string $email_id The email ID to override content for.
+ * @param string $content The canonical content to inject.
+ */
+ private function use_canonical_content( string $email_id, string $content ): void {
+ add_filter(
+ 'woocommerce_email_content_post_data',
+ static function ( array $post_data, string $type ) use ( $email_id, $content ): array {
+ if ( $type === $email_id ) {
+ $post_data['post_content'] = $content;
+ }
+ return $post_data;
+ },
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Create a `woo_email` post and associate it with the supplied email_id
+ * via the canonical option key the manager expects.
+ *
+ * @param string $email_id The email ID to associate.
+ * @param string $post_content Initial post content.
+ * @return int Post ID.
+ */
+ private function create_woo_email_post( string $email_id, string $post_content ): int {
+ $post_id = wp_insert_post(
+ array(
+ 'post_title' => 'Fixture for ' . $email_id,
+ 'post_name' => $email_id,
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_content' => $post_content,
+ 'post_status' => 'publish',
+ )
+ );
+
+ $this->assertIsInt( $post_id );
+ $this->assertGreaterThan( 0, $post_id );
+
+ $this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
+ return (int) $post_id;
+ }
+
/**
* Remove any stubs we injected into WC_Emails::$emails during the test.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
new file mode 100644
index 00000000000..864c9eb71e8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
@@ -0,0 +1,808 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateChangeSummary;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSelectiveApplier;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+
+/**
+ * Tests for the WCEmailTemplateSelectiveApplier class.
+ */
+class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
+ /**
+ * Absolute path to the change-summary fixtures directory (reused).
+ *
+ * @var string
+ */
+ private string $fixtures_base;
+
+ /**
+ * Keys injected into \WC_Emails::$emails during the current test.
+ *
+ * @var string[]
+ */
+ private array $injected_email_keys = array();
+
+ /**
+ * Transactional email post manager singleton.
+ *
+ * @var WCTransactionalEmailPostsManager
+ */
+ private WCTransactionalEmailPostsManager $posts_manager;
+
+ /**
+ * Setup test case.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+
+ $this->fixtures_base = __DIR__ . '/fixtures/';
+ $this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+
+ $this->posts_manager->clear_caches();
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateChangeSummary::reset_cache();
+ }
+
+ /**
+ * Cleanup after test.
+ */
+ public function tearDown(): void {
+ $this->cleanup_injected_emails();
+
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ remove_all_filters( 'woocommerce_email_content_post_data' );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateChangeSummary::reset_cache();
+ WCEmailTemplateChangeSummary::set_logger( null );
+ WCEmailTemplateSelectiveApplier::set_logger( null );
+
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * One conflict, no `choices` → default is `keep_yours`. Merged content
+ * keeps the merchant's edited paragraph text.
+ */
+ public function test_apply_selectively_keep_yours_default_skips_text_change(): void {
+ $email_id = 'sa_keep_yours_default';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Original copy.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>Edited copy.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'applied', $result['status'] );
+ $this->assertStringContainsString( 'Edited copy.', $result['merged_content'] );
+ $this->assertStringNotContainsString( 'Original copy.', $result['merged_content'] );
+ }
+
+ /**
+ * One conflict with `decision: 'use_core'` → merged paragraph reads core's text.
+ */
+ public function test_apply_selectively_use_core_replaces_text(): void {
+ $email_id = 'sa_use_core';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Core copy.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>Merchant copy.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively(
+ $post_id,
+ array(
+ array(
+ 'path' => array( 0 ),
+ 'decision' => 'use_core',
+ ),
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'applied', $result['status'] );
+ $this->assertStringContainsString( 'Core copy.', $result['merged_content'] );
+ $this->assertStringNotContainsString( 'Merchant copy.', $result['merged_content'] );
+ }
+
+ /**
+ * Core has a paragraph not in post; merged content includes the new
+ * paragraph at the position core has it (auto-resolved Apply core).
+ */
+ public function test_apply_selectively_inserts_core_added_blocks(): void {
+ $email_id = 'sa_added_block';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>First.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>New from core.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Last.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>First.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:paragraph -->\n<p>Last.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertStringContainsString( 'New from core.', $result['merged_content'] );
+ $this->assertStringContainsString( 'First.', $result['merged_content'] );
+ $this->assertStringContainsString( 'Last.', $result['merged_content'] );
+ }
+
+ /**
+ * Pins the "best-effort, append at closest level" fallback inside
+ * {@see WCEmailTemplateSelectiveApplier::insert_block_at_path()}: when
+ * core's path requires descending into an index that doesn't exist on
+ * the post side at an intermediate depth, the new block is appended at
+ * that level rather than dropped.
+ *
+ * The fallback isn't reachable through `apply_selectively()` end-to-end
+ * with current fixtures (the public flow's LCS + structural-wrapper
+ * skip combine to consume the only realistic deep-path scenario), so
+ * this test reaches into the private helper directly via reflection.
+ * Reviewer's risk on PR #64497: without a test, a future refactor could
+ * silently turn the fallback's `$blocks[] = $new_block;` append into a
+ * drop and no fixture would catch it.
+ *
+ * Setup: $blocks = [ Group with empty innerBlocks ]. $path = [ 0, 1, 0 ]
+ * — top-level idx 0 exists (Group), idx 1 at the inner level does NOT,
+ * so the recursive walker hits the fallback at depth=1 before reaching
+ * the leaf. Expectation: the new block lands inside Group's innerBlocks.
+ *
+ * @testdox Should append a deeply-nested added block at the closest reachable level when the post tree is shallower than core's insertion path.
+ */
+ public function test_insert_block_at_path_falls_back_to_closest_level_when_post_is_shallower(): void {
+ $reflection = new \ReflectionClass( WCEmailTemplateSelectiveApplier::class );
+ $method = $reflection->getMethod( 'insert_block_at_path' );
+ $method->setAccessible( true );
+
+ $post_blocks = array(
+ array(
+ 'blockName' => 'core/group',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ 'innerHTML' => '<div class="wp-block-group"></div>',
+ 'innerContent' => array( '<div class="wp-block-group"></div>' ),
+ ),
+ );
+ $path = array( 0, 1, 0 );
+ $new_block = array(
+ 'blockName' => 'core/paragraph',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ 'innerHTML' => '<p>Deeply nested addition.</p>',
+ 'innerContent' => array( '<p>Deeply nested addition.</p>' ),
+ );
+
+ $result = $method->invoke( null, $post_blocks, $path, $new_block );
+
+ $this->assertIsArray( $result );
+ $this->assertCount( 1, $result, 'Top-level shape unchanged: still one Group at the root.' );
+ $this->assertSame( 'core/group', $result[0]['blockName'] );
+
+ $inner = $result[0]['innerBlocks'] ?? array();
+ $this->assertCount(
+ 1,
+ $inner,
+ 'Fallback must append the new block to the Group\'s innerBlocks rather than dropping it.'
+ );
+ $this->assertSame(
+ 'core/paragraph',
+ $inner[0]['blockName'] ?? null,
+ 'Appended block must be the paragraph passed in, not anything else.'
+ );
+ $this->assertSame(
+ '<p>Deeply nested addition.</p>',
+ $inner[0]['innerHTML'] ?? null,
+ 'Block content must survive the append unchanged.'
+ );
+ }
+
+ /**
+ * Post has a custom block not in core; merged content still contains it
+ * (auto-resolved Keep yours — `removed_blocks` are preserved).
+ */
+ public function test_apply_selectively_preserves_merchant_only_blocks(): void {
+ $email_id = 'sa_preserve_merchant_block';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Core only.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>Core only.</p>\n<!-- /wp:paragraph -->\n\n"
+ . "<!-- wp:image -->\n<figure class=\"wp-block-image\"><img src=\"x\"/></figure>\n<!-- /wp:image -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertStringContainsString( 'wp:image', $result['merged_content'], 'Merchant-only image block must be preserved.' );
+ }
+
+ /**
+ * Group-wrap of an existing block: merchant has Group [Paragraph], core has
+ * Paragraph at top level. v1 punts structural changes — merchant's Group
+ * stays; the response surfaces `structural_skipped: true`.
+ */
+ public function test_apply_selectively_skips_structural_changes_in_v1(): void {
+ $email_id = 'sa_structural_skip';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Wrapped.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:group -->\n<div class=\"wp-block-group\">"
+ . "<!-- wp:paragraph -->\n<p>Wrapped.</p>\n<!-- /wp:paragraph -->"
+ . "</div>\n<!-- /wp:group -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertTrue( $result['structural_skipped'], 'Structural delta on a Group wrap must flag structural_skipped.' );
+ $this->assertStringContainsString( 'wp:group', $result['merged_content'], 'Merchant Group wrapper must be preserved.' );
+ }
+
+ /**
+ * After apply, the snapshot meta exists with the original content and a
+ * UUID-shaped revision_id.
+ */
+ public function test_apply_selectively_writes_pre_apply_snapshot(): void {
+ $email_id = 'sa_snapshot';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+ $this->assertIsArray( $result );
+
+ $snapshot = get_post_meta( $post_id, WCEmailTemplateSelectiveApplier::SNAPSHOT_META_KEY, true );
+ $this->assertIsArray( $snapshot );
+ $this->assertSame( $post_content, $snapshot['content'] );
+ $this->assertSame( $result['revision_id'], $snapshot['revision_id'] );
+ $this->assertMatchesRegularExpression( '/^[0-9a-f-]{36}$/', $snapshot['revision_id'] );
+ }
+
+ /**
+ * Successful apply stamps the four sync meta keys.
+ */
+ public function test_apply_selectively_stamps_sync_meta_on_success(): void {
+ $email_id = 'sa_sync_meta';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+ $this->assertIsArray( $result );
+
+ $this->assertSame(
+ $result['version_to'],
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true )
+ );
+ // Empty choices → keep_yours → diverges from canonical → stamp
+ // sha1(canonical), not sha1(merged). See the keep_yours regression
+ // test for the rationale.
+ $this->assertSame(
+ sha1( $core_content ),
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true )
+ );
+ $this->assertMatchesRegularExpression(
+ '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/',
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true )
+ );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ }
+
+ /**
+ * @testdox Should stamp source_hash from the post_content WordPress actually persisted on a use_core apply.
+ *
+ * Pins the saved-content invariant for the `merged_content === $core_content`
+ * branch. The keep_yours branch deliberately stamps `sha1($core_content)`
+ * instead — see the keep_yours regression test below.
+ */
+ public function test_apply_selectively_use_core_stamps_source_hash_from_saved_post_content(): void {
+ $email_id = 'sa_source_hash_after_save_use_core';
+ $this->register_fixture_email( $email_id );
+
+ $canonical = "<!-- wp:paragraph -->\n<p>Canonical.</p>\n<!-- /wp:paragraph -->";
+ $post_html = "<!-- wp:paragraph -->\n<p>Merchant.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $canonical );
+ $post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+ // content_save_pre mutates the body so saved !== in-memory merged.
+ $mutator = static function ( $content ) {
+ return $content . "\n<!-- filter mutated -->";
+ };
+ add_filter( 'content_save_pre', $mutator, 99 );
+
+ try {
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively(
+ $post_id,
+ array(
+ array(
+ 'path' => array( 0 ),
+ 'decision' => 'use_core',
+ ),
+ )
+ );
+ $this->assertIsArray( $result );
+
+ $persisted = get_post( $post_id );
+ $stored_hash = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+
+ $this->assertInstanceOf( \WP_Post::class, $persisted );
+ $this->assertStringContainsString( '<!-- filter mutated -->', (string) $persisted->post_content );
+ $this->assertSame(
+ sha1( (string) $persisted->post_content ),
+ $stored_hash,
+ 'use_core apply must stamp sha1(persisted post_content), not sha1(in-memory merged).'
+ );
+ } finally {
+ remove_filter( 'content_save_pre', $mutator, 99 );
+ }//end try
+ }
+
+ /**
+ * @testdox Should stamp source_hash to sha1(canonical) and status to core_updated_customized when the merchant keeps any of their version.
+ *
+ * Regression: stamping sha1(saved post_content) after a keep_yours apply
+ * made the next divergence sweep see "no merchant edits since sync" →
+ * STATUS_CORE_UPDATED_UNCUSTOMIZED → auto-applier silently overwrote the
+ * merchant's customisation on the next core bump.
+ */
+ public function test_apply_selectively_keep_yours_stamps_canonical_hash_and_customized_status(): void {
+ $email_id = 'sa_keep_yours_canonical_hash';
+ $this->register_fixture_email( $email_id );
+
+ $canonical = "<!-- wp:paragraph -->\n<p>Canonical copy from core.</p>\n<!-- /wp:paragraph -->";
+ $post_html = "<!-- wp:paragraph -->\n<p>Merchant-edited copy.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $canonical );
+ $post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+ $this->assertIsArray( $result );
+
+ $persisted = get_post( $post_id );
+ $stored_hash = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+ $stored_status = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true );
+
+ $this->assertInstanceOf( \WP_Post::class, $persisted );
+ $this->assertSame( sha1( $canonical ), $stored_hash );
+ $this->assertNotSame(
+ sha1( (string) $persisted->post_content ),
+ $stored_hash,
+ 'keep_yours must NOT stamp sha1(saved post_content) — would mis-classify on next core bump.'
+ );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ $stored_status
+ );
+ }
+
+ /**
+ * @testdox Should restore post_content and consume the snapshot meta on undo.
+ *
+ * Apply → undo round-trip: post_content matches the original and the
+ * snapshot meta is consumed. Status is recomputed by the classifier
+ * after the restore (see `test_undo_reclassifies_status_after_restoring_snapshot`
+ * for the core-moved scenario), so this test only pins the
+ * content-and-snapshot invariants — not a stale "prior status survives"
+ * assertion that the snapshot used to carry.
+ */
+ public function test_undo_restores_pre_apply_snapshot(): void {
+ $email_id = 'sa_undo_round_trip';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $apply_result = WCEmailTemplateSelectiveApplier::apply_selectively(
+ $post_id,
+ array(
+ array(
+ 'path' => array( 0 ),
+ 'decision' => 'use_core',
+ ),
+ )
+ );
+ $this->assertIsArray( $apply_result );
+
+ $undo_result = WCEmailTemplateSelectiveApplier::undo( $post_id, $apply_result['revision_id'] );
+
+ $this->assertIsArray( $undo_result );
+ $this->assertSame( 'restored', $undo_result['status'] );
+ $this->assertSame( $post_content, $undo_result['restored_content'] );
+
+ $persisted = get_post( $post_id );
+ $this->assertInstanceOf( \WP_Post::class, $persisted );
+ $this->assertSame( $post_content, $persisted->post_content );
+
+ // Status meta must be populated after undo; the precise classifier output is
+ // pinned by `test_undo_reclassifies_status_after_restoring_snapshot`.
+ $this->assertNotEmpty( get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+
+ $this->assertSame(
+ '',
+ (string) get_post_meta( $post_id, WCEmailTemplateSelectiveApplier::SNAPSHOT_META_KEY, true ),
+ 'Snapshot meta must be consumed by undo.'
+ );
+ }
+
+ /**
+ * @testdox Should reclassify after undo rather than restore the stored prior_status verbatim.
+ */
+ public function test_undo_reclassifies_status_after_restoring_snapshot(): void {
+ $email_id = 'undo_reclassify';
+ $this->register_fixture_email( $email_id );
+
+ $pre_apply_canonical = "<!-- wp:paragraph -->\n<p>Old canonical.</p>\n<!-- /wp:paragraph -->";
+ $post_html = "<!-- wp:paragraph -->\n<p>Old canonical.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $pre_apply_canonical );
+ $post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+ // Stamp a matching source_hash so initial state is in_sync.
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( $pre_apply_canonical ) );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC );
+
+ // Apply against the same canonical (no real diff — just exercise the path so a snapshot exists).
+ $apply_result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+ $this->assertIsArray( $apply_result );
+
+ // Now mutate the canonical underneath — simulating a core release between apply and undo.
+ $new_canonical = "<!-- wp:paragraph -->\n<p>New canonical from a core release.</p>\n<!-- /wp:paragraph -->";
+ $this->use_canonical_content( $email_id, $new_canonical );
+
+ $undo_result = WCEmailTemplateSelectiveApplier::undo( $post_id, $apply_result['revision_id'] );
+ $this->assertIsArray( $undo_result );
+
+ // After undo, the post is back to its pre-apply content but core has moved.
+ // Classifier output: stored hash != current canonical → core moved; post matches stored hash → uncustomized.
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'undo() must reclassify against current canonical, not blindly restore the snapshot prior_status.'
+ );
+ }
+
+ /**
+ * Undo without a prior apply (no snapshot) returns 410.
+ */
+ public function test_undo_returns_410_when_no_snapshot(): void {
+ $email_id = 'sa_undo_no_snapshot';
+ $this->register_fixture_email( $email_id );
+
+ $content = "<!-- wp:paragraph -->\n<p>Untouched.</p>\n<!-- /wp:paragraph -->";
+ $this->use_canonical_content( $email_id, $content );
+ $post_id = $this->create_woo_email_post( $email_id, $content );
+
+ $result = WCEmailTemplateSelectiveApplier::undo( $post_id, 'never-applied' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'undo_unavailable', $result->get_error_code() );
+ $this->assertSame( 410, $result->get_error_data()['status'] );
+ }
+
+ /**
+ * Undo with a stale revision_id (no longer matches the latest snapshot)
+ * returns 410.
+ */
+ public function test_undo_revision_id_mismatch_returns_410(): void {
+ $email_id = 'sa_undo_stale';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $apply_result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+ $this->assertIsArray( $apply_result );
+
+ $result = WCEmailTemplateSelectiveApplier::undo( $post_id, 'a-different-uuid' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'undo_unavailable', $result->get_error_code() );
+ $this->assertSame( 410, $result->get_error_data()['status'] );
+ }
+
+ /**
+ * `is_fallback: true` from the change-summary (e.g. post not in registry,
+ * inversion guard tripped) refuses to apply with 422.
+ */
+ public function test_apply_selectively_returns_422_when_summary_is_fallback(): void {
+ // No fixture email registered, so the change-summary returns is_fallback: true.
+ $post_id = $this->create_woo_email_post(
+ 'sa_unregistered',
+ "<!-- wp:paragraph -->\n<p>Anything.</p>\n<!-- /wp:paragraph -->"
+ );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $status_code = $result->get_error_data()['status'] ?? null;
+ $this->assertContains(
+ $status_code,
+ array( 404, 422 ),
+ 'Unregistered post must return either 404 (no email) or 422 (no actionable summary).'
+ );
+ }
+
+ /**
+ * Apply followed by a second apply: the snapshot is overwritten (single-
+ * step undo), and the second revision_id is what undo requires.
+ */
+ public function test_second_apply_overwrites_pre_apply_snapshot(): void {
+ $email_id = 'sa_second_apply';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = "<!-- wp:paragraph -->\n<p>Core.</p>\n<!-- /wp:paragraph -->";
+ $post_content = "<!-- wp:paragraph -->\n<p>Yours.</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $first = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+ $second = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $first );
+ $this->assertIsArray( $second );
+ $this->assertNotSame( $first['revision_id'], $second['revision_id'] );
+
+ // Undo with the FIRST revision_id should now fail (snapshot was overwritten).
+ $undo_first = WCEmailTemplateSelectiveApplier::undo( $post_id, $first['revision_id'] );
+ $this->assertInstanceOf( \WP_Error::class, $undo_first );
+
+ // Undo with the SECOND revision_id succeeds.
+ $undo_second = WCEmailTemplateSelectiveApplier::undo( $post_id, $second['revision_id'] );
+ $this->assertIsArray( $undo_second );
+ }
+
+ /**
+ * Apply rewrites the deprecated `wp:woo/email-content` namespace to the
+ * canonical `wp:woocommerce/email-content` form, including the
+ * `wp-block-woo-email-content` CSS class on the inner div. The
+ * migration runs unconditionally during apply (independent of `choices`)
+ * because `woo/email-content` is a known alias of the canonical block,
+ * not a customisation worth preserving. The response surfaces it via
+ * `aliases_migrated`.
+ */
+ public function test_apply_selectively_migrates_woo_email_content_to_woocommerce_namespace(): void {
+ $email_id = 'sa_alias_migration';
+ $this->register_fixture_email( $email_id );
+
+ $core_content = '<!-- wp:woocommerce/email-content {"lock":{"move":false,"remove":true}} -->'
+ . '<div class="wp-block-woocommerce-email-content"> ##WOO_CONTENT## </div>'
+ . '<!-- /wp:woocommerce/email-content -->';
+
+ $post_content = '<!-- wp:woo/email-content {"lock":{"move":false,"remove":true}} -->'
+ . '<div class="wp-block-woo-email-content"> ##WOO_CONTENT## </div>'
+ . '<!-- /wp:woo/email-content -->';
+
+ $this->use_canonical_content( $email_id, $core_content );
+ $post_id = $this->create_woo_email_post( $email_id, $post_content );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'applied', $result['status'] );
+
+ $merged = (string) $result['merged_content'];
+ $this->assertStringContainsString( 'wp:woocommerce/email-content', $merged, 'Block name comment must be migrated to canonical form.' );
+ $this->assertStringContainsString( 'wp-block-woocommerce-email-content', $merged, 'CSS class must be migrated to canonical form.' );
+ $this->assertStringNotContainsString( 'wp:woo/email-content', $merged, 'Deprecated namespace comment must not survive the apply.' );
+ $this->assertStringNotContainsString( 'wp-block-woo-email-content', $merged, 'Deprecated CSS class must not survive the apply.' );
+
+ // Block attrs and inner content are preserved — only the namespace label changes.
+ $this->assertStringContainsString( '"lock":{"move":false,"remove":true}', $merged, 'Block attrs must be preserved.' );
+ $this->assertStringContainsString( '##WOO_CONTENT##', $merged, 'Placeholder content must be preserved.' );
+
+ $this->assertContains( 'woo/email-content', $result['aliases_migrated'], 'Response must surface the migration via aliases_migrated.' );
+ $this->assertCount( 1, $result['aliases_migrated'], 'aliases_migrated must be deduped.' );
+
+ // The persisted post reflects the migrated content too.
+ $persisted = get_post( $post_id );
+ $this->assertInstanceOf( \WP_Post::class, $persisted );
+ $this->assertStringContainsString( 'wp:woocommerce/email-content', $persisted->post_content );
+ $this->assertStringNotContainsString( 'wp:woo/email-content', $persisted->post_content );
+ }
+
+ /**
+ * @testdox Should NOT stamp STATUS_IN_SYNC after an empty-choices apply when the merged content still differs from canonical.
+ */
+ public function test_apply_selectively_with_empty_choices_does_not_falsely_stamp_in_sync(): void {
+ $email_id = 'apply_keep_yours_status';
+ $this->register_fixture_email( $email_id );
+
+ $canonical = "<!-- wp:paragraph -->\n<p>Hi there [woocommerce/customer-username],</p>\n<!-- /wp:paragraph -->";
+ $post_html = "<!-- wp:paragraph -->\n<p>Hello, [woocommerce/customer-username]!</p>\n<!-- /wp:paragraph -->";
+
+ $this->use_canonical_content( $email_id, $canonical );
+ $post_id = $this->create_woo_email_post( $email_id, $post_html );
+
+ // Pre-stamp a stale source_hash so the classifier sees "core moved".
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( "<!-- wp:paragraph -->\n<p>Old core copy.</p>\n<!-- /wp:paragraph -->" ) );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( $post_id, array() );
+ $this->assertIsArray( $result );
+
+ $this->assertNotSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'keep_yours apply must not stamp in_sync — post still differs from canonical.'
+ );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ }
+
+ /**
+ * Apply on a post outside the sync registry returns 404 (no email
+ * resolved) — same gate as the change-summary endpoint.
+ */
+ public function test_apply_selectively_returns_404_for_post_with_no_email_type(): void {
+ $unassociated = $this->factory()->post->create_and_get(
+ array(
+ 'post_title' => 'Unassociated',
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'draft',
+ )
+ );
+
+ $result = WCEmailTemplateSelectiveApplier::apply_selectively( (int) $unassociated->ID, array() );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'email_not_found', $result->get_error_code() );
+ $this->assertSame( 404, $result->get_error_data()['status'] );
+ }
+
+ /**
+ * Register a fixture email and wire up the sync registry to pick it up.
+ *
+ * @param string $email_id Email ID to assign to the stub.
+ * @return \WC_Email Registered fixture email instance.
+ */
+ private function register_fixture_email( string $email_id ): \WC_Email {
+ $stub = $this->getMockBuilder( \WC_Email::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $stub->method( 'get_title' )->willReturn( 'Fixture email for selective-applier tests' );
+ $stub->method( 'get_description' )->willReturn( 'Fixture email used to cover selective-applier scenarios.' );
+ $stub->id = $email_id;
+ $stub->template_base = $this->fixtures_base;
+ $stub->template_block = 'block/third-party-with-version.php';
+ $stub->template_plain = null;
+
+ $class_key = 'WC_Test_Email_' . $email_id;
+
+ $emails_container = \WC_Emails::instance();
+ $reflection = new \ReflectionClass( $emails_container );
+ $property = $reflection->getProperty( 'emails' );
+ $property->setAccessible( true );
+ $current = $property->getValue( $emails_container );
+ $current[ $class_key ] = $stub;
+ $property->setValue( $emails_container, $current );
+
+ $this->injected_email_keys[] = $class_key;
+
+ add_filter(
+ 'woocommerce_transactional_emails_for_block_editor',
+ static function ( array $emails ) use ( $email_id ): array {
+ if ( ! in_array( $email_id, $emails, true ) ) {
+ $emails[] = $email_id;
+ }
+ return $emails;
+ }
+ );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ return $stub;
+ }
+
+ /**
+ * Hook the canonical content filter so `compute_canonical_post_content()`
+ * returns the supplied string for the given email_id, bypassing the
+ * file-rendered template body.
+ *
+ * @param string $email_id The email ID to override content for.
+ * @param string $content The canonical content to inject.
+ */
+ private function use_canonical_content( string $email_id, string $content ): void {
+ add_filter(
+ 'woocommerce_email_content_post_data',
+ static function ( array $post_data, string $type ) use ( $email_id, $content ): array {
+ if ( $type === $email_id ) {
+ $post_data['post_content'] = $content;
+ }
+ return $post_data;
+ },
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Create a `woo_email` post and associate it with the supplied email_id.
+ *
+ * @param string $email_id The email ID to associate.
+ * @param string $post_content Initial post content.
+ * @return int Post ID.
+ */
+ private function create_woo_email_post( string $email_id, string $post_content ): int {
+ $post_id = wp_insert_post(
+ array(
+ 'post_title' => 'Fixture for ' . $email_id,
+ 'post_name' => $email_id,
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_content' => $post_content,
+ 'post_status' => 'publish',
+ )
+ );
+
+ $this->assertIsInt( $post_id );
+ $this->assertGreaterThan( 0, $post_id );
+
+ $this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
+ return (int) $post_id;
+ }
+
+ /**
+ * Remove any stubs we injected into WC_Emails::$emails during the test.
+ */
+ private function cleanup_injected_emails(): void {
+ if ( empty( $this->injected_email_keys ) ) {
+ return;
+ }
+
+ $emails_container = \WC_Emails::instance();
+ $reflection = new \ReflectionClass( $emails_container );
+ $property = $reflection->getProperty( 'emails' );
+ $property->setAccessible( true );
+ $current = $property->getValue( $emails_container );
+
+ foreach ( $this->injected_email_keys as $class_key ) {
+ unset( $current[ $class_key ] );
+ }
+
+ $property->setValue( $emails_container, $current );
+ $this->injected_email_keys = array();
+ }
+}