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