Commit c91e8eadb65 for woocommerce
commit c91e8eadb651c84363d65e7ac3426d58efc84e4b
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date: Tue Apr 21 16:42:55 2026 +0100
Add email template sync registry driven by @version header (#64261)
* Add email template sync registry driven by @version header
Introduces WCEmailTemplateSyncRegistry, the single helper that downstream
stamping, sweep, auto-apply, merge, reset, backfill, UI, and Tracks
consumers will read the sync-enabled email set from. The registry walks
WCTransactionalEmails::get_transactional_emails(), resolves each email's
block template file, parses its @version header, and returns a cached
map of email_id => { version, template_path, source }. Emails missing a
parseable @version are silently skipped with a warning log so a malformed
production template never crashes the admin.
Also extracts template-name and path resolution from
WCTransactionalEmailPostsGenerator::get_email_template() into two public
static helpers so the registry and the existing post generator share a
single source of truth, including the plain -> block fallback used by
customer_invoice and other core emails that do not set template_block
explicitly.
No runtime behaviour change yet; the registry is wired up but has no
consumers until RSM-137 onward.
Made-with: Cursor
* Add changelog entry for email template sync registry
Made-with: Cursor
* Fix lint and PHPStan errors in email template sync registry
Add the required file-level declare( strict_types=1 ) to the four new
test fixtures under tests/php/src/Internal/EmailEditor/WCTransactional
Emails/fixtures/ (block/ and plain/). Without it PHPCS flags them under
Generic.PHP.RequireStrictTypes, since tests/ is in the sniff's include
list. Placement follows PSR12.Files.FileHeader order: opening tag ->
file-level docblock -> declare -> code.
Replace the two new $email->template_plain ?? '' / $email->template_base
?? '' usages introduced by the resolve_block_template_name() and
resolve_block_template_path() helpers with (string) $email->template_*
casts. WC_Email declares these properties as public @var string
(non-nullable), so PHPStan treats ?? '' as dead code
(nullCoalesce.property). The (string) cast preserves the defensive
null -> '' fallback at runtime while silencing the sniff, and lets us
use the same pattern for the pre-existing template_base usage inside
get_email_template() for consistency.
Drop the now-obsolete nullCoalesce.property baseline entry for
WCTransactionalEmailPostsGenerator.php from phpstan-baseline.neon;
the pattern no longer appears in the file, and leaving a counted
exception behind would fail the next PHPStan run.
Made-with: Cursor
* Extract get_core_transactional_emails() as single source of truth
Made-with: Cursor
* Remove feature-flag-dependent email sync registry test
Drops test_first_party_feature_email_is_classified_as_core, which
registered stubs for customer_pos_completed_order and
customer_fulfillment_created and asserted source === 'core'. Those IDs
are only appended to WCTransactionalEmails::get_core_transactional_emails()
when the point_of_sale / fulfillments feature flags are enabled, and the
default state of those flags varies across the CI matrix (PHP 7.4 WP
latest, WP 7.0-RC2, PHP 8.5 Legacy MiniCart), causing the stub to be
classified as 'third_party' on some runners even though it passed
locally.
The same branches of WCEmailTemplateSyncRegistry::classify_source() are
still covered:
- Core branch: test_core_emails_populate_registry_with_core_source
asserts every real core email registered with WC_Emails is labelled
source === 'core'.
- Third-party branch:
test_third_party_email_with_version_header_is_registered asserts a
stub outside the core list is labelled source === 'third_party'.
No runtime code change; registry behaviour is unaffected.
Made-with: Cursor
diff --git a/plugins/woocommerce/changelog/add-email-template-sync-registry b/plugins/woocommerce/changelog/add-email-template-sync-registry
new file mode 100644
index 00000000000..5881551aa53
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-email-template-sync-registry
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add internal WCEmailTemplateSyncRegistry helper that resolves block email templates participating in template update propagation via their @version header. No runtime behaviour change yet.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 9605070622c..d9f7e94536d 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -64944,12 +64944,6 @@ parameters:
count: 1
path: src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
- -
- message: '#^Property WC_Email\:\:\$template_base \(string\) on left side of \?\? is not nullable\.$#'
- identifier: nullCoalesce.property
- count: 1
- path: src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
-
-
message: '#^Method Automattic\\WooCommerce\\Internal\\EmailEditor\\WCTransactionalEmails\\WCTransactionalEmailPostsManager\:\:clear_caches\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistry.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistry.php
new file mode 100644
index 00000000000..1adf5c87aa2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistry.php
@@ -0,0 +1,304 @@
+<?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\Logger;
+
+/**
+ * Registry of block email templates that participate in template update propagation (sync).
+ *
+ * Walks the set of emails registered with the block editor via
+ * {@see WCTransactionalEmails::get_transactional_emails()}, resolves each email's
+ * block template file, parses its `@version` header and records an entry for every
+ * email whose template exposes a parseable version. Emails without a parseable header
+ * are silently skipped with a warning log.
+ *
+ * The resolved registry is cached in a static property for the lifetime of the request.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ */
+class WCEmailTemplateSyncRegistry {
+ /**
+ * Email IDs that are considered first-party (core + POS + Fulfillments).
+ *
+ * Mirrors the first-party union in
+ * {@see WCTransactionalEmails::get_transactional_emails()} — this list is only
+ * used to classify the `source` field on registry entries, not to gate sync
+ * participation. The gate is {@see WCTransactionalEmails::get_transactional_emails()}
+ * (which already runs through `woocommerce_transactional_emails_for_block_editor`)
+ * combined with the presence of a parseable `@version` header.
+ *
+ * @var string[]|null
+ */
+ private static $first_party_ids = null;
+
+ /**
+ * Request-scoped cache of the resolved registry.
+ *
+ * @var array<string, array{version: string, template_path: string, source: string}>|null
+ */
+ private static $registry_cache = null;
+
+ /**
+ * Logger instance. Lazily instantiated on first use; overridable for tests.
+ *
+ * @var Email_Editor_Logger_Interface|null
+ */
+ private static $logger = null;
+
+ /**
+ * Return the registry of emails participating in template sync.
+ *
+ * @return array<string, array{version: string, template_path: string, source: string}>
+ * Map keyed by email ID. Each entry holds the parsed `@version`, the absolute
+ * template path, and a `source` classification (`core` for first-party emails,
+ * `third_party` otherwise).
+ *
+ * @since 10.8.0
+ */
+ public static function get_sync_enabled_emails(): array {
+ if ( null === self::$registry_cache ) {
+ self::$registry_cache = self::resolve();
+ }
+
+ return self::$registry_cache;
+ }
+
+ /**
+ * Return the sync config for a single email ID, or null when not in the registry.
+ *
+ * @param string $email_id The email ID.
+ * @return array{version: string, template_path: string, source: string}|null
+ *
+ * @since 10.8.0
+ */
+ public static function get_email_sync_config( string $email_id ): ?array {
+ $registry = self::get_sync_enabled_emails();
+
+ return $registry[ $email_id ] ?? null;
+ }
+
+ /**
+ * Whether the given email ID participates in template sync.
+ *
+ * @param string $email_id The email ID.
+ * @return bool
+ *
+ * @since 10.8.0
+ */
+ public static function is_enabled( string $email_id ): bool {
+ $registry = self::get_sync_enabled_emails();
+
+ return isset( $registry[ $email_id ] );
+ }
+
+ /**
+ * Reset the request-scoped registry cache.
+ *
+ * Intended for tests and for call sites that mutate the underlying inputs
+ * (e.g. toggling a feature flag) within the same request.
+ *
+ * @internal
+ *
+ * @since 10.8.0
+ */
+ public static function reset_cache(): void {
+ self::$registry_cache = null;
+ self::$first_party_ids = null;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Resolve the registry from scratch.
+ *
+ * @return array<string, array{version: string, template_path: string, source: string}>
+ */
+ private static function resolve(): array {
+ $eligible_ids = WCTransactionalEmails::get_transactional_emails();
+ if ( empty( $eligible_ids ) ) {
+ return array();
+ }
+
+ $wc_emails = \WC_Emails::instance();
+ $all_emails = $wc_emails->get_emails();
+
+ $registry = array();
+
+ foreach ( $eligible_ids as $email_id ) {
+ if ( ! is_string( $email_id ) || '' === $email_id ) {
+ continue;
+ }
+
+ $email = self::find_email_for_id( $all_emails, $email_id );
+ if ( null === $email ) {
+ self::get_logger()->notice(
+ sprintf(
+ 'Email template sync skipped for email "%s": no WC_Email subclass registered.',
+ $email_id
+ ),
+ array(
+ 'email_id' => $email_id,
+ 'context' => 'email_template_sync_registry',
+ )
+ );
+ continue;
+ }
+
+ $source = self::classify_source( $email_id );
+ $template_path = WCTransactionalEmailPostsGenerator::resolve_block_template_path( $email );
+
+ if ( '' === $template_path || ! is_readable( $template_path ) ) {
+ self::get_logger()->notice(
+ sprintf(
+ 'Email template sync skipped for email "%s": template path not resolvable. source=%s',
+ $email_id,
+ $source
+ ),
+ array(
+ 'email_id' => $email_id,
+ 'source' => $source,
+ 'template_path' => $template_path,
+ 'context' => 'email_template_sync_registry',
+ )
+ );
+ continue;
+ }
+
+ $version = self::parse_version_header( $template_path );
+
+ if ( '' === $version ) {
+ self::get_logger()->warning(
+ sprintf(
+ 'Email template sync skipped for email "%s": missing @version header in %s. source=%s',
+ $email_id,
+ $template_path,
+ $source
+ ),
+ array(
+ 'email_id' => $email_id,
+ 'source' => $source,
+ 'template_path' => $template_path,
+ 'context' => 'email_template_sync_registry',
+ )
+ );
+ continue;
+ }
+
+ $registry[ $email_id ] = array(
+ 'version' => $version,
+ 'template_path' => $template_path,
+ 'source' => $source,
+ );
+ }
+
+ 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.
+ *
+ * @param string $email_id The email ID.
+ * @return string Either `core` or `third_party`.
+ */
+ private static function classify_source( string $email_id ): string {
+ if ( null === self::$first_party_ids ) {
+ self::$first_party_ids = array_values( WCTransactionalEmails::get_core_transactional_emails() );
+ }
+
+ return in_array( $email_id, self::$first_party_ids, true ) ? 'core' : 'third_party';
+ }
+
+ /**
+ * Parse the `@version` header from a block email template file.
+ *
+ * Mirrors {@see \WC_Admin_Status::get_file_version()}: WordPress' native
+ * {@see get_file_data()} only understands `Name: Value` headers, but the
+ * email-editor templates (and the wider WooCommerce template contract)
+ * document their version as a PHPDoc `@version X.Y.Z` tag, which is
+ * whitespace-separated. We reuse the exact regex used by the existing
+ * helper so core and third-party templates are parsed consistently.
+ *
+ * @param string $file Absolute path to the template file.
+ * @return string The parsed version, or an empty string if none is declared.
+ */
+ private static function parse_version_header( string $file ): string {
+ if ( ! is_readable( $file ) ) {
+ return '';
+ }
+
+ // Only read the first 8KiB — headers are always near the top of the file.
+ $handle = fopen( $file, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
+ if ( false === $handle ) {
+ return '';
+ }
+
+ $contents = fread( $handle, 8192 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
+ fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
+
+ if ( false === $contents ) {
+ return '';
+ }
+
+ // Normalize CR-only line endings so the multi-line regex behaves consistently.
+ $contents = str_replace( "\r", "\n", $contents );
+
+ /*
+ * Matches a PHPDoc-style `@version X.Y.Z` tag allowing the usual
+ * comment-leader characters (` * `, `#`, `@`, tabs) before it, on any
+ * line in the header block. Identical to the long-standing pattern in
+ * WC_Admin_Status::get_file_version() — see docblock above for why
+ * get_file_data() isn't a drop-in replacement here.
+ */
+ if ( preg_match( '/^[ \t\/*#@]*' . preg_quote( '@version', '/' ) . '(.*)$/mi', $contents, $match ) && ! empty( $match[1] ) ) {
+ return trim( _cleanup_header_comment( $match[1] ) );
+ }
+
+ return '';
+ }
+
+ /**
+ * 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/WCTransactionalEmailPostsGenerator.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
index 762fb938697..238432cd7c4 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
@@ -107,6 +107,55 @@ class WCTransactionalEmailPostsGenerator {
);
}
+ /**
+ * Resolve the block template name for the given email.
+ *
+ * Returns `$email->template_block` when set, otherwise derives it from
+ * `$email->template_plain` by replacing the `plain` segment with `block`
+ * (e.g. `emails/plain/customer-invoice.php` becomes `emails/block/customer-invoice.php`).
+ *
+ * @param \WC_Email $email The email object.
+ * @return string The block template name, or an empty string if none can be resolved.
+ *
+ * @since 10.8.0
+ */
+ public static function resolve_block_template_name( $email ): string {
+ if ( ! empty( $email->template_block ) ) {
+ return (string) $email->template_block;
+ }
+
+ $template_plain = (string) $email->template_plain;
+ if ( '' === $template_plain ) {
+ return '';
+ }
+
+ return str_replace( 'plain', 'block', $template_plain );
+ }
+
+ /**
+ * Resolve the absolute path of the block template for the given email.
+ *
+ * Uses {@see self::resolve_block_template_name()} for name resolution and then
+ * delegates to `wc_locate_template()` so theme overrides are honored.
+ *
+ * @param \WC_Email $email The email object.
+ * @return string The absolute template path, or an empty string if none can be resolved.
+ *
+ * @since 10.8.0
+ */
+ public static function resolve_block_template_path( $email ): string {
+ $template_name = self::resolve_block_template_name( $email );
+ if ( '' === $template_name ) {
+ return '';
+ }
+
+ return (string) wc_locate_template(
+ $template_name,
+ '',
+ (string) $email->template_base
+ );
+ }
+
/**
* Get the email template for the given email.
*
@@ -116,14 +165,14 @@ class WCTransactionalEmailPostsGenerator {
* @return string The email template.
*/
public function get_email_template( $email ) {
- $template_name = ! empty( $email->template_block ) ? $email->template_block : str_replace( 'plain', 'block', $email->template_plain );
+ $template_name = self::resolve_block_template_name( $email );
try {
$template_html = wc_get_template_html(
$template_name,
array(),
'',
- $email->template_base ?? ''
+ (string) $email->template_base
);
} catch ( \Exception $e ) {
// wc_get_template_html() uses ob_start(), so we need to clean the output buffer if an exception is thrown.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
index 97fff02f872..0c58cd3b9bb 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
@@ -63,11 +63,11 @@ class WCTransactionalEmails {
}
/**
- * Get the Core WooCommerce transactional emails for the block editor.
+ * Get the core transactional emails.
*
* @return array
*/
- public static function get_transactional_emails() {
+ public static function get_core_transactional_emails() {
$emails = self::$core_transactional_emails;
if ( FeaturesUtil::feature_is_enabled( 'point_of_sale' ) ) {
@@ -84,6 +84,17 @@ class WCTransactionalEmails {
$emails = array_merge( $emails, $fulfillment_emails );
}
+ return $emails;
+ }
+
+ /**
+ * Get the Core WooCommerce transactional emails for the block editor.
+ *
+ * @return array
+ */
+ public static function get_transactional_emails() {
+ $emails = self::get_core_transactional_emails();
+
/**
* Filter the transactional emails for the block editor.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistryTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistryTest.php
new file mode 100644
index 00000000000..658b009e3cc
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncRegistryTest.php
@@ -0,0 +1,496 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
+
+/**
+ * Tests for the WCEmailTemplateSyncRegistry class.
+ */
+class WCEmailTemplateSyncRegistryTest extends \WC_Unit_Test_Case {
+ /**
+ * Absolute path to the fixtures directory.
+ *
+ * @var string
+ */
+ private string $fixtures_base;
+
+ /**
+ * In-memory logger used to capture log calls across tests.
+ *
+ * @var object
+ */
+ private $logger_spy;
+
+ /**
+ * IDs injected into \WC_Emails::instance()->emails during the current test.
+ *
+ * @var string[]
+ */
+ private array $injected_email_keys = array();
+
+ /**
+ * Setup test case.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ add_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+
+ $this->fixtures_base = __DIR__ . '/fixtures/';
+
+ $this->logger_spy = $this->create_capturing_logger();
+ WCEmailTemplateSyncRegistry::set_logger( $this->logger_spy );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+ }
+
+ /**
+ * Cleanup after each test.
+ */
+ public function tearDown(): void {
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateSyncRegistry::set_logger( null );
+
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+
+ $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();
+
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test that the registry exposes core emails with source=core and a parseable version.
+ *
+ * Every core email that is actually loaded into the WC_Emails registry during this test
+ * run must appear in the sync registry with `source = 'core'` and a non-empty `@version`.
+ * `customer_partially_refunded_order` is gated by a feature flag whose state we cannot
+ * reliably flip after WC_Emails has been initialised, so we only assert on emails that
+ * are actually registered in the current process.
+ */
+ public function test_core_emails_populate_registry_with_core_source(): void {
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+ $registered_email_ids = array_map(
+ static fn ( \WC_Email $email ): string => (string) $email->id,
+ array_values( \WC_Emails::instance()->get_emails() )
+ );
+ $expected_emails_in_test = array_intersect(
+ WCTransactionalEmails::$core_transactional_emails,
+ $registered_email_ids
+ );
+
+ $this->assertNotEmpty(
+ $expected_emails_in_test,
+ 'Expected at least one core email to be registered with WC_Emails during the test.'
+ );
+
+ foreach ( $expected_emails_in_test as $email_id ) {
+ $this->assertArrayHasKey(
+ $email_id,
+ $registry,
+ sprintf( 'Expected core email "%s" to be in the sync registry.', $email_id )
+ );
+ $this->assertSame( 'core', $registry[ $email_id ]['source'] );
+ $this->assertNotSame( '', $registry[ $email_id ]['version'] );
+ $this->assertFileExists( $registry[ $email_id ]['template_path'] );
+ }
+
+ foreach ( $registry as $email_id => $entry ) {
+ if ( 'core' === $entry['source'] ) {
+ $this->assertContains(
+ $email_id,
+ $registered_email_ids,
+ sprintf( 'Core-classified email "%s" must be registered with WC_Emails.', $email_id )
+ );
+ }
+ }
+ }
+
+ /**
+ * Test that a third-party email added via the filter lands in the registry with source=third_party.
+ */
+ public function test_third_party_email_with_version_header_is_registered(): void {
+ $this->register_third_party_email(
+ 'third_party_with_version',
+ 'block/third-party-with-version.php',
+ null
+ );
+
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+
+ $this->assertArrayHasKey( 'third_party_with_version', $registry );
+ $this->assertSame( 'third_party', $registry['third_party_with_version']['source'] );
+ $this->assertSame( '1.2.3', $registry['third_party_with_version']['version'] );
+ $this->assertSame(
+ $this->fixtures_base . 'block/third-party-with-version.php',
+ $registry['third_party_with_version']['template_path']
+ );
+ }
+
+ /**
+ * Test that a third-party email without a @version header is silently skipped and a warning is logged.
+ */
+ public function test_third_party_email_without_version_is_skipped_and_warned(): void {
+ $this->register_third_party_email(
+ 'third_party_no_version',
+ 'block/third-party-without-version.php',
+ null
+ );
+
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+
+ $this->assertArrayNotHasKey( 'third_party_no_version', $registry );
+ $this->assertLoggedLevelMatches(
+ 'warning',
+ '/Email template sync skipped for email "third_party_no_version": missing @version header/'
+ );
+ }
+
+ /**
+ * Test that an email with an unresolvable template path is skipped with a notice.
+ */
+ public function test_unresolvable_template_path_is_skipped_with_notice(): void {
+ $this->register_third_party_email(
+ 'third_party_missing_file',
+ 'block/does-not-exist.php',
+ null
+ );
+
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+
+ $this->assertArrayNotHasKey( 'third_party_missing_file', $registry );
+ $this->assertLoggedLevelMatches(
+ 'notice',
+ '/Email template sync skipped for email "third_party_missing_file": template path not resolvable/'
+ );
+ }
+
+ /**
+ * Test that an email ID registered via the filter without a matching WC_Email subclass is skipped.
+ */
+ public function test_email_without_wc_email_subclass_is_skipped(): void {
+ add_filter(
+ 'woocommerce_transactional_emails_for_block_editor',
+ static function ( array $emails ): array {
+ $emails[] = 'phantom_email_without_class';
+ return $emails;
+ }
+ );
+
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+
+ $this->assertArrayNotHasKey( 'phantom_email_without_class', $registry );
+ $this->assertLoggedLevelMatches(
+ 'notice',
+ '/Email template sync skipped for email "phantom_email_without_class": no WC_Email subclass registered/'
+ );
+ }
+
+ /**
+ * Test that the registry resolves the block template via the plain -> block fallback
+ * for emails that set template_plain but not template_block.
+ */
+ public function test_plain_to_block_template_name_fallback(): void {
+ $this->register_third_party_email(
+ 'third_party_fallback',
+ null,
+ 'plain/test-fallback.php'
+ );
+
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+
+ $this->assertArrayHasKey( 'third_party_fallback', $registry );
+ $this->assertSame( '2.0.0', $registry['third_party_fallback']['version'] );
+ $this->assertSame(
+ $this->fixtures_base . 'block/test-fallback.php',
+ $registry['third_party_fallback']['template_path']
+ );
+ }
+
+ /**
+ * Test that the resolved registry is cached for the lifetime of the request.
+ */
+ public function test_registry_is_cached_across_calls(): void {
+ $first = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+ $second = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+
+ $this->assertSame( $first, $second );
+
+ $this->register_third_party_email(
+ 'third_party_with_version',
+ 'block/third-party-with-version.php',
+ null
+ );
+
+ $third = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+ $this->assertArrayNotHasKey( 'third_party_with_version', $third, 'Second call should return cached data and not include the newly added email.' );
+ }
+
+ /**
+ * Test that reset_cache() forces re-resolution on the next call.
+ */
+ public function test_reset_cache_forces_rebuild(): void {
+ WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+
+ $this->register_third_party_email(
+ 'third_party_with_version',
+ 'block/third-party-with-version.php',
+ null
+ );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+ $this->assertArrayHasKey( 'third_party_with_version', $registry );
+ }
+
+ /**
+ * Test the get_email_sync_config() and is_enabled() helpers.
+ */
+ public function test_convenience_helpers_return_expected_values(): void {
+ $this->register_third_party_email(
+ 'third_party_with_version',
+ 'block/third-party-with-version.php',
+ null
+ );
+
+ $this->assertTrue( WCEmailTemplateSyncRegistry::is_enabled( 'third_party_with_version' ) );
+ $this->assertFalse( WCEmailTemplateSyncRegistry::is_enabled( 'unknown_email_id' ) );
+
+ $config = WCEmailTemplateSyncRegistry::get_email_sync_config( 'third_party_with_version' );
+ $this->assertIsArray( $config );
+ $this->assertSame( '1.2.3', $config['version'] );
+ $this->assertSame( 'third_party', $config['source'] );
+
+ $this->assertNull( WCEmailTemplateSyncRegistry::get_email_sync_config( 'unknown_email_id' ) );
+ }
+
+ /**
+ * Test the extracted template-name resolver covers both branches.
+ */
+ public function test_resolve_block_template_name_covers_both_branches(): void {
+ $with_block = $this->createMock( \WC_Email::class );
+ $with_block->template_block = 'emails/block/custom.php';
+ $with_block->template_plain = 'emails/plain/ignored.php';
+
+ $this->assertSame(
+ 'emails/block/custom.php',
+ WCTransactionalEmailPostsGenerator::resolve_block_template_name( $with_block )
+ );
+
+ $plain_only = $this->createMock( \WC_Email::class );
+ $plain_only->template_plain = 'emails/plain/customer-invoice.php';
+
+ $this->assertSame(
+ 'emails/block/customer-invoice.php',
+ WCTransactionalEmailPostsGenerator::resolve_block_template_name( $plain_only )
+ );
+
+ $empty = $this->createMock( \WC_Email::class );
+ $empty->template_plain = '';
+
+ $this->assertSame(
+ '',
+ WCTransactionalEmailPostsGenerator::resolve_block_template_name( $empty )
+ );
+ }
+
+ /**
+ * Register a stub third-party WC_Email instance and hook the given ID into the
+ * block-editor filter so the registry considers it eligible.
+ *
+ * @param string $email_id Email ID to expose on the stub.
+ * @param string|null $template_block Block template name relative to the fixtures dir, or null.
+ * @param string|null $template_plain Plain template name relative to the fixtures dir, or null.
+ */
+ private function register_third_party_email( string $email_id, ?string $template_block, ?string $template_plain ): void {
+ $stub = $this->getMockBuilder( \WC_Email::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $stub->id = $email_id;
+ $stub->template_base = $this->fixtures_base;
+ $stub->template_block = $template_block;
+ $stub->template_plain = $template_plain;
+
+ $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;
+ }
+ );
+ }
+
+ /**
+ * Assert that at least one log call was recorded at the given level with a matching message.
+ *
+ * @param string $level Log level (e.g. warning, notice).
+ * @param string $message_regex Regex pattern to match against the logged message.
+ */
+ private function assertLoggedLevelMatches( string $level, string $message_regex ): void {
+ $matches = array_filter(
+ $this->logger_spy->records,
+ static fn ( array $record ) => $record['level'] === $level && (bool) preg_match( $message_regex, $record['message'] )
+ );
+
+ $this->assertNotEmpty(
+ $matches,
+ sprintf(
+ 'Expected at least one "%s" log matching %s. Recorded: %s',
+ $level,
+ $message_regex,
+ wp_json_encode( $this->logger_spy->records )
+ )
+ );
+ }
+
+ /**
+ * Create an in-memory logger that records every call for later assertions.
+ *
+ * @return object
+ */
+ private function create_capturing_logger() {
+ return new class() implements Email_Editor_Logger_Interface {
+ /**
+ * Recorded log events.
+ *
+ * @var array<int, array{level: string, message: string, context: array}>
+ */
+ public array $records = array();
+
+ /**
+ * Emergency log.
+ *
+ * @param string $message Log message.
+ * @param array $context Log context.
+ * @return void
+ */
+ public function emergency( string $message, array $context = array() ): void {
+ $this->log( 'emergency', $message, $context );
+ }
+
+ /**
+ * Alert log.
+ *
+ * @param string $message Log message.
+ * @param array $context Log context.
+ * @return void
+ */
+ public function alert( string $message, array $context = array() ): void {
+ $this->log( 'alert', $message, $context );
+ }
+
+ /**
+ * Critical log.
+ *
+ * @param string $message Log message.
+ * @param array $context Log context.
+ * @return void
+ */
+ public function critical( string $message, array $context = array() ): void {
+ $this->log( 'critical', $message, $context );
+ }
+
+ /**
+ * Error log.
+ *
+ * @param string $message Log message.
+ * @param array $context Log context.
+ * @return void
+ */
+ public function error( string $message, array $context = array() ): void {
+ $this->log( 'error', $message, $context );
+ }
+
+ /**
+ * Warning log.
+ *
+ * @param string $message Log message.
+ * @param array $context Log context.
+ * @return void
+ */
+ public function warning( string $message, array $context = array() ): void {
+ $this->log( 'warning', $message, $context );
+ }
+
+ /**
+ * Notice log.
+ *
+ * @param string $message Log message.
+ * @param array $context Log context.
+ * @return void
+ */
+ public function notice( string $message, array $context = array() ): void {
+ $this->log( 'notice', $message, $context );
+ }
+
+ /**
+ * Info log.
+ *
+ * @param string $message Log message.
+ * @param array $context Log context.
+ * @return void
+ */
+ public function info( string $message, array $context = array() ): void {
+ $this->log( 'info', $message, $context );
+ }
+
+ /**
+ * Debug log.
+ *
+ * @param string $message Log message.
+ * @param array $context Log context.
+ * @return void
+ */
+ public function debug( string $message, array $context = array() ): void {
+ $this->log( 'debug', $message, $context );
+ }
+
+ /**
+ * Arbitrary-level log.
+ *
+ * @param string $level Log level.
+ * @param string $message Log message.
+ * @param array $context Log context.
+ * @return void
+ */
+ public function log( string $level, string $message, array $context = array() ): void {
+ $this->records[] = array(
+ 'level' => $level,
+ 'message' => $message,
+ 'context' => $context,
+ );
+ }
+ };
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/block/test-fallback.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/block/test-fallback.php
new file mode 100644
index 00000000000..642085e258a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/block/test-fallback.php
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Fallback block email template fixture used to verify the plain -> block path derivation.
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails\fixtures
+ * @version 2.0.0
+ */
+
+declare( strict_types=1 );
+
+defined( 'ABSPATH' ) || exit;
+?>
+<!-- wp:paragraph -->
+<p>Fallback block email fixture.</p>
+<!-- /wp:paragraph -->
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/block/third-party-with-version.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/block/third-party-with-version.php
new file mode 100644
index 00000000000..5af04bbee9e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/block/third-party-with-version.php
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Third-party block email template fixture (with @version).
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails\fixtures
+ * @version 1.2.3
+ */
+
+declare( strict_types=1 );
+
+defined( 'ABSPATH' ) || exit;
+?>
+<!-- wp:paragraph -->
+<p>Third-party email fixture.</p>
+<!-- /wp:paragraph -->
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/block/third-party-without-version.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/block/third-party-without-version.php
new file mode 100644
index 00000000000..077f80289e0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/block/third-party-without-version.php
@@ -0,0 +1,14 @@
+<?php
+/**
+ * Third-party block email template fixture (no @version header).
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails\fixtures
+ */
+
+declare( strict_types=1 );
+
+defined( 'ABSPATH' ) || exit;
+?>
+<!-- wp:paragraph -->
+<p>Third-party email fixture without version.</p>
+<!-- /wp:paragraph -->
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/plain/test-fallback.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/plain/test-fallback.php
new file mode 100644
index 00000000000..ee6bb3b2018
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/fixtures/plain/test-fallback.php
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Plain-text fallback email template fixture.
+ *
+ * @package Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails\fixtures
+ */
+
+declare( strict_types=1 );
+
+defined( 'ABSPATH' ) || exit;
+
+echo "Fallback plain email fixture.\n";