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' );