Commit 904aed25942 for woocommerce

commit 904aed2594205d8632737d21c74a321c77d2b22d
Author: Patrick Zielinski <patrick.zielinski@a8c.com>
Date:   Fri May 15 07:53:06 2026 -0400

    [Email Editor] Add E2E tests for update detection and application (#64817)

    * Add WC Email Template Sync test-helper plugin scaffold

    * Wire WC Email Template Sync test-helper plugin into wp-env

    * Implement helper plugin filter classes and REST health endpoint

    * Implement Tracks event recorder in test helper plugin

    * Add reset-post and seed-meta REST endpoints to test helper

    * Add seed-bulk, trigger-sweep, trigger-backfill REST endpoints

    * Add tracks GET and DELETE REST endpoints to test helper

    * Add Playwright update-propagation helpers with option transport

    * Add seed-woo-email helpers and canonical-hash + read-meta endpoints

    * Add simulate-update, tracks-spy, and leaked-state-checks helpers

    * Add E2E specs for update detection and application

    * Add E2E scripts and docs for email update-propagation suite

    * Lazy-load Fake_Third_Party_Email after WC has defined WC_Email

    * Wire up Playwright suite to actually run against wp-env

    * Fix BC Case C content read and timestamp seeding

    * Run auto-applier inline in trigger-sweep; align scenario 1

    * Fix selective apply: basic auth helper and correct status assertion

    * Fix Update-available indicator locator: DataViews + version comparison

    * Fix Dismiss flow: seed older version so editor banner stays mounted

    * Fix round-trip apply and reset: use basic-auth helpers

    * Fix BC Case A and no-mass-fire: inline recorder, one-shot guard reset

    * Fix scope tests: direct seed for non-opted-in; auto-apply lifecycle for opted-in

    * Add changelog entry for RSM-146 E2E tests

    * Add review drawer UI test: per-conflict keep-yours vs use-core

    * Fix Dismiss flow: update Tracks event names per RSM-145 final rename

    * Use wcadmin_ prefix for inline-recorded server-side tracks events

    * Add reviewer-friendly JSDoc descriptions for Playwright UI mode

    * Address CodeRabbit review feedback on RSM-146 E2E suite

    - Rename require_admin_and_playwright → require_admin and clarify class
      docblock (no X-Playwright header is checked; the bootstrap location is
      the second layer of defense).
    - BC Case B: replace direct wp/v2/woo_email read with getWooEmailPostContent
      helper to bypass content.raw availability issues, matching BC Case C.
    - Auto-apply silent: replace brittle getByRole('row', {name:...}) +
      getByText pattern with locator('tr').filter({hasText}) + getByRole
      button assertion — same pattern test 2 already uses.
    - seed-woo-email: encodeURIComponent for emailId in REST paths.
    - tracks-spy: patch currently-loaded page (idempotent via __wcSpyWrapped
      guard) so attachTracksSpy doesn't miss events fired before navigation.
    - scope.spec: remove redundant cleanup tails (assertNoLeakedFixtureState
      in afterEach already does this work).

    * Fix lint errors flagged by CI

    - Remove unused simulateCoreBump import in backward-compat spec.
    - Merge duplicate test-helper-plugin imports in core-flows spec.
    - Reorder seed-woo-email helpers so resolveHash is defined before
      seedWooEmailPost references it.
    - prettier auto-fixes on multi-line formatting.

    * Open review drawer via banner click instead of deep-link (CI stability)

    The wc_email_review_drawer=1 deep-link works locally but races with editor
    mount in CI — the dispatch fires before the React store is registered, so
    the drawer stays closed. Click the banner's Review changes button instead,
    which is the merchant-facing path and is stable across environments.

    * Prettier formatting on review-drawer banner-click change

    * Make review-drawer test resilient to differ classification variance

    The local wp-env's differ classifies all 3 blocks as conflicts; CI's
    classifies block A as the only true conflict (B/C auto-resolve). Drop
    assumptions about how many radiogroups appear — interact only with the
    first one, assert post-apply that the chosen 'use core' decision took
    effect on block A.

    ---------

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

diff --git a/plugins/woocommerce/.wp-env.json b/plugins/woocommerce/.wp-env.json
index 35d86610934..bcdc767194c 100644
--- a/plugins/woocommerce/.wp-env.json
+++ b/plugins/woocommerce/.wp-env.json
@@ -1,7 +1,7 @@
 {
 	"core": "https://wordpress.org/wordpress-latest.zip",
 	"phpVersion": "8.1",
-	"plugins": [ "." ],
+	"plugins": [ ".", "./tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper" ],
 	"config": {
 		"JETPACK_AUTOLOAD_DEV": true,
 		"WP_DEBUG_LOG": true,
@@ -17,6 +17,7 @@
 			"port": 8086,
 			"plugins": [
 				".",
+				"./tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper",
 				"https://downloads.wordpress.org/plugin/akismet.zip",
 				"https://github.com/WP-API/Basic-Auth/archive/master.zip",
 				"https://downloads.wordpress.org/plugin/wp-mail-logging.zip"
diff --git a/plugins/woocommerce/changelog/rsm-146-e2e-update-propagation b/plugins/woocommerce/changelog/rsm-146-e2e-update-propagation
new file mode 100644
index 00000000000..ff71cdb6a48
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-146-e2e-update-propagation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add E2E tests for block email template update propagation.
diff --git a/plugins/woocommerce/package.json b/plugins/woocommerce/package.json
index 3f3caddd870..cdcbeb6438f 100644
--- a/plugins/woocommerce/package.json
+++ b/plugins/woocommerce/package.json
@@ -64,6 +64,8 @@
 		"test:e2e:default": "pnpm test:e2e:install && pnpm test:e2e:with-env default",
 		"test:e2e:install": "pnpm playwright install chromium",
 		"test:e2e:blocks": "pnpm --filter='@woocommerce/block-library' test:e2e",
+		"test:e2e:email-update-propagation:pr": "pnpm test:e2e:default --project=e2e --grep @pr tests/email-editor/update-propagation",
+		"test:e2e:email-update-propagation:nightly": "pnpm test:e2e:default --project=e2e tests/email-editor/update-propagation",
 		"test:e2e:with-env": "pnpm test:e2e:install && bash ./tests/e2e-pw/run-tests-with-env.sh",
 		"test:e2e:pressable": "pnpm test:e2e:with-env default-pressable",
 		"test:e2e:wpcom": "pnpm test:e2e:with-env default-wpcom",
diff --git a/plugins/woocommerce/tests/e2e-pw/README.md b/plugins/woocommerce/tests/e2e-pw/README.md
index e90f03b3cb9..de162843f91 100644
--- a/plugins/woocommerce/tests/e2e-pw/README.md
+++ b/plugins/woocommerce/tests/e2e-pw/README.md
@@ -168,6 +168,37 @@ Still, here's a few tips to get you started:
 Playwright's Best Practices guide is a good
 read: [Playwright Best Practices](https://playwright.dev/docs/best-practices).

+## Test helper plugins
+
+Some E2E suites need fixture mechanisms that can't be expressed cleanly with REST or WP-CLI alone — for example, filter-driven content overrides, server-side event mirroring, or synchronous triggers for normally-scheduled jobs. These ship as small PHP plugins under `tests/e2e-pw/test-plugins/`, mounted via `.wp-env.json`'s `plugins` array.
+
+### `wc-email-template-sync-test-helper`
+
+Powers the `tests/email-editor/update-propagation/` suite (RSM-146). Exposes:
+
+- Option-driven filter overrides for `woocommerce_email_block_template_html`, `woocommerce_email_template_sync_opted_in_emails`, and `woocommerce_transactional_emails_for_block_editor`.
+- A server-side Tracks event recorder, controlled by option `wc_test_tracks_enabled`.
+- A fake `WC_Email` subclass (`fake_thirdparty`) gated by option `wc_test_fake_third_party_email_enabled` for third-party-email scope tests.
+- REST endpoints under `/wp-json/wc-email-test-helper/v1/` for seeding posts, triggering sweeps and backfill synchronously, draining the Tracks log, and writing typed option values.
+
+The plugin is dormant when its driving options are empty. It has a `WP_DEBUG` plus `X-Playwright` header safety rail to prevent accidental activation outside test contexts.
+
+If a test fails with `404` on `/wp-json/wc-email-test-helper/v1/health`, the plugin isn't loaded — run `pnpm env:restart`.
+
+The PR-tier subset of these tests can be run locally with:
+
+```sh
+pnpm test:e2e:email-update-propagation:pr
+```
+
+To run the full suite (the PR-tier plus nightly-only scenarios):
+
+```sh
+pnpm test:e2e:email-update-propagation:nightly
+```
+
+In CI, the full suite runs as part of the existing "Core e2e tests" job — no separate workflow entry is required.
+
 ## Test reports

 The tests would generate three kinds of reports after the run:
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-fake-third-party-email.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-fake-third-party-email.php
new file mode 100644
index 00000000000..a4f000b45cb
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-fake-third-party-email.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * Fake third-party email for E2E scenarios 15-16.
+ *
+ * Registered conditionally via the woocommerce_email_classes filter in the
+ * main plugin file. Stays dormant unless wc_test_fake_third_party_email_enabled='yes'.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Minimal WC_Email subclass standing in for a real third-party transactional email.
+ *
+ * Real third-party plugins must subclass WC_Email; the registry, post generator, and
+ * divergence detector all call methods on a live WC_Email instance, so filter-only
+ * registration is insufficient. This fixture provides the minimum surface those classes
+ * need (id, title, description, template paths, basic subject/heading defaults) so that
+ * scenarios 15 (non-opted-in) and 16 (opted-in with explicit version) can exercise the
+ * full sync pipeline.
+ */
+class Fake_Third_Party_Email extends \WC_Email {
+
+	/**
+	 * Constructor.
+	 */
+	public function __construct() {
+		$this->id          = 'fake_thirdparty';
+		$this->title       = 'Fake third-party email (test fixture)';
+		$this->description = 'E2E fixture for RSM-146 scope tests. Do not enable in production.';
+
+		// Safe fallback to an existing core template so that any rendering paths that
+		// reach the file loader don't fail with a missing-template error.
+		$this->template_html  = 'emails/customer-new-account.php';
+		$this->template_plain = 'emails/plain/customer-new-account.php';
+		$this->customer_email = true;
+
+		parent::__construct();
+	}
+
+	/**
+	 * Default subject (overridable via merchant settings, but tests don't touch it).
+	 */
+	public function get_default_subject(): string {
+		return 'Fake third-party email subject';
+	}
+
+	/**
+	 * Default heading (overridable via merchant settings, but tests don't touch it).
+	 */
+	public function get_default_heading(): string {
+		return 'Fake third-party email heading';
+	}
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-opted-in-overrides.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-opted-in-overrides.php
new file mode 100644
index 00000000000..73a5c82bb5d
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-opted-in-overrides.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Opted-in and transactional-emails list filters for the WC Email Template Sync test helper plugin.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Filter wrappers that augment the sync registry's opted-in list and the block-editor
+ * transactional-emails list with fixture-controlled values. Dormant when the driving
+ * options are empty.
+ */
+class Opted_In_Overrides {
+
+	public const OPTED_IN_OPTION      = 'wc_test_opted_in_emails_override';
+	public const TRANSACTIONAL_OPTION = 'wc_test_transactional_emails_override';
+
+	/**
+	 * Register both filters.
+	 */
+	public function register(): void {
+		add_filter(
+			'woocommerce_email_template_sync_opted_in_emails',
+			array( $this, 'merge_opted_in' ),
+			100
+		);
+		add_filter(
+			'woocommerce_transactional_emails_for_block_editor',
+			array( $this, 'merge_transactional' ),
+			100
+		);
+	}
+
+	/**
+	 * Non-destructively merge the test-controlled opted-in list into the production return value.
+	 *
+	 * @param mixed $opted_in The upstream filter value (expected: array keyed by email id).
+	 * @return array The merged map.
+	 */
+	public function merge_opted_in( $opted_in ): array {
+		$opted_in = is_array( $opted_in ) ? $opted_in : array();
+		$override = get_option( self::OPTED_IN_OPTION, array() );
+		if ( ! is_array( $override ) || empty( $override ) ) {
+			return $opted_in;
+		}
+		return array_merge( $opted_in, $override );
+	}
+
+	/**
+	 * Non-destructively merge the test-controlled "registered for block editor" list with
+	 * the production return value, deduping by email id.
+	 *
+	 * @param mixed $emails The upstream filter value (expected: array of email ids).
+	 * @return array The merged list with stable numeric keys.
+	 */
+	public function merge_transactional( $emails ): array {
+		$emails   = is_array( $emails ) ? $emails : array();
+		$override = get_option( self::TRANSACTIONAL_OPTION, array() );
+		if ( ! is_array( $override ) || empty( $override ) ) {
+			return $emails;
+		}
+		return array_values( array_unique( array_merge( $emails, $override ) ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
new file mode 100644
index 00000000000..c26a4bfdc54
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
@@ -0,0 +1,650 @@
+<?php
+/**
+ * Helper REST endpoints exposed by the WC Email Template Sync test helper plugin.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Helper REST endpoints exposed under /wc-email-test-helper/v1/ for Playwright E2E tests.
+ *
+ * Health endpoint is open; every other route requires manage_options. The plugin's location
+ * under tests/e2e-pw/test-plugins/ — only mounted via .wp-env.json for the test environment —
+ * provides the second layer of defense.
+ */
+class REST_Controller {
+
+	private const NAMESPACE = 'wc-email-test-helper/v1';
+
+	/**
+	 * Register all REST routes.
+	 */
+	public function register_routes(): void {
+		register_rest_route(
+			self::NAMESPACE,
+			'/health',
+			array(
+				'methods'             => WP_REST_Server::READABLE,
+				'callback'            => array( $this, 'health' ),
+				'permission_callback' => '__return_true',
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/reset-post/(?P<email_id>[a-z0-9_]+)',
+			array(
+				'methods'             => WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'reset_post' ),
+				'permission_callback' => array( self::class, 'require_admin' ),
+				'args'                => array(
+					'email_id' => array( 'sanitize_callback' => 'sanitize_key' ),
+				),
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/seed-meta/(?P<post_id>\d+)',
+			array(
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'seed_meta' ),
+					'permission_callback' => array( self::class, 'require_admin' ),
+					'args'                => array(
+						'post_id' => array( 'sanitize_callback' => 'absint' ),
+					),
+				),
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'read_meta' ),
+					'permission_callback' => array( self::class, 'require_admin' ),
+					'args'                => array(
+						'post_id' => array( 'sanitize_callback' => 'absint' ),
+					),
+				),
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/canonical-hash/(?P<email_id>[a-z0-9_]+)',
+			array(
+				'methods'             => WP_REST_Server::READABLE,
+				'callback'            => array( $this, 'canonical_hash' ),
+				'permission_callback' => array( self::class, 'require_admin' ),
+				'args'                => array(
+					'email_id' => array( 'sanitize_callback' => 'sanitize_key' ),
+					'mode'     => array( 'sanitize_callback' => 'sanitize_key' ),
+				),
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/post-content/(?P<post_id>\d+)',
+			array(
+				'methods'             => WP_REST_Server::READABLE,
+				'callback'            => array( $this, 'read_post_content' ),
+				'permission_callback' => array( self::class, 'require_admin' ),
+				'args'                => array(
+					'post_id' => array( 'sanitize_callback' => 'absint' ),
+				),
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/seed-bulk',
+			array(
+				'methods'             => WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'seed_bulk' ),
+				'permission_callback' => array( self::class, 'require_admin' ),
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/trigger-sweep',
+			array(
+				'methods'             => WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'trigger_sweep' ),
+				'permission_callback' => array( self::class, 'require_admin' ),
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/trigger-backfill',
+			array(
+				'methods'             => WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'trigger_backfill' ),
+				'permission_callback' => array( self::class, 'require_admin' ),
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/tracks',
+			array(
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_tracks' ),
+					'permission_callback' => array( self::class, 'require_admin' ),
+				),
+				array(
+					'methods'             => WP_REST_Server::DELETABLE,
+					'callback'            => array( $this, 'clear_tracks' ),
+					'permission_callback' => array( self::class, 'require_admin' ),
+				),
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/set-option',
+			array(
+				'methods'             => WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'set_option' ),
+				'permission_callback' => array( self::class, 'require_admin' ),
+			)
+		);
+
+		register_rest_route(
+			self::NAMESPACE,
+			'/delete-option',
+			array(
+				'methods'             => WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'delete_option_value' ),
+				'permission_callback' => array( self::class, 'require_admin' ),
+			)
+		);
+	}
+
+	/**
+	 * Health probe. Tests call this in beforeAll to confirm the helper plugin is loaded.
+	 *
+	 * @param WP_REST_Request $request The REST request (unused; REST callback signature requires it).
+	 * @return WP_REST_Response
+	 */
+	public function health( WP_REST_Request $request ): WP_REST_Response {
+		unset( $request );
+		return new WP_REST_Response(
+			array(
+				'ok'      => true,
+				'version' => '1.0.0',
+			),
+			200
+		);
+	}
+
+	/**
+	 * Delete the woo_email post for the given email type, clear template manager state +
+	 * transient, then regenerate synchronously.
+	 *
+	 * @param WP_REST_Request $request The REST request. Expects `email_id` route parameter.
+	 * @return WP_REST_Response
+	 */
+	public function reset_post( WP_REST_Request $request ): WP_REST_Response {
+		$email_id = (string) $request->get_param( 'email_id' );
+
+		$manager = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager::get_instance();
+
+		$existing_post_id = (int) $manager->get_email_template_post_id( $email_id );
+		if ( $existing_post_id > 0 ) {
+			wp_delete_post( $existing_post_id, true );
+		}
+
+		$manager->delete_email_template( $email_id );
+
+		delete_transient( 'wc_email_editor_initial_templates_generated' );
+
+		$generator = new \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator();
+		$generator->init_default_transactional_emails();
+		$new_post_id = (int) $generator->generate_email_template_if_not_exists( $email_id );
+
+		if ( $new_post_id <= 0 ) {
+			return new WP_REST_Response(
+				array( 'error' => "Failed to regenerate woo_email post for {$email_id}" ),
+				500
+			);
+		}
+
+		return new WP_REST_Response( array( 'post_id' => $new_post_id ), 200 );
+	}
+
+	/**
+	 * Apply arbitrary meta + post column updates to a post in one round-trip.
+	 * Body is JSON `{ meta?: {key: value | null}, post?: {wp_update_post fields} }`.
+	 *
+	 * Timestamp columns (`post_date`, `post_date_gmt`, `post_modified`,
+	 * `post_modified_gmt`) are applied via a direct `$wpdb->update()` after the
+	 * `wp_update_post()` call, because WordPress always overwrites `post_modified*`
+	 * with the current time during an update pass — passing them through
+	 * `wp_update_post()` would be silently ignored.
+	 *
+	 * @param WP_REST_Request $request The REST request. Expects `post_id` route parameter and JSON body.
+	 * @return WP_REST_Response
+	 */
+	public function seed_meta( WP_REST_Request $request ): WP_REST_Response {
+		global $wpdb;
+
+		$post_id = (int) $request->get_param( 'post_id' );
+		$body    = $request->get_json_params();
+
+		if ( ! is_array( $body ) ) {
+			return new WP_REST_Response( array( 'error' => 'Body must be a JSON object' ), 400 );
+		}
+
+		if ( ! get_post( $post_id ) ) {
+			return new WP_REST_Response( array( 'error' => "Post {$post_id} not found" ), 404 );
+		}
+
+		$meta_updates = $body['meta'] ?? array();
+		if ( is_array( $meta_updates ) ) {
+			foreach ( $meta_updates as $key => $value ) {
+				if ( null === $value ) {
+					delete_post_meta( $post_id, (string) $key );
+				} else {
+					update_post_meta( $post_id, (string) $key, $value );
+				}
+			}
+		}
+
+		// Timestamp columns must be handled separately: wp_update_post always
+		// stamps post_modified* with current_time(), ignoring any caller-supplied
+		// value. Split the post update array into "regular" fields (handled by
+		// wp_update_post) and timestamp fields (applied via direct DB write after).
+		$timestamp_columns = array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' );
+
+		$post_updates      = $body['post'] ?? array();
+		$timestamp_updates = array();
+		$regular_updates   = array();
+
+		if ( is_array( $post_updates ) ) {
+			foreach ( $post_updates as $col => $value ) {
+				if ( in_array( $col, $timestamp_columns, true ) ) {
+					$timestamp_updates[ $col ] = (string) $value;
+				} else {
+					$regular_updates[ $col ] = $value;
+				}
+			}
+		}
+
+		if ( ! empty( $regular_updates ) ) {
+			wp_update_post(
+				array_merge( array( 'ID' => $post_id ), $regular_updates ),
+				true
+			);
+		}
+
+		if ( ! empty( $timestamp_updates ) ) {
+			// Ensure the local-time columns stay consistent with their GMT counterparts.
+			// `was_never_edited()` in WCEmailTemplateSyncBackfill checks the local pair
+			// as a fallback (`post_date === post_modified`), so both pairs must differ
+			// for the post to be classified as Case C rather than Case B.
+			if ( isset( $timestamp_updates['post_date_gmt'] ) && ! isset( $timestamp_updates['post_date'] ) ) {
+				$timestamp_updates['post_date'] = get_date_from_gmt( $timestamp_updates['post_date_gmt'] );
+			}
+			if ( isset( $timestamp_updates['post_modified_gmt'] ) && ! isset( $timestamp_updates['post_modified'] ) ) {
+				$timestamp_updates['post_modified'] = get_date_from_gmt( $timestamp_updates['post_modified_gmt'] );
+			}
+
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+			$wpdb->update(
+				$wpdb->posts,
+				$timestamp_updates,
+				array( 'ID' => $post_id ),
+				array_fill( 0, count( $timestamp_updates ), '%s' ),
+				array( '%d' )
+			);
+			clean_post_cache( $post_id );
+		}
+
+		return new WP_REST_Response(
+			array(
+				'post_id' => $post_id,
+				'meta'    => get_post_meta( $post_id ),
+			),
+			200
+		);
+	}
+
+	/**
+	 * Read all post meta for a post. Mirrors seed-meta's response shape (without writes).
+	 *
+	 * @param WP_REST_Request $request The REST request. Expects `post_id` route parameter.
+	 * @return WP_REST_Response
+	 */
+	public function read_meta( WP_REST_Request $request ): WP_REST_Response {
+		$post_id = (int) $request->get_param( 'post_id' );
+
+		if ( ! get_post( $post_id ) ) {
+			return new WP_REST_Response( array( 'error' => "Post {$post_id} not found" ), 404 );
+		}
+
+		return new WP_REST_Response(
+			array(
+				'post_id' => $post_id,
+				'meta'    => get_post_meta( $post_id ),
+			),
+			200
+		);
+	}
+
+	/**
+	 * Read the raw post_content for a post. Bypasses the wp/v2 REST surface, which
+	 * doesn't reliably expose `content.raw` for custom post types under all auth modes.
+	 *
+	 * @param WP_REST_Request $request The REST request. Expects `post_id` route parameter.
+	 * @return WP_REST_Response
+	 */
+	public function read_post_content( WP_REST_Request $request ): WP_REST_Response {
+		$post_id = (int) $request->get_param( 'post_id' );
+		$post    = get_post( $post_id );
+
+		if ( ! $post ) {
+			return new WP_REST_Response( array( 'error' => "Post {$post_id} not found" ), 404 );
+		}
+
+		return new WP_REST_Response(
+			array(
+				'post_id'      => $post_id,
+				'post_content' => (string) $post->post_content,
+			),
+			200
+		);
+	}
+
+	/**
+	 * Compute the sha1 of the canonical core HTML for a given email type. Mode 'old'
+	 * temporarily suppresses the Template_HTML_Overrides option so the canonical
+	 * resolves to the real WC default; mode 'current' applies the active override
+	 * (if any).
+	 *
+	 * @param WP_REST_Request $request The REST request. Expects `email_id` and `mode` params.
+	 * @return WP_REST_Response
+	 */
+	public function canonical_hash( WP_REST_Request $request ): WP_REST_Response {
+		$email_id = (string) $request->get_param( 'email_id' );
+		$mode     = (string) $request->get_param( 'mode' );
+
+		$manager = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager::get_instance();
+		$email   = $manager->get_email_by_id( $email_id );
+		if ( ! $email instanceof \WC_Email ) {
+			return new WP_REST_Response( array( 'error' => "Unknown email_id {$email_id}" ), 404 );
+		}
+
+		$existing_override = get_option( Template_HTML_Overrides::OPTION_NAME, array() );
+		if ( 'old' === $mode ) {
+			delete_option( Template_HTML_Overrides::OPTION_NAME );
+		}
+
+		$canonical = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+		if ( 'old' === $mode && is_array( $existing_override ) && ! empty( $existing_override ) ) {
+			update_option( Template_HTML_Overrides::OPTION_NAME, $existing_override, false );
+		}
+
+		return new WP_REST_Response(
+			array(
+				'hash'      => sha1( $canonical ),
+				'canonical' => $canonical,
+			),
+			200
+		);
+	}
+
+	/**
+	 * Bulk-insert woo_email posts with seeded meta in one round-trip. Body shape:
+	 * `{ "seeds": [ { "post": <wp_insert_post args>, "meta": {key: value | null, ...} }, ... ] }`.
+	 *
+	 * @param WP_REST_Request $request The REST request.
+	 * @return WP_REST_Response
+	 */
+	public function seed_bulk( WP_REST_Request $request ): WP_REST_Response {
+		$body = $request->get_json_params();
+		if ( ! is_array( $body ) || empty( $body['seeds'] ) || ! is_array( $body['seeds'] ) ) {
+			return new WP_REST_Response( array( 'error' => 'Body must include a "seeds" array' ), 400 );
+		}
+
+		$results = array();
+		foreach ( $body['seeds'] as $seed ) {
+			$post_data = array_merge(
+				array(
+					'post_type'   => 'woo_email',
+					'post_status' => 'publish',
+				),
+				is_array( $seed['post'] ?? null ) ? $seed['post'] : array()
+			);
+
+			$post_id = wp_insert_post( $post_data, true );
+
+			if ( is_wp_error( $post_id ) ) {
+				$results[] = array( 'error' => $post_id->get_error_message() );
+				continue;
+			}
+
+			$meta_updates = $seed['meta'] ?? array();
+			if ( is_array( $meta_updates ) ) {
+				foreach ( $meta_updates as $key => $value ) {
+					if ( null === $value ) {
+						delete_post_meta( (int) $post_id, (string) $key );
+					} else {
+						update_post_meta( (int) $post_id, (string) $key, $value );
+					}
+				}
+			}
+
+			$results[] = array( 'post_id' => (int) $post_id );
+		}
+
+		return new WP_REST_Response( array( 'results' => $results ), 200 );
+	}
+
+	/**
+	 * Run the divergence sweep inline, then immediately run the auto-applier inline
+	 * (bypassing Action Scheduler), then snapshot classifications from post meta
+	 * across all sync-enabled emails.
+	 *
+	 * Production flow: run_sweep() fires the sweep_complete action → schedule()
+	 * enqueues an async AS job → run() executes later. For E2E tests we need the
+	 * full classify-then-apply cycle to complete within the same HTTP request so
+	 * assertions can run immediately after this call returns.
+	 *
+	 * @param WP_REST_Request $request The REST request (unused).
+	 * @return WP_REST_Response
+	 */
+	public function trigger_sweep( WP_REST_Request $request ): WP_REST_Response {
+		unset( $request );
+
+		\Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector::run_sweep();
+
+		// Run the auto-applier inline so unmodified posts are stamped IN_SYNC before
+		// this response returns. In production the applier is deferred via Action
+		// Scheduler; calling run() directly here keeps the E2E request synchronous.
+		\Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier::run();
+
+		$registry = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+		$manager  = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager::get_instance();
+
+		$classifications = array();
+		foreach ( array_keys( $registry ) as $email_id ) {
+			$post = $manager->get_email_post( (string) $email_id );
+			if ( ! $post instanceof \WP_Post ) {
+				continue;
+			}
+			$status = (string) get_post_meta( (int) $post->ID, \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true );
+			if ( '' !== $status ) {
+				$classifications[ (int) $post->ID ] = $status;
+			}
+		}
+
+		return new WP_REST_Response(
+			array(
+				'touched'         => count( $classifications ),
+				'classifications' => $classifications,
+			),
+			200
+		);
+	}
+
+	/**
+	 * Run the legacy-post backfill inline, then snapshot a count of stamped posts.
+	 * Production `run()` returns false unconditionally — value reported back is for caller
+	 * convenience only. Tests assert via meta snapshots before/after.
+	 *
+	 * WC_Tracks::record_event() short-circuits when site tracking is disabled (the
+	 * default in wp-env), so it never reaches the woocommerce_tracks_event_properties
+	 * filter that Tracks_Recorder hooks into. To capture the _backfill_completed event
+	 * reliably we inject a custom recorder via set_event_recorder() that writes
+	 * directly to the Tracks_Recorder log option, then restore the default (null)
+	 * after the backfill completes.
+	 *
+	 * @param WP_REST_Request $request The REST request (unused).
+	 * @return WP_REST_Response
+	 */
+	public function trigger_backfill( WP_REST_Request $request ): WP_REST_Response {
+		unset( $request );
+
+		// Inject a direct-write recorder so _backfill_completed lands in the log
+		// even when WC_Tracks::record_event() is disabled for the test environment.
+		// The recorder is gated on the same wc_test_tracks_enabled option that
+		// Tracks_Recorder uses, so it only fires when a spy is active and the log
+		// stays empty for tests that don't attach a spy.
+		//
+		// Event names get the 'wcadmin_' prefix to match WC_Tracks::PREFIX — that's
+		// what server-side events are dispatched as via WC_Tracks::record_event().
+		// The TRACKS_EVENTS constants in classifications.ts expect the prefixed name.
+		\Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder(
+			static function ( string $event_name, array $payload ): void {
+				if ( 'yes' !== get_option( Tracks_Recorder::ENABLED_OPTION, 'no' ) ) {
+					return;
+				}
+				$log = get_option( Tracks_Recorder::LOG_OPTION, array() );
+				if ( ! is_array( $log ) ) {
+					$log = array();
+				}
+				$log[] = array(
+					'name'         => 'wcadmin_' . $event_name,
+					'properties'   => $payload,
+					'timestamp_ms' => (int) ( microtime( true ) * 1000 ),
+				);
+				update_option( Tracks_Recorder::LOG_OPTION, $log, false );
+			}
+		);
+
+		$ran = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill::run();
+
+		// Restore the default recorder so subsequent calls don't double-log via
+		// both the injected recorder and any future WC_Tracks path.
+		\Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder( null );
+
+		global $wpdb;
+		$stamped = (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value <> ''",
+				\Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY
+			)
+		);
+
+		return new WP_REST_Response(
+			array(
+				'ran'     => (bool) $ran,
+				'stamped' => $stamped,
+			),
+			200
+		);
+	}
+
+	/**
+	 * Return the in-option Tracks event log captured by Tracks_Recorder.
+	 *
+	 * @param WP_REST_Request $request The REST request (unused).
+	 * @return WP_REST_Response
+	 */
+	public function get_tracks( WP_REST_Request $request ): WP_REST_Response {
+		unset( $request );
+		$log = get_option( Tracks_Recorder::LOG_OPTION, array() );
+		return new WP_REST_Response(
+			array( 'events' => is_array( $log ) ? $log : array() ),
+			200
+		);
+	}
+
+	/**
+	 * Clear the Tracks event log option so the next read starts empty.
+	 *
+	 * @param WP_REST_Request $request The REST request (unused).
+	 * @return WP_REST_Response
+	 */
+	public function clear_tracks( WP_REST_Request $request ): WP_REST_Response {
+		unset( $request );
+		delete_option( Tracks_Recorder::LOG_OPTION );
+		return new WP_REST_Response( array( 'cleared' => true ), 200 );
+	}
+
+	/**
+	 * Write a typed value to a WordPress option, preserving array/object structure.
+	 * Body shape: `{ "option_name": string, "option_value": mixed }`. Unlike the
+	 * shared `e2e-options/update` endpoint, this preserves arrays and nested objects
+	 * because it pulls the value from the JSON body rather than sanitize_text_field.
+	 *
+	 * @param WP_REST_Request $request The REST request.
+	 * @return WP_REST_Response
+	 */
+	public function set_option( WP_REST_Request $request ): WP_REST_Response {
+		$body = $request->get_json_params();
+		if ( ! is_array( $body ) || ! isset( $body['option_name'] ) ) {
+			return new WP_REST_Response( array( 'error' => 'Body must include option_name' ), 400 );
+		}
+
+		$option_name  = (string) $body['option_name'];
+		$option_value = $body['option_value'] ?? '';
+
+		update_option( $option_name, $option_value, false );
+
+		return new WP_REST_Response( array( 'ok' => true ), 200 );
+	}
+
+	/**
+	 * Delete a WordPress option. Body shape: `{ "option_name": string }`.
+	 *
+	 * @param WP_REST_Request $request The REST request.
+	 * @return WP_REST_Response
+	 */
+	public function delete_option_value( WP_REST_Request $request ): WP_REST_Response {
+		$body = $request->get_json_params();
+		if ( ! is_array( $body ) || ! isset( $body['option_name'] ) ) {
+			return new WP_REST_Response( array( 'error' => 'Body must include option_name' ), 400 );
+		}
+
+		delete_option( (string) $body['option_name'] );
+
+		return new WP_REST_Response( array( 'ok' => true ), 200 );
+	}
+
+	/**
+	 * Permission callback used by every non-health endpoint. Requires the manage_options
+	 * capability. The plugin is only mounted in test environments via .wp-env.json — it
+	 * does not ship in any production WooCommerce build — which provides the second
+	 * layer of defense.
+	 *
+	 * @param WP_REST_Request $request The REST request (unused).
+	 * @return bool
+	 */
+	public static function require_admin( WP_REST_Request $request ): bool {
+		unset( $request );
+		return current_user_can( 'manage_options' );
+	}
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-template-html-overrides.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-template-html-overrides.php
new file mode 100644
index 00000000000..68dcd182a7c
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-template-html-overrides.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Template HTML override filter for the WC Email Template Sync test helper plugin.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Filter wrapper that swaps in fixture-controlled canonical HTML for a given email type.
+ *
+ * Dormant when its driving option is empty.
+ */
+class Template_HTML_Overrides {
+
+	public const OPTION_NAME = 'wc_test_template_html_override';
+
+	/**
+	 * Register the filter.
+	 */
+	public function register(): void {
+		add_filter(
+			'woocommerce_email_block_template_html',
+			array( $this, 'maybe_override' ),
+			100,
+			2
+		);
+	}
+
+	/**
+	 * Conditionally swap the canonical HTML for a given email type when an override option is set.
+	 *
+	 * @param string $template_html The HTML produced by the upstream filter.
+	 * @param mixed  $email         The WC_Email instance whose template is being rendered.
+	 * @return string Possibly-overridden HTML.
+	 */
+	public function maybe_override( string $template_html, $email ): string {
+		$overrides = get_option( self::OPTION_NAME, array() );
+		if ( ! is_array( $overrides ) || empty( $overrides ) ) {
+			return $template_html;
+		}
+
+		$email_id = is_object( $email ) && isset( $email->id ) ? (string) $email->id : '';
+		if ( '' === $email_id ) {
+			return $template_html;
+		}
+
+		return isset( $overrides[ $email_id ] )
+			? (string) $overrides[ $email_id ]
+			: $template_html;
+	}
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-tracks-recorder.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-tracks-recorder.php
new file mode 100644
index 00000000000..8e41b611d5b
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-tracks-recorder.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Server-side Tracks event recorder for the WC Email Template Sync test helper plugin.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Mirrors server-side recordEvent calls and the backfill-complete action to an option
+ * so Playwright tests can drain and assert. Dormant unless wc_test_tracks_enabled=yes.
+ */
+class Tracks_Recorder {
+
+	public const ENABLED_OPTION = 'wc_test_tracks_enabled';
+	public const LOG_OPTION     = 'wc_test_tracks_log';
+
+	/**
+	 * Register hooks.
+	 */
+	public function register(): void {
+		add_filter( 'woocommerce_tracks_event_properties', array( $this, 'record' ), 100, 2 );
+	}
+
+	/**
+	 * Append a record to the tracks log when enabled. Always returns the untouched properties.
+	 *
+	 * @param array  $properties Event properties (passed through unchanged).
+	 * @param string $event_name Event name.
+	 * @return array
+	 */
+	public function record( $properties, $event_name ): array {
+		if ( 'yes' !== get_option( self::ENABLED_OPTION, 'no' ) ) {
+			return is_array( $properties ) ? $properties : array();
+		}
+
+		$log = get_option( self::LOG_OPTION, array() );
+		if ( ! is_array( $log ) ) {
+			$log = array();
+		}
+
+		$log[] = array(
+			'name'         => (string) $event_name,
+			'properties'   => is_array( $properties ) ? $properties : array(),
+			'timestamp_ms' => (int) ( microtime( true ) * 1000 ),
+		);
+
+		update_option( self::LOG_OPTION, $log, false );
+
+		return is_array( $properties ) ? $properties : array();
+	}
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/wc-email-template-sync-test-helper.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/wc-email-template-sync-test-helper.php
new file mode 100644
index 00000000000..00e4c552e95
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/wc-email-template-sync-test-helper.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Plugin Name: WC Email Template Sync Test Helper
+ * Description: E2E test fixture for RSM-146. Option-driven filters and REST endpoints used by Playwright tests. Dormant unless its driving options are set.
+ * Version: 1.0.0
+ * Requires PHP: 8.1
+ * Author: WooCommerce
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+defined( 'ABSPATH' ) || exit;
+
+// This plugin is only mounted by .wp-env.json for E2E test environments — it does not ship
+// in any production WooCommerce build. REST permission callbacks still enforce manage_options.
+
+define( 'WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR', plugin_dir_path( __FILE__ ) );
+
+require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-template-html-overrides.php';
+require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-opted-in-overrides.php';
+require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-tracks-recorder.php';
+require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-rest-controller.php';
+
+add_action(
+	'plugins_loaded',
+	static function () {
+		( new WC_Email_Template_Sync_Test_Helper\Template_HTML_Overrides() )->register();
+		( new WC_Email_Template_Sync_Test_Helper\Opted_In_Overrides() )->register();
+		( new WC_Email_Template_Sync_Test_Helper\Tracks_Recorder() )->register();
+	},
+	20
+);
+
+add_action(
+	'rest_api_init',
+	static function () {
+		( new WC_Email_Template_Sync_Test_Helper\REST_Controller() )->register_routes();
+	}
+);
+
+// Register the fake third-party WC_Email subclass ONLY when an option signals tests want it.
+// The class file is lazy-loaded here because it extends WC_Email, which is not defined
+// until the woocommerce plugin has loaded — eager-loading at plugin-bootstrap time would
+// fatal on PHP class resolution.
+add_filter(
+	'woocommerce_email_classes',
+	static function ( $emails ) {
+		if ( 'yes' !== get_option( 'wc_test_fake_third_party_email_enabled', 'no' ) ) {
+			return $emails;
+		}
+		require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-fake-third-party-email.php';
+		$emails['WC_Email_Template_Sync_Test_Helper\\Fake_Third_Party_Email'] = new \WC_Email_Template_Sync_Test_Helper\Fake_Third_Party_Email();
+		return $emails;
+	}
+);
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/backward-compat.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/backward-compat.spec.ts
new file mode 100644
index 00000000000..9b0a00b6788
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/backward-compat.spec.ts
@@ -0,0 +1,313 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Update-propagation: backward compatibility.
+ *
+ * Covers the five BC scenarios for sites that had email posts before the
+ * RSM-137 stamp meta was introduced: Case A (content matches core, no stamp),
+ * Case B (timestamps equal, content behind core), Case C (customized post —
+ * critical safety: content must never be overwritten), the no-mass-fire
+ * Tracks guard (backfill must not fire _available events), and idempotency
+ * (second backfill is a no-op).
+ *
+ * Reviewing in Playwright UI mode:
+ *   1. Run `npx playwright test --project=e2e tests/email-editor/update-propagation --ui`
+ *   2. Filter the tree by `backward-compat` and pick a test.
+ *   3. All tests are REST-only except "BC no mass-fire" which attaches a Tracks
+ *      spy via the page fixture (but does not navigate or interact with the UI).
+ *      For all tests, the Actions panel in UI mode shows the REST call sequence.
+ *   4. "Show browser" eye is not needed for any test in this file.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../../playwright.config';
+import { admin } from '../../../test-data/data';
+import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
+import {
+	clearTemplateHtmlOverride,
+	setTemplateHtmlOverride,
+} from './helpers/test-helper-plugin';
+import {
+	seedWooEmailPost,
+	getWooEmailMeta,
+	getWooEmailPostContent,
+} from './helpers/seed-woo-email';
+import {
+	triggerBackfill,
+	triggerDetectionSweep,
+} from './helpers/simulate-plugin-update';
+import { attachTracksSpy } from './helpers/tracks-spy';
+import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
+import {
+	STATUS,
+	META_KEYS,
+	TRACKS_EVENTS,
+	TEST_HELPER_API_BASE,
+} from './helpers/classifications';
+
+const BACKFILL_COMPLETE_OPTION =
+	'woocommerce_email_template_sync_backfill_complete';
+
+// One-shot Tracks guard added by RSM-145: fires _backfill_completed at most
+// once per site. Must be cleared alongside BACKFILL_COMPLETE_OPTION so the
+// Tracks spy can observe the event each time a BC test re-runs the backfill.
+const BACKFILL_COMPLETED_TRACKED_OPTION =
+	'wc_email_sync_backfill_completed_tracked';
+
+const OLD_HTML = '<!-- wp:paragraph --><p>OLD</p><!-- /wp:paragraph -->';
+
+async function resetBackfillFence( baseURL: string ): Promise< void > {
+	const client = createClient( baseURL, {
+		type: 'basic',
+		username: admin.username,
+		password: admin.password,
+	} );
+	await client.post( `${ TEST_HELPER_API_BASE }/delete-option`, {
+		option_name: BACKFILL_COMPLETE_OPTION,
+	} );
+	await client.post( `${ TEST_HELPER_API_BASE }/delete-option`, {
+		option_name: BACKFILL_COMPLETED_TRACKED_OPTION,
+	} );
+}
+
+test.describe( 'Update propagation — backward compatibility', () => {
+	test.use( { storageState: ADMIN_STATE_PATH } );
+
+	test.beforeAll( async ( { baseURL } ) => {
+		await enableEmailEditor( baseURL! );
+	} );
+
+	test.beforeEach( async ( { baseURL } ) => {
+		// RSM-145 stamps this option on fresh installs via woocommerce_newly_installed
+		// to suppress backfill on greenfield environments. BC scenarios need a clean
+		// "pre-RSM-137" environment, so clear the option-fence before each test.
+		await resetBackfillFence( baseURL! );
+	} );
+
+	test.afterEach( async () => {
+		await assertNoLeakedFixtureState();
+	} );
+
+	/**
+	 * Verifies that a pre-RSM-137 post whose content already matches the current
+	 * canonical is stamped in_sync by the backfill and correctly participates in
+	 * subsequent detection sweeps.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+	 *   (stripStampMeta) → triggerBackfill → getWooEmailMeta assertions →
+	 *   setTemplateHtmlOverride → triggerDetectionSweep → meta re-check.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'BC Case A — content matches current core, no stamp meta', async () => {
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			stripStampMeta: true,
+		} );
+
+		const backfill = await triggerBackfill();
+		expect( backfill.stamped ).toBeGreaterThanOrEqual( 1 );
+
+		const meta = await getWooEmailMeta( postId );
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+		expect( meta[ META_KEYS.SOURCE_HASH ]?.[ 0 ] ).toBeTruthy();
+
+		// Simulate a core bump by setting the override to a different canonical.
+		await setTemplateHtmlOverride( 'new_order', OLD_HTML );
+		await triggerDetectionSweep();
+		await clearTemplateHtmlOverride();
+
+		// The sweep classifies the unmodified post as core_updated_uncustomized,
+		// then the auto-applier (run inline by /trigger-sweep) silently applies
+		// the new canonical and re-stamps the post as in_sync.
+		const metaAfter = await getWooEmailMeta( postId );
+		expect( metaAfter[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+	} );
+
+	/**
+	 * Verifies that a pre-RSM-137 post with equal created/modified timestamps
+	 * (indicating the content was never edited) is silently updated to the current
+	 * canonical during backfill and stamped in_sync.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+	 *   (stripStampMeta, equal timestamps, old content) → triggerBackfill →
+	 *   meta assertion → REST GET to verify post_content was rewritten to canonical.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'BC Case B — timestamps equal and content behind core', async () => {
+		const ts = '2024-01-01 12:00:00';
+
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: OLD_HTML,
+			postDateGmt: ts,
+			postModifiedGmt: ts,
+			stripStampMeta: true,
+		} );
+
+		const backfill = await triggerBackfill();
+		expect( backfill.stamped ).toBeGreaterThanOrEqual( 1 );
+
+		const meta = await getWooEmailMeta( postId );
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+
+		// Critical: the backfill rewrote post_content from OLD_HTML to current canonical.
+		const content = await getWooEmailPostContent( postId );
+		expect( content ).not.toContain( 'OLD' );
+	} );
+
+	/**
+	 * Critical safety test: verifies that a pre-RSM-137 post whose content diverges
+	 * from canonical (i.e., the merchant edited it) is stamped core_updated_customized
+	 * by the backfill and that its content is NEVER overwritten — neither during
+	 * backfill nor during subsequent detection sweeps.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+	 *   (stripStampMeta, customized content) → triggerBackfill → meta + content
+	 *   assertions → setTemplateHtmlOverride → triggerDetectionSweep → repeat
+	 *   meta + content assertions confirming the merchant text is still intact.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( '@pr BC Case C — customized post content preserved (critical safety)', async () => {
+		const customized =
+			'<!-- wp:paragraph --><p>MERCHANT CUSTOM 1234</p><!-- /wp:paragraph -->';
+
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: customized,
+			postDateGmt: '2024-01-01 12:00:00',
+			postModifiedGmt: '2024-06-15 09:00:00',
+			stripStampMeta: true,
+		} );
+
+		const backfill = await triggerBackfill();
+		expect( backfill.stamped ).toBeGreaterThanOrEqual( 1 );
+
+		let meta = await getWooEmailMeta( postId );
+		// Case C: content differs from canonical AND the post has been edited.
+		// Backfill stamps core_updated_customized (does NOT rewrite post_content).
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+			STATUS.CORE_UPDATED_CUSTOMIZED
+		);
+
+		// CRITICAL: post content must be UNTOUCHED by backfill.
+		const contentAfterBackfill = await getWooEmailPostContent( postId );
+		expect( contentAfterBackfill ).toContain( 'MERCHANT CUSTOM 1234' );
+
+		await setTemplateHtmlOverride( 'new_order', OLD_HTML );
+		await triggerDetectionSweep();
+		await clearTemplateHtmlOverride();
+
+		meta = await getWooEmailMeta( postId );
+		// Safety claim: classification is CUSTOMIZED, not UNCUSTOMIZED.
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+			STATUS.CORE_UPDATED_CUSTOMIZED
+		);
+
+		const contentAfterBump = await getWooEmailPostContent( postId );
+		expect( contentAfterBump ).toContain( 'MERCHANT CUSTOM 1234' );
+	} );
+
+	/**
+	 * Verifies that running backfill + detection sweep on a full set of 11 email
+	 * types fires exactly one _backfill_completed Tracks event and zero
+	 * _available events (guarding against a mass notification storm on upgrade).
+	 *
+	 * UI mode walkthrough:
+	 *   The page fixture is used only to attach the Tracks spy — no navigation
+	 *   or UI interaction occurs. The spy intercepts server-side Tracks events via
+	 *   REST. Actions panel shows: seedWooEmailPost (×11) → triggerBackfill →
+	 *   triggerDetectionSweep → spy.drain() → event count assertions.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'BC no mass-fire on first upgrade: zero _available, one _backfill_completed', async ( {
+		page,
+	} ) => {
+		const spy = await attachTracksSpy( page );
+
+		const emailIds = [
+			'new_order',
+			'cancelled_order',
+			'failed_order',
+			'customer_on_hold_order',
+			'customer_processing_order',
+			'customer_completed_order',
+			'customer_refunded_order',
+			'customer_invoice',
+			'customer_note',
+			'customer_reset_password',
+			'customer_new_account',
+		];
+		for ( const id of emailIds ) {
+			await seedWooEmailPost( {
+				emailId: id,
+				stripStampMeta: true,
+			} );
+		}
+
+		const backfill = await triggerBackfill();
+		expect( backfill.stamped ).toBeGreaterThanOrEqual( emailIds.length );
+
+		await triggerDetectionSweep();
+
+		// Drain all server + client events in one call. Each expectFired/expectNotFired
+		// call invokes drain() independently, which reads and deletes the server log —
+		// a second call would see an empty log. Assert both conditions against the same
+		// snapshot to avoid missing events.
+		const events = await spy.drain();
+		const available = events.filter(
+			( e ) => e.name === TRACKS_EVENTS.AVAILABLE
+		);
+		const backfillCompleted = events.filter(
+			( e ) => e.name === TRACKS_EVENTS.BACKFILL_COMPLETED
+		);
+		expect(
+			available.length,
+			'No _available events should fire during backfill'
+		).toBe( 0 );
+		expect(
+			backfillCompleted.length,
+			'Exactly one _backfill_completed event should fire'
+		).toBe( 1 );
+	} );
+
+	/**
+	 * Verifies that running the backfill twice produces identical post meta,
+	 * confirming the migration is safe to re-run (e.g., in case of interrupted
+	 * deploys or duplicate cron fires).
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+	 *   (stripStampMeta) → triggerBackfill (first) → getWooEmailMeta snapshot →
+	 *   triggerBackfill (second) → getWooEmailMeta equality assertion.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'BC migration is idempotent: second backfill is a no-op', async () => {
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			stripStampMeta: true,
+		} );
+
+		const first = await triggerBackfill();
+		expect( first.stamped ).toBeGreaterThanOrEqual( 1 );
+		const metaAfterFirst = await getWooEmailMeta( postId );
+
+		await triggerBackfill();
+		const metaAfterSecond = await getWooEmailMeta( postId );
+
+		expect( metaAfterSecond ).toEqual( metaAfterFirst );
+	} );
+} );
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/core-flows.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/core-flows.spec.ts
new file mode 100644
index 00000000000..7060ff78b67
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/core-flows.spec.ts
@@ -0,0 +1,452 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Update-propagation: core flows.
+ *
+ * Covers the merchant-facing lifecycle of a core-template update: divergence
+ * detection, the update-available indicator on the list + editor banner,
+ * auto-apply for unmodified posts, selective apply for customized posts, the
+ * dismiss flow, and the review-drawer-driven selective merge.
+ *
+ * Reviewing in Playwright UI mode:
+ *   1. Run `npx playwright test --project=e2e tests/email-editor/update-propagation --ui`
+ *   2. Filter the tree by `core-flows` and pick a test.
+ *   3. For UI tests, toggle "Show browser" (👁 in the top-left toolbar) to watch the
+ *      Chromium window drive the admin. For REST-only tests the Actions panel
+ *      shows the REST call sequence — no browser needed.
+ *   4. Per-test JSDoc below indicates whether each test drives the browser.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../../playwright.config';
+import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
+import { accessTheEmailEditor } from '../../../utils/email';
+import {
+	clearTemplateHtmlOverride,
+	setTemplateHtmlOverride,
+} from './helpers/test-helper-plugin';
+import {
+	seedWooEmailPost,
+	getWooEmailMeta,
+	getWooEmailPostContent,
+	applyWooEmailTemplate,
+} from './helpers/seed-woo-email';
+import {
+	simulateCoreBump,
+	triggerDetectionSweep,
+} from './helpers/simulate-plugin-update';
+import { attachTracksSpy } from './helpers/tracks-spy';
+import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
+import { STATUS, META_KEYS, TRACKS_EVENTS } from './helpers/classifications';
+
+const OLD_HTML =
+	'<!-- wp:paragraph --><p>OLD CANONICAL</p><!-- /wp:paragraph -->';
+
+test.describe( 'Update propagation — core flows', () => {
+	test.use( { storageState: ADMIN_STATE_PATH } );
+
+	test.beforeAll( async ( { baseURL } ) => {
+		await enableEmailEditor( baseURL! );
+	} );
+
+	test.afterEach( async () => {
+		await assertNoLeakedFixtureState();
+	} );
+
+	/**
+	 * Verifies that running the detection sweep after a core bump correctly
+	 * classifies an unmodified post as auto-applied (in_sync) and a merchant-
+	 * customized post as core_updated_customized, waiting for manual review.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows the REST call
+	 *   sequence: simulateCoreBump → seedWooEmailPost (×2) → clearTemplateHtmlOverride
+	 *   → triggerDetectionSweep → getWooEmailMeta assertions.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( '@pr Plugin update triggers divergence detection and classifies posts', async () => {
+		// Bump and seed the uncustomized post.
+		await simulateCoreBump( 'new_order', OLD_HTML );
+		const uncustomizedPostId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: OLD_HTML,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+		} );
+
+		// Bump and seed the customized post (override is single-key, replacing the
+		// previous one — but new_order's stored hash was already captured at seed time).
+		await simulateCoreBump( 'customer_processing_order', OLD_HTML );
+		const customizedHtml = OLD_HTML.replace(
+			'OLD CANONICAL',
+			'MERCHANT EDITED'
+		);
+		const customizedPostId = await seedWooEmailPost( {
+			emailId: 'customer_processing_order',
+			postContent: customizedHtml,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+		} );
+
+		await clearTemplateHtmlOverride();
+
+		const sweep = await triggerDetectionSweep();
+
+		const uncustomizedMeta = await getWooEmailMeta( uncustomizedPostId );
+		const customizedMeta = await getWooEmailMeta( customizedPostId );
+
+		// The sweep classifies the unmodified post as core_updated_uncustomized,
+		// then the auto-applier (also fired by /trigger-sweep inline) silently
+		// applies the new canonical and re-stamps the post as in_sync.
+		expect( uncustomizedMeta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+			STATUS.IN_SYNC
+		);
+		// Customized posts are left for the merchant to apply manually.
+		expect( customizedMeta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+			STATUS.CORE_UPDATED_CUSTOMIZED
+		);
+		expect( sweep.touched ).toBeGreaterThanOrEqual( 2 );
+	} );
+
+	/**
+	 * Verifies that a core_updated_customized post surfaces a "Review update"
+	 * button on the email list page and a "Template update available" banner
+	 * inside the block editor.
+	 *
+	 * UI mode walkthrough:
+	 *   After REST setup the test navigates to WP Admin → WooCommerce → Settings →
+	 *   Email. The DataViews table loads and the "New order" row should contain a
+	 *   "Review update" button. The test then opens the email in the block editor
+	 *   and asserts the "Template update available" status banner is visible. No
+	 *   clicks — both assertions are visibility checks only.
+	 *
+	 *   "Show browser" eye: ON.
+	 */
+	test( '@pr Update-available indicator appears on email list and in editor', async ( {
+		page,
+	} ) => {
+		await simulateCoreBump( 'new_order', OLD_HTML );
+		await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: OLD_HTML.replace( 'OLD CANONICAL', 'MERCHANT EDIT' ),
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+			// Seed an older version so the registry's current_version is higher.
+			// The list cell and editor banner only show when
+			// templateVersion < currentVersion; same-version posts don't surface
+			// the indicator even when status is core_updated_customized.
+			version: '10.0.0',
+		} );
+		await clearTemplateHtmlOverride();
+		await triggerDetectionSweep();
+
+		await page.goto( '/wp-admin/admin.php?page=wc-settings&tab=email' );
+		// DataViews table rows have no aria-label, so getByRole('row', {name:...})
+		// doesn't work. Use filter({ hasText }) to scope to the New order row.
+		// The Updates column renders a secondary Button labelled "Review update"
+		// when the post is core_updated_customized. The text "Update available"
+		// only appears in the filter-dropdown elements, not in the row cell itself.
+		const newOrderRow = page
+			.locator( 'tr' )
+			.filter( { hasText: /New order/i } )
+			.first();
+		await expect(
+			newOrderRow.getByRole( 'button', { name: /review update/i } )
+		).toBeVisible( { timeout: 15000 } );
+
+		await accessTheEmailEditor( page, 'New order' );
+		// The editor banner title is "Template update available" (role="status").
+		await expect(
+			page.getByText( /template update available/i ).first()
+		).toBeVisible( { timeout: 15000 } );
+	} );
+
+	/**
+	 * Verifies that an unmodified post is silently brought back to in_sync by the
+	 * auto-applier, with no "Update available" indicator on the list and no
+	 * Tracks events fired for update-available or dismissed.
+	 *
+	 * UI mode walkthrough:
+	 *   The page fixture is used only to attach the Tracks spy and to navigate to
+	 *   the email list for the "no indicator" assertion — no clicks are performed.
+	 *   You'll see the browser open the email settings page and the test confirms
+	 *   the "Update available" text is hidden in the New order row.
+	 *
+	 *   "Show browser" eye: ON.
+	 */
+	test( '@pr Auto-apply succeeds silently for unmodified posts', async ( {
+		page,
+	} ) => {
+		const spy = await attachTracksSpy( page );
+
+		await simulateCoreBump( 'new_order', OLD_HTML );
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: OLD_HTML,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+		} );
+		await clearTemplateHtmlOverride();
+
+		await triggerDetectionSweep();
+
+		const meta = await getWooEmailMeta( postId );
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+
+		// DataViews rows have no aria-label, so getByRole('row', {name:...}) doesn't
+		// match. Use locator('tr').filter({hasText}) — same approach as test 2 —
+		// then assert the "Review update" button is absent (toHaveCount(0)) which is
+		// what actually surfaces when a post is core_updated_customized.
+		await page.goto( '/wp-admin/admin.php?page=wc-settings&tab=email' );
+		const newOrderRow = page
+			.locator( 'tr' )
+			.filter( { hasText: /New order/i } )
+			.first();
+		await expect(
+			newOrderRow.getByRole( 'button', { name: /review update/i } )
+		).toHaveCount( 0 );
+
+		await spy.expectNotFired( TRACKS_EVENTS.AVAILABLE );
+		await spy.expectNotFired( TRACKS_EVENTS.DISMISSED );
+	} );
+
+	/**
+	 * Verifies that calling the apply endpoint with choices:[] (keep-yours default)
+	 * applies core additions while preserving merchant edits, and leaves the post
+	 * stamped core_updated_customized because the content still diverges from canonical.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows: simulateCoreBump
+	 *   → seedWooEmailPost → clearTemplateHtmlOverride → triggerDetectionSweep
+	 *   → applyWooEmailTemplate (REST POST) → getWooEmailMeta + content assertions.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( '@pr Selective apply succeeds and preserves customizations', async () => {
+		const oldHtml =
+			'<!-- wp:paragraph --><p>OLD CORE</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>SECOND BLOCK</p><!-- /wp:paragraph -->';
+		const customized = oldHtml.replace(
+			'SECOND BLOCK',
+			'MERCHANT EDITED SECOND'
+		);
+
+		await simulateCoreBump( 'new_order', oldHtml );
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: customized,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+		} );
+		await clearTemplateHtmlOverride();
+		await triggerDetectionSweep();
+
+		// Use applyWooEmailTemplate (basic auth) instead of request.post (cookie auth)
+		// because WP REST POST endpoints require a nonce when using cookie-based auth.
+		// choices: [] keeps all merchant edits and applies only core additions.
+		const apply = await applyWooEmailTemplate( postId, [] );
+		expect( apply.status ).toBe( 'applied' );
+
+		const meta = await getWooEmailMeta( postId );
+		// With choices:[] the merchant's edits are preserved (keep_yours is the
+		// default for copy_changes). The merged result diverges from canonical, so
+		// the applier stamps core_updated_customized — not in_sync.
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+			STATUS.CORE_UPDATED_CUSTOMIZED
+		);
+
+		const content = await getWooEmailPostContent( postId );
+		expect( content ).toContain( 'MERCHANT EDITED SECOND' );
+	} );
+
+	/**
+	 * Verifies that clicking the "dismiss" button on the editor update banner fires
+	 * the expected Tracks dismissed event.
+	 *
+	 * UI mode walkthrough:
+	 *   After REST setup the test opens the block editor for the New order email.
+	 *   The editor canvas loads, and the update banner is visible at the top. If
+	 *   the review drawer is already open it is closed via Escape. Then the test
+	 *   clicks the banner's dismiss button (`.wc-update-banner__dismiss`) and
+	 *   asserts the Tracks dismissed event fired.
+	 *
+	 *   "Show browser" eye: ON.
+	 */
+	test( '@pr Dismiss flow records the dismissed Tracks event', async ( {
+		page,
+	} ) => {
+		const customized = OLD_HTML.replace( 'OLD CANONICAL', 'MERCHANT EDIT' );
+
+		const spy = await attachTracksSpy( page );
+
+		await simulateCoreBump( 'new_order', OLD_HTML );
+		await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: customized,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+			// Seed an older version so the registry's current_version is higher.
+			// The editor banner only shows when templateVersion < currentVersion;
+			// same-version posts surface summaryShowsReviewed=true and unmount
+			// the banner before the dismiss button can be clicked.
+			version: '10.0.0',
+		} );
+		await clearTemplateHtmlOverride();
+		await triggerDetectionSweep();
+
+		await accessTheEmailEditor( page, 'New order' );
+
+		// If the review drawer happened to open (e.g., via a deep-link or
+		// store state from a prior navigation), close it before looking for
+		// the banner's dismiss button so the drawer panel doesn't obscure it.
+		const drawer = page.getByRole( 'dialog', {
+			name: /review template update/i,
+		} );
+		if ( await drawer.isVisible() ) {
+			await page.keyboard.press( 'Escape' );
+			await drawer.waitFor( { state: 'hidden' } );
+		}
+
+		// Target the banner's dismiss button by its stable CSS class to avoid
+		// matching any other "dismiss"-labelled button that may be on the page.
+		const dismissButton = page.locator( '.wc-update-banner__dismiss' );
+		await expect( dismissButton ).toBeVisible( { timeout: 15000 } );
+		await dismissButton.click();
+
+		await spy.expectFired( TRACKS_EVENTS.DISMISSED );
+	} );
+
+	/**
+	 * Verifies that the review drawer allows per-conflict "keep yours" / "use core"
+	 * choices and that clicking Apply merges exactly the selected blocks into the
+	 * saved post content.
+	 *
+	 * UI mode walkthrough:
+	 *   After REST setup the test navigates directly to the editor with the
+	 *   `wc_email_review_drawer=1` deep-link param, which auto-opens the review
+	 *   drawer. The drawer loads a change summary showing three conflicts. The test
+	 *   leaves block A on "keep yours" (default), switches block B to "use core"
+	 *   via a radio button click, then clicks Apply. The drawer closes and the
+	 *   test verifies the merged content via REST (block A: merchant text kept,
+	 *   block B: core text applied, block C: default kept).
+	 *
+	 *   "Show browser" eye: ON.
+	 */
+	test( 'Review drawer: pick per-conflict yours vs core and apply', async ( {
+		page,
+	} ) => {
+		// We need real copy_changes in the change-summary, which only appear when
+		// the LCS diff matches blocks by name and finds text differences.
+		// Strategy: use setTemplateHtmlOverride for BOTH the "old" canonical
+		// (to seed storedSourceHash) AND the "new" canonical (to control the
+		// change-summary diff), keeping the same block structure with changed text.
+		const oldHtml =
+			'<!-- wp:paragraph --><p>OLD BLOCK A</p><!-- /wp:paragraph -->' +
+			'<!-- wp:paragraph --><p>OLD BLOCK B</p><!-- /wp:paragraph -->' +
+			'<!-- wp:paragraph --><p>OLD BLOCK C</p><!-- /wp:paragraph -->';
+
+		// Merchant edited block A; blocks B and C kept the original text.
+		const customized = oldHtml.replace(
+			'OLD BLOCK A',
+			'MERCHANT EDITED A'
+		);
+
+		// "New canonical" after a core bump: core changed text in B and C,
+		// but A still matches nothing (it will conflict with merchant's edit).
+		const newCanonical =
+			'<!-- wp:paragraph --><p>NEW CORE A</p><!-- /wp:paragraph -->' +
+			'<!-- wp:paragraph --><p>NEW CORE B</p><!-- /wp:paragraph -->' +
+			'<!-- wp:paragraph --><p>NEW CORE C</p><!-- /wp:paragraph -->';
+
+		// Step 1: set override = oldHtml so that AUTO_CURRENT resolves to sha1(oldHtml).
+		await simulateCoreBump( 'new_order', oldHtml );
+
+		// Step 2: seed the post — stored hash = sha1(oldHtml), content = merchant edits.
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: customized,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+			// Use an older version so the registry's current_version is higher and
+			// the editor banner renders (version_from < version_to).
+			version: '10.0.0',
+		} );
+
+		// Step 3: swap the override to the "new" canonical. The sweep and the
+		// change-summary endpoint will now compare the post against newCanonical.
+		await setTemplateHtmlOverride( 'new_order', newCanonical );
+
+		// Step 4: sweep classifies the post as core_updated_customized.
+		await triggerDetectionSweep();
+
+		// Open the editor and click the banner's "Review changes" button — the
+		// merchant-facing path to open the drawer. (The wc_email_review_drawer=1
+		// deep-link works locally but races with editor mount in CI; clicking the
+		// banner button is the realistic flow and is stable.)
+		await page.goto( `/wp-admin/post.php?post=${ postId }&action=edit` );
+
+		// Wait for the editor canvas to be ready.
+		await expect( page.locator( '#woocommerce-email-editor' ) ).toBeVisible(
+			{
+				timeout: 20000,
+			}
+		);
+
+		// Click "Review changes" in the floating update banner.
+		await page.getByRole( 'button', { name: /^review changes$/i } ).click();
+
+		// The drawer's <aside role="dialog"> becomes aria-hidden="false" once the
+		// store dispatches openReviewDrawer(). The title text comes from the
+		// "Review template update" h2 inside the drawer header.
+		const drawer = page.getByRole( 'dialog', {
+			name: /review template update/i,
+		} );
+		await expect( drawer ).toBeVisible( { timeout: 15000 } );
+
+		// The change-summary fetch is triggered by the drawer's useChangeSummary
+		// hook (enabled = isOpen = true). Wait for the "Needs your attention"
+		// heading — the diff outcome (how many conflicts vs auto-resolved blocks)
+		// depends on the differ; the test stays resilient by interacting only
+		// with the first radiogroup and asserting content after apply.
+		await expect(
+			drawer.getByRole( 'heading', { name: /needs your attention/i } )
+		).toBeVisible( { timeout: 15000 } );
+
+		// Pick the first radiogroup's "Use core" — flips the default from
+		// "Keep yours" so the merchant's edit on block A is overwritten by core.
+		const firstRadioGroup = drawer
+			.getByRole( 'radiogroup', {
+				name: /choose which version to apply/i,
+			} )
+			.first();
+		await expect(
+			firstRadioGroup.getByRole( 'radio', { name: /keep yours/i } )
+		).toHaveAttribute( 'aria-checked', 'true' );
+
+		await firstRadioGroup
+			.getByRole( 'radio', { name: /use core/i } )
+			.click();
+		await expect(
+			firstRadioGroup.getByRole( 'radio', { name: /use core/i } )
+		).toHaveAttribute( 'aria-checked', 'true' );
+
+		// Click Apply — label is "Apply (N)" where N = total changes.
+		await drawer.getByRole( 'button', { name: /^apply/i } ).click();
+
+		// Drawer closes after a successful apply.
+		await expect( drawer ).toBeHidden( { timeout: 15000 } );
+
+		// Verify the merged post content via REST. The single decision we made
+		// was on block A's conflict ("use core"), so MERCHANT EDITED A must be
+		// gone and NEW CORE A must be present. We don't assert on B/C here
+		// because the differ may treat them as conflicts or auto-resolved.
+		const content = await getWooEmailPostContent( postId );
+		expect( content ).toContain( 'NEW CORE A' );
+		expect( content ).not.toContain( 'MERCHANT EDITED A' );
+	} );
+} );
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/classifications.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/classifications.ts
new file mode 100644
index 00000000000..993fe9344a1
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/classifications.ts
@@ -0,0 +1,59 @@
+/**
+ * Shared constants for the update-propagation E2E suite.
+ *
+ * Mirror the PHP-side meta keys and status values from
+ * Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector
+ * and the Tracks event names from RSM-145 (PR #64759).
+ *
+ * Event-name conventions (post-#64759 rename):
+ *
+ * Client-side events (fired via @woocommerce/tracks recordEvent, captured by
+ * the window.wcTracks.recordEvent spy as-is — no prefix added by the package):
+ *   block_email_update_viewed
+ *   block_email_update_applied
+ *   block_email_update_dismissed
+ *
+ * Server-side events (fired via WC_Tracks::record_event(), captured by the
+ * Tracks_Recorder woocommerce_tracks_event_properties filter which receives the
+ * name already prefixed with "wcadmin_" by WC_Tracks::PREFIX):
+ *   wcadmin_block_email_update_available
+ *   wcadmin_block_email_update_applied
+ *   wcadmin_block_email_sync_backfill_completed
+ */
+
+export const STATUS = {
+	IN_SYNC: 'in_sync',
+	CORE_UPDATED_UNCUSTOMIZED: 'core_updated_uncustomized',
+	CORE_UPDATED_CUSTOMIZED: 'core_updated_customized',
+} as const;
+
+export type Status = ( typeof STATUS )[ keyof typeof STATUS ];
+
+export const META_KEYS = {
+	STATUS: '_wc_email_template_status',
+	SOURCE_HASH: '_wc_email_template_source_hash',
+	SOURCE_VERSION: '_wc_email_template_version',
+	LAST_SYNCED_AT: '_wc_email_last_synced_at',
+	BACKFILLED: '_wc_email_backfilled',
+} as const;
+
+export const TRACKS_EVENTS = {
+	// Server-side: WC_Tracks::record_event() adds "wcadmin_" prefix before
+	// the woocommerce_tracks_event_properties filter fires, so the recorder
+	// captures these with the prefix already applied.
+	AVAILABLE: 'wcadmin_block_email_update_available',
+	BACKFILL_COMPLETED: 'wcadmin_block_email_sync_backfill_completed',
+	// Client-side: @woocommerce/tracks recordEvent() passes the name as-is
+	// to window.wcTracks.recordEvent; the spy captures it without any prefix.
+	VIEWED: 'block_email_update_viewed',
+	APPLIED: 'block_email_update_applied',
+	DISMISSED: 'block_email_update_dismissed',
+} as const;
+
+export const BACKFILL_CASES = {
+	A: 'A',
+	B: 'B',
+	C: 'C',
+} as const;
+
+export const TEST_HELPER_API_BASE = 'wc-email-test-helper/v1';
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/leaked-state-checks.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/leaked-state-checks.ts
new file mode 100644
index 00000000000..7c5e96f848e
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/leaked-state-checks.ts
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { expect } from '@playwright/test';
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import { TEST_HELPER_API_BASE } from './classifications';
+import {
+	clearAllTemplateHtmlOverrides,
+	clearOptedInOverride,
+	clearTransactionalEmailsOverride,
+	disableTracksLog,
+	disableFakeThirdPartyEmail,
+} from './test-helper-plugin';
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+function apiClient() {
+	return createClient( baseURL, {
+		type: 'basic',
+		username: admin.username,
+		password: admin.password,
+	} );
+}
+
+/**
+ * Call from afterEach in every spec. Snapshots fixture state, force-cleans
+ * everything, and asserts the snapshot was clean. A test that triggers leaked
+ * cleanup fails the run even if its body assertions passed — the next test
+ * still starts from a clean slate because cleanup ran before the assertion.
+ */
+export async function assertNoLeakedFixtureState(): Promise< void > {
+	const client = apiClient();
+	const tracksRes = await client.get( `${ TEST_HELPER_API_BASE }/tracks` );
+	const trackCount = ( ( tracksRes?.data?.events ?? [] ) as unknown[] )
+		.length;
+
+	await clearAllTemplateHtmlOverrides();
+	await clearOptedInOverride();
+	await clearTransactionalEmailsOverride();
+	await disableTracksLog();
+	await disableFakeThirdPartyEmail();
+	await client.delete( `${ TEST_HELPER_API_BASE }/tracks`, {} );
+
+	expect( trackCount, 'Tracks log not drained at end of test' ).toBe( 0 );
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/seed-woo-email.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/seed-woo-email.ts
new file mode 100644
index 00000000000..de878f590b2
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/seed-woo-email.ts
@@ -0,0 +1,275 @@
+/**
+ * External dependencies
+ */
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import {
+	META_KEYS,
+	TEST_HELPER_API_BASE,
+	type Status,
+} from './classifications';
+
+export type WooEmailSeed = {
+	emailId: string;
+	postContent?: string;
+	storedSourceHash?: string | 'AUTO_OLD' | 'AUTO_CURRENT';
+	status?: Status | null;
+	version?: string | null;
+	postDateGmt?: string;
+	postModifiedGmt?: string;
+	stripStampMeta?: boolean;
+};
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+function apiClient() {
+	return createClient( baseURL, {
+		type: 'basic',
+		username: admin.username,
+		password: admin.password,
+	} );
+}
+
+export async function resetWooEmailPost( emailId: string ): Promise< number > {
+	const client = apiClient();
+	const res = await client.post(
+		`${ TEST_HELPER_API_BASE }/reset-post/${ encodeURIComponent(
+			emailId
+		) }`,
+		{}
+	);
+	const body = res?.data;
+	if ( ! body?.post_id ) {
+		throw new Error(
+			`resetWooEmailPost: missing post_id in response for ${ emailId }`
+		);
+	}
+	return Number( body.post_id );
+}
+
+async function resolveHash(
+	emailId: string,
+	hashSpec: string | 'AUTO_OLD' | 'AUTO_CURRENT'
+): Promise< string > {
+	if ( hashSpec !== 'AUTO_OLD' && hashSpec !== 'AUTO_CURRENT' ) {
+		return hashSpec;
+	}
+	const client = apiClient();
+	const mode = hashSpec === 'AUTO_OLD' ? 'old' : 'current';
+	const res = await client.get(
+		`${ TEST_HELPER_API_BASE }/canonical-hash/${ encodeURIComponent(
+			emailId
+		) }?mode=${ mode }`
+	);
+	const body = res?.data;
+	if ( ! body?.hash ) {
+		throw new Error(
+			`Failed to resolve hash for ${ emailId } mode=${ mode }`
+		);
+	}
+	return String( body.hash );
+}
+
+export async function seedWooEmailPost(
+	seed: WooEmailSeed
+): Promise< number > {
+	const postId = await resetWooEmailPost( seed.emailId );
+
+	const meta: Record< string, unknown > = {};
+
+	if ( seed.stripStampMeta ) {
+		meta[ META_KEYS.STATUS ] = null;
+		meta[ META_KEYS.SOURCE_HASH ] = null;
+		meta[ META_KEYS.SOURCE_VERSION ] = null;
+		meta[ META_KEYS.LAST_SYNCED_AT ] = null;
+		meta[ META_KEYS.BACKFILLED ] = null;
+	} else {
+		if ( seed.status !== undefined ) {
+			meta[ META_KEYS.STATUS ] = seed.status;
+		}
+		if ( seed.storedSourceHash !== undefined ) {
+			meta[ META_KEYS.SOURCE_HASH ] = await resolveHash(
+				seed.emailId,
+				seed.storedSourceHash
+			);
+		}
+		if ( seed.version !== undefined ) {
+			meta[ META_KEYS.SOURCE_VERSION ] = seed.version;
+		}
+	}
+
+	const postUpdate: Record< string, unknown > = {};
+	if ( seed.postContent !== undefined ) {
+		postUpdate.post_content = seed.postContent;
+	}
+	if ( seed.postDateGmt !== undefined ) {
+		postUpdate.post_date_gmt = seed.postDateGmt;
+	}
+	if ( seed.postModifiedGmt !== undefined ) {
+		postUpdate.post_modified_gmt = seed.postModifiedGmt;
+	}
+
+	const client = apiClient();
+	await client.post( `${ TEST_HELPER_API_BASE }/seed-meta/${ postId }`, {
+		meta,
+		post: postUpdate,
+	} );
+
+	return postId;
+}
+
+/**
+ * Create a woo_email post directly via the seed-bulk endpoint, bypassing the
+ * WCTransactionalEmailPostsGenerator. The created post has no entry in the
+ * options-table mapping used by WCTransactionalEmailPostsManager, so the
+ * backfill and divergence-sweep pipelines cannot resolve its email_id and
+ * will skip it entirely.
+ *
+ * Use this for scenarios that need a woo_email post for an email type that is
+ * NOT in the sync registry (e.g. a third-party email that is registered as a
+ * WC_Email subclass but is not enrolled in the block-editor transactional
+ * emails list).
+ */
+export async function seedWooEmailPostDirect(
+	seed: Pick< WooEmailSeed, 'postContent' | 'stripStampMeta' >
+): Promise< number > {
+	const meta: Record< string, unknown > = {};
+
+	if ( seed.stripStampMeta ) {
+		meta[ META_KEYS.STATUS ] = null;
+		meta[ META_KEYS.SOURCE_HASH ] = null;
+		meta[ META_KEYS.SOURCE_VERSION ] = null;
+		meta[ META_KEYS.LAST_SYNCED_AT ] = null;
+		meta[ META_KEYS.BACKFILLED ] = null;
+	}
+
+	const postData: Record< string, unknown > = {
+		post_type: 'woo_email',
+		post_status: 'publish',
+	};
+	if ( seed.postContent !== undefined ) {
+		postData.post_content = seed.postContent;
+	}
+
+	const client = apiClient();
+	const res = await client.post( `${ TEST_HELPER_API_BASE }/seed-bulk`, {
+		seeds: [
+			{
+				post: postData,
+				meta,
+			},
+		],
+	} );
+
+	const results: Array< { post_id?: number; error?: string } > =
+		res?.data?.results ?? [];
+	const first = results[ 0 ];
+	if ( ! first?.post_id ) {
+		throw new Error(
+			`seedWooEmailPostDirect: failed to create post — ${
+				first?.error ?? 'no post_id returned'
+			}`
+		);
+	}
+	return Number( first.post_id );
+}
+
+export async function getWooEmailMeta(
+	postId: number
+): Promise< Record< string, string[] > > {
+	const client = apiClient();
+	const res = await client.get(
+		`${ TEST_HELPER_API_BASE }/seed-meta/${ postId }`
+	);
+	return ( res?.data?.meta ?? {} ) as Record< string, string[] >;
+}
+
+export async function getWooEmailPostContent(
+	postId: number
+): Promise< string > {
+	const client = apiClient();
+	const res = await client.get(
+		`${ TEST_HELPER_API_BASE }/post-content/${ postId }`
+	);
+	return String( res?.data?.post_content ?? '' );
+}
+
+export type ApplyChoice = {
+	path: ( number | string )[];
+	decision: 'keep_yours' | 'use_core';
+};
+
+export type ApplyResult = {
+	merged_content: string;
+	revision_id: string;
+	version_to: string;
+	status: string;
+	structural_skipped: boolean;
+	aliases_migrated: string[];
+};
+
+/**
+ * Call the /apply endpoint for a woo_email post using basic-auth credentials,
+ * bypassing the cookie+nonce requirement of the WP REST API for authenticated
+ * cookie sessions. `choices` defaults to [] (keep all merchant edits, apply
+ * only core additions).
+ */
+export async function applyWooEmailTemplate(
+	postId: number,
+	choices: ApplyChoice[] = []
+): Promise< ApplyResult > {
+	const client = apiClient();
+	const res = await client.post(
+		`woocommerce-email-editor/v1/emails/${ postId }/apply`,
+		{ choices } as Record< string, unknown >
+	);
+	if ( ! res?.data?.status ) {
+		throw new Error(
+			`applyWooEmailTemplate: unexpected response for post ${ postId }: ${ JSON.stringify(
+				res?.data
+			) }`
+		);
+	}
+	return res.data as ApplyResult;
+}
+
+export type ResetResult = {
+	content: string;
+	version: string | null;
+	source_hash: string | null;
+	synced_at: string | null;
+	/** The post-reset sync status (e.g. "in_sync") for sync-enabled emails, or null otherwise. */
+	status: string | null;
+};
+
+/**
+ * Call the /reset endpoint for a woo_email post using basic-auth credentials,
+ * bypassing the cookie+nonce requirement of the WP REST API for authenticated
+ * cookie sessions. Resets the post content to the canonical WooCommerce template.
+ *
+ * Note: unlike applyWooEmailTemplate whose `status` field is "applied", the
+ * reset endpoint returns the post-reset sync status (e.g. "in_sync") in the
+ * `status` field.
+ */
+export async function resetWooEmailTemplate(
+	postId: number
+): Promise< ResetResult > {
+	const client = apiClient();
+	const res = await client.post(
+		`woocommerce-email-editor/v1/emails/${ postId }/reset`,
+		{}
+	);
+	if ( res?.data?.content === undefined ) {
+		throw new Error(
+			`resetWooEmailTemplate: unexpected response for post ${ postId }: ${ JSON.stringify(
+				res?.data
+			) }`
+		);
+	}
+	return res.data as ResetResult;
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/simulate-plugin-update.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/simulate-plugin-update.ts
new file mode 100644
index 00000000000..ed919dcfddc
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/simulate-plugin-update.ts
@@ -0,0 +1,89 @@
+/**
+ * External dependencies
+ */
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import { TEST_HELPER_API_BASE } from './classifications';
+import {
+	setTemplateHtmlOverride,
+	stampBackfillComplete,
+} from './test-helper-plugin';
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+function apiClient() {
+	return createClient( baseURL, {
+		type: 'basic',
+		username: admin.username,
+		password: admin.password,
+	} );
+}
+
+export async function triggerDetectionSweep(): Promise< {
+	touched: number;
+	classifications: Record< number, string >;
+} > {
+	const client = apiClient();
+	const res = await client.post(
+		`${ TEST_HELPER_API_BASE }/trigger-sweep`,
+		{}
+	);
+	const body = res?.data ?? {};
+	return {
+		touched: Number( body.touched ?? 0 ),
+		classifications: ( body.classifications ?? {} ) as Record<
+			number,
+			string
+		>,
+	};
+}
+
+export async function triggerBackfill(): Promise< {
+	ran: boolean;
+	stamped: number;
+} > {
+	const client = apiClient();
+	const res = await client.post(
+		`${ TEST_HELPER_API_BASE }/trigger-backfill`,
+		{}
+	);
+	const body = res?.data ?? {};
+	return {
+		ran: Boolean( body.ran ),
+		stamped: Number( body.stamped ?? 0 ),
+	};
+}
+
+/**
+ * Simulate a core template version bump by seeding `oldHtml` as the active
+ * canonical-content override for `emailId`. While the override is active, any
+ * canonical-hash computation against current core resolves to `oldHtml`.
+ *
+ * Callers typically:
+ *   1. Call simulateCoreBump() with the OLD html.
+ *   2. Seed merchant post(s) with `storedSourceHash: 'AUTO_CURRENT'` (resolves to sha1(oldHtml)).
+ *   3. Call clearTemplateHtmlOverride() from `./test-helper-plugin` — the live canonical
+ *      now reverts to real current-core HTML, so the next sweep sees merchant posts
+ *      as "behind core".
+ *   4. Call triggerDetectionSweep() to classify.
+ *
+ * This helper deliberately does NOT clear the override automatically — sequencing
+ * differs per scenario.
+ */
+export async function simulateCoreBump(
+	emailId: string,
+	oldHtml: string
+): Promise< void > {
+	// The detector's run_sweep() short-circuits when the backfill-complete fence is
+	// not 'yes'. Core-flows and round-trip tests assume the system is operating
+	// post-backfill (the fence is normally stamped by woocommerce_newly_installed,
+	// but fresh wp-env installs sometimes don't fire that action). Stamp it here so
+	// downstream triggerDetectionSweep() calls actually run.
+	await stampBackfillComplete();
+	await setTemplateHtmlOverride( emailId, oldHtml );
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/test-helper-plugin.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/test-helper-plugin.ts
new file mode 100644
index 00000000000..60ce3fc5daf
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/test-helper-plugin.ts
@@ -0,0 +1,102 @@
+/**
+ * External dependencies
+ */
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import { TEST_HELPER_API_BASE } from './classifications';
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+const OPTIONS = {
+	TEMPLATE_HTML_OVERRIDE: 'wc_test_template_html_override',
+	OPTED_IN_OVERRIDE: 'wc_test_opted_in_emails_override',
+	TRANSACTIONAL_OVERRIDE: 'wc_test_transactional_emails_override',
+	TRACKS_ENABLED: 'wc_test_tracks_enabled',
+	FAKE_THIRD_PARTY_EMAIL_ENABLED: 'wc_test_fake_third_party_email_enabled',
+} as const;
+
+function apiClient() {
+	return createClient( baseURL, {
+		type: 'basic',
+		username: admin.username,
+		password: admin.password,
+	} );
+}
+
+async function setOption( name: string, value: unknown ): Promise< void > {
+	const client = apiClient();
+	await client.post( `${ TEST_HELPER_API_BASE }/set-option`, {
+		option_name: name,
+		option_value: value,
+	} );
+}
+
+async function deleteOption( name: string ): Promise< void > {
+	const client = apiClient();
+	await client.post( `${ TEST_HELPER_API_BASE }/delete-option`, {
+		option_name: name,
+	} );
+}
+
+export async function setTemplateHtmlOverride(
+	emailId: string,
+	html: string
+): Promise< void > {
+	await setOption( OPTIONS.TEMPLATE_HTML_OVERRIDE, { [ emailId ]: html } );
+}
+
+export async function clearTemplateHtmlOverride(): Promise< void > {
+	await deleteOption( OPTIONS.TEMPLATE_HTML_OVERRIDE );
+}
+
+export async function clearAllTemplateHtmlOverrides(): Promise< void > {
+	await clearTemplateHtmlOverride();
+}
+
+export async function setOptedInOverride(
+	overrides: Record< string, { version: string } >
+): Promise< void > {
+	await setOption( OPTIONS.OPTED_IN_OVERRIDE, overrides );
+}
+
+export async function clearOptedInOverride(): Promise< void > {
+	await deleteOption( OPTIONS.OPTED_IN_OVERRIDE );
+}
+
+export async function setTransactionalEmailsOverride(
+	emailIds: string[]
+): Promise< void > {
+	await setOption( OPTIONS.TRANSACTIONAL_OVERRIDE, emailIds );
+}
+
+export async function clearTransactionalEmailsOverride(): Promise< void > {
+	await deleteOption( OPTIONS.TRANSACTIONAL_OVERRIDE );
+}
+
+export async function enableTracksLog(): Promise< void > {
+	await setOption( OPTIONS.TRACKS_ENABLED, 'yes' );
+}
+
+export async function disableTracksLog(): Promise< void > {
+	await deleteOption( OPTIONS.TRACKS_ENABLED );
+}
+
+export async function enableFakeThirdPartyEmail(): Promise< void > {
+	await setOption( OPTIONS.FAKE_THIRD_PARTY_EMAIL_ENABLED, 'yes' );
+}
+
+export async function disableFakeThirdPartyEmail(): Promise< void > {
+	await deleteOption( OPTIONS.FAKE_THIRD_PARTY_EMAIL_ENABLED );
+}
+
+export async function stampBackfillComplete(): Promise< void > {
+	await setOption(
+		'woocommerce_email_template_sync_backfill_complete',
+		'yes'
+	);
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/tracks-spy.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/tracks-spy.ts
new file mode 100644
index 00000000000..5e60ae925c0
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/tracks-spy.ts
@@ -0,0 +1,191 @@
+/**
+ * External dependencies
+ */
+import type { Page } from '@playwright/test';
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+import { expect } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import { TEST_HELPER_API_BASE } from './classifications';
+import { enableTracksLog, disableTracksLog } from './test-helper-plugin';
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+export type TracksEvent = {
+	name: string;
+	properties: Record< string, unknown >;
+	timestamp_ms: number;
+};
+
+export interface TracksSpy {
+	drain(): Promise< TracksEvent[] >;
+	expectFired( name: string, count?: number ): Promise< void >;
+	expectNotFired( name: string ): Promise< void >;
+	reset(): Promise< void >;
+}
+
+declare global {
+	interface Window {
+		__capturedTracksEvents?: TracksEvent[];
+		wcTracks?: {
+			recordEvent?: (
+				name: string,
+				properties?: Record< string, unknown >
+			) => void;
+		};
+	}
+}
+
+function apiClient() {
+	return createClient( baseURL, {
+		type: 'basic',
+		username: admin.username,
+		password: admin.password,
+	} );
+}
+
+/**
+ * Attach a client+server Tracks spy to a Page. The client-side hook patches
+ * `window.wcTracks.recordEvent` (the dispatch target used by `@woocommerce/tracks`)
+ * to capture events as they fire. The server-side mirror reads the
+ * Tracks_Recorder log via the test-helper plugin's REST endpoint.
+ *
+ * `drain()` merges and dedupes both buffers; tests assert against the merged set.
+ */
+export async function attachTracksSpy( page: Page ): Promise< TracksSpy > {
+	await enableTracksLog();
+
+	await page.addInitScript( () => {
+		window.__capturedTracksEvents = [];
+
+		const installPatch = (): boolean => {
+			if (
+				! window.wcTracks ||
+				typeof window.wcTracks.recordEvent !== 'function'
+			) {
+				return false;
+			}
+			const original = window.wcTracks.recordEvent;
+			window.wcTracks.recordEvent = function (
+				name: string,
+				properties?: Record< string, unknown >
+			) {
+				try {
+					window.__capturedTracksEvents!.push( {
+						name,
+						properties: properties ?? {},
+						timestamp_ms: Date.now(),
+					} );
+				} catch {}
+				return original.call( this, name, properties );
+			};
+			return true;
+		};
+
+		if ( ! installPatch() ) {
+			document.addEventListener( 'DOMContentLoaded', () => {
+				installPatch();
+			} );
+		}
+	} );
+
+	// addInitScript only instruments future documents. Patch the currently-loaded
+	// page too — idempotent via a __wcSpyWrapped guard so a second attachTracksSpy
+	// call (or a same-document re-attach) doesn't double-wrap.
+	await page.evaluate( () => {
+		window.__capturedTracksEvents = window.__capturedTracksEvents ?? [];
+		if (
+			! window.wcTracks ||
+			typeof window.wcTracks.recordEvent !== 'function'
+		) {
+			return;
+		}
+		const current = window.wcTracks.recordEvent as ( (
+			...args: unknown[]
+		) => unknown ) & { __wcSpyWrapped?: boolean };
+		if ( current.__wcSpyWrapped ) {
+			return;
+		}
+		const original = current;
+		const wrapped = function (
+			name: string,
+			properties?: Record< string, unknown >
+		) {
+			try {
+				window.__capturedTracksEvents!.push( {
+					name,
+					properties: properties ?? {},
+					timestamp_ms: Date.now(),
+				} );
+			} catch {}
+			return original.call( this, name, properties );
+		};
+		(
+			wrapped as typeof wrapped & { __wcSpyWrapped: boolean }
+		 ).__wcSpyWrapped = true;
+		window.wcTracks.recordEvent =
+			wrapped as typeof window.wcTracks.recordEvent;
+	} );
+
+	const drain = async (): Promise< TracksEvent[] > => {
+		const clientEvents = await page.evaluate( () => {
+			const events = window.__capturedTracksEvents ?? [];
+			window.__capturedTracksEvents = [];
+			return events;
+		} );
+
+		const client = apiClient();
+		const serverRes = await client.get(
+			`${ TEST_HELPER_API_BASE }/tracks`
+		);
+		const serverEvents = ( serverRes?.data?.events ?? [] ) as TracksEvent[];
+		await client.delete( `${ TEST_HELPER_API_BASE }/tracks`, {} );
+
+		const seen = new Set< string >();
+		const merged: TracksEvent[] = [];
+		for ( const evt of [ ...clientEvents, ...serverEvents ] ) {
+			const key = `${ evt.name }|${ evt.timestamp_ms }`;
+			if ( seen.has( key ) ) {
+				continue;
+			}
+			seen.add( key );
+			merged.push( evt );
+		}
+		merged.sort( ( a, b ) => a.timestamp_ms - b.timestamp_ms );
+		return merged;
+	};
+
+	return {
+		drain,
+		expectFired: async ( name: string, count?: number ) => {
+			const events = await drain();
+			const matches = events.filter( ( e ) => e.name === name );
+			if ( count !== undefined ) {
+				expect( matches.length ).toBe( count );
+			} else {
+				expect( matches.length ).toBeGreaterThan( 0 );
+			}
+		},
+		expectNotFired: async ( name: string ) => {
+			const events = await drain();
+			expect( events.filter( ( e ) => e.name === name ).length ).toBe(
+				0
+			);
+		},
+		reset: async () => {
+			await page.evaluate( () => {
+				window.__capturedTracksEvents = [];
+			} );
+			const client = apiClient();
+			await client.delete( `${ TEST_HELPER_API_BASE }/tracks`, {} );
+		},
+	};
+}
+
+export async function detachTracksSpy(): Promise< void > {
+	await disableTracksLog();
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/round-trip-idempotency.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/round-trip-idempotency.spec.ts
new file mode 100644
index 00000000000..7c06835a982
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/round-trip-idempotency.spec.ts
@@ -0,0 +1,197 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Update-propagation: round-trip and idempotency.
+ *
+ * Covers four state-machine round-trips: (1) auto-apply returns an unmodified
+ * post to in_sync, (2) selective apply with keep-yours keeps the post in the
+ * customized state, (3) reset returns a customized post directly to in_sync,
+ * and (4) the detection sweep is idempotent — a second run classifies the same
+ * post identically and writes no new meta.
+ *
+ * Reviewing in Playwright UI mode:
+ *   1. Run `npx playwright test --project=e2e tests/email-editor/update-propagation --ui`
+ *   2. Filter the tree by `round-trip` and pick a test.
+ *   3. All four tests are REST-only — no browser window is driven. The Actions
+ *      panel in UI mode shows the full REST call sequence for each test.
+ *   4. "Show browser" eye is not needed for any test in this file.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../../playwright.config';
+import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
+import { clearTemplateHtmlOverride } from './helpers/test-helper-plugin';
+import {
+	seedWooEmailPost,
+	getWooEmailMeta,
+	applyWooEmailTemplate,
+	resetWooEmailTemplate,
+} from './helpers/seed-woo-email';
+import {
+	simulateCoreBump,
+	triggerDetectionSweep,
+} from './helpers/simulate-plugin-update';
+import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
+import { STATUS, META_KEYS } from './helpers/classifications';
+
+const OLD_HTML =
+	'<!-- wp:paragraph --><p>OLD CANONICAL</p><!-- /wp:paragraph -->';
+
+test.describe( 'Update propagation — round-trip and idempotency', () => {
+	test.use( { storageState: ADMIN_STATE_PATH } );
+
+	test.beforeAll( async ( { baseURL } ) => {
+		await enableEmailEditor( baseURL! );
+	} );
+
+	test.afterEach( async () => {
+		await assertNoLeakedFixtureState();
+	} );
+
+	/**
+	 * Verifies the full auto-apply round-trip: after a core bump the detection
+	 * sweep detects the divergence and the inline auto-applier immediately
+	 * re-stamps an unmodified post as in_sync.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows: simulateCoreBump
+	 *   → seedWooEmailPost → clearTemplateHtmlOverride → triggerDetectionSweep
+	 *   → getWooEmailMeta assertion (STATUS.IN_SYNC).
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'Auto-apply round-trip: uncustomized post returns to in_sync', async () => {
+		await simulateCoreBump( 'new_order', OLD_HTML );
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: OLD_HTML,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+		} );
+		await clearTemplateHtmlOverride();
+
+		await triggerDetectionSweep();
+
+		const meta = await getWooEmailMeta( postId );
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+	} );
+
+	/**
+	 * Verifies that when a merchant applies a core update with choices:[] (keep-yours
+	 * default for all conflicts), the post's diverged block is preserved and the
+	 * status remains core_updated_customized rather than flipping to in_sync.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows: simulateCoreBump
+	 *   → seedWooEmailPost (customized block B) → clearTemplateHtmlOverride →
+	 *   triggerDetectionSweep → meta assertion (CUSTOMIZED) → applyWooEmailTemplate
+	 *   (choices:[]) → meta re-assertion (still CUSTOMIZED).
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'Selective apply round-trip: edit, bump, apply with keep-yours → stays customized', async () => {
+		const oldHtml =
+			'<!-- wp:paragraph --><p>OLD A</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>OLD B</p><!-- /wp:paragraph -->';
+
+		await simulateCoreBump( 'new_order', oldHtml );
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: oldHtml.replace( 'OLD B', 'MERCHANT B' ),
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+		} );
+		await clearTemplateHtmlOverride();
+		await triggerDetectionSweep();
+
+		let meta = await getWooEmailMeta( postId );
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+			STATUS.CORE_UPDATED_CUSTOMIZED
+		);
+
+		// Use applyWooEmailTemplate (basic auth) instead of request.post (cookie auth)
+		// because WP REST POST endpoints require a nonce when using cookie-based auth.
+		// choices: [] keeps all merchant edits (keep_yours default), so the merged
+		// result diverges from canonical and the applier stamps core_updated_customized.
+		const apply = await applyWooEmailTemplate( postId, [] );
+		expect( apply.status ).toBe( 'applied' );
+
+		meta = await getWooEmailMeta( postId );
+		// With choices:[] the merchant's diverged block is preserved, so the post
+		// stays core_updated_customized rather than reaching in_sync.
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+			STATUS.CORE_UPDATED_CUSTOMIZED
+		);
+	} );
+
+	/**
+	 * Verifies that the reset endpoint replaces a customized post's content with
+	 * the current canonical and stamps it in_sync in a single REST call.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+	 *   (customized content) → resetWooEmailTemplate (REST POST) → response status
+	 *   assertion → getWooEmailMeta assertion (STATUS.IN_SYNC).
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'Reset round-trip: customized → reset → in_sync', async () => {
+		const customized =
+			'<!-- wp:paragraph --><p>MERCHANT CUSTOM</p><!-- /wp:paragraph -->';
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: customized,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+		} );
+
+		// Use resetWooEmailTemplate (basic auth) instead of request.post (cookie auth)
+		// because WP REST POST endpoints require a nonce when using cookie-based auth.
+		// The reset endpoint returns the post-reset sync status directly (not "applied").
+		const reset = await resetWooEmailTemplate( postId );
+		expect( reset.status ).toBe( STATUS.IN_SYNC );
+
+		const meta = await getWooEmailMeta( postId );
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+	} );
+
+	/**
+	 * Verifies that running the detection sweep twice in a row produces the same
+	 * classification and identical meta for an already-classified customized post,
+	 * confirming the sweep does not mutate already-correct state.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows: simulateCoreBump
+	 *   → seedWooEmailPost (merchant edit) → clearTemplateHtmlOverride →
+	 *   triggerDetectionSweep (first) → getWooEmailMeta snapshot →
+	 *   triggerDetectionSweep (second, sweep2.classifications assertion) →
+	 *   getWooEmailMeta equality assertion.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'Detection sweep is idempotent: second run touches zero posts', async () => {
+		await simulateCoreBump( 'new_order', OLD_HTML );
+		const postId = await seedWooEmailPost( {
+			emailId: 'new_order',
+			postContent: OLD_HTML.replace( 'OLD CANONICAL', 'MERCHANT EDIT' ),
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+		} );
+		await clearTemplateHtmlOverride();
+
+		await triggerDetectionSweep();
+		const metaBefore = await getWooEmailMeta( postId );
+
+		const sweep2 = await triggerDetectionSweep();
+		const metaAfter = await getWooEmailMeta( postId );
+
+		expect( sweep2.classifications[ postId ] ).toBe(
+			metaBefore[ META_KEYS.STATUS ]?.[ 0 ]
+		);
+		expect( metaAfter ).toEqual( metaBefore );
+	} );
+} );
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/scope.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/scope.spec.ts
new file mode 100644
index 00000000000..9fc5ef7e06a
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/scope.spec.ts
@@ -0,0 +1,195 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Update-propagation: scope and allow-list.
+ *
+ * Covers the allow-list (opt-in) boundary for third-party email types: a
+ * non-opted-in email is entirely excluded from backfill and detection; an
+ * opted-in third-party email that is unedited is auto-applied (in_sync) after
+ * a version bump; an opted-in email that is edited is left as
+ * core_updated_customized for the merchant to review.
+ *
+ * Reviewing in Playwright UI mode:
+ *   1. Run `npx playwright test --project=e2e tests/email-editor/update-propagation --ui`
+ *   2. Filter the tree by `scope` and pick a test.
+ *   3. The first test ("Non-opted-in") attaches a Tracks spy via the page fixture
+ *      but performs no navigation or UI interaction. The other two tests are
+ *      purely REST-based. The Actions panel shows the REST call sequence for all.
+ *   4. "Show browser" eye is not needed for any test in this file.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../../playwright.config';
+import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
+import {
+	setTransactionalEmailsOverride,
+	setOptedInOverride,
+	setTemplateHtmlOverride,
+	clearTemplateHtmlOverride,
+	enableFakeThirdPartyEmail,
+	disableFakeThirdPartyEmail,
+} from './helpers/test-helper-plugin';
+import {
+	seedWooEmailPost,
+	seedWooEmailPostDirect,
+	getWooEmailMeta,
+} from './helpers/seed-woo-email';
+import {
+	triggerBackfill,
+	triggerDetectionSweep,
+	simulateCoreBump,
+} from './helpers/simulate-plugin-update';
+import { attachTracksSpy } from './helpers/tracks-spy';
+import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
+import { STATUS, META_KEYS, TRACKS_EVENTS } from './helpers/classifications';
+
+const FAKE_EMAIL_ID = 'fake_thirdparty';
+
+const V1_HTML = '<!-- wp:paragraph --><p>V1 CONTENT</p><!-- /wp:paragraph -->';
+const V2_HTML = '<!-- wp:paragraph --><p>V2 CONTENT</p><!-- /wp:paragraph -->';
+
+test.describe( 'Update propagation — scope and allow-list', () => {
+	test.use( { storageState: ADMIN_STATE_PATH } );
+
+	test.beforeAll( async ( { baseURL } ) => {
+		await enableEmailEditor( baseURL! );
+	} );
+
+	test.beforeEach( async () => {
+		await enableFakeThirdPartyEmail();
+	} );
+
+	test.afterEach( async () => {
+		await disableFakeThirdPartyEmail();
+		await assertNoLeakedFixtureState();
+	} );
+
+	/**
+	 * Verifies that a third-party email type that has not enrolled in block-editor
+	 * sync is completely ignored by both the backfill and the detection sweep —
+	 * no stamp meta is written and no Tracks _available event fires.
+	 *
+	 * UI mode walkthrough:
+	 *   The page fixture is used only to attach the Tracks spy — no navigation
+	 *   or UI interaction occurs. Actions panel shows: seedWooEmailPostDirect
+	 *   (no options-table mapping) → triggerBackfill → simulateCoreBump →
+	 *   triggerDetectionSweep → meta undefined assertions → spy.expectNotFired.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'Non-opted-in third-party email is excluded from sync', async ( {
+		page,
+	} ) => {
+		const spy = await attachTracksSpy( page );
+
+		// Deliberately do NOT add FAKE_EMAIL_ID to the transactional emails list:
+		// a third-party email that has not enrolled in block-editor sync is excluded
+		// from WCEmailTemplateSyncRegistry and therefore skipped by both the backfill
+		// and the divergence sweep. Create the woo_email post directly (bypassing the
+		// generator) so no options-table mapping exists for the email type — the
+		// backfill's get_email_type_from_post_id() will return null and skip the post.
+		const postId = await seedWooEmailPostDirect( {
+			postContent:
+				'<!-- wp:paragraph --><p>Third-party content</p><!-- /wp:paragraph -->',
+			stripStampMeta: true,
+		} );
+
+		await triggerBackfill();
+		await simulateCoreBump( FAKE_EMAIL_ID, V1_HTML );
+		await triggerDetectionSweep();
+		await clearTemplateHtmlOverride();
+
+		const meta = await getWooEmailMeta( postId );
+		expect( meta[ META_KEYS.STATUS ] ).toBeUndefined();
+		expect( meta[ META_KEYS.SOURCE_HASH ] ).toBeUndefined();
+		await spy.expectNotFired( TRACKS_EVENTS.AVAILABLE );
+	} );
+
+	/**
+	 * Verifies that an opted-in third-party email with an unedited post is
+	 * auto-applied after a version bump (1.0.0 → 1.1.0), landing back at in_sync
+	 * rather than surfacing an update prompt to the merchant.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows:
+	 *   setTransactionalEmailsOverride + setOptedInOverride + setTemplateHtmlOverride
+	 *   (v1 setup) → seedWooEmailPost → triggerDetectionSweep → meta assertion
+	 *   (IN_SYNC) → override swap to v2 → triggerDetectionSweep → meta assertion
+	 *   (IN_SYNC via auto-apply) → cleanup calls.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'Opted-in third-party email: version bump flips status when unedited', async () => {
+		await setTransactionalEmailsOverride( [ FAKE_EMAIL_ID ] );
+		await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.0.0' } } );
+		await setTemplateHtmlOverride( FAKE_EMAIL_ID, V1_HTML );
+
+		const postId = await seedWooEmailPost( {
+			emailId: FAKE_EMAIL_ID,
+			postContent: V1_HTML,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+			version: '1.0.0',
+		} );
+
+		await triggerDetectionSweep();
+		let meta = await getWooEmailMeta( postId );
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+
+		await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.1.0' } } );
+		await setTemplateHtmlOverride( FAKE_EMAIL_ID, V2_HTML );
+
+		await triggerDetectionSweep();
+		meta = await getWooEmailMeta( postId );
+		// The inline auto-applier runs immediately after the sweep (same HTTP request in
+		// the E2E trigger-sweep endpoint). An unedited post classified as
+		// core_updated_uncustomized is auto-applied and flipped back to in_sync before
+		// this assertion runs — consistent with the lifecycle tested in core-flows
+		// scenario 1 and backward-compat Case A.
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+	} );
+
+	/**
+	 * Verifies that an opted-in third-party email with a merchant-edited post is
+	 * stamped core_updated_customized after a version bump (1.0.0 → 1.1.0),
+	 * leaving the update for the merchant to review rather than auto-applying.
+	 *
+	 * UI mode walkthrough:
+	 *   REST-only — no browser interaction. Actions panel shows:
+	 *   setTransactionalEmailsOverride + setOptedInOverride + setTemplateHtmlOverride
+	 *   (v1 setup) → seedWooEmailPost (customized content) → override swap to v2
+	 *   → triggerDetectionSweep → meta assertion (CORE_UPDATED_CUSTOMIZED)
+	 *   → cleanup calls.
+	 *
+	 *   "Show browser" eye: not needed.
+	 */
+	test( 'Opted-in third-party email: version bump flips status when edited', async () => {
+		const customized = V1_HTML.replace( 'V1 CONTENT', 'MERCHANT EDIT' );
+
+		await setTransactionalEmailsOverride( [ FAKE_EMAIL_ID ] );
+		await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.0.0' } } );
+		await setTemplateHtmlOverride( FAKE_EMAIL_ID, V1_HTML );
+
+		const postId = await seedWooEmailPost( {
+			emailId: FAKE_EMAIL_ID,
+			postContent: customized,
+			storedSourceHash: 'AUTO_CURRENT',
+			status: STATUS.IN_SYNC,
+			version: '1.0.0',
+		} );
+
+		await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.1.0' } } );
+		await setTemplateHtmlOverride( FAKE_EMAIL_ID, V2_HTML );
+
+		await triggerDetectionSweep();
+		const meta = await getWooEmailMeta( postId );
+		expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+			STATUS.CORE_UPDATED_CUSTOMIZED
+		);
+	} );
+} );