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";