Commit d8628b8e969 for woocommerce
commit d8628b8e969e722dd25d2c12454cd7aab7dad342
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date: Tue May 12 19:52:25 2026 +0100
[Email Editor] Add Tracks instrumentation for the update flow (#64759)
* [Email Editor] Stamp backfill-complete option on fresh installs (RSM-145)
Fresh WooCommerce installs never cross the 10.8 db-update boundary, so the
RSM-149 backfill migration never runs and `BACKFILL_COMPLETE_OPTION` is
never written. That leaves `WCEmailTemplateDivergenceDetector::run_sweep()`
permanently dormant on every site installed on 10.9+, which would also
silently disable the RSM-145 Tracks instrumentation built on top of it.
Hook `woocommerce_newly_installed` and stamp the option there. A fresh
install has no legacy posts to backfill, so the migration is trivially
complete — recording that here is the truthful statement.
* [Email Editor] Add WCEmailTemplateSyncTracker class (RSM-145)
Central tracker for the block-email update flow. Owns the shared payload
shape that every Tracks event for this feature carries, the
suppress-during-backfill guard, and the per-(post_id, template_version_to)
dedup transient that guarantees `_update_available` fires at most once
per tuple.
Surfaces four public methods, one per firing site:
- `record_update_available()` — used by the divergence detector after a
status flip to `core_updated_customized` at a newer registry version.
- `record_auto_applied()` — used by the auto-applier on successful write.
Static extensions: `applied_from='auto'`, `had_customizations=false`.
- `record_selective_applied()` — used by the selective applier on
successful write. Static extensions: `applied_from='selective_rest'`,
`had_customizations=true`.
- `on_backfill_complete()` — listener body for the existing
`woocommerce_email_template_sync_backfill_complete` action, fires the
one-shot `_backfill_completed` event. Payload omits per-case counters
and elapsed_ms (the backfill class is in the 10.8 feature freeze).
Wires through `WC_Tracks::record_event()` by default but accepts a
test-only recorder override (mirrors the existing `set_logger` seam on
the detector) because `WC_Tracks::record_event()` short-circuits early
under the PHPUnit user.
* [Email Editor] Fire _update_available from reclassify() (RSM-145)
When a post transitions to `core_updated_customized` at a newer registry
version than the merchant last reviewed, fire
`woocommerce_block_email_update_available` through the tracker. Gates
on (status flip, version advance) so a repeat sweep at the same core
version is a no-op. Per-tuple dedup + suppress-during-backfill live in
the tracker, not here.
* [Email Editor] Fire _update_applied from auto-applier success (RSM-145)
After a successful `apply_to_post()` write, fire
`woocommerce_block_email_update_applied` through the tracker with
`applied_from='auto'`, `auto_resolved=true`, `had_customizations=false`.
Static-per-call-site: the auto-applier only acts on
`core_updated_uncustomized` posts.
* [Email Editor] Fire _update_applied from selective-applier success (RSM-145)
After a successful `apply_selectively()` write, fire
`woocommerce_block_email_update_applied` through the tracker with
`applied_from='selective_rest'`, `auto_resolved=false`,
`had_customizations=true`. Static-per-call-site: the selective applier
only acts on `core_updated_customized` posts.
* [Email Editor] Register backfill-complete Tracks listener (RSM-145)
Hook `WCEmailTemplateSyncTracker::on_backfill_complete` on the existing
`woocommerce_email_template_sync_backfill_complete` action so the
`_backfill_completed` event fires exactly once per site when the
RSM-149 sync-meta backfill finalises. The backfill class is in the
10.8 feature freeze, so this is the lightest-touch way to surface
the migration-finished signal.
* [Email Editor] Extract shared Tracks payload helper (RSM-145)
Move `buildSharedTracksPayload` out of `use-update-banner.ts` into a new
`tracks/build-shared-payload.ts` module so the list-page `<UpdatesCell>`
can reuse the same helper without pulling the banner hook.
Two wire-shape changes carried in here (RSM-145 spec §15):
- Rename `email_type` -> `email_id`. Matches the rest of the codebase
and removes ambiguity with `post_type` ('woo_email').
- Drop `source_hash_from`. Redundant with `template_version_from`,
`was_backfilled`, and `had_customizations`. `source_hash_to` stays
as the third-party-integrator-filter canary. The banner's internal
`had_customizations` computation now reads
`_wc_email_template_source_hash` from `record.meta` directly.
Zero production cost: RSM-145 hasn't shipped, no dashboards consume
these events yet. PR description carries the heads-up for the
dashboards-builder.
* [Email Editor] Fire _update_viewed from list-page cell (RSM-145)
When the email list renders the "Review update" indicator for a
sync-eligible row, fire `woocommerce_block_email_update_viewed` with
`viewed_from='email_list'`. Deduped per (post_id, version_to) in
`sessionStorage` so re-renders and unmount/remount cycles within a
session don't refire — but a refresh starts fresh, matching the
banner's store-backed dedup behaviour.
Adds a `wasBackfilled` field to `EmailType` projected from
`_wc_email_backfilled` meta in the listing-data hook so the
`was_backfilled` payload key is available from list-cell side too.
`source_hash_to` is intentionally `null` on the list-side event: the
list payload is built from server-rendered slotfill data and would
otherwise need a `/change-summary` REST round-trip per row. The
corresponding banner `_viewed` (fired when the merchant opens the
editor) carries the hash for third-party-filter cohort analysis.
* Add changelog entry for RSM-145
* Fix class-close brace spacing in tracker test
PSR2.Classes.ClassDeclaration.CloseBraceAfterBody: the closing class
brace must follow the last method's body without an intermediate blank
line. Per-file `lint:php:changes` missed this; only branch-level
`lint:changes:branch` (and CI) catches it.
* Gate auto-applier _update_applied firing on require_uncustomized
`apply_to_post()` is shared with the reset endpoint via
`require_uncustomized=false`. The unconditional `record_auto_applied()`
call after the apply was tagging reset operations as `applied_from=auto`,
which doesn't match the event's documented semantics (the reset surface
is not in RSM-145's event taxonomy).
Only fire the tracker on the auto-applier path. Adds two tests: one that
asserts the auto path fires `_update_applied`, one that asserts the reset
path does NOT.
* Silent-swallow Throwables inside tracker record()
A throw from the Tracks pipeline (third-party filter on
`woocommerce_tracks_event_properties` mishandling the payload, a
misconfigured Tracks client, etc.) used to bubble up into the caller —
which would surface as a 500 from the selective-applier REST endpoint
even though the post had already been saved and reclassified.
Wrap the call in a try/catch and intentionally drop the exception. A
failed Tracks event isn't actionable for the merchant or operator, and
logging per event would generate noise out of proportion to its
diagnostic value.
* Add one-shot guard on _backfill_completed Tracks event
`WCEmailTemplateSyncBackfill::run()` always calls `finalize()`, which
fires `BACKFILL_COMPLETE_ACTION` every invocation. In production
`run()` is wired to a 10.8 db-update callback that runs once per site,
but manual re-runs (wp-cli, tests, future site-tools) would re-fire
the action and double-count the migration in analytics.
Guard the tracker listener with a persistent option so the event
fires at most once per site regardless of how `run()` is invoked.
* Tighten reset-path and throw-recorder tracker tests
Two test-quality nits from review:
1. The reset-path silence test could false-pass if `apply_to_post()`
bailed early (no events fired). Capture the return value and
assert it succeeded before asserting Tracks silence.
2. The throw-recorder spy used a zero-arg closure. PHP 8.x silently
tolerates the extra arguments, but the mismatched signature
obscures the recorder interface used everywhere else in this
file. Align to `(string $event_name, array $payload)`.
* Wrap build_base_payload() body in Throwable catch
The previous try/catch in `record()` only protected the
`WC_Tracks::record_event()` call, not `build_base_payload()` itself.
A throw from any of the meta-lookups or registry/manager calls inside
the payload builder would still bubble up to the selective-applier
REST endpoint and surface as a 500 after the apply had already
persisted.
Wrap the whole payload-building body in a `\Throwable` catch and
return `null` on failure — every `record_*` caller already treats
`null` as "skip this event," keeping the Tracks surface fire-and-
forget. The narrower inner try/catch around
`compute_canonical_post_content()` stays put: that path deliberately
still emits the event with an empty `source_hash_to`.
* Refine block-email Tracks events per pre-merge review
- reclassify(): fire `_update_available` before the idempotency
early-return so a long-divergent post emits one event per release
boundary, not a single lifetime event. Regression test added.
- Replace per-row list `_viewed` with a single page-level
`woocommerce_block_email_list_viewed` carrying `eligible_count` and
`total_count` from EmailListingFill.
- Tighten the `_update_available` transient-ordering comment so it
matches reality — record() already swallows throws.
- Extract `applied_from` / `viewed_from` JS string literals as named
constants in tracks/build-shared-payload.ts, paralleling the PHP
WCEmailTemplateSyncTracker::APPLIED_FROM_* constants.
- Move the cell test's removed _viewed assertions to a new
settings-email-listing-slotfill.test.tsx covering the aggregate event.
* Fix list-page _list_viewed eligible_count always reporting zero
The aggregate `woocommerce_block_email_list_viewed` event computes
`eligible_count` by filtering `emailTypes` through `shouldShowReviewUpdate`,
but the slotfill JSON only carried `current_version` server-side — the
`template_status` / `template_version` / `was_backfilled` fields it reads
were only populated via REST enrichment in `useTransactionalEmails`, which
runs below the slotfill component. As a result, every page-load fired the
event with `eligible_count=0` regardless of how many posts were divergent.
- Project the three meta fields onto the slotfill `$email_types[]` payload
in `class-wc-settings-emails.php`, sourced from post meta via the
detector's constant keys.
- Add an exported `normalizeEmailTypePayload` helper that maps snake_case
to camelCase at JSON parse time in `registerSettingsEmailListingFill`,
so the React tree receives properly-shaped data immediately.
- Add regression-witness tests: one renders the raw snake_case payload
through `EmailListingFill` and pins the broken `eligible_count=0`
behavior; the companion test pipes the same payload through
`normalizeEmailTypePayload` and pins the corrected `eligible_count=1`.
Plus coverage for boolean/numeric `was_backfilled` and missing fields.
Discovered during manual browser testing of the RSM-145 post-review patch.
REST enrichment still runs and overrides these values once it resolves —
both sources read from the same post meta, so they always agree.
* Rename block-email Tracks events to drop woocommerce_ prefix
---------
Co-authored-by: PZ01 <patrick.zielinski@automattic.com>
diff --git a/plugins/woocommerce/changelog/rsm-145-tracks-block-email-update b/plugins/woocommerce/changelog/rsm-145-tracks-block-email-update
new file mode 100644
index 00000000000..148c4fdcb84
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-145-tracks-block-email-update
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add Tracks instrumentation for the block-email update flow: server-side `_available`, `_applied` (auto + selective), and `_backfill_completed` events, plus list-page `_viewed`. Also stamps the backfill-complete option on fresh installs so the divergence sweep is not permanently dormant.
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx
new file mode 100644
index 00000000000..2cf2e5a6f5a
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx
@@ -0,0 +1,262 @@
+/**
+ * Tests for the list-page aggregate `_list_viewed` Tracks event fired by
+ * `EmailListingFill` on mount (RSM-145).
+ *
+ * The list emits one page-level event per browser session — not one per row —
+ * with `eligible_count` and `total_count` only. Per-post drilldown is covered
+ * by the editor-banner `_viewed` event.
+ */
+
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import {
+ EmailListingFill,
+ normalizeEmailTypePayload,
+ type EmailType,
+} from '../settings-email-listing-slotfill';
+
+const recordEventMock = jest.fn();
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: ( name: string, payload: Record< string, unknown > ) =>
+ recordEventMock( name, payload ),
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+ createSlotFill: () => ( {
+ Fill: ( { children }: { children: React.ReactNode } ) => (
+ <div>{ children }</div>
+ ),
+ } ),
+ Button: ( { children }: { children: React.ReactNode } ) => (
+ <button>{ children }</button>
+ ),
+} ) );
+
+jest.mock( '../settings-email-listing-listview', () => ( {
+ ListView: () => <div data-testid="listview" />,
+} ) );
+
+const baseEmail: EmailType = {
+ id: 'new-order',
+ post_id: '123',
+ title: 'New order',
+ description: '',
+ enabled: true,
+ manual: false,
+ email_key: 'new_order',
+ recipients: { to: '', cc: '', bcc: '' },
+ status: 'enabled',
+ templateStatus: null,
+ templateVersion: null,
+ currentVersion: null,
+ wasBackfilled: false,
+};
+
+const eligibleEmail: EmailType = {
+ ...baseEmail,
+ id: 'customer-processing',
+ post_id: '456',
+ templateStatus: 'core_updated_customized',
+ templateVersion: '10.6.0',
+ currentVersion: '10.7.0',
+};
+
+describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
+ beforeEach( () => {
+ recordEventMock.mockClear();
+ window.sessionStorage.clear();
+ } );
+
+ it( 'fires one block_email_list_viewed on mount with eligible_count and total_count', () => {
+ render(
+ <EmailListingFill
+ emailTypes={ [ baseEmail, eligibleEmail ] }
+ editTemplateUrl={ null }
+ />
+ );
+
+ expect( recordEventMock ).toHaveBeenCalledTimes( 1 );
+ expect( recordEventMock ).toHaveBeenCalledWith(
+ 'block_email_list_viewed',
+ expect.objectContaining( {
+ viewed_from: 'email_list',
+ eligible_count: 1,
+ total_count: 2,
+ } )
+ );
+ } );
+
+ it( 'dedups within a session: a second mount in the same tab does not refire', () => {
+ const { unmount } = render(
+ <EmailListingFill
+ emailTypes={ [ eligibleEmail ] }
+ editTemplateUrl={ null }
+ />
+ );
+ unmount();
+ render(
+ <EmailListingFill
+ emailTypes={ [ eligibleEmail ] }
+ editTemplateUrl={ null }
+ />
+ );
+
+ expect( recordEventMock ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'still fires when sessionStorage is unavailable (privacy-mode fallback)', () => {
+ const setItemSpy = jest
+ .spyOn( window.sessionStorage.__proto__, 'setItem' )
+ .mockImplementation( () => {
+ throw new Error( 'quota / privacy mode' );
+ } );
+
+ try {
+ render(
+ <EmailListingFill
+ emailTypes={ [ eligibleEmail ] }
+ editTemplateUrl={ null }
+ />
+ );
+
+ expect( recordEventMock ).toHaveBeenCalledTimes( 1 );
+ } finally {
+ setItemSpy.mockRestore();
+ }
+ } );
+
+ it( 'reports eligible_count=0 when no rows are eligible', () => {
+ render(
+ <EmailListingFill
+ emailTypes={ [ baseEmail, baseEmail ] }
+ editTemplateUrl={ null }
+ />
+ );
+
+ expect( recordEventMock ).toHaveBeenCalledWith(
+ 'block_email_list_viewed',
+ expect.objectContaining( { eligible_count: 0, total_count: 2 } )
+ );
+ } );
+} );
+
+/**
+ * Regression-witness tests for the snake_case→camelCase projection done by
+ * `normalizeEmailTypePayload`.
+ *
+ * During manual testing of the RSM-145 list-page `_list_viewed` event we
+ * discovered that the slotfill's raw `data-email-types` JSON carries snake_case
+ * keys (`template_status`, `template_version`, `current_version`,
+ * `was_backfilled`) — but `EmailListingFill.useEffect` calls
+ * `shouldShowReviewUpdate(post)` which reads the camelCase TS fields. Without
+ * a projection step, every eligible post is silently classified as ineligible
+ * and `eligible_count` reports 0 regardless of how many posts are actually
+ * divergent.
+ *
+ * Two tests below pin this down:
+ * 1. Reproduction: a raw payload object missing camelCase fields renders as
+ * `eligible_count: 0` through `EmailListingFill`. This is the broken
+ * shape that previously reached the component before the fix.
+ * 2. Fix verification: piping the same raw payload through
+ * `normalizeEmailTypePayload` first yields `eligible_count: 1`. This is
+ * what `registerSettingsEmailListingFill` now does at JSON-parse time.
+ */
+describe( 'normalizeEmailTypePayload — regression for eligible_count=0', () => {
+ const rawDivergentRow = {
+ // Exact shape PHP serializes into `data-email-types` (snake_case keys
+ // for the template-sync meta fields; other fields match the TS type
+ // case directly).
+ id: 'customer_processing_order',
+ post_id: '18',
+ title: 'Processing order',
+ description: '',
+ enabled: true,
+ manual: false,
+ email_key: 'customer_processing_order',
+ recipients: { to: '', cc: '', bcc: '' },
+ status: 'enabled',
+ template_status: 'core_updated_customized',
+ template_version: '9.4.0-test',
+ current_version: '10.7.0',
+ was_backfilled: false,
+ };
+
+ beforeEach( () => {
+ recordEventMock.mockClear();
+ window.sessionStorage.clear();
+ } );
+
+ it( 'witness: rendering raw snake_case payload directly yields eligible_count=0 (the regression we observed in browser)', () => {
+ // Casting to EmailType bypasses the type system to reproduce the
+ // runtime shape that reached EmailListingFill before the fix.
+ const rawAsEmailType = rawDivergentRow as unknown as EmailType;
+
+ render(
+ <EmailListingFill
+ emailTypes={ [ rawAsEmailType ] }
+ editTemplateUrl={ null }
+ />
+ );
+
+ expect( recordEventMock ).toHaveBeenCalledWith(
+ 'block_email_list_viewed',
+ expect.objectContaining( {
+ eligible_count: 0,
+ total_count: 1,
+ } )
+ );
+ } );
+
+ it( 'fix: normalizeEmailTypePayload projects snake_case meta so eligible_count=1', () => {
+ const normalized = normalizeEmailTypePayload( rawDivergentRow );
+
+ // Projection writes the camelCase fields shouldShowReviewUpdate reads.
+ expect( normalized.templateStatus ).toBe( 'core_updated_customized' );
+ expect( normalized.templateVersion ).toBe( '9.4.0-test' );
+ expect( normalized.currentVersion ).toBe( '10.7.0' );
+ expect( normalized.wasBackfilled ).toBe( false );
+
+ render(
+ <EmailListingFill
+ emailTypes={ [ normalized ] }
+ editTemplateUrl={ null }
+ />
+ );
+
+ expect( recordEventMock ).toHaveBeenCalledWith(
+ 'block_email_list_viewed',
+ expect.objectContaining( {
+ eligible_count: 1,
+ total_count: 1,
+ } )
+ );
+ } );
+
+ it( 'projects was_backfilled=1 (number) and true (bool) consistently', () => {
+ const numericTrue = normalizeEmailTypePayload( {
+ ...rawDivergentRow,
+ was_backfilled: 1,
+ } );
+ const boolTrue = normalizeEmailTypePayload( {
+ ...rawDivergentRow,
+ was_backfilled: true,
+ } );
+
+ expect( numericTrue.wasBackfilled ).toBe( true );
+ expect( boolTrue.wasBackfilled ).toBe( true );
+ } );
+
+ it( 'treats missing template_status as null (not undefined)', () => {
+ const { template_status: _omit, ...withoutStatus } = rawDivergentRow;
+ const normalized = normalizeEmailTypePayload( withoutStatus );
+
+ expect( normalized.templateStatus ).toBeNull();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
index 245cb8122f6..9f4c861490b 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
@@ -45,6 +45,7 @@ const baseEmail: EmailType = {
templateStatus: null,
templateVersion: null,
currentVersion: null,
+ wasBackfilled: false,
};
describe( '<UpdatesCell>', () => {
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
index bce10524a99..a788e4f8c33 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
@@ -106,6 +106,12 @@ export const useTransactionalEmails = (
? rawVersion
: null;
+ const rawBackfilled = meta?._wc_email_backfilled;
+ const wasBackfilled =
+ rawBackfilled === true ||
+ rawBackfilled === '1' ||
+ rawBackfilled === 1;
+
// PHP serializes the registry's current version under
// `current_version` (snake) on the slotfill payload; project to
// `currentVersion` (camel) for the row's TS contract.
@@ -125,6 +131,7 @@ export const useTransactionalEmails = (
templateStatus,
templateVersion,
currentVersion,
+ wasBackfilled,
};
} ),
[ emailTypesData, emailPosts, postIdsMap ]
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
index 85fc70cdb24..e61552e0b29 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
@@ -3,13 +3,17 @@
*/
import { createSlotFill, Button } from '@wordpress/components';
import { registerPlugin } from '@wordpress/plugins';
+import { useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
+import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { SETTINGS_SLOT_FILL_CONSTANT } from '~/settings/settings-slots';
import { ListView } from './settings-email-listing-listview';
+import { shouldShowReviewUpdate } from './settings-email-listing-update-state';
+import { VIEWED_FROM_EMAIL_LIST } from '../wp-admin-scripts/email-editor-integration/tracks/build-shared-payload';
export type Recipients = {
to: string;
@@ -54,14 +58,66 @@ export type EmailType = {
* merchant has not yet reviewed this version.
*/
currentVersion: string | null;
+ /**
+ * Whether the post was stamped by the RSM-149 backfill rather than created
+ * natively by the modern generator. Sourced from `_wc_email_backfilled`
+ * post meta and projected from `wp/v2/woo_email` REST in
+ * {@link useTransactionalEmails}. Reserved for future surfaces — the list
+ * page emits a single aggregate `_list_viewed` event with row counts only,
+ * so this field is not part of that payload.
+ */
+ wasBackfilled: boolean;
};
const { Fill } = createSlotFill( SETTINGS_SLOT_FILL_CONSTANT );
-const EmailListingFill: React.FC< {
+/**
+ * Session-storage key for the list-page `_list_viewed` Tracks dedup. Fires once
+ * per tab session: sessionStorage persists across reloads in the same tab, so
+ * refreshes do not re-fire. Closing the tab (or opening the page in a new tab)
+ * resets the gate.
+ */
+const LIST_VIEWED_DEDUP_SESSION_KEY = 'wc_email_update_list_viewed';
+
+export const EmailListingFill: React.FC< {
emailTypes: EmailType[];
editTemplateUrl: string | null;
} > = ( { emailTypes, editTemplateUrl } ) => {
+ // Fire one aggregate `_list_viewed` per session covering the entire list.
+ // Tracking per-row creates one event per visible cell (~20+ on a default
+ // install) per page load with limited analytical lift over a single
+ // page-level signal — the editor-banner `_viewed` covers per-post drilldown
+ // already. sessionStorage persists for the tab's lifetime, so refreshes
+ // dedup; a new tab fires once.
+ useEffect( () => {
+ try {
+ if (
+ window.sessionStorage.getItem( LIST_VIEWED_DEDUP_SESSION_KEY )
+ ) {
+ return;
+ }
+ window.sessionStorage.setItem( LIST_VIEWED_DEDUP_SESSION_KEY, '1' );
+ } catch {
+ // sessionStorage unavailable (privacy mode / quota). Fall through
+ // and fire the event anyway — duplicate counts are preferable to
+ // silent dropouts when storage is blocked.
+ }
+
+ const eligibleCount = emailTypes.filter( ( post ) =>
+ shouldShowReviewUpdate( post )
+ ).length;
+
+ recordEvent( 'block_email_list_viewed', {
+ viewed_from: VIEWED_FROM_EMAIL_LIST,
+ eligible_count: eligibleCount,
+ total_count: emailTypes.length,
+ } );
+ // `emailTypes` is sourced once from the server-rendered slot payload
+ // and does not change during the page's lifetime, so this effect runs
+ // exactly once per mount.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [] );
+
return (
<Fill>
<div
@@ -89,6 +145,46 @@ const EmailListingFill: React.FC< {
);
};
+/**
+ * Normalize the raw snake_case slotfill payload into the camelCase `EmailType`
+ * shape consumed by the React tree. The server projects `template_status`,
+ * `template_version`, `was_backfilled`, and `current_version` directly (so the
+ * list-page `_list_viewed` event can compute `eligible_count` on mount without
+ * waiting for the REST enrichment in `useTransactionalEmails`). Other fields
+ * already match the TS shape and pass through unchanged.
+ *
+ * Exported for unit testing.
+ */
+export const normalizeEmailTypePayload = (
+ raw: Record< string, unknown >
+): EmailType => {
+ const templateStatus =
+ typeof raw.template_status === 'string' &&
+ raw.template_status.length > 0
+ ? ( raw.template_status as EmailType[ 'templateStatus' ] )
+ : null;
+ const templateVersion =
+ typeof raw.template_version === 'string' &&
+ raw.template_version.length > 0
+ ? ( raw.template_version as string )
+ : null;
+ const currentVersion =
+ typeof raw.current_version === 'string' &&
+ raw.current_version.length > 0
+ ? ( raw.current_version as string )
+ : null;
+ const wasBackfilled =
+ raw.was_backfilled === true || raw.was_backfilled === 1;
+
+ return {
+ ...( raw as unknown as EmailType ),
+ templateStatus,
+ templateVersion,
+ currentVersion,
+ wasBackfilled,
+ };
+};
+
export const registerSettingsEmailListingFill = () => {
const slotElementId = 'wc_settings_email_listing_slotfill';
const slotElement = document.getElementById( slotElementId );
@@ -101,7 +197,12 @@ export const registerSettingsEmailListingFill = () => {
);
let emailTypes: EmailType[] = [];
try {
- emailTypes = JSON.parse( emailTypesData || '' );
+ const parsed = JSON.parse( emailTypesData || '' );
+ emailTypes = Array.isArray( parsed )
+ ? parsed.map( ( item: Record< string, unknown > ) =>
+ normalizeEmailTypePayload( item )
+ )
+ : [];
} catch ( e ) {}
registerPlugin( 'woocommerce-admin-settings-email-listing', {
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
index d28ec62c29a..8126195d27d 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
@@ -16,6 +16,7 @@ const baseEmail: EmailType = {
templateStatus: null,
templateVersion: null,
currentVersion: null,
+ wasBackfilled: false,
};
export const CoreUpdatedCustomized = () => (
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
index a753d9df1b7..a8b89f23201 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.tsx
@@ -34,7 +34,9 @@ interface UpdatesCellProps {
}
export const UpdatesCell = ( { post }: UpdatesCellProps ) => {
- if ( ! shouldShowReviewUpdate( post ) ) {
+ const eligible = shouldShowReviewUpdate( post );
+
+ if ( ! eligible ) {
return <span aria-label={ __( 'Up to date', 'woocommerce' ) }>—</span>;
}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts
index bf381c7db47..415abf10a36 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/use-update-banner.test.ts
@@ -506,10 +506,9 @@ describe( 'useUpdateBanner — apply / gates / dispatchers (6c)', () => {
// ==========================================================================
describe( 'useUpdateBanner — Tracks (6d)', () => {
const sharedPayloadMatcher = {
- email_type: 'customer_processing_order',
+ email_id: 'customer_processing_order',
template_version_from: '9.4',
template_version_to: '9.5',
- source_hash_from: 'abc123',
source_hash_to: 'def456',
classification: 'core_updated_customized',
was_backfilled: false,
@@ -523,7 +522,7 @@ describe( 'useUpdateBanner — Tracks (6d)', () => {
setUpMocks( { summary: summaryFixture() } );
renderHook( () => useUpdateBanner() );
expect( recordEventMock ).toHaveBeenCalledWith(
- 'woocommerce_block_email_update_viewed',
+ 'block_email_update_viewed',
expect.objectContaining( {
...sharedPayloadMatcher,
viewed_from: 'editor_banner',
@@ -535,7 +534,7 @@ describe( 'useUpdateBanner — Tracks (6d)', () => {
setUpMocks( { summary: summaryFixture(), wasViewed: true } );
renderHook( () => useUpdateBanner() );
expect( recordEventMock ).not.toHaveBeenCalledWith(
- 'woocommerce_block_email_update_viewed',
+ 'block_email_update_viewed',
expect.anything()
);
} );
@@ -546,7 +545,7 @@ describe( 'useUpdateBanner — Tracks (6d)', () => {
recordEventMock.mockClear();
act( () => result.current.dismiss() );
expect( recordEventMock ).toHaveBeenCalledWith(
- 'woocommerce_block_email_update_dismissed',
+ 'block_email_update_dismissed',
expect.objectContaining( sharedPayloadMatcher )
);
} );
@@ -561,7 +560,7 @@ describe( 'useUpdateBanner — Tracks (6d)', () => {
dispatchMocks.dismissUpdateBanner.mockClear();
act( () => result.current.autoDismiss() );
expect( recordEventMock ).not.toHaveBeenCalledWith(
- 'woocommerce_block_email_update_dismissed',
+ 'block_email_update_dismissed',
expect.anything()
);
expect( dispatchMocks.dismissUpdateBanner ).toHaveBeenCalledWith( 42 );
@@ -575,7 +574,7 @@ describe( 'useUpdateBanner — Tracks (6d)', () => {
await result.current.apply();
} );
expect( recordEventMock ).toHaveBeenCalledWith(
- 'woocommerce_block_email_update_applied',
+ 'block_email_update_applied',
expect.objectContaining( {
...sharedPayloadMatcher,
applied_from: 'editor_banner',
@@ -596,7 +595,7 @@ describe( 'useUpdateBanner — Tracks (6d)', () => {
await result.current.apply();
} );
expect( recordEventMock ).not.toHaveBeenCalledWith(
- 'woocommerce_block_email_update_applied',
+ 'block_email_update_applied',
expect.anything()
);
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
index 694f728ce14..be9da517441 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/hooks/use-update-banner.ts
@@ -18,77 +18,12 @@ import { recordEvent } from '@woocommerce/tracks';
import { useChangeSummary, type ChangeSummary } from './use-change-summary';
import { useApplyUpdate } from './use-apply-update';
import { STORE_NAME } from '../store';
-
-/**
- * Shape of the Tracks payload shared by every banner-fired event.
- *
- * RSM-145 standardizes these keys across the divergence-related events
- * so they can be correlated end-to-end (`viewed` → `applied`/`dismissed`).
- */
-interface SharedTracksPayload {
- email_type: string;
- template_version_from: string;
- template_version_to: string | null;
- source_hash_from: string | null;
- source_hash_to: string | null;
- classification: string;
- was_backfilled: boolean;
- // `recordEvent` types its payload as `{ [k: string]: unknown }`;
- // the index signature lets us pass `SharedTracksPayload` directly.
- [ key: string ]: unknown;
-}
-
-interface SharedPayloadInputs {
- record: {
- slug?: unknown;
- meta?: Record< string, unknown >;
- } | null;
- summary: ChangeSummary | null;
-}
-
-/**
- * Build the shared Tracks payload from the current entity record + summary.
- *
- * Returns `null` when there's no record yet (eligibility hasn't fired,
- * so no event should fire either). Falsy `summary` is permitted: in
- * that case the version_to / source_hash_to fields are `null`.
- */
-function buildSharedTracksPayload( {
- record,
- summary,
-}: SharedPayloadInputs ): SharedTracksPayload | null {
- const meta = record?.meta;
- if ( ! meta ) {
- return null;
- }
- const slug = typeof record?.slug === 'string' ? record.slug : '';
- const versionFrom =
- typeof meta._wc_email_template_version === 'string'
- ? ( meta._wc_email_template_version as string )
- : '';
- const sourceHashFrom =
- typeof meta._wc_email_template_source_hash === 'string'
- ? ( meta._wc_email_template_source_hash as string )
- : null;
- const wasBackfilled =
- meta._wc_email_backfilled === true ||
- meta._wc_email_backfilled === '1' ||
- meta._wc_email_backfilled === 1;
- const classification =
- typeof meta._wc_email_template_status === 'string'
- ? ( meta._wc_email_template_status as string )
- : '';
-
- return {
- email_type: slug,
- template_version_from: versionFrom,
- template_version_to: summary?.version_to ?? null,
- source_hash_from: sourceHashFrom,
- source_hash_to: summary?.source_hash_to ?? null,
- classification,
- was_backfilled: wasBackfilled,
- };
-}
+import {
+ buildSharedTracksPayload,
+ APPLIED_FROM_EDITOR_BANNER,
+ VIEWED_FROM_EDITOR_BANNER,
+ type SharedTracksPayload,
+} from '../tracks/build-shared-payload';
/**
* Numeric semver compare. Returns negative if `a < b`, zero if equal, positive
@@ -441,9 +376,9 @@ export function useUpdateBanner(): UseUpdateBannerResult {
return;
}
markUpdateBannerViewed( postId, sharedPayload.template_version_to );
- recordEvent( 'woocommerce_block_email_update_viewed', {
+ recordEvent( 'block_email_update_viewed', {
...sharedPayload,
- viewed_from: 'editor_banner',
+ viewed_from: VIEWED_FROM_EDITOR_BANNER,
} );
}, [ finalShouldRender, postId, sharedPayload, markUpdateBannerViewed ] );
@@ -459,19 +394,27 @@ export function useUpdateBanner(): UseUpdateBannerResult {
// Compute `had_customizations` BEFORE the apply round-trip so
// the comparison is against the pre-apply content, not the
// merged content that core-data caches mid-flight.
+ //
+ // `source_hash_from` is not part of the shared wire payload
+ // (RSM-145 §15.4) — read it directly from the entity record's
+ // meta and use it only as an in-memory comparison input.
const contentRaw =
( record as { content?: { raw?: string } } ).content?.raw ?? '';
- const hadCustomizations = sharedPayload.source_hash_from
- ? ( await sha1Hex( contentRaw ) ) !==
- sharedPayload.source_hash_from
+ const sourceHashFrom =
+ record?.meta &&
+ typeof record.meta._wc_email_template_source_hash === 'string'
+ ? ( record.meta._wc_email_template_source_hash as string )
+ : '';
+ const hadCustomizations = sourceHashFrom
+ ? ( await sha1Hex( contentRaw ) ) !== sourceHashFrom
: false;
const res = await doApply( [] );
if ( res ) {
setApplyState( 'applied' );
- recordEvent( 'woocommerce_block_email_update_applied', {
+ recordEvent( 'block_email_update_applied', {
...sharedPayload,
- applied_from: 'editor_banner',
+ applied_from: APPLIED_FROM_EDITOR_BANNER,
auto_resolved: true,
had_customizations: hadCustomizations,
} );
@@ -496,10 +439,7 @@ export function useUpdateBanner(): UseUpdateBannerResult {
return;
}
dismissUpdateBanner( postId );
- recordEvent(
- 'woocommerce_block_email_update_dismissed',
- sharedPayload
- );
+ recordEvent( 'block_email_update_dismissed', sharedPayload );
}, [ postId, sharedPayload, dismissUpdateBanner ] );
// Auto-dismiss path — used by the success morph (timer + ×). Mirrors
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/tracks/__tests__/build-shared-payload.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/tracks/__tests__/build-shared-payload.test.ts
new file mode 100644
index 00000000000..817b6db5d5a
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/tracks/__tests__/build-shared-payload.test.ts
@@ -0,0 +1,151 @@
+/**
+ * Unit tests for the shared Tracks payload helper (RSM-145 §5.2).
+ *
+ * The helper mirrors the PHP-side `WCEmailTemplateSyncTracker::build_base_payload()`
+ * — these tests pin the wire shape so client and server emit the same six base
+ * keys for the same logical transition.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { buildSharedTracksPayload } from '../build-shared-payload';
+import type { ChangeSummary } from '../../hooks/use-change-summary';
+
+const baseRecord = {
+ slug: 'customer_processing_order',
+ meta: {
+ _wc_email_template_version: '10.6.0',
+ _wc_email_template_source_hash:
+ 'deadbeef0011223344556677889900aabbccddee',
+ _wc_email_template_status: 'core_updated_customized',
+ _wc_email_backfilled: true,
+ },
+};
+
+const baseSummary: ChangeSummary = {
+ version_from: '10.6.0',
+ version_to: '10.7.0',
+ source_hash_to: 'cafebabe0011223344556677889900aabbccddee',
+ is_fallback: false,
+ added_blocks: [],
+ removed_blocks: [],
+ copy_changes: [],
+ structural_changes: [],
+ summary_lines: [],
+ cache_hit: false,
+};
+
+describe( 'buildSharedTracksPayload', () => {
+ it( 'returns the six base keys exactly when both record and summary are present', () => {
+ const result = buildSharedTracksPayload( {
+ record: baseRecord,
+ summary: baseSummary,
+ } );
+
+ expect( result ).toEqual( {
+ email_id: 'customer_processing_order',
+ template_version_from: '10.6.0',
+ template_version_to: '10.7.0',
+ source_hash_to: 'cafebabe0011223344556677889900aabbccddee',
+ classification: 'core_updated_customized',
+ was_backfilled: true,
+ } );
+ } );
+
+ it( 'does not include source_hash_from in the wire payload (RSM-145 §15.4)', () => {
+ const result = buildSharedTracksPayload( {
+ record: baseRecord,
+ summary: baseSummary,
+ } );
+
+ expect( result ).not.toBeNull();
+ expect( result ).not.toHaveProperty( 'source_hash_from' );
+ } );
+
+ it( 'returns null when the record has no meta', () => {
+ expect(
+ buildSharedTracksPayload( {
+ record: { slug: 'customer_processing_order' },
+ summary: baseSummary,
+ } )
+ ).toBeNull();
+ } );
+
+ it( 'returns null when the record is null', () => {
+ expect(
+ buildSharedTracksPayload( {
+ record: null,
+ summary: baseSummary,
+ } )
+ ).toBeNull();
+ } );
+
+ it( 'treats missing summary as null template_version_to and source_hash_to', () => {
+ const result = buildSharedTracksPayload( {
+ record: baseRecord,
+ summary: null,
+ } );
+
+ expect( result ).toMatchObject( {
+ template_version_to: null,
+ source_hash_to: null,
+ } );
+ } );
+
+ it( 'coerces was_backfilled from string "1" and number 1', () => {
+ const stringRecord = {
+ ...baseRecord,
+ meta: { ...baseRecord.meta, _wc_email_backfilled: '1' },
+ };
+ expect(
+ buildSharedTracksPayload( {
+ record: stringRecord,
+ summary: baseSummary,
+ } )?.was_backfilled
+ ).toBe( true );
+
+ const numberRecord = {
+ ...baseRecord,
+ meta: { ...baseRecord.meta, _wc_email_backfilled: 1 },
+ };
+ expect(
+ buildSharedTracksPayload( {
+ record: numberRecord,
+ summary: baseSummary,
+ } )?.was_backfilled
+ ).toBe( true );
+ } );
+
+ it( 'treats missing _wc_email_backfilled as false', () => {
+ const noBackfillRecord = {
+ ...baseRecord,
+ meta: {
+ _wc_email_template_version: '10.6.0',
+ _wc_email_template_status: 'core_updated_customized',
+ },
+ };
+ expect(
+ buildSharedTracksPayload( {
+ record: noBackfillRecord,
+ summary: baseSummary,
+ } )?.was_backfilled
+ ).toBe( false );
+ } );
+
+ it( 'falls back to empty strings for missing meta fields', () => {
+ const sparseRecord = {
+ slug: 'customer_processing_order',
+ meta: {},
+ };
+ expect(
+ buildSharedTracksPayload( {
+ record: sparseRecord,
+ summary: baseSummary,
+ } )
+ ).toMatchObject( {
+ template_version_from: '',
+ classification: '',
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/tracks/build-shared-payload.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/tracks/build-shared-payload.ts
new file mode 100644
index 00000000000..a5fb09cdf19
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/tracks/build-shared-payload.ts
@@ -0,0 +1,101 @@
+/**
+ * Internal dependencies
+ */
+import type { ChangeSummary } from '../hooks/use-change-summary';
+
+/**
+ * Per-surface identifiers for the `applied_from` and `viewed_from` extension
+ * keys on the block-email update Tracks events (RSM-145 §3.2). Mirror the PHP
+ * constants on `WCEmailTemplateSyncTracker` (`APPLIED_FROM_AUTO`,
+ * `APPLIED_FROM_SELECTIVE_REST`) so dashboard queries that segment by surface
+ * stay aligned across the server- and client-emitted events.
+ */
+export const APPLIED_FROM_EDITOR_BANNER = 'editor_banner' as const;
+export const APPLIED_FROM_AUTO = 'auto' as const;
+export const APPLIED_FROM_SELECTIVE_REST = 'selective_rest' as const;
+
+export const VIEWED_FROM_EDITOR_BANNER = 'editor_banner' as const;
+export const VIEWED_FROM_EMAIL_LIST = 'email_list' as const;
+
+export type AppliedFrom =
+ | typeof APPLIED_FROM_EDITOR_BANNER
+ | typeof APPLIED_FROM_AUTO
+ | typeof APPLIED_FROM_SELECTIVE_REST;
+
+export type ViewedFrom =
+ | typeof VIEWED_FROM_EDITOR_BANNER
+ | typeof VIEWED_FROM_EMAIL_LIST;
+
+/**
+ * Shape of the base payload shared by every block-email update Tracks event
+ * (RSM-145 §3). The same six keys are produced server-side by
+ * `WCEmailTemplateSyncTracker::build_base_payload()`; consumer dashboards
+ * should be able to query by any of these regardless of which surface fired
+ * the event.
+ *
+ * Each event adds its own extension fields on top — see the individual call
+ * sites (`use-update-banner.ts`, `settings-email-listing-update-cell.tsx`,
+ * and the PHP tracker class) for the per-event keys.
+ */
+export interface SharedTracksPayload {
+ email_id: string;
+ template_version_from: string;
+ template_version_to: string | null;
+ source_hash_to: string | null;
+ classification: string;
+ was_backfilled: boolean;
+ // `recordEvent` types its payload as `{ [k: string]: unknown }`; the index
+ // signature lets callers spread `SharedTracksPayload` directly.
+ [ key: string ]: unknown;
+}
+
+export interface SharedPayloadInputs {
+ record: {
+ slug?: unknown;
+ meta?: Record< string, unknown >;
+ } | null;
+ summary: ChangeSummary | null;
+}
+
+/**
+ * Build the shared Tracks payload from the current entity record + summary.
+ *
+ * Returns `null` when there's no record yet (eligibility hasn't fired, so no
+ * event should fire either). Falsy `summary` is permitted: in that case the
+ * `template_version_to` and `source_hash_to` fields are `null`.
+ *
+ * Mirrors `WCEmailTemplateSyncTracker::build_base_payload()` on the PHP side
+ * so server- and client-emitted events for the same logical transition land
+ * on identical keys.
+ */
+export function buildSharedTracksPayload( {
+ record,
+ summary,
+}: SharedPayloadInputs ): SharedTracksPayload | null {
+ const meta = record?.meta;
+ if ( ! meta ) {
+ return null;
+ }
+ const slug = typeof record?.slug === 'string' ? record.slug : '';
+ const versionFrom =
+ typeof meta._wc_email_template_version === 'string'
+ ? ( meta._wc_email_template_version as string )
+ : '';
+ const wasBackfilled =
+ meta._wc_email_backfilled === true ||
+ meta._wc_email_backfilled === '1' ||
+ meta._wc_email_backfilled === 1;
+ const classification =
+ typeof meta._wc_email_template_status === 'string'
+ ? ( meta._wc_email_template_status as string )
+ : '';
+
+ return {
+ email_id: slug,
+ template_version_from: versionFrom,
+ template_version_to: summary?.version_to ?? null,
+ source_hash_to: summary?.source_hash_to ?? null,
+ classification,
+ was_backfilled: wasBackfilled,
+ };
+}
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
index 0c7011293f3..24c763e430f 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Internal\Email\EmailFont;
use Automattic\WooCommerce\Internal\Email\EmailStyleSync;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -609,16 +610,30 @@ class WC_Settings_Emails extends WC_Settings_Page {
// stays customized but stops showing the indicator once the
// merchant has reviewed this release.
$current_version = is_array( $sync_config ) ? (string) ( $sync_config['version'] ?? '' ) : '';
- $email_types[] = array(
- 'title' => $email->get_title(),
- 'description' => $email->get_description(),
- 'id' => $email->id,
- 'email_key' => strtolower( $email_key ),
- 'post_id' => $post_id,
- 'enabled' => $email->is_enabled(),
- 'manual' => $email->is_manual(),
- 'current_version' => '' !== $current_version ? $current_version : null,
- 'recipients' => array(
+
+ // Project the template-sync meta directly onto the slotfill payload
+ // so the RSM-145 `_list_viewed` aggregate event can compute
+ // `eligible_count` immediately on mount without waiting for the
+ // REST enrichment in `useTransactionalEmails` to resolve. REST
+ // enrichment still runs and overrides these values once it lands —
+ // both sources read from the same post meta, so they always agree.
+ $template_status = $post_id ? (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) : '';
+ $template_version = $post_id ? (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) : '';
+ $was_backfilled = $post_id ? (bool) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, true ) : false;
+
+ $email_types[] = array(
+ 'title' => $email->get_title(),
+ 'description' => $email->get_description(),
+ 'id' => $email->id,
+ 'email_key' => strtolower( $email_key ),
+ 'post_id' => $post_id,
+ 'enabled' => $email->is_enabled(),
+ 'manual' => $email->is_manual(),
+ 'current_version' => '' !== $current_version ? $current_version : null,
+ 'template_status' => '' !== $template_status ? $template_status : null,
+ 'template_version' => '' !== $template_version ? $template_version : null,
+ 'was_backfilled' => $was_backfilled,
+ 'recipients' => array(
'to' => $email->is_customer_email() ? __( 'Customers', 'woocommerce' ) : $email->get_recipient(),
'cc' => $email->get_cc_recipient(),
'bcc' => $email->get_bcc_recipient(),
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index f71e6bfc2c0..84b803c4efe 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplatesControll
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplateApiController;
@@ -153,8 +154,17 @@ class Integration {
add_action( 'init', array( WCEmailTemplateDivergenceDetector::class, 'register_meta' ), 11 );
add_action( 'woocommerce_updated', array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 20 );
add_action( WCEmailTemplateSyncBackfill::BACKFILL_COMPLETE_ACTION, array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 10 );
+ // Fresh installs never cross the 10.8 db-update boundary, so the RSM-149
+ // backfill never runs and `BACKFILL_COMPLETE_OPTION` is never written.
+ // Stamp it from the `woocommerce_newly_installed` action so `run_sweep()`
+ // doesn't sit dormant forever on new sites.
+ add_action( 'woocommerce_newly_installed', array( WCEmailTemplateDivergenceDetector::class, 'mark_backfill_complete_on_fresh_install' ), 20 );
add_action( WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK, array( WCEmailTemplateAutoApplier::class, 'run' ), 10 );
add_action( 'woocommerce_email_template_divergence_sweep_complete', array( WCEmailTemplateAutoApplier::class, 'schedule' ), 10 );
+ // RSM-145 Tracks instrumentation: fire `_backfill_completed` once when the
+ // RSM-149 sync-meta backfill finalises. The backfill class itself is in the
+ // 10.8 feature freeze, so we hook the existing action it already publishes.
+ add_action( WCEmailTemplateSyncBackfill::BACKFILL_COMPLETE_ACTION, array( WCEmailTemplateSyncTracker::class, 'on_backfill_complete' ), 20 );
}
/**
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
index baad8dae4c1..7a26f712ce2 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
@@ -203,6 +203,17 @@ class WCEmailTemplateAutoApplier {
self::$is_auto_applying = false;
}//end try
+ // Fire `_update_applied` for the auto-applier path. Static extensions:
+ // the auto-applier only acts on `core_updated_uncustomized` posts, so
+ // `had_customizations` is always false and `auto_resolved` is always true.
+ // Gate on `$require_uncustomized`: this method is also reused by the
+ // reset endpoint (with `require_uncustomized = false`) — the reset
+ // surface is not in RSM-145's event taxonomy and must not be tagged
+ // as `applied_from='auto'`.
+ if ( $require_uncustomized ) {
+ WCEmailTemplateSyncTracker::record_auto_applied( $post_id );
+ }
+
return array(
'content' => $canonical,
'version' => $version,
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index 1a66d5117ca..b879aa98118 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -261,6 +261,33 @@ class WCEmailTemplateDivergenceDetector {
return user_can( $user_id, 'edit_post', $object_id );
}
+ /**
+ * Stamp {@see self::BACKFILL_COMPLETE_OPTION} on fresh WooCommerce installs.
+ *
+ * The RSM-149 backfill runs as a 10.8 db-update migration callback. Fresh
+ * installs on 10.9 (or any later release) never cross the 10.8 db-update
+ * boundary — {@see \WC_Install::needs_db_update()} short-circuits on a null
+ * `woocommerce_db_version` — so the migration never runs and the backfill
+ * option never flips. Without this fix, {@see self::run_sweep()} would
+ * early-return on every subsequent WC upgrade for the lifetime of the site
+ * and Tracks instrumentation would be silently dead.
+ *
+ * A fresh install has no legacy `woo_email` posts to backfill: every post
+ * the generator creates is already 10.9-stamped at creation. The migration
+ * is trivially complete; recording that here is the truthful statement.
+ *
+ * Hooked on `woocommerce_newly_installed`, the WP-style public action
+ * contract WC fires from {@see \WC_Install::newly_installed()} after the
+ * fresh-install flag flips.
+ *
+ * @return void
+ *
+ * @since 10.9.0
+ */
+ public static function mark_backfill_complete_on_fresh_install(): void {
+ update_option( self::BACKFILL_COMPLETE_OPTION, 'yes' );
+ }
+
/**
* Run the post-upgrade divergence sweep.
*
@@ -447,6 +474,28 @@ class WCEmailTemplateDivergenceDetector {
return null;
}
+ // Fire `_update_available` on every sweep where the merchant hasn't yet
+ // reviewed the current registry version, *before* the idempotency early-
+ // return below. A post that stays `core_updated_customized` across
+ // multiple core releases (merchant sits on the divergence through 10.7,
+ // 10.8, 10.9…) still represents a fresh "update available" signal at
+ // each new `version_to`: the status meta is unchanged but the registry
+ // version has advanced, so analytics should see one event per release
+ // boundary. The per-`(post_id, version_to)` dedup transient in the
+ // tracker prevents same-release re-fires; the suppress-during-backfill
+ // gate lives there too. Order matters: we fire here rather than after
+ // the meta write so the cross-release case isn't accidentally
+ // short-circuited by the status-unchanged guard.
+ if ( self::STATUS_CORE_UPDATED_CUSTOMIZED === $status ) {
+ $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id );
+ $version_to = null !== $sync_config ? (string) ( $sync_config['version'] ?? '' ) : '';
+ $version_from = (string) get_post_meta( $post_id, self::VERSION_META_KEY, true );
+
+ if ( '' !== $version_to && version_compare( $version_from, $version_to, '<' ) ) {
+ WCEmailTemplateSyncTracker::record_update_available( $post_id );
+ }
+ }
+
// 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).
@@ -456,6 +505,7 @@ class WCEmailTemplateDivergenceDetector {
}
update_post_meta( $post_id, self::STATUS_META_KEY, $status );
+
return $status;
}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
index 6623e4e248b..6c4d50083a3 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplier.php
@@ -283,6 +283,11 @@ class WCEmailTemplateSelectiveApplier {
// Invalidate the change-summary cache so the next read reflects the merged state.
WCEmailTemplateChangeSummary::reset_cache();
+ // Fire `_update_applied` for the selective-applier path. Static extensions:
+ // the selective applier only acts on `core_updated_customized` posts, so
+ // `had_customizations` is always true and `auto_resolved` is always false.
+ WCEmailTemplateSyncTracker::record_selective_applied( $post_id );
+
return array(
'merged_content' => $merged_content,
'revision_id' => $revision_id,
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTracker.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTracker.php
new file mode 100644
index 00000000000..58ee91c9bf6
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTracker.php
@@ -0,0 +1,406 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
+
+use WC_Tracks;
+
+/**
+ * Centralises the Tracks instrumentation for the block-email update flow (RSM-145).
+ *
+ * Every event funnels through this class so the shared payload stays in one place
+ * and we cannot silently drift between callers. The class is stateless: every
+ * method is `public static` and reads the per-post values it needs from meta and
+ * the sync registry at call time.
+ *
+ * Events shipped here (server-side):
+ *
+ * - `block_email_update_available` — fired from
+ * {@see WCEmailTemplateDivergenceDetector::reclassify()} when a post transitions
+ * into `core_updated_customized` at a newer `template_version_to` than the
+ * merchant last reviewed. Deduplicated per `(post_id, template_version_to)` via
+ * a 30-day transient so repeat sweeps at the same core version do not refire.
+ * - `block_email_update_applied` (`applied_from: 'auto'`) — fired
+ * from {@see WCEmailTemplateAutoApplier::apply_to_post()} on a successful write.
+ * - `block_email_update_applied` (`applied_from: 'selective_rest'`) —
+ * fired from {@see WCEmailTemplateSelectiveApplier::apply_selectively()} on a
+ * successful write.
+ * - `block_email_sync_backfill_completed` — fired exactly once per
+ * site, from the listener on
+ * {@see WCEmailTemplateSyncBackfill::BACKFILL_COMPLETE_ACTION}.
+ *
+ * The JS-side `_viewed`, `_applied` (`applied_from: 'editor_banner'`), and
+ * `_dismissed` events are fired from RSM-141's banner code and the RSM-140
+ * list cell directly via `recordEvent` from `@woocommerce/tracks`. The shared
+ * payload shape is mirrored in `tracks/build-shared-payload.ts`.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 10.9.0
+ */
+class WCEmailTemplateSyncTracker {
+ /**
+ * Tracks event name fired when a sync-eligible post first becomes available
+ * for review at a new core template version.
+ *
+ * @var string
+ */
+ public const EVENT_UPDATE_AVAILABLE = 'block_email_update_available';
+
+ /**
+ * Tracks event name fired on every successful template apply.
+ *
+ * @var string
+ */
+ public const EVENT_UPDATE_APPLIED = 'block_email_update_applied';
+
+ /**
+ * Tracks event name fired once when the RSM-149 sync-meta backfill finalises.
+ *
+ * @var string
+ */
+ public const EVENT_BACKFILL_COMPLETED = 'block_email_sync_backfill_completed';
+
+ /**
+ * Persistent guard option recording that `_backfill_completed` has already
+ * been recorded for this site. Once set, repeat invocations of
+ * {@see self::on_backfill_complete()} short-circuit — protects against
+ * manual re-runs of {@see WCEmailTemplateSyncBackfill::run()} (e.g. via
+ * wp-cli or tests) double-counting the migration in analytics.
+ *
+ * @var string
+ */
+ public const BACKFILL_COMPLETED_TRACKED_OPTION = 'wc_email_sync_backfill_completed_tracked';
+
+ /**
+ * Prefix for the per-`(post_id, template_version_to)` dedup transients used by
+ * `_update_available`. The composite suffix is md5-hashed so the resulting
+ * option name stays comfortably under WordPress's 191-char limit even for
+ * long locale-aware version strings.
+ *
+ * @var string
+ */
+ private const AVAILABLE_DEDUP_TRANSIENT_PREFIX = 'wc_email_update_available_fired_';
+
+ /**
+ * `applied_from` value identifying the {@see WCEmailTemplateAutoApplier} call site.
+ *
+ * @var string
+ */
+ public const APPLIED_FROM_AUTO = 'auto';
+
+ /**
+ * `applied_from` value identifying the {@see WCEmailTemplateSelectiveApplier} call site.
+ *
+ * @var string
+ */
+ public const APPLIED_FROM_SELECTIVE_REST = 'selective_rest';
+
+ /**
+ * Optional event-recorder override. Defaults to
+ * {@see WC_Tracks::record_event()} when null. Tests can inject a spy here
+ * because `WC_Tracks::record_event()` short-circuits early under the
+ * PHPUnit `wptests_capabilities` user (see `class-wc-tracks.php:130-132`).
+ *
+ * @var callable|null
+ */
+ private static $event_recorder = null;
+
+ /**
+ * Override the event recorder. Intended for tests only.
+ *
+ * @internal
+ *
+ * @param callable|null $recorder Receives `(string $event_name, array $payload)`. Pass null to restore default.
+ * @return void
+ */
+ public static function set_event_recorder( ?callable $recorder ): void {
+ self::$event_recorder = $recorder;
+ }
+
+ /**
+ * Fire one Tracks event, routed through the injected recorder when present.
+ *
+ * Wrapped in a try/catch so a throw from the Tracks pipeline (a third-party
+ * `woocommerce_tracks_event_properties` filter that mishandles the payload,
+ * a misconfigured Tracks client, etc.) cannot bubble up into the caller's
+ * code path. Every consumer of this method is a fire-and-forget telemetry
+ * surface — a logging failure must never turn a successful apply or
+ * reclassify into an error response. Silent-swallow is intentional: a
+ * failed Tracks event is not actionable for the merchant or the operator,
+ * and logging the failure per event would generate noise out of proportion
+ * to its diagnostic value.
+ *
+ * @param string $event_name The event name (without the `wcadmin_` prefix).
+ * @param array<string,mixed> $payload Event properties.
+ * @return void
+ */
+ private static function record( string $event_name, array $payload ): void {
+ try {
+ if ( null !== self::$event_recorder ) {
+ ( self::$event_recorder )( $event_name, $payload );
+ return;
+ }
+ WC_Tracks::record_event( $event_name, $payload );
+ } catch ( \Throwable $e ) {
+ unset( $e );
+ }
+ }
+
+ /**
+ * Record `_update_available` for a post that just transitioned into
+ * `core_updated_customized` at a newer `template_version_to` than was last
+ * stamped on the post.
+ *
+ * Callers must already have confirmed that the new status is
+ * `core_updated_customized` and that the version-meta advance has happened
+ * (or is about to). This method only owns the suppress-during-backfill check
+ * and the per-`(post_id, template_version_to)` dedup transient.
+ *
+ * @param int $post_id The `woo_email` post that just became eligible.
+ * @return void
+ *
+ * @since 10.9.0
+ */
+ public static function record_update_available( int $post_id ): void {
+ if ( self::should_suppress() ) {
+ return;
+ }
+
+ $payload = self::build_base_payload( $post_id );
+ if ( null === $payload ) {
+ return;
+ }
+
+ $version_to = (string) $payload['template_version_to'];
+ if ( '' === $version_to ) {
+ return;
+ }
+
+ $transient_key = self::available_dedup_transient_key( $post_id, $version_to );
+ if ( false !== get_transient( $transient_key ) ) {
+ return;
+ }
+
+ self::record( self::EVENT_UPDATE_AVAILABLE, $payload );
+
+ // Set the dedup transient after the record() call. record() swallows
+ // throws from the Tracks pipeline in production, so in steady state
+ // either ordering would dedup identically. The post-record ordering is
+ // intentional for the testing path: when set_event_recorder() injects a
+ // throwing spy, the transient stays unwritten and a retry remains
+ // possible without manually clearing it. 30-day TTL outlasts the gap
+ // between core releases without leaving stale dedup keys forever.
+ set_transient( $transient_key, 1, MONTH_IN_SECONDS );
+ }
+
+ /**
+ * Record `_update_applied` for the auto-applier success path.
+ *
+ * The auto-applier only runs against posts classified as
+ * `core_updated_uncustomized`, so `had_customizations` is statically `false`
+ * and `auto_resolved` is statically `true`. Documented at the call site
+ * rather than computed.
+ *
+ * @param int $post_id The `woo_email` post that was just rewritten with canonical core content.
+ * @return void
+ *
+ * @since 10.9.0
+ */
+ public static function record_auto_applied( int $post_id ): void {
+ if ( self::should_suppress() ) {
+ return;
+ }
+
+ $payload = self::build_base_payload( $post_id );
+ if ( null === $payload ) {
+ return;
+ }
+
+ $payload['applied_from'] = self::APPLIED_FROM_AUTO;
+ $payload['auto_resolved'] = true;
+ $payload['had_customizations'] = false;
+
+ self::record( self::EVENT_UPDATE_APPLIED, $payload );
+ }
+
+ /**
+ * Record `_update_applied` for the selective-applier success path (the REST
+ * `/apply` endpoint behind RSM-143's Review drawer).
+ *
+ * The selective applier only runs against `core_updated_customized` posts,
+ * so `had_customizations` is statically `true` and `auto_resolved` is
+ * statically `false`.
+ *
+ * @param int $post_id The `woo_email` post that was just rewritten via selective merge.
+ * @return void
+ *
+ * @since 10.9.0
+ */
+ public static function record_selective_applied( int $post_id ): void {
+ if ( self::should_suppress() ) {
+ return;
+ }
+
+ $payload = self::build_base_payload( $post_id );
+ if ( null === $payload ) {
+ return;
+ }
+
+ $payload['applied_from'] = self::APPLIED_FROM_SELECTIVE_REST;
+ $payload['auto_resolved'] = false;
+ $payload['had_customizations'] = true;
+
+ self::record( self::EVENT_UPDATE_APPLIED, $payload );
+ }
+
+ /**
+ * Listener body for `woocommerce_email_template_sync_backfill_complete`.
+ *
+ * Fires `_backfill_completed` once per site. Payload is intentionally
+ * minimal (per-case counters and elapsed_ms would require touching the
+ * 10.8-frozen {@see WCEmailTemplateSyncBackfill} class):
+ *
+ * - `posts_backfilled`: count of `woo_email` posts whose
+ * {@see WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY} flag is true.
+ * - `wc_version`: current WooCommerce version string.
+ *
+ * @return void
+ *
+ * @since 10.9.0
+ */
+ public static function on_backfill_complete(): void {
+ // Persistent one-shot guard: `WCEmailTemplateSyncBackfill::run()`
+ // fires `finalize()` on every invocation, and finalize() always
+ // fires `BACKFILL_COMPLETE_ACTION`. In production `run()` only
+ // runs once (10.8 db-update callback), but manual re-runs via
+ // wp-cli or tests can re-fire the action — this gate ensures the
+ // Tracks event still lands at most once per site.
+ if ( 'yes' === get_option( self::BACKFILL_COMPLETED_TRACKED_OPTION, 'no' ) ) {
+ return;
+ }
+
+ $posts_backfilled = self::count_backfilled_posts();
+
+ self::record(
+ self::EVENT_BACKFILL_COMPLETED,
+ array(
+ 'posts_backfilled' => $posts_backfilled,
+ 'wc_version' => function_exists( 'WC' ) && WC() ? (string) WC()->version : '',
+ )
+ );
+
+ update_option( self::BACKFILL_COMPLETED_TRACKED_OPTION, 'yes', false );
+ }
+
+ /**
+ * Assemble the shared base payload for one post.
+ *
+ * Returns `null` when the post is not a sync-eligible `woo_email` or when
+ * any inner call throws — every `record_*` caller treats `null` as "skip
+ * this event," keeping the Tracks surface fire-and-forget.
+ *
+ * @param int $post_id The `woo_email` post ID.
+ * @return array<string,mixed>|null
+ */
+ private static function build_base_payload( int $post_id ): ?array {
+ try {
+ $post = get_post( $post_id );
+ if ( ! $post instanceof \WP_Post ) {
+ 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;
+ }
+
+ $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id );
+ if ( null === $sync_config ) {
+ return null;
+ }
+
+ $emails = $posts_manager->get_emails_by_id();
+ $email = $emails[ $email_id ] ?? null;
+
+ $source_hash_to = '';
+ if ( $email instanceof \WC_Email ) {
+ try {
+ $source_hash_to = sha1( WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ) );
+ } catch ( \Throwable $e ) {
+ // Canonical compute can throw on misconfigured templates. The event
+ // is still useful with an empty `source_hash_to` — analytics will
+ // see "site with broken canonical render" as a distinct cohort.
+ $source_hash_to = '';
+ }
+ }
+
+ $version_from = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true );
+ $status = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true );
+ $was_backfilled = (bool) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, true );
+
+ return array(
+ 'email_id' => $email_id,
+ 'template_version_from' => $version_from,
+ 'template_version_to' => (string) ( $sync_config['version'] ?? '' ),
+ 'source_hash_to' => $source_hash_to,
+ 'classification' => $status,
+ 'was_backfilled' => $was_backfilled,
+ );
+ } catch ( \Throwable $e ) {
+ unset( $e );
+ return null;
+ }
+ }
+
+ /**
+ * Whether per-post telemetry should be suppressed in the current request.
+ *
+ * `true` while the one-time RSM-149 backfill is rewriting post content so the
+ * `_available` / `_applied` events do not storm during the migration. The
+ * single `_backfill_completed` event covers that surface instead.
+ *
+ * @return bool
+ */
+ private static function should_suppress(): bool {
+ return WCEmailTemplateSyncBackfill::is_backfilling();
+ }
+
+ /**
+ * Build the transient option key used by the `_update_available` dedup gate.
+ *
+ * @param int $post_id The post ID.
+ * @param string $version_to The current registry version for the email.
+ * @return string
+ */
+ private static function available_dedup_transient_key( int $post_id, string $version_to ): string {
+ return self::AVAILABLE_DEDUP_TRANSIENT_PREFIX . $post_id . '_' . md5( $version_to );
+ }
+
+ /**
+ * Count `woo_email` posts flagged as backfilled by {@see WCEmailTemplateSyncBackfill}.
+ *
+ * @return int
+ */
+ private static function count_backfilled_posts(): int {
+ $ids = get_posts(
+ array(
+ 'post_type' => 'woo_email',
+ 'post_status' => 'any',
+ 'fields' => 'ids',
+ 'posts_per_page' => -1,
+ 'no_found_rows' => true,
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded set; sync-registered emails only.
+ array(
+ 'key' => WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY,
+ 'value' => '1',
+ 'compare' => '=',
+ ),
+ ),
+ )
+ );
+
+ return is_array( $ids ) ? count( $ids ) : 0;
+ }
+}
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 820a2e28542..c59cb56f79f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Internal\EmailEditor\Integration;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
@@ -350,6 +351,81 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
$this->assertSame( $expected_canonical, (string) get_post( $post_id )->post_content );
}
+ /**
+ * @testdox Should fire `_update_applied` Tracks event on successful auto-applier write.
+ */
+ public function test_apply_to_post_fires_update_applied_in_auto_mode(): void {
+ $email_id = 'wc_test_auto_apply_tracks_auto';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+
+ $captured = array();
+ WCEmailTemplateSyncTracker::set_event_recorder(
+ static function ( string $event_name, array $payload ) use ( &$captured ): void {
+ $captured[] = array( $event_name, $payload );
+ }
+ );
+
+ try {
+ WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+ } finally {
+ WCEmailTemplateSyncTracker::set_event_recorder( null );
+ }
+
+ $applied_events = array_values(
+ array_filter(
+ $captured,
+ static fn( array $entry ): bool => WCEmailTemplateSyncTracker::EVENT_UPDATE_APPLIED === $entry[0]
+ )
+ );
+
+ $this->assertCount( 1, $applied_events, 'Auto-applier path should record exactly one _update_applied event.' );
+ $this->assertSame( WCEmailTemplateSyncTracker::APPLIED_FROM_AUTO, $applied_events[0][1]['applied_from'] );
+ }
+
+ /**
+ * @testdox Should NOT fire `_update_applied` when apply_to_post is invoked from the reset path.
+ */
+ public function test_apply_to_post_does_not_fire_update_applied_on_reset_path(): void {
+ $email_id = 'wc_test_auto_apply_tracks_reset_silent';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+
+ $captured = array();
+ WCEmailTemplateSyncTracker::set_event_recorder(
+ static function ( string $event_name, array $payload ) use ( &$captured ): void {
+ $captured[] = array( $event_name, $payload );
+ }
+ );
+
+ try {
+ $result = WCEmailTemplateAutoApplier::apply_to_post(
+ $email,
+ $post_id,
+ array( 'require_uncustomized' => false )
+ );
+ } finally {
+ WCEmailTemplateSyncTracker::set_event_recorder( null );
+ }
+
+ // Assert the reset itself succeeded — otherwise an early bail would
+ // produce zero events and false-pass the silence assertion below.
+ $this->assertIsArray( $result, 'Reset path setup must succeed before asserting Tracks silence.' );
+
+ $applied_events = array_values(
+ array_filter(
+ $captured,
+ static fn( array $entry ): bool => WCEmailTemplateSyncTracker::EVENT_UPDATE_APPLIED === $entry[0]
+ )
+ );
+
+ $this->assertSame( array(), $applied_events, 'Reset path must not emit `_update_applied` with applied_from=auto.' );
+ }
+
/**
* apply_to_post() with require_uncustomized=false on a non-sync-enabled email
* must rewrite content but stamp NO meta. Return shape carries null for the
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 c81c1f29c38..ee3ebe4ccbc 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -177,6 +177,164 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should fire `_update_available` from reclassify() on version-advance transition into customized.
+ */
+ public function test_reclassify_fires_update_available_on_version_advance(): void {
+ $email_id = 'wc_test_divergence_available_fires';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ // Stage the divergence: simulate "core has moved AND merchant has drifted"
+ // by stamping a different source hash (so neither current_core_hash nor
+ // current_post_hash matches the stamp). The classifier returns
+ // `core_updated_customized` in that case.
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+ sha1( 'stamped-from-an-earlier-core-render' )
+ );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, '1.0.0' );
+
+ $captured = array();
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder(
+ static function ( string $event_name, array $payload ) use ( &$captured ): void {
+ $captured[] = array( $event_name, $payload );
+ }
+ );
+
+ $status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder( null );
+
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED, $status );
+ $this->assertCount( 1, $captured, 'reclassify must fire one _update_available event on version-advance transition.' );
+ $this->assertSame( 'block_email_update_available', $captured[0][0] );
+ }
+
+ /**
+ * @testdox Should fire `_update_available` on a cross-release sweep even when status stays customized.
+ *
+ * Regression for the case where a merchant sits on a `core_updated_customized`
+ * divergence across multiple core releases. Status meta does not change between
+ * sweeps (still customized), but `version_to` advances each release — analytics
+ * must see one event per release boundary, not a single lifetime event.
+ */
+ public function test_reclassify_fires_update_available_on_subsequent_release_when_status_unchanged(): void {
+ $email_id = 'wc_test_divergence_available_cross_release';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ // Stage the divergence as in the fires-on-version-advance test, but also
+ // pre-stamp the status meta to `core_updated_customized` so the
+ // idempotency early-return inside reclassify() is the only thing between
+ // the classifier verdict and the event-firing block.
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+ sha1( 'stamped-from-an-earlier-core-render' )
+ );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, '1.0.0' );
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED
+ );
+
+ $captured = array();
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder(
+ static function ( string $event_name, array $payload ) use ( &$captured ): void {
+ $captured[] = array( $event_name, $payload );
+ }
+ );
+
+ $status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder( null );
+
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED, $status );
+ $this->assertCount(
+ 1,
+ $captured,
+ 'reclassify must fire _update_available across release boundaries even when status meta is unchanged.'
+ );
+ $this->assertSame( 'block_email_update_available', $captured[0][0] );
+ }
+
+ /**
+ * @testdox Should not fire `_update_available` from reclassify() when version_from equals version_to.
+ */
+ public function test_reclassify_skips_update_available_when_version_unchanged(): void {
+ $email_id = 'wc_test_divergence_available_no_advance';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ // Stage the divergence: as above, but leave the version stamp at the
+ // fixture's `@version` so the version-advance gate fails.
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+ sha1( 'stamped-from-an-earlier-core-render' )
+ );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, '1.2.3' );
+
+ $captured = array();
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder(
+ static function ( string $event_name, array $payload ) use ( &$captured ): void {
+ $captured[] = array( $event_name, $payload );
+ }
+ );
+
+ WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder( null );
+
+ $this->assertSame( array(), $captured, 'reclassify must not fire _update_available when the merchant has already reviewed this version.' );
+ }
+
+ /**
+ * @testdox Should stamp BACKFILL_COMPLETE_OPTION when fresh-install listener fires.
+ */
+ public function test_mark_backfill_complete_on_fresh_install_stamps_option(): void {
+ // Start from the "option missing" state a fresh 10.9 install would have.
+ delete_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+ $this->assertFalse( get_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION ) );
+
+ WCEmailTemplateDivergenceDetector::mark_backfill_complete_on_fresh_install();
+
+ $this->assertSame(
+ 'yes',
+ (string) get_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION ),
+ 'Fresh-install listener must stamp the backfill-complete option.'
+ );
+ }
+
+ /**
+ * @testdox Should let run_sweep() classify posts after fresh-install listener runs.
+ */
+ public function test_run_sweep_proceeds_after_fresh_install_listener(): void {
+ $email_id = 'wc_test_divergence_fresh_install_sweep';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ // Simulate a fresh-install scenario: the migration never ran so the option is missing.
+ delete_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY );
+ delete_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+
+ // Sweep gate trips → no classification work.
+ WCEmailTemplateDivergenceDetector::run_sweep();
+ $this->assertSame(
+ '',
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Sweep must early-return when the backfill option is missing.'
+ );
+
+ // Listener stamps the option; the next sweep proceeds.
+ WCEmailTemplateDivergenceDetector::mark_backfill_complete_on_fresh_install();
+ WCEmailTemplateDivergenceDetector::run_sweep();
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Sweep should classify normally once the fresh-install listener has stamped the option.'
+ );
+ }
+
/**
* Cleanup after test.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php
new file mode 100644
index 00000000000..8f229b86835
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php
@@ -0,0 +1,424 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+
+/**
+ * Tests for the {@see WCEmailTemplateSyncTracker} class.
+ *
+ * Verifies the shared payload shape, the suppress-during-backfill gate, and the
+ * per-`(post_id, template_version_to)` dedup transient that guarantees the
+ * `_update_available` event fires at most once per tuple.
+ */
+class WCEmailTemplateSyncTrackerTest extends \WC_Unit_Test_Case {
+ /**
+ * Absolute path to the fixtures directory shared with the detector tests.
+ *
+ * @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;
+
+ /**
+ * Captured `(event_name, payload)` tuples written by the test event recorder.
+ *
+ * @var array<int, array{string, array<string,mixed>}>
+ */
+ private array $captured_events = array();
+
+ /**
+ * Setup test case.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+ update_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION, 'yes' );
+
+ $this->fixtures_base = __DIR__ . '/fixtures/';
+ $this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+ $this->posts_manager->clear_caches();
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ $this->captured_events = array();
+ WCEmailTemplateSyncTracker::set_event_recorder(
+ function ( string $event_name, array $payload ): void {
+ $this->captured_events[] = array( $event_name, $payload );
+ }
+ );
+ }
+
+ /**
+ * Cleanup after test.
+ */
+ public function tearDown(): void {
+ $this->cleanup_injected_emails();
+
+ WCEmailTemplateSyncTracker::set_event_recorder( null );
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ remove_all_filters( 'woocommerce_email_content_post_data' );
+
+ // Clear any dedup transients written by the tests so a re-run starts clean.
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_transient\\_wc_email_update_available_fired\\_%'" );
+
+ delete_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+ delete_option( WCEmailTemplateSyncTracker::BACKFILL_COMPLETED_TRACKED_OPTION );
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should fire `_update_available` with the documented shared payload keys.
+ */
+ public function test_record_update_available_fires_with_shared_payload(): void {
+ $email_id = 'wc_test_tracker_payload';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ WCEmailTemplateSyncTracker::record_update_available( $post_id );
+
+ $this->assertCount( 1, $this->captured_events, 'Tracker should record exactly one event.' );
+
+ list( $event_name, $payload ) = $this->captured_events[0];
+
+ $this->assertSame( WCEmailTemplateSyncTracker::EVENT_UPDATE_AVAILABLE, $event_name );
+
+ $expected_keys = array(
+ 'email_id',
+ 'template_version_from',
+ 'template_version_to',
+ 'source_hash_to',
+ 'classification',
+ 'was_backfilled',
+ );
+ foreach ( $expected_keys as $key ) {
+ $this->assertArrayHasKey( $key, $payload, "Payload is missing `{$key}` key." );
+ }
+ $this->assertArrayNotHasKey( 'source_hash_from', $payload, 'Payload must not include `source_hash_from` (RSM-145 §15.4).' );
+
+ $this->assertSame( $email_id, $payload['email_id'] );
+ $this->assertNotSame( '', $payload['template_version_to'], 'template_version_to should come from the registry.' );
+ $this->assertNotSame( '', $payload['source_hash_to'], 'source_hash_to should be sha1 of the canonical render.' );
+ }
+
+ /**
+ * @testdox Should dedup repeat `_update_available` calls at the same template_version_to.
+ */
+ public function test_record_update_available_dedups_same_version_to(): void {
+ $email_id = 'wc_test_tracker_dedup';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ WCEmailTemplateSyncTracker::record_update_available( $post_id );
+ WCEmailTemplateSyncTracker::record_update_available( $post_id );
+ WCEmailTemplateSyncTracker::record_update_available( $post_id );
+
+ $this->assertCount( 1, $this->captured_events, 'Repeat calls at the same template_version_to must dedup.' );
+ }
+
+ /**
+ * @testdox Should re-fire `_update_available` when the per-tuple transient is cleared.
+ */
+ public function test_record_update_available_refires_when_transient_cleared(): void {
+ $email_id = 'wc_test_tracker_transient_cleared';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ WCEmailTemplateSyncTracker::record_update_available( $post_id );
+ $this->assertCount( 1, $this->captured_events );
+
+ // Simulate the dedup window expiring (or a new `template_version_to`
+ // producing a different transient key — same observable effect).
+ // Use `delete_transient()` so wp_cache's in-memory copy is invalidated
+ // alongside the row in `$wpdb->options`.
+ $config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id );
+ $version_to = is_array( $config ) ? (string) ( $config['version'] ?? '' ) : '';
+ delete_transient( 'wc_email_update_available_fired_' . $post_id . '_' . md5( $version_to ) );
+
+ WCEmailTemplateSyncTracker::record_update_available( $post_id );
+
+ $this->assertCount( 2, $this->captured_events, 'Cleared dedup transient must allow a fresh event.' );
+ }
+
+ /**
+ * @testdox Should suppress every per-post event while the backfill is running.
+ */
+ public function test_events_are_suppressed_during_backfill(): void {
+ $email_id = 'wc_test_tracker_suppress';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $this->set_backfill_running( true );
+
+ WCEmailTemplateSyncTracker::record_update_available( $post_id );
+ WCEmailTemplateSyncTracker::record_auto_applied( $post_id );
+ WCEmailTemplateSyncTracker::record_selective_applied( $post_id );
+
+ $this->set_backfill_running( false );
+
+ $this->assertSame( array(), $this->captured_events, 'No per-post events should fire while backfilling.' );
+ }
+
+ /**
+ * @testdox Should fire `_update_applied` with auto-applier extensions.
+ */
+ public function test_record_auto_applied_payload(): void {
+ $email_id = 'wc_test_tracker_auto_applied';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ WCEmailTemplateSyncTracker::record_auto_applied( $post_id );
+
+ $this->assertCount( 1, $this->captured_events );
+ list( $event_name, $payload ) = $this->captured_events[0];
+ $this->assertSame( WCEmailTemplateSyncTracker::EVENT_UPDATE_APPLIED, $event_name );
+ $this->assertSame( WCEmailTemplateSyncTracker::APPLIED_FROM_AUTO, $payload['applied_from'] );
+ $this->assertTrue( $payload['auto_resolved'] );
+ $this->assertFalse( $payload['had_customizations'] );
+ }
+
+ /**
+ * @testdox Should fire `_update_applied` with selective-applier extensions.
+ */
+ public function test_record_selective_applied_payload(): void {
+ $email_id = 'wc_test_tracker_selective_applied';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ WCEmailTemplateSyncTracker::record_selective_applied( $post_id );
+
+ $this->assertCount( 1, $this->captured_events );
+ list( $event_name, $payload ) = $this->captured_events[0];
+ $this->assertSame( WCEmailTemplateSyncTracker::EVENT_UPDATE_APPLIED, $event_name );
+ $this->assertSame( WCEmailTemplateSyncTracker::APPLIED_FROM_SELECTIVE_REST, $payload['applied_from'] );
+ $this->assertFalse( $payload['auto_resolved'] );
+ $this->assertTrue( $payload['had_customizations'] );
+ }
+
+ /**
+ * @testdox Should fire `_backfill_completed` with posts_backfilled count and wc_version.
+ */
+ public function test_on_backfill_complete_records_event_with_count(): void {
+ $email_id = 'wc_test_tracker_backfill_completed';
+ $post_a = $this->generate_stamped_post( $email_id );
+
+ // Stamp `was_backfilled = true` on this post so the count includes it.
+ update_post_meta( $post_a, WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, true );
+
+ WCEmailTemplateSyncTracker::on_backfill_complete();
+
+ $this->assertCount( 1, $this->captured_events );
+ list( $event_name, $payload ) = $this->captured_events[0];
+ $this->assertSame( WCEmailTemplateSyncTracker::EVENT_BACKFILL_COMPLETED, $event_name );
+ $this->assertSame( 1, $payload['posts_backfilled'] );
+ $this->assertArrayHasKey( 'wc_version', $payload );
+ $this->assertNotSame( '', $payload['wc_version'] );
+ }
+
+ /**
+ * @testdox Should only fire `_backfill_completed` once per site even on repeat hook firings.
+ */
+ public function test_on_backfill_complete_is_one_shot(): void {
+ $email_id = 'wc_test_tracker_backfill_one_shot';
+ $post_id = $this->generate_stamped_post( $email_id );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, true );
+
+ WCEmailTemplateSyncTracker::on_backfill_complete();
+ WCEmailTemplateSyncTracker::on_backfill_complete();
+ WCEmailTemplateSyncTracker::on_backfill_complete();
+
+ $this->assertCount( 1, $this->captured_events, 'Repeat invocations must not double-count the backfill.' );
+ $this->assertSame(
+ 'yes',
+ (string) get_option( WCEmailTemplateSyncTracker::BACKFILL_COMPLETED_TRACKED_OPTION ),
+ 'One-shot guard option should be stamped after the first record.'
+ );
+ }
+
+ /**
+ * @testdox Should swallow exceptions thrown inside build_base_payload so callers don't surface failures.
+ */
+ public function test_record_swallows_exceptions_from_payload_builder(): void {
+ $email_id = 'wc_test_tracker_payload_builder_throws';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ // Simulate a third-party callback throwing inside the payload-building
+ // pipeline by hooking `get_post_metadata` (which `build_base_payload`
+ // calls via `get_post_meta`) and throwing from the filter.
+ $throw_filter = static function (): void {
+ throw new \RuntimeException( 'simulated meta-filter failure' );
+ };
+ add_filter( 'get_post_metadata', $throw_filter );
+
+ try {
+ // Should not bubble up despite the inner filter throwing.
+ WCEmailTemplateSyncTracker::record_selective_applied( $post_id );
+ } finally {
+ remove_filter( 'get_post_metadata', $throw_filter );
+ }
+
+ $this->assertSame(
+ array(),
+ $this->captured_events,
+ 'A throw from build_base_payload must result in zero events, not a propagated exception.'
+ );
+ }
+
+ /**
+ * @testdox Should swallow exceptions from the event recorder so callers don't surface failures.
+ */
+ public function test_record_swallows_exceptions_from_recorder(): void {
+ $email_id = 'wc_test_tracker_swallow_throws';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ WCEmailTemplateSyncTracker::set_event_recorder(
+ static function ( string $event_name, array $payload ): void {
+ unset( $event_name, $payload );
+ throw new \RuntimeException( 'simulated tracker failure' );
+ }
+ );
+
+ // Should not bubble up despite the recorder throwing.
+ WCEmailTemplateSyncTracker::record_selective_applied( $post_id );
+
+ // `captured_events` is empty because we replaced the spy with a thrower —
+ // the test passes simply by not propagating the exception.
+ $this->assertTrue( true, 'A thrown recorder must not propagate to the caller.' );
+ }
+
+ /**
+ * @testdox Should silently no-op when the post is not in the sync registry.
+ */
+ public function test_record_update_available_noop_for_unregistered_post(): void {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'woo_email',
+ 'post_status' => 'publish',
+ )
+ );
+
+ WCEmailTemplateSyncTracker::record_update_available( $post_id );
+
+ $this->assertSame( array(), $this->captured_events, 'Unregistered posts should not produce events.' );
+ }
+
+ // ------------------------------------------------------------------
+ // Helpers (mirror the detector test's fixture flow).
+ // ------------------------------------------------------------------
+
+ /**
+ * Drive the real generator flow to produce a stamped woo_email post for the given fixture.
+ *
+ * @param string $email_id Email ID to generate a post for.
+ * @return int The generated post ID.
+ */
+ private function generate_stamped_post( string $email_id ): int {
+ $this->register_fixture_email( $email_id );
+
+ $generator = new WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+ $this->posts_manager->delete_email_template( $email_id );
+
+ $post_id = $generator->generate_email_template_if_not_exists( $email_id );
+
+ $this->assertIsInt( $post_id );
+ $this->assertGreaterThan( 0, $post_id );
+
+ return $post_id;
+ }
+
+ /**
+ * Inject a stub WC_Email into the global container and register it for sync.
+ *
+ * @param string $email_id Email ID to register.
+ * @return \WC_Email
+ */
+ 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 tracker tests' );
+ $stub->method( 'get_description' )->willReturn( 'Fixture email used to cover tracker 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_Tracker_' . $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;
+ }
+
+ /**
+ * Remove every fixture email this test injected.
+ */
+ 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 $key ) {
+ unset( $current[ $key ] );
+ }
+ $property->setValue( $emails_container, $current );
+ $this->injected_email_keys = array();
+ }
+
+ /**
+ * Toggle the static backfill-running flag via reflection so suppress-during-backfill paths can be exercised.
+ *
+ * @param bool $running Whether the backfill is currently running.
+ */
+ private function set_backfill_running( bool $running ): void {
+ $reflection = new \ReflectionClass( WCEmailTemplateSyncBackfill::class );
+ $property = $reflection->getProperty( 'is_backfilling' );
+ $property->setAccessible( true );
+ $property->setValue( null, $running );
+ }
+}