Commit e37907fccdc for woocommerce
commit e37907fccdc464c4596b6f475ec43decfb8cfb27
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date: Fri Apr 24 16:49:43 2026 +0100
[Email Editor] Backfill template sync meta for pre-existing block email posts (#64364)
* Centralize email sync meta keys and email-by-id lookups
Promotes the four `woo_email` sync meta keys to public constants on
`WCEmailTemplateDivergenceDetector` and moves the duplicated
`WC_Email` id-indexing pattern into instance helpers on
`WCTransactionalEmailPostsManager`. Callers (the generator, the
divergence detector, the sync registry) now share a single source of
truth, which removes hard-coded strings and prepares the ground for
the upcoming backfill migration.
Made-with: Cursor
* Backfill template sync meta for pre-existing block email posts
Adds a one-shot 10.8.0 migration that stamps the four sync meta keys
(version, source hash, last-synced timestamp, divergence status) onto
every `woo_email` post that predates the sync pipeline. Classification
follows the same three-case contract used by the runtime divergence
detector: in-sync hashes stay `in_sync`, never-edited drifting posts
are rewritten to the canonical content and marked `in_sync`, and
edited drifting posts are preserved as `core_updated_customized`.
On completion the backfill sets a done flag and fires
`woocommerce_email_template_sync_backfill_complete`, which the
divergence detector listens on so the first post-upgrade sweep runs
only after every legacy post has a baseline, avoiding spurious
classification on mid-upgrade state.
Made-with: Cursor
* Fix phpcs formatting in WCEmailTemplateSyncBackfillTest
Made-with: Cursor
* Fall back to Case C when Case B rewrite fails in sync backfill
wp_update_post() is called with $wp_error = true, so a failure
returns a WP_Error rather than throwing, and the outer Throwable
catch in run() cannot see it. Because the migration is one-shot
(the woocommerce_db_version fence flips on completion), an
unstamped post would be orphaned: the divergence detector skips
posts without a source hash with a recurring warning forever.
On rewrite failure, stamp the canonical hash and timestamps as
usual but downgrade the status to core_updated_customized so the
post surfaces for merchant review instead of being silently marked
in_sync over stale content. A warning log records the failure.
Adds a test that forces wp_update_post() to return a WP_Error via
wp_insert_post_empty_content and asserts the content is preserved,
the hash is stamped against canonical core, and the status reflects
the fallback.
Made-with: Cursor
* Drop dead 0-return check from Case B rewrite failure path
With `$wp_error = true`, every failure path in
`wp_update_post()` / `wp_insert_post()` returns a `WP_Error`; the
`0` return is only surfaced when the caller opts out of errors.
PHPStan (correctly) flagged the dual `is_wp_error()` check and
`! $updated` branch as unreachable.
Simplify to a single `is_wp_error()` check and drop the
unreachable "wp_update_post returned 0" log branch. Behaviour and
tests are unchanged.
Made-with: Cursor
diff --git a/plugins/woocommerce/changelog/add-email-template-sync-backfill b/plugins/woocommerce/changelog/add-email-template-sync-backfill
new file mode 100644
index 00000000000..34c9d4a3db3
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-email-template-sync-backfill
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Backfill template sync metadata onto pre-existing block email posts during upgrade so the divergence detector can classify legacy installs safely.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 8a489899019..b4a533fe1c4 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -328,6 +328,7 @@ class WC_Install {
'10.8.0' => array(
'wc_update_1080_migrate_analytics_import_option',
'wc_update_1080_slim_orders_meta_key_index',
+ 'wc_update_1080_backfill_email_template_sync_meta',
),
);
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index fd6075ef608..3648be5bb37 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -30,6 +30,7 @@ use Automattic\WooCommerce\Internal\AssignDefaultCategory;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
@@ -3496,3 +3497,17 @@ function wc_update_1080_slim_orders_meta_key_index(): void {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "ALTER TABLE {$table_name} DROP INDEX {$index_name}, ADD INDEX {$index_name} (meta_key(100))" );
}
+
+/**
+ * Backfill sync meta onto pre-existing `woo_email` posts so the template
+ * divergence detector introduced in the same release (RSM-138) can classify
+ * legacy installs safely.
+ *
+ * @since 10.8.0
+ *
+ * @return bool Always false. Once-per-site is enforced by the
+ * `woocommerce_db_version` fence in `$db_updates`.
+ */
+function wc_update_1080_backfill_email_template_sync_meta(): bool {
+ return WCEmailTemplateSyncBackfill::run();
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index 5dca63d20e6..309de62f81d 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
use Automattic\WooCommerce\Internal\EmailEditor\EmailPatterns\PatternsController;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplatesController;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplateApiController;
@@ -147,6 +148,7 @@ class Integration {
add_filter( 'woocommerce_email_editor_send_preview_email_subject', array( $this, 'update_email_subject_for_send_preview_email' ), 10, 2 );
add_action( 'rest_api_init', array( $this->email_api_controller, 'register_routes' ) );
add_action( 'woocommerce_updated', array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 20 );
+ add_action( WCEmailTemplateSyncBackfill::BACKFILL_COMPLETE_ACTION, array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 10 );
}
/**
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index bde1fbcc986..717e6c51eb5 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -32,7 +32,7 @@ use Automattic\WooCommerce\Internal\EmailEditor\Logger;
*/
class WCEmailTemplateDivergenceDetector {
/**
- * Option written by the RSM-149 backfill to signal that every existing `woo_email`
+ * Option written by the WCEmailTemplateSyncBackfill backfill to signal that every existing `woo_email`
* post has been stamped with the RSM-137 sync meta. The detector refuses to run
* until this flag flips to `yes` — otherwise legacy posts would be evaluated with
* no stored hash and silently skipped, giving a misleadingly quiet sweep.
@@ -53,7 +53,23 @@ class WCEmailTemplateDivergenceDetector {
*
* @var string
*/
- private const SOURCE_HASH_META_KEY = '_wc_email_template_source_hash';
+ public const SOURCE_HASH_META_KEY = '_wc_email_template_source_hash';
+
+ /**
+ * Post meta key storing the version of the block template the post was stamped
+ * against. Written by the generator and by the RSM-149 backfill.
+ *
+ * @var string
+ */
+ public const VERSION_META_KEY = '_wc_email_template_version';
+
+ /**
+ * Post meta key storing the UTC timestamp (Y-m-d H:i:s) of the last sync stamp.
+ * Written by the generator and by the RSM-149 backfill.
+ *
+ * @var string
+ */
+ public const LAST_SYNCED_AT_META_KEY = '_wc_email_last_synced_at';
/**
* Classification outcomes.
@@ -94,15 +110,8 @@ class WCEmailTemplateDivergenceDetector {
return;
}
- $wc_emails = \WC_Emails::instance();
- /**
- * WooCommerce Transactional Emails instance.
- *
- * @var \WC_Email[]
- */
- $all_emails = $wc_emails->get_emails();
$posts_manager = WCTransactionalEmailPostsManager::get_instance();
- $canonical_emails = self::index_emails_by_id( $all_emails );
+ $canonical_emails = $posts_manager->get_emails_by_id();
foreach ( $registry as $email_id => $_config ) {
try {
@@ -263,22 +272,4 @@ class WCEmailTemplateDivergenceDetector {
private static function is_sha1_hash( string $hash ): bool {
return 40 === strlen( $hash ) && ctype_xdigit( $hash );
}
-
- /**
- * Build an `email_id => WC_Email` lookup from the list returned by
- * `WC_Emails::get_emails()` (which is keyed by class name).
- *
- * @param array<string, \WC_Email> $all_emails Registered WC_Email instances keyed by class.
- * @return array<string, \WC_Email>
- */
- private static function index_emails_by_id( array $all_emails ): array {
- $indexed = array();
- foreach ( $all_emails as $email ) {
- if ( $email instanceof \WC_Email && is_string( $email->id ) && '' !== $email->id ) {
- $indexed[ $email->id ] = $email;
- }
- }
-
- return $indexed;
- }
}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfill.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfill.php
new file mode 100644
index 00000000000..d31633689b5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfill.php
@@ -0,0 +1,415 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\Logger;
+
+/**
+ * Backfills sync meta onto pre-existing `woo_email` posts so the divergence
+ * detector introduced by RSM-138 can classify legacy installs safely.
+ *
+ * Runs once per site as part of WooCommerce's standard db-updates pipeline
+ * (see {@see \WC_Install::$db_updates}). The `woocommerce_db_version` fence
+ * guarantees the migration fires exactly once; Action Scheduler handles the
+ * async dispatch. A single synchronous pass is sufficient — the registered
+ * post set is bounded (core + opted-in third parties), not a user-generated
+ * dataset — so we deliberately avoid batching.
+ *
+ * For every eligible post (woo_email, not trashed, no stored source hash) the
+ * callback classifies the post into one of three cases and stamps the four
+ * sync meta keys plus an initial status:
+ *
+ * - **Case A** — post content already matches the current canonical core
+ * render. Stamp only; status `in_sync`.
+ * - **Case B** — content differs from core but the post has never been edited
+ * (`post_date_gmt === post_modified_gmt`). Rewrite `post_content` to the
+ * canonical render, then stamp; status `in_sync`.
+ * - **Case C** — content differs from core and the post has been edited.
+ * Stamp only using the current core hash (never `sha1(post_content)` —
+ * that would misclassify as `core_updated_uncustomized` on the next core
+ * bump). Status is seeded to `core_updated_customized` because
+ * {@see WCEmailTemplateDivergenceDetector::classify_post()} returns `null`
+ * for the "merchant drift, no core move" state and Case C is by definition
+ * customized content relative to current core.
+ *
+ * When Case B auto-apply lands (tracked in RSM-139), the content-rewrite
+ * block here can be extracted into a shared helper; the `is_backfilling()`
+ * flag is a forward hook for that listener.
+ *
+ * Finalization is a two-step handshake:
+ * 1. Flip {@see WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION}
+ * to `yes` — the detector refuses to run until this flips.
+ * 2. Fire `do_action( 'woocommerce_email_template_sync_backfill_complete' )`
+ * so the first real detector sweep can run immediately, closing the
+ * ordering gap with `woocommerce_updated` (which fires before async
+ * db-update callbacks finish).
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 10.8.0
+ */
+class WCEmailTemplateSyncBackfill {
+ /**
+ * Case identifiers for the three-case backfill algorithm.
+ *
+ * Exposed as public so consumers (currently: tests asserting classifier
+ * behaviour) can refer to the cases by name rather than by their raw
+ * single-letter values. The set is stable — any change here implies a
+ * change to the backfill's semantic contract.
+ */
+ public const CASE_A = 'A';
+ public const CASE_B = 'B';
+ public const CASE_C = 'C';
+
+ /**
+ * Action fired after the backfill finalizes. The divergence detector hooks
+ * onto this to run its first real sweep now that every post is stamped.
+ *
+ * @var string
+ */
+ public const BACKFILL_COMPLETE_ACTION = 'woocommerce_email_template_sync_backfill_complete';
+
+ /**
+ * Re-entrancy flag set while `wp_update_post()` writes Case B content.
+ *
+ * Future `save_post` listeners (e.g. the RSM-139 auto-apply hook) should
+ * consult {@see self::is_backfilling()} before interpreting a write as a
+ * merchant edit.
+ *
+ * @var bool
+ */
+ private static $is_backfilling = false;
+
+ /**
+ * Logger instance. Lazily instantiated on first use; overridable for tests.
+ *
+ * @var Email_Editor_Logger_Interface|null
+ */
+ private static $logger = null;
+
+ /**
+ * Action Scheduler entry point for the RSM-149 migration.
+ *
+ * Always returns `false` (one-shot). The return type is declared `bool` to
+ * match the contract {@see \WC_Install::run_update_callback_end()} expects;
+ * `(bool) false` tells the queue manager this callback is complete and
+ * should not be re-scheduled.
+ *
+ * The `woocommerce_db_version` fence around `$db_updates` provides the
+ * once-per-site guarantee, so there is no internal idempotency gate here;
+ * retry-safety comes from the `NOT EXISTS` filter on the stored source
+ * hash in {@see self::fetch_eligible_posts()}.
+ *
+ * @return bool Always false.
+ *
+ * @since 10.8.0
+ */
+ public static function run(): bool {
+ $eligible = self::fetch_eligible_posts();
+ if ( empty( $eligible ) ) {
+ self::finalize();
+ return false;
+ }
+
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+ if ( empty( $registry ) ) {
+ self::finalize();
+ return false;
+ }
+
+ $posts_manager = WCTransactionalEmailPostsManager::get_instance();
+ $emails_by_id = $posts_manager->get_emails_by_id();
+
+ foreach ( $eligible as $row ) {
+ try {
+ $post_id = (int) $row->ID;
+
+ $email_id = (string) $posts_manager->get_email_type_from_post_id( $post_id );
+ if ( '' === $email_id || ! isset( $registry[ $email_id ] ) ) {
+ continue;
+ }
+
+ $email = $emails_by_id[ $email_id ] ?? null;
+ if ( ! $email instanceof \WC_Email ) {
+ continue;
+ }
+
+ $canonical_post_content = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ $current_core_hash = sha1( $canonical_post_content );
+
+ $case_id = self::classify( $row, $current_core_hash );
+
+ self::apply_case_to_post( $post_id, $case_id, $canonical_post_content, $current_core_hash, $email );
+ } catch ( \Throwable $e ) {
+ self::get_logger()->error(
+ sprintf(
+ 'Email template sync backfill failed for post %d: %s',
+ (int) $row->ID,
+ $e->getMessage()
+ ),
+ array(
+ 'post_id' => (int) $row->ID,
+ 'context' => 'email_template_sync_backfill',
+ )
+ );
+ continue;
+ }//end try
+ }//end foreach
+
+ self::finalize();
+ return false;
+ }
+
+ /**
+ * Whether the backfill is currently rewriting post content.
+ *
+ * Future save_post listeners that differentiate merchant edits from
+ * system-initiated writes should consult this flag.
+ *
+ * @return bool
+ *
+ * @since 10.8.0
+ */
+ public static function is_backfilling(): bool {
+ return self::$is_backfilling;
+ }
+
+ /**
+ * Override the logger implementation. Intended for tests only.
+ *
+ * @internal
+ *
+ * @param Email_Editor_Logger_Interface|null $logger The logger implementation, or null to restore the default.
+ */
+ public static function set_logger( ?Email_Editor_Logger_Interface $logger ): void {
+ self::$logger = $logger;
+ }
+
+ /**
+ * Classify a post into one of the three backfill cases.
+ *
+ * Pure function: given identical inputs, always returns the same case.
+ *
+ * @param \stdClass $row Row with `post_content`, `post_date`,
+ * `post_modified`, `post_date_gmt`,
+ * `post_modified_gmt`.
+ * @param string $current_core_hash `sha1()` of the canonical core render for the post's email.
+ * @return string One of self::CASE_A, self::CASE_B, self::CASE_C.
+ */
+ private static function classify( \stdClass $row, string $current_core_hash ): string {
+ $current_post_hash = sha1( (string) ( $row->post_content ?? '' ) );
+
+ if ( $current_post_hash === $current_core_hash ) {
+ return self::CASE_A;
+ }
+
+ return self::was_never_edited( $row ) ? self::CASE_B : self::CASE_C;
+ }
+
+ /**
+ * Decide whether a row represents a post that has never been edited since
+ * creation, using the timestamp pair available.
+ *
+ * Returns true when *either* the GMT pair or the local pair compare equal.
+ * The OR makes the classifier resilient to legacy insert paths that left
+ * one pair blank or sentinel-valued, which is the common case we've
+ * observed in the wild.
+ *
+ * Known limitation: when *both* pairs independently compare equal for
+ * reasons unrelated to edit state (e.g. both `_gmt` columns are the
+ * `'0000-00-00 00:00:00'` sentinel *and* the local pair happens to match),
+ * the function can report "never edited" for a post that has in fact been
+ * edited, which would cause Case B to rewrite the merchant's content. We
+ * accept that trade-off because the population of rows where every
+ * timestamp pair is simultaneously corrupt is effectively empty in
+ * practice; the simpler predicate is worth the theoretical exposure.
+ *
+ * @param \stdClass $row Row with `post_date`, `post_modified`, `post_date_gmt`, `post_modified_gmt`.
+ * @return bool True if at least one timestamp pair matches.
+ */
+ private static function was_never_edited( \stdClass $row ): bool {
+ $post_date_gmt = (string) ( $row->post_date_gmt ?? '' );
+ $post_modified_gmt = (string) ( $row->post_modified_gmt ?? '' );
+
+ $post_date = (string) ( $row->post_date ?? '' );
+ $post_modified = (string) ( $row->post_modified ?? '' );
+
+ return $post_date_gmt === $post_modified_gmt || $post_date === $post_modified;
+ }
+
+ /**
+ * Apply the chosen case to the post: rewrite content for Case B, then stamp
+ * the four sync meta keys plus the initial status.
+ *
+ * All case-specific branching is confined to this method; callers only
+ * need to pass the pre-computed canonical content and core hash.
+ *
+ * @param int $post_id The target post ID.
+ * @param string $case_id One of self::CASE_A/B/C.
+ * @param string $canonical_post_content The canonical core render (used by Case B).
+ * @param string $current_core_hash `sha1( $canonical_post_content )`.
+ * @param \WC_Email $email The registered email instance (used to resolve template path for the version stamp).
+ */
+ private static function apply_case_to_post( int $post_id, string $case_id, string $canonical_post_content, string $current_core_hash, \WC_Email $email ): void {
+ $version = self::resolve_version_for_email( $email );
+ $status_for_stamp = self::status_for_case( $case_id );
+
+ if ( self::CASE_B === $case_id ) {
+ self::$is_backfilling = true;
+ try {
+ $updated = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => $canonical_post_content,
+ ),
+ true
+ );
+ } finally {
+ self::$is_backfilling = false;
+ }
+
+ // With `$wp_error = true`, every `wp_update_post()` / `wp_insert_post()`
+ // failure path returns `WP_Error` (the `0` return is reserved for the
+ // `$wp_error = false` path). The outer `\Throwable` catch in `run()`
+ // can't see a returned `WP_Error`, so we handle it here. This
+ // migration is one-shot (the `woocommerce_db_version` fence flips on
+ // completion), so an unstamped post would be orphaned — the detector
+ // skips posts without a source hash with a recurring warning. Instead,
+ // fall back to Case C semantics: stamp with the canonical hash but
+ // flag the post as `core_updated_customized` so it surfaces for
+ // merchant review.
+ if ( is_wp_error( $updated ) ) {
+ $status_for_stamp = WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED;
+ self::get_logger()->warning(
+ sprintf(
+ 'Email template sync backfill: Case B content rewrite failed for post %d (%s); stamping as core_updated_customized so the post surfaces for merchant review.',
+ $post_id,
+ $updated->get_error_message()
+ ),
+ array(
+ 'post_id' => $post_id,
+ 'context' => 'email_template_sync_backfill',
+ )
+ );
+ }
+ }//end if
+
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $version );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $current_core_hash );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, gmdate( 'Y-m-d H:i:s' ) );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, $status_for_stamp );
+ }
+
+ /**
+ * Map a case to the initial status meta value.
+ *
+ * Cases A and B produce `in_sync` (both result in post content matching
+ * the canonical core render, so the stamp and the content agree). Case C
+ * deliberately seeds `core_updated_customized` — see class docblock.
+ *
+ * @param string $case_id One of self::CASE_A/B/C.
+ * @return string One of the WCEmailTemplateDivergenceDetector::STATUS_* constants.
+ */
+ private static function status_for_case( string $case_id ): string {
+ if ( self::CASE_C === $case_id ) {
+ return WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED;
+ }
+
+ return WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC;
+ }
+
+ /**
+ * Resolve the version string for an email by parsing the `@version` tag
+ * from its block template file. Falls back to the registry's cached
+ * version if the direct parse returns empty.
+ *
+ * @param \WC_Email $email The registered email instance.
+ * @return string The parsed version, or an empty string if none can be resolved.
+ */
+ private static function resolve_version_for_email( \WC_Email $email ): string {
+ $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email->id );
+ if ( is_array( $sync_config ) && isset( $sync_config['template_path'] ) ) {
+ $parsed = WCEmailTemplateSyncRegistry::parse_version_header( (string) $sync_config['template_path'] );
+ if ( '' !== $parsed ) {
+ return $parsed;
+ }
+
+ return (string) ( $sync_config['version'] ?? '' );
+ }
+
+ return '';
+ }
+
+ /**
+ * Fetch every `woo_email` post that has not yet been stamped with a
+ * source hash.
+ *
+ * The `NOT EXISTS` clause is what makes the callback retry-safe: posts
+ * stamped by RSM-137 (new installs) or by a previous invocation of this
+ * migration are filtered out, so an Action Scheduler retry converges on
+ * exactly the posts that still need work.
+ *
+ * @return \stdClass[] Rows with `ID`, `post_content`, `post_date_gmt`, `post_modified_gmt`.
+ */
+ private static function fetch_eligible_posts(): array {
+ global $wpdb;
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT ID, post_content, post_date, post_modified, post_date_gmt, post_modified_gmt
+ FROM {$wpdb->posts}
+ WHERE post_type = %s
+ AND post_status <> 'trash'
+ AND NOT EXISTS (
+ SELECT 1 FROM {$wpdb->postmeta} pm
+ WHERE pm.post_id = {$wpdb->posts}.ID
+ AND pm.meta_key = %s
+ )
+ ORDER BY ID ASC",
+ Integration::EMAIL_POST_TYPE,
+ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY
+ )
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+
+ return is_array( $rows ) ? $rows : array();
+ }
+
+ /**
+ * Flip the backfill-complete option and fire the completion action.
+ *
+ * Order matters: the option is updated first so any listener that
+ * inspects it inside the action sees the final state.
+ */
+ private static function finalize(): void {
+ update_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION, 'yes' );
+
+ /**
+ * Fires once, immediately after the RSM-149 sync-meta backfill
+ * finalizes for this site.
+ *
+ * Hooked by {@see WCEmailTemplateDivergenceDetector::run_sweep()} so the
+ * first real divergence sweep runs with a fully-stamped post set.
+ *
+ * @since 10.8.0
+ */
+ do_action( self::BACKFILL_COMPLETE_ACTION );
+ }
+
+ /**
+ * Return the logger instance, lazily creating it the first time.
+ *
+ * @return Email_Editor_Logger_Interface
+ */
+ private static function get_logger(): Email_Editor_Logger_Interface {
+ if ( null === self::$logger ) {
+ self::$logger = new Logger( wc_get_logger() );
+ }
+
+ return self::$logger;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistry.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistry.php
index 1adf5c87aa2..63176a22b3b 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistry.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistry.php
@@ -132,8 +132,7 @@ class WCEmailTemplateSyncRegistry {
return array();
}
- $wc_emails = \WC_Emails::instance();
- $all_emails = $wc_emails->get_emails();
+ $emails_by_id = WCTransactionalEmailPostsManager::get_instance()->get_emails_by_id();
$registry = array();
@@ -142,7 +141,7 @@ class WCEmailTemplateSyncRegistry {
continue;
}
- $email = self::find_email_for_id( $all_emails, $email_id );
+ $email = $emails_by_id[ $email_id ] ?? null;
if ( null === $email ) {
self::get_logger()->notice(
sprintf(
@@ -207,26 +206,6 @@ class WCEmailTemplateSyncRegistry {
return $registry;
}
- /**
- * Look up the registered WC_Email instance for the given ID.
- *
- * `WC_Emails::get_emails()` keys entries by class name, not by email ID, so a
- * linear search over the `->id` property is required.
- *
- * @param array<string, \WC_Email> $all_emails Registered WC_Email instances keyed by class.
- * @param string $email_id The email ID to look up.
- * @return \WC_Email|null
- */
- private static function find_email_for_id( array $all_emails, string $email_id ): ?\WC_Email {
- foreach ( $all_emails as $email ) {
- if ( $email instanceof \WC_Email && $email->id === $email_id ) {
- return $email;
- }
- }
-
- return null;
- }
-
/**
* Classify an email ID as either first-party (core / POS / Fulfillments) or third-party.
*
@@ -253,8 +232,10 @@ class WCEmailTemplateSyncRegistry {
*
* @param string $file Absolute path to the template file.
* @return string The parsed version, or an empty string if none is declared.
+ *
+ * @since 10.8.0
*/
- private static function parse_version_header( string $file ): string {
+ public static function parse_version_header( string $file ): string {
if ( ! is_readable( $file ) ) {
return '';
}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
index 2c822bd28f2..3bab49498ed 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
@@ -404,9 +404,9 @@ class WCTransactionalEmailPostsGenerator {
if ( ! isset( $post_data['meta_input'] ) || ! is_array( $post_data['meta_input'] ) ) {
$post_data['meta_input'] = array();
}
- $post_data['meta_input']['_wc_email_template_version'] = (string) $sync_config['version'];
- $post_data['meta_input']['_wc_email_template_source_hash'] = sha1( (string) ( $post_data['post_content'] ?? '' ) );
- $post_data['meta_input']['_wc_email_last_synced_at'] = gmdate( 'Y-m-d H:i:s' );
+ $post_data['meta_input'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ] = (string) $sync_config['version'];
+ $post_data['meta_input'][ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY ] = sha1( (string) ( $post_data['post_content'] ?? '' ) );
+ $post_data['meta_input'][ WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY ] = gmdate( 'Y-m-d H:i:s' );
}
$post_id = wp_insert_post( $post_data, true );
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.php
index b523115f5b9..7a561462b2a 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.php
@@ -57,6 +57,50 @@ class WCTransactionalEmailPostsManager {
return self::$instance;
}
+ /**
+ * Build an `email_id => WC_Email` lookup from the list returned by
+ * `WC_Emails::get_emails()`, which keys entries by class name rather than
+ * by email ID.
+ *
+ * @return array<string, \WC_Email> Registered emails keyed by their `id` property.
+ *
+ * @since 10.8.0
+ */
+ public function get_emails_by_id(): array {
+ /**
+ * Registered emails keyed by class name.
+ *
+ * @var array<string, \WC_Email>
+ */
+ $all_emails = \WC_Emails::instance()->get_emails();
+
+ $indexed = array();
+ foreach ( $all_emails as $email ) {
+ if ( $email instanceof \WC_Email && ! empty( $email->id ) ) {
+ $indexed[ $email->id ] = $email;
+ }
+ }
+
+ return $indexed;
+ }
+
+ /**
+ * Look up a single registered `WC_Email` instance by its ID.
+ *
+ * @param string $email_id The email ID to look up (`WC_Email->id`).
+ * @return \WC_Email|null The matching instance, or null if no email with
+ * that ID is registered.
+ *
+ * @since 10.8.0
+ */
+ public function get_email_by_id( string $email_id ): ?\WC_Email {
+ if ( '' === $email_id ) {
+ return null;
+ }
+
+ return $this->get_emails_by_id()[ $email_id ] ?? null;
+ }
+
/**
* Retrieves the email post by its type.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php
new file mode 100644
index 00000000000..bdda5009e24
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php
@@ -0,0 +1,565 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+
+/**
+ * Tests for the RSM-149 sync-meta backfill.
+ */
+class WCEmailTemplateSyncBackfillTest extends \WC_Unit_Test_Case {
+ /**
+ * Absolute path to the fixtures directory.
+ *
+ * @var string
+ */
+ private string $fixtures_base;
+
+ /**
+ * Keys injected into \WC_Emails::$emails during the current test.
+ *
+ * @var string[]
+ */
+ private array $injected_email_keys = array();
+
+ /**
+ * Transactional email post manager singleton.
+ *
+ * @var WCTransactionalEmailPostsManager
+ */
+ private WCTransactionalEmailPostsManager $posts_manager;
+
+ /**
+ * Setup test case.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+
+ // Eagerly boot \WC_Emails so the \WC_Email class is autoloaded before any
+ // test reflects on it via getMockBuilder() / onlyMethods().
+ \WC_Emails::instance();
+
+ $this->fixtures_base = dirname( __DIR__ ) . '/WCTransactionalEmails/fixtures/';
+ $this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+
+ $this->posts_manager->clear_caches();
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ // The backfill is gated by the completion option being unset — ensure it is.
+ delete_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+ }
+
+ /**
+ * Cleanup after test.
+ */
+ public function tearDown(): void {
+ $this->cleanup_injected_emails();
+
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ remove_all_actions( WCEmailTemplateSyncBackfill::BACKFILL_COMPLETE_ACTION );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateSyncBackfill::set_logger( null );
+
+ delete_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Case A: post_content already matches the canonical core render.
+ * Expectation: content untouched, four meta keys stamped, status = in_sync.
+ */
+ public function test_case_a_stamps_in_sync_when_content_matches_current_core(): void {
+ $email_id = 'wc_test_backfill_case_a';
+ $email = $this->register_fixture_email( $email_id );
+
+ $canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+ $post_id = $this->create_unstamped_post( $email_id, $canonical, true );
+
+ WCEmailTemplateSyncBackfill::run();
+
+ $post_after = $this->require_post( $post_id );
+
+ $this->assertSame( $canonical, (string) $post_after->post_content, 'Case A must not rewrite post_content.' );
+ $this->assertSame(
+ sha1( $canonical ),
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true )
+ );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ $this->assertNotSame(
+ '',
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true )
+ );
+ $this->assertNotSame(
+ '',
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true )
+ );
+ }
+
+ /**
+ * Case B: content diverges from canonical but the post has never been edited.
+ * Expectation: post_content rewritten to canonical, status = in_sync.
+ */
+ public function test_case_b_rewrites_post_content_when_never_edited(): void {
+ $email_id = 'wc_test_backfill_case_b';
+ $email = $this->register_fixture_email( $email_id );
+
+ $canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ $legacy_body = "<!-- wp:paragraph -->\n<p>Legacy content from an older core version.</p>\n<!-- /wp:paragraph -->";
+
+ // "Never edited" is modelled by identical local + GMT timestamps, which `create_unstamped_post( ..., $never_edited = true )` sets up.
+ $post_id = $this->create_unstamped_post( $email_id, $legacy_body, true );
+
+ WCEmailTemplateSyncBackfill::run();
+
+ $post_after = $this->require_post( $post_id );
+
+ $this->assertSame( $canonical, (string) $post_after->post_content, 'Case B must rewrite post_content to the canonical render.' );
+ $this->assertSame(
+ sha1( $canonical ),
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true )
+ );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ }
+
+ /**
+ * Case C: content diverges from canonical AND the post has been edited.
+ * Expectation: content untouched, source_hash = sha1(canonical) (NOT sha1(post_content)),
+ * status seeded to core_updated_customized to match what the divergence detector
+ * communicates for this state.
+ */
+ public function test_case_c_seeds_core_updated_customized_when_customized(): void {
+ $email_id = 'wc_test_backfill_case_c';
+ $email = $this->register_fixture_email( $email_id );
+
+ $canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ $merchant_body = "<!-- wp:paragraph -->\n<p>Merchant-authored customisations must survive the backfill.</p>\n<!-- /wp:paragraph -->";
+
+ $post_id = $this->create_unstamped_post( $email_id, $merchant_body, false );
+
+ WCEmailTemplateSyncBackfill::run();
+
+ $post_after = $this->require_post( $post_id );
+
+ $this->assertSame(
+ $merchant_body,
+ (string) $post_after->post_content,
+ 'Case C must not touch merchant-edited post_content.'
+ );
+ $this->assertSame(
+ sha1( $canonical ),
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+ 'Case C must stamp sha1(canonical) — never sha1(post_content) — to prevent a catastrophic core_updated_uncustomized classification on the next core bump.'
+ );
+ $this->assertNotSame(
+ sha1( $merchant_body ),
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true )
+ );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ }
+
+ /**
+ * Case B rewrite failure: wp_update_post() returns a WP_Error (silent
+ * failure because `$wp_error = true`). The migration is one-shot — the
+ * `woocommerce_db_version` fence flips on completion and this callback
+ * never runs again — so the post cannot be left unstamped (the detector
+ * would skip it with a recurring warning forever). Instead, the post must
+ * still be stamped, but with Case C semantics so it surfaces for merchant
+ * review rather than being silently marked in_sync over stale content.
+ *
+ * Expectation when the rewrite fails:
+ * - post_content is preserved (legacy body, because the rewrite failed).
+ * - source_hash = sha1(canonical) (same stamp a real Case C writes, so
+ * the detector's "core has not moved" branch returns null and does not
+ * overwrite the status on the next sweep).
+ * - status = core_updated_customized (so the post is visible in the
+ * divergence UI as needing merchant attention).
+ * - version + last_synced_at stamps are still written (no orphan posts).
+ */
+ public function test_case_b_falls_back_to_core_updated_customized_when_rewrite_fails(): void {
+ $email_id = 'wc_test_backfill_case_b_rewrite_failure';
+ $email = $this->register_fixture_email( $email_id );
+
+ $canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ $legacy_body = "<!-- wp:paragraph -->\n<p>Legacy content from an older core version.</p>\n<!-- /wp:paragraph -->";
+
+ $post_id = $this->create_unstamped_post( $email_id, $legacy_body, true );
+
+ // Force wp_update_post() to return a WP_Error during the Case B rewrite.
+ // `wp_insert_post_empty_content` short-circuits both wp_insert_post and
+ // wp_update_post (the latter delegates to the former), and because the
+ // backfill passes `$wp_error = true`, we get a WP_Error back rather
+ // than an exception — exactly the silent-failure path we're testing.
+ add_filter( 'wp_insert_post_empty_content', '__return_true' );
+ try {
+ WCEmailTemplateSyncBackfill::run();
+ } finally {
+ remove_filter( 'wp_insert_post_empty_content', '__return_true' );
+ }
+
+ $post_after = $this->require_post( $post_id );
+
+ $this->assertSame(
+ $legacy_body,
+ (string) $post_after->post_content,
+ 'post_content must be preserved when the Case B rewrite fails.'
+ );
+ $this->assertSame(
+ sha1( $canonical ),
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+ 'Source hash must still stamp sha1(canonical) so the detector treats it the same as a real Case C on the next sweep.'
+ );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'A failed Case B rewrite must downgrade status to core_updated_customized so the post surfaces for merchant review.'
+ );
+ $this->assertNotSame(
+ '',
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+ 'Version stamp must still be written on rewrite failure — leaving posts unstamped would orphan them permanently.'
+ );
+ $this->assertNotSame(
+ '',
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+ 'Last-synced stamp must still be written on rewrite failure — leaving posts unstamped would orphan them permanently.'
+ );
+ }
+
+ /**
+ * Pure-function coverage for `classify()`. Exercises the timestamp truth
+ * table the classifier is contracted to handle — without depending on
+ * wp_insert_post()'s date normalisation or MySQL sql_mode behaviour.
+ *
+ * Note: `was_never_edited()` uses an OR across the GMT pair and the local
+ * pair (see its docblock for the rationale and known limitation), so
+ * scenarios where every timestamp pair is simultaneously corrupt are
+ * explicitly out of scope for this test — the classifier may misclassify
+ * those and we've accepted that trade-off.
+ *
+ * Each scenario pairs a synthetic row with an expected case. Ordering
+ * mirrors the branches in `classify()` / `was_never_edited()` so a
+ * regression points at the specific branch that broke.
+ *
+ * @dataProvider provide_classify_scenarios
+ *
+ * @param string $scenario Human-readable label surfaced by PHPUnit.
+ * @param array $row_fields Fields to seed on the \stdClass row.
+ * @param string $post_content Post content to hash.
+ * @param string $canonical_content Canonical core content to hash.
+ * @param string $expected_case One of WCEmailTemplateSyncBackfill::CASE_A|B|C.
+ */
+ public function test_classify_truth_table( string $scenario, array $row_fields, string $post_content, string $canonical_content, string $expected_case ): void {
+ $row = (object) array_merge( array( 'post_content' => $post_content ), $row_fields );
+ $current_core_hash = sha1( $canonical_content );
+
+ $method = ( new \ReflectionClass( WCEmailTemplateSyncBackfill::class ) )->getMethod( 'classify' );
+ $method->setAccessible( true );
+ $actual = (string) $method->invoke( null, $row, $current_core_hash );
+
+ $this->assertSame( $expected_case, $actual, sprintf( 'Scenario: %s', $scenario ) );
+ }
+
+ /**
+ * Scenarios for {@see self::test_classify_truth_table()}.
+ *
+ * @return array<string, array{0:string,1:array<string,string>,2:string,3:string,4:string}>
+ */
+ public function provide_classify_scenarios(): array {
+ $canonical = '<!-- wp:paragraph --><p>Canonical core.</p><!-- /wp:paragraph -->';
+ $merchant_edit = '<!-- wp:paragraph --><p>Merchant edit.</p><!-- /wp:paragraph -->';
+ $created_at = '2023-01-01 00:00:00';
+ $edited_at = '2024-06-15 12:34:56';
+ $zero_date = '0000-00-00 00:00:00';
+ $case_a = WCEmailTemplateSyncBackfill::CASE_A;
+ $case_b = WCEmailTemplateSyncBackfill::CASE_B;
+ $case_c = WCEmailTemplateSyncBackfill::CASE_C;
+
+ return array(
+ 'Case A: content matches core (short-circuits before timestamps)' => array(
+ 'Case A short-circuit',
+ array(
+ 'post_date' => $created_at,
+ 'post_modified' => $edited_at,
+ 'post_date_gmt' => $created_at,
+ 'post_modified_gmt' => $edited_at,
+ ),
+ $canonical,
+ $canonical,
+ $case_a,
+ ),
+ 'Case B: GMT pair valid and equal (never edited)' => array(
+ 'GMT valid + equal -> never edited',
+ array(
+ 'post_date' => $created_at,
+ 'post_modified' => $created_at,
+ 'post_date_gmt' => $created_at,
+ 'post_modified_gmt' => $created_at,
+ ),
+ $merchant_edit,
+ $canonical,
+ $case_b,
+ ),
+ 'Case C: GMT pair valid and differs (edited)' => array(
+ 'GMT valid + differs -> edited',
+ array(
+ 'post_date' => $created_at,
+ 'post_modified' => $edited_at,
+ 'post_date_gmt' => $created_at,
+ 'post_modified_gmt' => $edited_at,
+ ),
+ $merchant_edit,
+ $canonical,
+ $case_c,
+ ),
+ 'Case B via fallback: both GMT zero, local pair equal (never edited)' => array(
+ 'GMT zero + local equal -> fallback to local -> never edited',
+ array(
+ 'post_date' => $created_at,
+ 'post_modified' => $created_at,
+ 'post_date_gmt' => $zero_date,
+ 'post_modified_gmt' => $zero_date,
+ ),
+ $merchant_edit,
+ $canonical,
+ $case_b,
+ ),
+ 'Case C via local fallback: GMT pair differs (edited), local pair also differs' => array(
+ 'GMT differs + local differs -> edited',
+ array(
+ 'post_date' => $created_at,
+ 'post_modified' => $edited_at,
+ 'post_date_gmt' => $created_at,
+ 'post_modified_gmt' => $zero_date,
+ ),
+ $merchant_edit,
+ $canonical,
+ $case_c,
+ ),
+ 'Case B via OR: GMT pair differs but local pair is equal (legacy never-edited signal)' => array(
+ 'GMT differs + local equal -> OR resolves to never edited',
+ array(
+ 'post_date' => $created_at,
+ 'post_modified' => $created_at,
+ 'post_date_gmt' => $zero_date,
+ 'post_modified_gmt' => $created_at,
+ ),
+ $merchant_edit,
+ $canonical,
+ $case_b,
+ ),
+ );
+ }
+
+ /**
+ * finalize() flips the BACKFILL_COMPLETE_OPTION to 'yes' BEFORE firing the
+ * completion action, so listeners (notably the RSM-138 detector) see the
+ * final state when they run.
+ */
+ public function test_finalize_flips_option_before_firing_action(): void {
+ $this->register_fixture_email( 'wc_test_backfill_finalize_ordering' );
+
+ $option_seen_inside_action = null;
+ $action_invocations = 0;
+
+ add_action(
+ WCEmailTemplateSyncBackfill::BACKFILL_COMPLETE_ACTION,
+ static function () use ( &$option_seen_inside_action, &$action_invocations ): void {
+ $option_seen_inside_action = get_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+ ++$action_invocations;
+ }
+ );
+
+ $result = WCEmailTemplateSyncBackfill::run();
+
+ $this->assertFalse( $result, 'run() must return false (one-shot).' );
+ $this->assertSame( 1, $action_invocations, 'Completion action must fire exactly once per run.' );
+ $this->assertSame(
+ 'yes',
+ $option_seen_inside_action,
+ 'BACKFILL_COMPLETE_OPTION must already be "yes" when the completion action fires.'
+ );
+ $this->assertSame(
+ 'yes',
+ (string) get_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION ),
+ 'BACKFILL_COMPLETE_OPTION must remain "yes" after run() returns.'
+ );
+ }
+
+ /**
+ * Build a WC_Email stub backed by the fixture template, inject it into
+ * \WC_Emails::$emails, and opt the email ID into the block-editor filter so
+ * the sync registry picks it up. Mirrors the helper used by the detector
+ * test suite.
+ *
+ * @param string $email_id Email ID to assign to the stub.
+ * @return \WC_Email Registered fixture email instance.
+ */
+ private function register_fixture_email( string $email_id ): \WC_Email {
+ $stub = $this->getMockBuilder( \WC_Email::class )
+ ->disableOriginalConstructor()
+ ->onlyMethods( array( 'get_title', 'get_description' ) )
+ ->getMock();
+ $stub->method( 'get_title' )->willReturn( 'Fixture email for backfill tests' );
+ $stub->method( 'get_description' )->willReturn( 'Fixture email used to cover RSM-149 backfill scenarios.' );
+ $stub->id = $email_id;
+ $stub->template_base = $this->fixtures_base;
+ $stub->template_block = 'block/third-party-with-version.php';
+ $stub->template_plain = null;
+
+ $class_key = 'WC_Test_Email_' . $email_id;
+
+ $emails_container = \WC_Emails::instance();
+ $reflection = new \ReflectionClass( $emails_container );
+ $property = $reflection->getProperty( 'emails' );
+ $property->setAccessible( true );
+ $current = $property->getValue( $emails_container );
+ $current[ $class_key ] = $stub;
+ $property->setValue( $emails_container, $current );
+
+ $this->injected_email_keys[] = $class_key;
+
+ add_filter(
+ 'woocommerce_transactional_emails_for_block_editor',
+ static function ( array $emails ) use ( $email_id ): array {
+ if ( ! in_array( $email_id, $emails, true ) ) {
+ $emails[] = $email_id;
+ }
+ return $emails;
+ }
+ );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ return $stub;
+ }
+
+ /**
+ * Create a `woo_email` post that is NOT stamped with RSM-137 sync meta,
+ * mirroring a legacy post that the backfill should pick up.
+ *
+ * The GMT timestamps can be overridden independently of the local
+ * timestamps to simulate the real-world anomaly where legacy/broken insert
+ * paths left `post_date_gmt` and/or `post_modified_gmt` as the sentinel
+ * `'0000-00-00 00:00:00'` while local dates remained healthy.
+ *
+ * @param string $email_id Email ID to link the post to via the manager option.
+ * @param string $post_content Initial post content.
+ * @param bool $never_edited When true, set post_date === post_modified (Case B eligibility); otherwise, set them apart (Case C).
+ * @param string|null $post_date_gmt_override Optional explicit value for post_date_gmt; defaults to the local post_date.
+ * @param string|null $post_modified_gmt_override Optional explicit value for post_modified_gmt; defaults to the local post_modified.
+ * @return int The created post ID.
+ */
+ private function create_unstamped_post(
+ string $email_id,
+ string $post_content,
+ bool $never_edited,
+ ?string $post_date_gmt_override = null,
+ ?string $post_modified_gmt_override = null
+ ): int {
+ $created_at = '2023-01-01 00:00:00';
+ $modified_at = $never_edited ? $created_at : '2024-06-15 12:34:56';
+
+ $post_date_gmt = $post_date_gmt_override ?? $created_at;
+ $post_modified_gmt = $post_modified_gmt_override ?? $modified_at;
+
+ $inserted = wp_insert_post(
+ array(
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'publish',
+ 'post_title' => 'Backfill fixture for ' . $email_id,
+ 'post_content' => $post_content,
+ 'post_date' => $created_at,
+ 'post_date_gmt' => $post_date_gmt,
+ 'post_modified' => $modified_at,
+ 'post_modified_gmt' => $post_modified_gmt,
+ ),
+ true
+ );
+
+ if ( is_wp_error( $inserted ) ) {
+ throw new \RuntimeException( 'wp_insert_post failed: ' . esc_html( $inserted->get_error_message() ) );
+ }
+
+ $post_id = (int) $inserted;
+ $this->assertGreaterThan( 0, $post_id );
+
+ // wp_insert_post will overwrite post_modified* to `now` when `edit_date` is not set — set it explicitly via the DB.
+ global $wpdb;
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_date' => $created_at,
+ 'post_date_gmt' => $post_date_gmt,
+ 'post_modified' => $modified_at,
+ 'post_modified_gmt' => $post_modified_gmt,
+ ),
+ array( 'ID' => $post_id )
+ );
+ clean_post_cache( $post_id );
+
+ // Link post ID <-> email ID so WCTransactionalEmailPostsManager::get_email_type_from_post_id() resolves.
+ $this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
+ return $post_id;
+ }
+
+ /**
+ * Fetch a post by ID and assert/narrow to `WP_Post`. Throws rather than
+ * calling PHPUnit's `$this->fail()` so the type narrowing is visible to
+ * static analysis and failures produce a readable stack trace.
+ *
+ * @param int $post_id The post ID to load.
+ * @return \WP_Post
+ */
+ private function require_post( int $post_id ): \WP_Post {
+ $post = get_post( $post_id );
+ if ( ! $post instanceof \WP_Post ) {
+ throw new \RuntimeException( sprintf( 'Expected WP_Post for ID %s, got %s.', esc_html( (string) $post_id ), esc_html( gettype( $post ) ) ) );
+ }
+
+ return $post;
+ }
+
+ /**
+ * Remove any stubs we injected into \WC_Emails::$emails during the test.
+ */
+ private function cleanup_injected_emails(): void {
+ if ( empty( $this->injected_email_keys ) ) {
+ return;
+ }
+
+ $emails_container = \WC_Emails::instance();
+ $reflection = new \ReflectionClass( $emails_container );
+ $property = $reflection->getProperty( 'emails' );
+ $property->setAccessible( true );
+ $current = $property->getValue( $emails_container );
+ foreach ( $this->injected_email_keys as $key ) {
+ unset( $current[ $key ] );
+ }
+ $property->setValue( $emails_container, $current );
+ $this->injected_email_keys = array();
+ }
+}