Commit 2f655724adb for woocommerce

commit 2f655724adb4ac1bfdae794fa39aaf92caedb128
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date:   Wed Apr 22 15:37:39 2026 +0100

    [Email Editor] Stamp sync meta on generated block email posts (#64287)

    * Stamp sync meta on generated block email posts

    Stamp _wc_email_template_version, _wc_email_template_source_hash, and
    _wc_email_last_synced_at on every woo_email post whose email ID is
    resolvable through WCEmailTemplateSyncRegistry. The stamp is added to
    $post_data['meta_input'] after the woocommerce_email_content_post_data
    filter so third-party integrators cannot forge or clobber it, and the
    source hash is computed over the final post_content so the stored hash
    always matches what is actually persisted.

    Emails outside the sync registry (including templates without a
    parseable @version header) are not stamped and see no behaviour change.

    Made-with: Cursor

    * Add changelog entry for email template sync meta

    Made-with: Cursor

    * Update option for email editor feature flag in test setup

    Replaced add_option with update_option for 'woocommerce_feature_block_email_editor_enabled' in WCEmailTemplateSyncRegistryTest setup method to ensure the option is updated correctly during tests.

diff --git a/plugins/woocommerce/changelog/add-email-template-sync-meta b/plugins/woocommerce/changelog/add-email-template-sync-meta
new file mode 100644
index 00000000000..88ea83bd8ea
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-email-template-sync-meta
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Stamp sync meta on generated block email posts (version, source hash, synced-at) for emails whose templates expose a parseable @version header. No runtime behaviour change for emails outside the sync registry.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
index 238432cd7c4..d3685e8a22d 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
@@ -339,6 +339,17 @@ class WCTransactionalEmailPostsGenerator {
 		 */
 		$post_data = apply_filters( 'woocommerce_email_content_post_data', $post_data, $email_type, $email_data );

+		// Sync meta stamp for emails participating in template update propagation.
+		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_data->id );
+		if ( null !== $sync_config ) {
+			if ( ! isset( $post_data['meta_input'] ) || ! is_array( $post_data['meta_input'] ) ) {
+				$post_data['meta_input'] = array();
+			}
+			$post_data['meta_input']['_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_id = wp_insert_post( $post_data, true );

 		if ( is_wp_error( $post_id ) ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistryTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistryTest.php
index 658b009e3cc..36d2ee2e10a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistryTest.php
@@ -40,7 +40,7 @@ class WCEmailTemplateSyncRegistryTest extends \WC_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();

-		add_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );

 		$this->fixtures_base = __DIR__ . '/fixtures/';

diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGeneratorTest.php
index f03335f33d5..ea2494353c3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGeneratorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGeneratorTest.php
@@ -4,6 +4,8 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;

+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;

@@ -21,6 +23,20 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 	 */
 	private WCTransactionalEmailPostsManager $template_manager;

+	/**
+	 * Absolute path to the fixtures directory used for sync-stamping tests.
+	 *
+	 * @var string
+	 */
+	private string $fixtures_base;
+
+	/**
+	 * Keys of WC_Emails::$emails entries injected during the current test.
+	 *
+	 * @var string[]
+	 */
+	private array $injected_email_keys = array();
+
 	/**
 	 * Setup test case.
 	 */
@@ -29,6 +45,15 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 		add_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
 		$this->email_generator  = new WCTransactionalEmailPostsGenerator();
 		$this->template_manager = WCTransactionalEmailPostsManager::get_instance();
+		$this->fixtures_base    = __DIR__ . '/fixtures/';
+
+		// WCTransactionalEmailPostsManager is a process-wide singleton; its in-memory
+		// post_id <-> email_type cache survives DB transaction rollback between tests
+		// and would otherwise make generate_email_template_if_not_exists() return a
+		// stale post ID whose backing post was rolled back.
+		$this->template_manager->clear_caches();
+
+		WCEmailTemplateSyncRegistry::reset_cache();
 	}

 	/**
@@ -138,10 +163,168 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 		$this->assertFalse( $result );
 	}

+	/**
+	 * Core email is stamped with all three sync meta keys, and the hash is self-consistent with post_content.
+	 */
+	public function test_core_email_is_stamped_with_all_three_meta_keys(): void {
+		$email_type = 'customer_new_account';
+
+		$this->email_generator->init_default_transactional_emails();
+		$this->template_manager->delete_email_template( $email_type );
+
+		$post_id = $this->email_generator->generate_email_template_if_not_exists( $email_type );
+
+		$this->assertIsInt( $post_id );
+		$this->assertGreaterThan( 0, $post_id );
+
+		$version   = (string) get_post_meta( $post_id, '_wc_email_template_version', true );
+		$hash      = (string) get_post_meta( $post_id, '_wc_email_template_source_hash', true );
+		$synced_at = (string) get_post_meta( $post_id, '_wc_email_last_synced_at', true );
+
+		$this->assertNotSame( '', $version, '_wc_email_template_version should be populated for a core email.' );
+		$this->assertMatchesRegularExpression( '/^\d+\.\d+(?:\.\d+)?/', $version, 'Version should be semver-ish.' );
+
+		$post_content = (string) get_post( $post_id )->post_content;
+		$this->assertSame(
+			sha1( $post_content ),
+			$hash,
+			'_wc_email_template_source_hash must equal sha1() of the stored post_content.'
+		);
+
+		$this->assertMatchesRegularExpression(
+			'/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/',
+			$synced_at,
+			'_wc_email_last_synced_at should be a GMT MySQL-format timestamp.'
+		);
+	}
+
+	/**
+	 * Emails that are opted in for the block editor but whose templates lack a parseable @version
+	 * header are absent from the sync registry and must not be stamped.
+	 */
+	public function test_email_absent_from_registry_is_not_stamped(): void {
+		$email_id = 'wc_test_email_no_version';
+		$this->register_third_party_email_without_version( $email_id );
+
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		$this->email_generator->init_default_transactional_emails();
+		$this->template_manager->delete_email_template( $email_id );
+
+		$post_id = $this->email_generator->generate_email_template_if_not_exists( $email_id );
+
+		$this->assertIsInt( $post_id );
+		$this->assertGreaterThan( 0, $post_id );
+
+		$this->assertSame( '', (string) get_post_meta( $post_id, '_wc_email_template_version', true ) );
+		$this->assertSame( '', (string) get_post_meta( $post_id, '_wc_email_template_source_hash', true ) );
+		$this->assertSame( '', (string) get_post_meta( $post_id, '_wc_email_last_synced_at', true ) );
+	}
+
+	/**
+	 * Regression guard: every core transactional email that is actually loaded by WC_Emails
+	 * must expose a parseable @version header through the sync registry.
+	 *
+	 * This replaces a runtime throw in the registry and catches template drift (missing or
+	 * malformed @version) at CI time. Feature-gated emails may not be loaded in the test
+	 * process, so we intersect against the actually-registered email IDs.
+	 */
+	public function test_every_loaded_core_template_has_parseable_version(): void {
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		$registered_email_ids = array_map(
+			static fn ( \WC_Email $email ): string => (string) $email->id,
+			array_values( \WC_Emails::instance()->get_emails() )
+		);
+
+		$core_emails_to_check = array_intersect(
+			WCTransactionalEmails::get_core_transactional_emails(),
+			$registered_email_ids
+		);
+
+		$this->assertNotEmpty(
+			$core_emails_to_check,
+			'Expected at least one core transactional email to be registered with WC_Emails during the test run.'
+		);
+
+		foreach ( $core_emails_to_check as $email_id ) {
+			$config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_id );
+
+			$this->assertNotNull(
+				$config,
+				sprintf( 'Core email "%s" must be resolvable through the sync registry.', $email_id )
+			);
+			$this->assertIsArray( $config );
+			$this->assertArrayHasKey( 'version', $config );
+			$this->assertMatchesRegularExpression(
+				'/^\d+\.\d+(?:\.\d+)?/',
+				(string) $config['version'],
+				sprintf( 'Core email "%s" must expose a semver-ish @version header.', $email_id )
+			);
+		}
+	}
+
+	/**
+	 * Inject a WC_Email stub whose block template has no parseable @version header into
+	 * WC_Emails::$emails and opt it in via the block-editor filter.
+	 *
+	 * @param string $email_id Email ID to inject.
+	 */
+	private function register_third_party_email_without_version( string $email_id ): void {
+		$stub = $this->getMockBuilder( \WC_Email::class )
+			->disableOriginalConstructor()
+			->getMock();
+		$stub->method( 'get_title' )->willReturn( 'Third-party test email' );
+		$stub->method( 'get_description' )->willReturn( 'Fixture email without a parseable @version header.' );
+		$stub->id             = $email_id;
+		$stub->template_base  = $this->fixtures_base;
+		$stub->template_block = 'block/third-party-without-version.php';
+		$stub->template_plain = 'plain/test-fallback.php';
+
+		$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;
+			}
+		);
+	}
+
 	/**
 	 * Cleanup after test.
 	 */
 	public function tearDown(): void {
+		if ( ! empty( $this->injected_email_keys ) ) {
+			$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();
+		}
+
+		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+
+		WCEmailTemplateSyncRegistry::reset_cache();
+
 		parent::tearDown();
 		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
 		delete_transient( 'wc_email_editor_initial_templates_generated' );