Commit 5099a453396 for woocommerce

commit 5099a4533960c04218c198fbf34a2b8db38ad643
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date:   Fri Aug 7 18:35:27 2026 +0200

    Block Emails - Use file templates until an email is edited and saved (#67025)

    * Add postless rendering support to the email editor package

    Consumers need to render email content that has no database record, e.g.
    WooCommerce rendering transactional emails directly from file-based
    templates. Add Renderer::render_from_content(), which takes block markup
    and a required template slug and wraps the markup in a synthetic post
    (ID 0) internally. The rendering pipeline treats ID 0 as 'no database
    record': the rendering globals and the post-content block read from the
    post object instead of querying the database (a query for post 0 would
    fetch latest posts and leak loop globals into the output).

    * Add customSaveButton support to the email editor JS package

    Integrations replacing the editor's Publish flow (e.g. a Save button that
    publishes in the background) need a supported way to swap the header
    button. Gutenberg's Editor already accepts customSaveButton, so thread it
    through Editor/ExperimentalEmailEditor/InnerEditor the same way as the
    existing customSavePanel prop, skipping template mode. The editor-save
    notice override now also matches core's 'Post published.' and
    'Draft saved.' so background publishes surface as 'Email saved.'

    * Render block emails from file templates until edited and saved

    Bulk-generating woo_email posts on initialization froze email content:
    template updates never propagated and content was locked to the locale
    active at generation time. Follow the Site Editor pattern instead — the
    file template is the rendering source of truth until the user customizes
    an email. Posts are created lazily as drafts when the editor is opened
    (registered email types only), and the email type mapping is written
    when the post is published, which is also when it becomes the rendering
    source. Emails without a published post render from the canonical file
    template content. A regular draft is used instead of an auto-draft
    because the editor treats auto-draft titles as placeholders and blanks
    them.

    The bulk-generation entry points stay as deprecated shims because the
    Internal namespace is not a guarantee that nothing external calls them:
    the lifecycle methods are no-ops that trigger a deprecation notice, and
    generate_email_template_if_not_exists() keeps its contract by creating a
    published, mapped post directly. All are slated for removal in a future
    version.

    * Update email listing and editor UI for lazy email post creation

    With posts no longer pre-generated, the listing's Edit, Send test and
    Edit template actions create the post on demand before navigating or
    sending, and Preview is limited to published posts (a permalink only
    shows saved content). In the editor, an unpublished post gets a Save
    button that publishes it in the background, so every explicit save keeps
    making the content live, matching how saving an email always behaved.
    Once the post is published, core's stock save flow takes over again,
    including the multi-entity save panel when e.g. template changes are
    pending alongside content changes.

    * Delete never-customized block email posts on upgrade (#67166)

    Add migration deleting never-customized block email posts

    With file templates as the rendering source until an email is customized,
    previously bulk-generated posts that were never touched only freeze
    outdated content on existing stores. A one-shot db update deletes copies
    that are provably uncustomized (canonical content match, untouched sync
    source hash, or never-edited timestamps) so those emails pick up template
    updates and the current site locale; customized posts are kept and
    stamped with the email type meta used by lazy creation.

    * Re-add listing Preview for emails without a saved post (#67167)

    Add listing preview for block emails without a saved post

    With file-first rendering the listing Preview action was hidden for any
    email without a published post, because it opens the post permalink and
    only published posts render. Reuse the postless send-test render chain
    (canonical file template, preview order context, personalization) behind
    a nonce-gated admin preview page, and point the Preview action at it for
    rows without a published post — the preview always shows what customers
    receive, consistent with the send-test behavior.

diff --git a/docs/features/email/email-editor-integration.md b/docs/features/email/email-editor-integration.md
index 7115e5a61ce..1b28e8837f8 100644
--- a/docs/features/email/email-editor-integration.md
+++ b/docs/features/email/email-editor-integration.md
@@ -122,13 +122,11 @@ add_filter( 'woocommerce_transactional_emails_for_block_editor', 'your_plugin_re

 **Important:** Without this step, your email may still appear in the email list, but it will not use the email editor, as explicit opt-in is required from third-party developers.

-**Note:** For third-party extensions, WooCommerce will not create an email post unless you opt-in using the `woocommerce_transactional_emails_for_block_editor` filter.
+**Note:** For third-party extensions, WooCommerce will not create an email post unless you opt-in using the `woocommerce_transactional_emails_for_block_editor` filter. Email posts are created lazily. A draft post is created only when the user opens the email in the editor. Until that draft is published, it is not the rendering source: the email renders directly from the file template, regardless of the draft's content.

-**Development tip:** WooCommerce caches email post-generation with a transient. When testing or developing, delete the transient `wc_email_editor_initial_templates_generated` to force post-generation.
+### Customizing email template post creation

-### Customizing email template post generation
-
-You can modify the email template post data before it's created using the `woocommerce_email_content_post_data` filter. This allows you to customize the post title, content, meta, or any other post data during template generation.
+You can modify the email post data before it's created using the `woocommerce_email_content_post_data` filter. This allows you to customize the post title, content, meta, or any other post data when the post is created (that is, when the user first opens the email in the editor). The filtered content is also what renders when no saved post exists yet. Note that `post_status` is system-owned and a value returned by the filter is ignored: posts are created as `draft` and only become the rendering source when published.

 **Filter details:**

@@ -183,7 +181,7 @@ add_filter( 'woocommerce_email_content_post_data', 'your_plugin_customize_email_

 **Important notes:**

--   You can modify any valid `wp_insert_post()` parameter (`post_title`, `post_content`, `post_excerpt`, `post_status`, `post_name`, `meta_input`, etc.).
+-   You can modify any valid `wp_insert_post()` parameter (`post_title`, `post_content`, `post_excerpt`, `post_name`, `meta_input`, etc.) — except `post_status`, which is system-owned and ignored.
 -   Always return the modified `$post_data` array.
 -   When modifying `post_content`, ensure valid block markup is maintained.
 -   The filter runs for all email types; check `$email_type` to target specific emails.
diff --git a/packages/js/email-editor/changelog/wooplug-6171-custom-save-button b/packages/js/email-editor/changelog/wooplug-6171-custom-save-button
new file mode 100644
index 00000000000..c44313c6335
--- /dev/null
+++ b/packages/js/email-editor/changelog/wooplug-6171-custom-save-button
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add optional customSaveButton prop to Editor/ExperimentalEmailEditor (threaded to the Gutenberg editor header, skipped in template mode) and extend the editor-save notice override to also match "Post published." and "Draft saved." for integrations whose save button publishes in the background.
diff --git a/packages/js/email-editor/src/components/block-editor/editor.tsx b/packages/js/email-editor/src/components/block-editor/editor.tsx
index a1f136454a1..00ea383c25e 100644
--- a/packages/js/email-editor/src/components/block-editor/editor.tsx
+++ b/packages/js/email-editor/src/components/block-editor/editor.tsx
@@ -55,21 +55,21 @@ export function InnerEditor( {
 	settings,
 	contentRef,
 	customSavePanel,
+	customSaveButton,
 }: {
 	postId: number | string;
 	postType: string;
 	settings: Record< string, unknown >;
 	contentRef?: React.Ref< HTMLDivElement > | null;
 	customSavePanel?: React.ReactElement;
+	customSaveButton?: React.ReactElement;
 } ) {
 	const {
 		currentPost,
 		onNavigateToEntityRecord,
 		onNavigateToPreviousEntityRecord,
 	} = useNavigateToEntityRecord(
-		// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
 		initialPostId,
-		// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
 		initialPostType,
 		'post-only'
 	);
@@ -130,7 +130,6 @@ export function InnerEditor( {
 	const [ styles ] = useEmailCss();

 	const editorSettings = useMemo(
-		// eslint-disable-next-line @typescript-eslint/no-unsafe-return
 		() => ( {
 			...settings,
 			onNavigateToEntityRecord,
@@ -184,6 +183,11 @@ export function InnerEditor( {
 					contentRef={ contentRef }
 					styles={ styles } // This is needed for BC for Gutenberg below v22
 					customSavePanel={ customSavePanel }
+					customSaveButton={
+						currentPost.postType === 'wp_template'
+							? undefined
+							: customSaveButton
+					}
 				>
 					<AutosaveMonitor />
 					<LocalAutosaveMonitor />
diff --git a/packages/js/email-editor/src/editor.tsx b/packages/js/email-editor/src/editor.tsx
index 30552f20658..251e4b3cbb2 100644
--- a/packages/js/email-editor/src/editor.tsx
+++ b/packages/js/email-editor/src/editor.tsx
@@ -45,12 +45,14 @@ function Editor( {
 	isPreview = false,
 	contentRef = null,
 	customSavePanel,
+	customSaveButton,
 }: {
 	postId: number | string;
 	postType: string;
 	isPreview?: boolean;
 	contentRef?: React.Ref< HTMLDivElement > | null;
 	customSavePanel?: React.ReactElement;
+	customSaveButton?: React.ReactElement;
 } ) {
 	const [ isInitialized, setIsInitialized ] = useState( false );
 	const { settings } = useSelect(
@@ -98,6 +100,7 @@ function Editor( {
 				settings={ editorSettings }
 				contentRef={ mergedContentRef }
 				customSavePanel={ customSavePanel }
+				customSaveButton={ customSaveButton }
 			/>
 		</StrictMode>
 	);
@@ -182,6 +185,7 @@ export function ExperimentalEmailEditor( {
 	contentRef = null,
 	config,
 	customSavePanel,
+	customSaveButton,
 }: {
 	postId: string;
 	postType: string;
@@ -189,6 +193,7 @@ export function ExperimentalEmailEditor( {
 	contentRef?: React.Ref< HTMLDivElement > | null;
 	config?: EmailEditorConfig;
 	customSavePanel?: React.ReactElement;
+	customSaveButton?: React.ReactElement;
 } ) {
 	const [ isInitialized, setIsInitialized ] = useState( false );

@@ -228,6 +233,7 @@ export function ExperimentalEmailEditor( {
 			isPreview={ isPreview }
 			contentRef={ contentRef }
 			customSavePanel={ customSavePanel }
+			customSaveButton={ customSaveButton }
 		/>
 	);
 }
diff --git a/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts b/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
index 8a9f1efe561..de7619c0a10 100644
--- a/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
+++ b/packages/js/email-editor/src/hooks/test/use-notice-overrides.spec.ts
@@ -144,6 +144,55 @@ describe( 'useNoticeOverrides — memoized selector stability', () => {
 		expect( result[ 0 ].content ).toBe( 'Email saved.' );
 	} );

+	it( 'transforms an editor-save notice with "Post published." content', () => {
+		// Emitted when an integration's save button publishes the post in the
+		// background (lazy post creation) instead of a plain update.
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Post published.',
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ] );
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Email saved.' );
+	} );
+
+	it( 'leaves an editor-save notice with "Draft saved." content unchanged', () => {
+		// A saved draft is not used for sending; rewriting the notice to
+		// "Email saved." would suggest the opposite.
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Draft saved.',
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ] );
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Draft saved.' );
+	} );
+
+	it( 'leaves an editor-save notice with unrelated content unchanged', () => {
+		const originalNotice = makeNotice( {
+			id: 'editor-save',
+			content: 'Saving failed.',
+		} );
+		const { pluginResult } = buildSelectOverride( [ originalNotice ] );
+
+		const selectors = pluginResult.select( 'core/notices' ) as {
+			getNotices: () => Notice[];
+		};
+		const result = selectors.getNotices();
+
+		expect( result[ 0 ].content ).toBe( 'Saving failed.' );
+	} );
+
 	it( 'transforms site-editor-save-success notice and removes actions', () => {
 		const originalNotice = makeNotice( {
 			id: 'site-editor-save-success',
diff --git a/packages/js/email-editor/src/hooks/use-notice-overrides.ts b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
index ed6fa5e3f15..5b92698fd10 100644
--- a/packages/js/email-editor/src/hooks/use-notice-overrides.ts
+++ b/packages/js/email-editor/src/hooks/use-notice-overrides.ts
@@ -30,10 +30,12 @@ function getNoticeOverrides(): Record< string, NoticeOverride > {
 		'editor-save': {
 			content: __( 'Email saved.', __i18n_text_domain__ ),
 			removeActions: false,
+			// "Draft saved." is intentionally NOT rewritten: a saved draft is
+			// not used for sending, and "Email saved." would suggest it is.
 			contentCheck: ( content: string ) =>
-				// Intentionally without text domain to match the core translation.
-				// eslint-disable-next-line @wordpress/i18n-text-domain
-				content.includes( __( 'Post updated.' ) ),
+				// Intentionally without text domain to match the core translations.
+				content.includes( __( 'Post updated.' ) ) ||
+				content.includes( __( 'Post published.' ) ),
 		},
 	};
 }
diff --git a/packages/php/email-editor/changelog/wooplug-6171-send-preview-without-post b/packages/php/email-editor/changelog/wooplug-6171-send-preview-without-post
new file mode 100644
index 00000000000..33b27bb907b
--- /dev/null
+++ b/packages/php/email-editor/changelog/wooplug-6171-send-preview-without-post
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Allow integrations to authorize and handle send-preview requests without a backing post
diff --git a/packages/php/email-editor/changelog/wooplug-6171-synthetic-post-template-globals b/packages/php/email-editor/changelog/wooplug-6171-synthetic-post-template-globals
new file mode 100644
index 00000000000..466cc0ef473
--- /dev/null
+++ b/packages/php/email-editor/changelog/wooplug-6171-synthetic-post-template-globals
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add Renderer::render_from_content() for rendering block markup that has no backing post, such as file-based templates. See docs/rendering.md.
diff --git a/packages/php/email-editor/docs/rendering.md b/packages/php/email-editor/docs/rendering.md
index 703bc2f80cb..bf04d3f5717 100644
--- a/packages/php/email-editor/docs/rendering.md
+++ b/packages/php/email-editor/docs/rendering.md
@@ -118,6 +118,45 @@ $html_content = $rendered_email['html'];
 $text_content = $rendered_email['text'];
 ```

+#### Rendering without a saved post
+
+Use `render_from_content()` to render block markup that has no database record.
+
+```php
+/**
+ * Renders block markup that has no backing post.
+ *
+ * @param string $content       Block HTML markup to render.
+ * @param string $template_slug Block template slug to render the content with.
+ * @param string $subject Email subject.
+ * @param string $pre_header An email preheader or preview text.
+ * @param string $language Email language.
+ * @param string $meta_robots Optional meta robots value for browser display.
+ * @return array
+ */
+public function render_from_content(
+    string $content,
+    string $template_slug,
+    string $subject,
+    string $pre_header,
+    string $language = 'en',
+    string $meta_robots = ''
+): array
+```
+
+**Example Usage:**
+
+```php
+$rendered_email = $renderer->render_from_content(
+    $block_markup,
+    'my-email-template-slug',
+    'Order Confirmation',
+    'Your order has been confirmed'
+);
+```
+
+Internally the renderer wraps the markup in a synthetic `WP_Post` with `ID === 0`; the rendering pipeline treats that ID as "no database record" and reads everything from the post object.
+
 ### Content_Renderer

 The `Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Content_Renderer` class is responsible for rendering only the HTML of block template content and a post. The block template has to contain a `core/post-content` block.
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
index 0e7464f2748..d1177f100d7 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
@@ -499,6 +499,9 @@ class Content_Renderer {
 	/**
 	 * Set template globals
 	 *
+	 * Supports synthetic posts (`ID === 0`, no DB record): the globals are
+	 * populated from the post object itself without running a query.
+	 *
 	 * @param WP_Post           $email_post Post object.
 	 * @param WP_Block_Template $template Block template.
 	 * @return void
@@ -515,8 +518,18 @@ class Content_Renderer {

 		$_wp_current_template_id      = $template->id;
 		$_wp_current_template_content = $template->content;
-		$wp_query                     = new \WP_Query( array( 'p' => $email_post->ID ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- We need to set the query for correct rendering the blocks.
-		$post                         = $email_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- We need to set the post for correct rendering the blocks.
+		if ( $email_post->ID > 0 ) {
+			$wp_query = new \WP_Query( array( 'p' => $email_post->ID ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- We need to set the query for correct rendering the blocks.
+		} else {
+			// Synthetic post (e.g. file-template rendering): querying `p => 0` would
+			// run a real "latest posts" query, so populate an empty query manually.
+			$wp_query              = new \WP_Query(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- We need to set the query for correct rendering the blocks.
+			$wp_query->post        = $email_post;
+			$wp_query->posts       = array( $email_post );
+			$wp_query->post_count  = 1;
+			$wp_query->found_posts = 1;
+		}
+		$post = $email_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- We need to set the post for correct rendering the blocks.
 	}

 	/**
diff --git a/packages/php/email-editor/src/Engine/Renderer/class-renderer.php b/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
index 816c0b16927..9e1bab33466 100644
--- a/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/class-renderer.php
@@ -102,6 +102,9 @@ class Renderer {
 	/**
 	 * Renders the email template
 	 *
+	 * To render block markup that has no backing post (e.g. file-based
+	 * templates), use {@see self::render_from_content()} instead.
+	 *
 	 * @param \WP_Post $post Post object.
 	 * @param string   $subject Email subject.
 	 * @param string   $pre_header An email preheader or preview text is the short snippet of text that follows the subject line in an inbox. See https://kb.mailpoet.com/article/418-preview-text.
@@ -183,6 +186,35 @@ class Renderer {
 		}
 	}

+	/**
+	 * Renders block markup that has no backing post.
+	 *
+	 * Use this for content that only exists outside the database. A synthetic
+	 * post (ID 0) is built internally, and because there is no post to carry
+	 * a template association, the block template slug must always be provided.
+	 *
+	 * @param string $content       Block HTML markup to render.
+	 * @param string $template_slug Block template slug to render the content with.
+	 * @param string $subject Email subject.
+	 * @param string $pre_header An email preheader or preview text is the short snippet of text that follows the subject line in an inbox. See https://kb.mailpoet.com/article/418-preview-text.
+	 * @param string $language Email language.
+	 * @param string $meta_robots Optional string. Can be left empty for sending, but you can provide a value (e.g. noindex, nofollow) when you want to display email html in a browser.
+	 * @return array
+	 *
+	 * @since 2.16.0
+	 */
+	public function render_from_content( string $content, string $template_slug, string $subject, string $pre_header, string $language = 'en', string $meta_robots = '' ): array {
+		$synthetic_post = new \WP_Post(
+			(object) array(
+				'ID'           => 0,
+				'post_status'  => 'publish',
+				'post_content' => $content,
+			)
+		);
+
+		return $this->render( $synthetic_post, $subject, $pre_header, $language, $meta_robots, $template_slug );
+	}
+
 	/**
 	 * Inlines CSS styles into the HTML
 	 *
diff --git a/packages/php/email-editor/src/Engine/class-email-editor.php b/packages/php/email-editor/src/Engine/class-email-editor.php
index f784b12edef..0d962687666 100644
--- a/packages/php/email-editor/src/Engine/class-email-editor.php
+++ b/packages/php/email-editor/src/Engine/class-email-editor.php
@@ -269,10 +269,23 @@ class Email_Editor {
 						return false;
 					}
 					$post_id = $request->get_param( 'postId' );
-					if ( ! is_numeric( $post_id ) || (int) $post_id <= 0 ) {
-						return false;
+					if ( is_numeric( $post_id ) && (int) $post_id > 0 ) {
+						return current_user_can( 'edit_post', (int) $post_id );
 					}
-					return current_user_can( 'edit_post', (int) $post_id );
+
+					/**
+					 * Filters whether a preview email may be sent for a request without a backing post.
+					 *
+					 * Defaults to false: postless requests are rejected unless an integration
+					 * that handles them (via the `woocommerce_email_editor_send_preview_email`
+					 * filter) explicitly authorizes the request.
+					 *
+					 * @param bool             $allowed Whether the postless request is authorized. Default false.
+					 * @param \WP_REST_Request $request The send-preview REST request.
+					 *
+					 * @since 2.16.0
+					 */
+					return (bool) apply_filters( 'woocommerce_email_editor_send_preview_email_without_post_permission', false, $request );
 				},
 			)
 		);
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-content.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-content.php
index 7601bc63555..2bedcbe6fb1 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-content.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-post-content.php
@@ -45,11 +45,18 @@ class Post_Content {
 		// This method is only called during email rendering, so we always use stateless logic.
 		$post_id = $block->context['postId'] ?? null;

-		if ( ! $post_id ) {
+		if ( $post_id ) {
+			$email_post = get_post( $post_id );
+		} elseif ( isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof \WP_Post && 0 === $GLOBALS['post']->ID ) {
+			// Synthetic posts (ID 0, e.g. rendering directly from a file template)
+			// exist only as the global set up by the content renderer — the postId
+			// block context is 0 and get_post() cannot resolve them. The ID check
+			// keeps a real page's global post from ever leaking into email output.
+			$email_post = $GLOBALS['post'];
+		} else {
 			return '';
 		}

-		$email_post = get_post( $post_id );
 		if ( ! $email_post || empty( $email_post->post_content ) ) {
 			return '';
 		}
@@ -63,7 +70,17 @@ class Post_Content {
 		// This ensures that blocks which depend on global $post work correctly.
 		$post = $email_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
 		// Create a query specifically for this post to ensure proper context.
-		$wp_query = new \WP_Query( array( 'p' => $post_id ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		// A synthetic post (ID 0) would make the query fetch latest posts, so
+		// populate an empty query manually instead.
+		if ( $email_post->ID > 0 ) {
+			$wp_query = new \WP_Query( array( 'p' => $email_post->ID ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		} else {
+			$wp_query              = new \WP_Query(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			$wp_query->post        = $email_post;
+			$wp_query->posts       = array( $email_post );
+			$wp_query->post_count  = 1;
+			$wp_query->found_posts = 1;
+		}

 		// Get raw post content and apply the_content filter.
 		// The the_content filter processes blocks, shortcodes, etc.
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
index fdcc34b1835..a848f3812fb 100644
--- a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
@@ -63,6 +63,39 @@ class Content_Renderer_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'Hello!', $content );
 	}

+	/**
+	 * Test render() uses a synthetic post's own content and never leaks other published posts.
+	 */
+	public function testItRendersSyntheticPostWithoutLeakingOtherPosts(): void {
+		// A published decoy: if the synthetic post (ID 0) triggered a real
+		// "latest posts" query, this is the content that would leak.
+		$decoy_id = $this->factory->post->create(
+			array(
+				'post_status'  => 'publish',
+				'post_content' => '<!-- wp:paragraph --><p>DECOY_PUBLISHED_POST_MARKER</p><!-- /wp:paragraph -->',
+			)
+		);
+		$this->assertIsInt( $decoy_id );
+
+		$synthetic_post = new \WP_Post(
+			(object) array(
+				'ID'           => 0,
+				'post_type'    => 'post',
+				'post_status'  => 'publish',
+				'post_content' => '<!-- wp:paragraph --><p>SYNTHETIC_POST_MARKER</p><!-- /wp:paragraph -->',
+			)
+		);
+
+		$template          = new \WP_Block_Template();
+		$template->id      = 'template-id';
+		$template->content = '<!-- wp:post-content /-->';
+
+		$content = $this->renderer->render( $synthetic_post, $template );
+
+		$this->assertStringContainsString( 'SYNTHETIC_POST_MARKER', $content );
+		$this->assertStringNotContainsString( 'DECOY_PUBLISHED_POST_MARKER', $content );
+	}
+
 	/**
 	 * Test render() inlines content styles into the HTML.
 	 */
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php b/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php
index 39c6dcfb2a7..f1db56e0683 100644
--- a/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/Renderer_Test.php
@@ -348,6 +348,77 @@ class Renderer_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'test-template-class-extra', $rendered['html'] );
 	}

+	/**
+	 * Test it renders block markup without a backing post.
+	 */
+	public function testItRendersFromContentWithoutBackingPost(): void {
+		// @phpstan-ignore-next-line PHPStan is not aware of the register_block_template function's side effects.
+		register_block_template(
+			'renderer-tests//test-email-template-content',
+			array(
+				'title'       => 'Test Email Template',
+				'description' => 'A test email template.',
+				'content'     => '<!-- wp:group --><div class="wp-block-group test-template-class-content"><!-- wp:post-content /--></div><!-- /wp:group -->',
+			)
+		);
+
+		$rendered = $this->renderer->render_from_content(
+			'<!-- wp:paragraph --><p>Content without a post!</p><!-- /wp:paragraph -->',
+			'test-email-template-content',
+			'Subject',
+			'Preheader content'
+		);
+
+		$this->assertStringContainsString( 'test-template-class-content', $rendered['html'] );
+		$this->assertStringContainsString( 'Content without a post!', $rendered['html'] );
+		$this->assertStringContainsString( 'Subject', $rendered['html'] );
+		$this->assertStringContainsString( 'Content without a post!', $rendered['text'] );
+		// The fixture post created in setUp must not leak into the output.
+		$this->assertStringNotContainsString( 'Hello!', $rendered['html'] );
+	}
+
+	/**
+	 * Test it renders the template chrome with an empty body for empty content.
+	 */
+	public function testItRendersFromEmptyContent(): void {
+		// @phpstan-ignore-next-line PHPStan is not aware of the register_block_template function's side effects.
+		register_block_template(
+			'renderer-tests//test-email-template-empty',
+			array(
+				'title'       => 'Test Email Template',
+				'description' => 'A test email template.',
+				'content'     => '<!-- wp:group --><div class="wp-block-group test-template-class-empty"><!-- wp:post-content /--></div><!-- /wp:group -->',
+			)
+		);
+
+		$rendered = $this->renderer->render_from_content(
+			'',
+			'test-email-template-empty',
+			'Subject',
+			'Preheader content'
+		);
+
+		$this->assertStringContainsString( 'test-template-class-empty', $rendered['html'] );
+		$this->assertStringContainsString( 'Subject', $rendered['html'] );
+	}
+
+	/**
+	 * Test the documented contract: a resolvable template slug is required.
+	 *
+	 * Pins the current failure mode so a behavior change (e.g. a graceful
+	 * fallback) is a conscious API decision, not an accident.
+	 */
+	public function testItRequiresResolvableTemplateSlug(): void {
+		$this->expectException( \TypeError::class );
+
+		$this->renderer->render_from_content(
+			'<!-- wp:paragraph --><p>Content</p><!-- /wp:paragraph -->',
+			'this-template-slug-is-not-registered',
+			'Subject',
+			'Preheader content'
+		);
+	}
+
 	/**
 	 * Test that rendering preserves personalization tags.
 	 */
diff --git a/plugins/woocommerce/changelog/wooplug-6171-delete-never-customized-email-posts b/plugins/woocommerce/changelog/wooplug-6171-delete-never-customized-email-posts
new file mode 100644
index 00000000000..6a7e57a489e
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6171-delete-never-customized-email-posts
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Email editor: delete previously generated email posts that were never customized, so those emails render from the current template files and site locale.
diff --git a/plugins/woocommerce/changelog/wooplug-6171-listing-preview-postless-emails b/plugins/woocommerce/changelog/wooplug-6171-listing-preview-postless-emails
new file mode 100644
index 00000000000..7febf33352e
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6171-listing-preview-postless-emails
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Email editor: re-add the listing Preview action for emails without a saved post, rendering the current file template.
diff --git a/plugins/woocommerce/changelog/wooplug-6171-use-template-file-until-block-email-content-is-edited b/plugins/woocommerce/changelog/wooplug-6171-use-template-file-until-block-email-content-is-edited
new file mode 100644
index 00000000000..e234f3478f7
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6171-use-template-file-until-block-email-content-is-edited
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Email editor: render emails from file-based templates until they are customized and saved, so uncustomized emails always reflect the current template files and site locale.
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-listview.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-listview.test.tsx
index db233b60616..4538bf239aa 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-listview.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-listview.test.tsx
@@ -11,39 +11,47 @@ import { ListView } from '../settings-email-listing-listview';
 import type { EmailType } from '../settings-email-listing-slotfill';
 import { useSendTestEmail } from '../settings-email-send-test';

+type DataViewsAction = {
+	id: string;
+	callback?: ( items: EmailType[] ) => void | Promise< void >;
+	isEligible?: ( item: EmailType ) => boolean;
+	RenderModal?: ComponentType< {
+		items: EmailType[];
+		closeModal?: () => void;
+	} >;
+};
+
+// Captured on each render so tests can drive the actions directly.
+let capturedActions: DataViewsAction[] = [];
+
 jest.mock( '@wordpress/dataviews/wp', () => ( {
-	DataViews: ( {
-		actions,
-		data,
-	}: {
-		actions: Array< {
-			id: string;
-			RenderModal?: ComponentType< {
-				items: EmailType[];
-				closeModal?: () => void;
-			} >;
-		} >;
-		data: EmailType[];
-	} ) => {
-		const SendTestEmailModal = actions.find(
-			( action ) => action.id === 'test'
-		)?.RenderModal;
-
-		return SendTestEmailModal ? (
-			<SendTestEmailModal items={ data } closeModal={ () => {} } />
-		) : null;
+	DataViews: ( { actions }: { actions: DataViewsAction[] } ) => {
+		capturedActions = actions;
+		return null;
 	},
 } ) );

+const mockRecreateEmailPost = jest.fn();
+const mockCreateErrorNotice = jest.fn();
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	dispatch: () => ( { createErrorNotice: mockCreateErrorNotice } ),
+} ) );
+
 jest.mock( '../settings-email-listing-data', () => ( {
 	useTransactionalEmails: ( emailTypes: EmailType[] ) => ( {
 		emails: emailTypes,
 		total: emailTypes.length,
 		updateEmailEnabledStatus: jest.fn(),
-		recreateEmailPost: jest.fn(),
+		recreateEmailPost: mockRecreateEmailPost,
 	} ),
 } ) );

+jest.mock( '@woocommerce/settings', () => ( {
+	getAdminLink: ( path: string ) => `https://example.test/wp-admin/${ path }`,
+} ) );
+
 jest.mock( '../settings-email-send-test', () => ( {
 	useSendTestEmail: jest.fn( () => ( {
 		email: '',
@@ -67,6 +75,8 @@ const emailType: EmailType = {
 	email_key: 'wc_email_new_order',
 	email_class_name: 'WC_Email_New_Order',
 	post_id: '123',
+	file_template_preview_url:
+		'https://example.test/wp-admin/?preview_woo_block_email=true&email_id=new_order&_wpnonce=abc',
 	recipients: {
 		to: 'admin@example.com',
 		cc: '',
@@ -74,27 +84,236 @@ const emailType: EmailType = {
 	},
 	enabled: true,
 	manual: false,
+	postStatus: 'publish',
 	templateStatus: null,
 	templateVersion: null,
 	currentVersion: null,
 	wasBackfilled: false,
 };

+const getAction = ( id: string ) =>
+	capturedActions.find( ( action ) => action.id === id );
+
+const renderTestModal = ( email: EmailType ) => {
+	render( <ListView emailTypes={ [ email ] } /> );
+	const RenderModal = getAction( 'test' )?.RenderModal;
+	if ( ! RenderModal ) {
+		throw new Error( 'Send test email action has no RenderModal' );
+	}
+	return render(
+		<RenderModal items={ [ email ] } closeModal={ () => {} } />
+	);
+};
+
 describe( 'ListView', () => {
+	let originalLocation: typeof window.location;
+
 	beforeEach( () => {
+		capturedActions = [];
 		useSendTestEmailMock.mockClear();
+		mockRecreateEmailPost.mockReset();
+
+		originalLocation = window.location;
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		delete ( window as any ).location;
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		( window as any ).location = {
+			...originalLocation,
+			href: '',
+			assign: jest.fn(),
+		};
+	} );
+
+	afterEach( () => {
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		( window as any ).location = originalLocation;
+	} );
+
+	describe( 'edit action', () => {
+		it( 'navigates directly to the post editor when a post already exists', async () => {
+			render( <ListView emailTypes={ [ emailType ] } /> );
+
+			await getAction( 'edit' )?.callback?.( [ emailType ] );
+
+			expect( mockRecreateEmailPost ).not.toHaveBeenCalled();
+			expect( window.location.href ).toBe(
+				'https://example.test/wp-admin/post.php?post=123&action=edit'
+			);
+		} );
+
+		it( 'lazily creates the post and navigates to it when no post exists', async () => {
+			mockRecreateEmailPost.mockResolvedValue( {
+				message: 'ok',
+				post_id: '456',
+			} );
+			const emailWithoutPost = {
+				...emailType,
+				post_id: '',
+				postStatus: null,
+			};
+			render( <ListView emailTypes={ [ emailWithoutPost ] } /> );
+
+			await getAction( 'edit' )?.callback?.( [ emailWithoutPost ] );
+
+			expect( mockRecreateEmailPost ).toHaveBeenCalledWith( 'new_order' );
+			expect( window.location.href ).toBe(
+				'https://example.test/wp-admin/post.php?post=456&action=edit'
+			);
+		} );
+
+		it( 'does not navigate and surfaces an error notice when the post could not be created', async () => {
+			mockRecreateEmailPost.mockResolvedValue( null );
+			const emailWithoutPost = {
+				...emailType,
+				post_id: '',
+				postStatus: null,
+			};
+			render( <ListView emailTypes={ [ emailWithoutPost ] } /> );
+
+			await getAction( 'edit' )?.callback?.( [ emailWithoutPost ] );
+
+			expect( mockRecreateEmailPost ).toHaveBeenCalledWith( 'new_order' );
+			expect( window.location.href ).toBe( '' );
+			expect( mockCreateErrorNotice ).toHaveBeenCalled();
+		} );
 	} );

-	it( 'uses the email class name when tracking a test send', () => {
+	it( 'does not register a recreate-email-post action', () => {
 		render( <ListView emailTypes={ [ emailType ] } /> );

-		expect( useSendTestEmailMock ).toHaveBeenCalledWith(
-			{
-				endpoint: 'editor',
-				postId: 123,
-				emailType: 'WC_Email_New_Order',
-			},
-			'email_listing'
+		expect( getAction( 'recreate-email-post' ) ).toBeUndefined();
+	} );
+
+	describe( 'preview action', () => {
+		it( 'is eligible for published posts and for rows with a file-template preview URL', () => {
+			render( <ListView emailTypes={ [ emailType ] } /> );
+			const isEligible = getAction( 'preview' )?.isEligible;
+
+			expect(
+				isEligible?.( { ...emailType, postStatus: 'publish' } )
+			).toBe( true );
+			expect(
+				isEligible?.( { ...emailType, postStatus: 'draft' } )
+			).toBe( true );
+			expect(
+				isEligible?.( { ...emailType, post_id: '', postStatus: null } )
+			).toBe( true );
+			// Emails not registered for the block editor have no preview URL
+			// and no published post — no preview.
+			expect(
+				isEligible?.( {
+					...emailType,
+					post_id: '',
+					postStatus: null,
+					file_template_preview_url: null,
+				} )
+			).toBe( false );
+			// A published row needs a resolved permalink; without one (and
+			// without a file-template URL) there is nothing to open.
+			expect(
+				isEligible?.( {
+					...emailType,
+					postStatus: 'publish',
+					link: 'https://example.test/?woo_email=new-order',
+					file_template_preview_url: null,
+				} )
+			).toBe( true );
+			expect(
+				isEligible?.( {
+					...emailType,
+					postStatus: 'publish',
+					link: '',
+					file_template_preview_url: null,
+				} )
+			).toBe( false );
+			expect(
+				isEligible?.( {
+					...emailType,
+					postStatus: 'draft',
+					file_template_preview_url: null,
+				} )
+			).toBe( false );
+		} );
+
+		it( 'opens the permalink for published posts and the file-template preview otherwise', () => {
+			const windowOpenSpy = jest
+				.spyOn( window, 'open' )
+				.mockImplementation( () => null );
+			render( <ListView emailTypes={ [ emailType ] } /> );
+			const callback = getAction( 'preview' )?.callback;
+
+			callback?.( [
+				{
+					...emailType,
+					postStatus: 'publish',
+					link: 'https://example.test/?woo_email=new-order',
+				},
+			] );
+			expect( windowOpenSpy ).toHaveBeenLastCalledWith(
+				'https://example.test/?woo_email=new-order'
+			);
+
+			// An unpublished draft is not what customers receive — preview the
+			// file template instead.
+			callback?.( [ { ...emailType, postStatus: 'draft' } ] );
+			expect( windowOpenSpy ).toHaveBeenLastCalledWith(
+				emailType.file_template_preview_url
+			);
+
+			callback?.( [ { ...emailType, post_id: '', postStatus: null } ] );
+			expect( windowOpenSpy ).toHaveBeenLastCalledWith(
+				emailType.file_template_preview_url
+			);
+
+			windowOpenSpy.mockRestore();
+		} );
+	} );
+
+	describe( 'send test email action', () => {
+		it( 'sends by post ID for a published post, tracking with the email class name', () => {
+			renderTestModal( emailType );
+
+			expect( useSendTestEmailMock ).toHaveBeenCalledWith(
+				{
+					endpoint: 'editor',
+					postId: 123,
+					emailType: 'WC_Email_New_Order',
+					emailTypeId: 'new_order',
+				},
+				'email_listing'
+			);
+		} );
+
+		it( 'is available for emails without a post', () => {
+			render( <ListView emailTypes={ [ emailType ] } /> );
+			const action = getAction( 'test' );
+
+			// No eligibility gate — every email can send a test; without a
+			// published post the server renders the file template.
+			expect( action ).toBeDefined();
+			expect( action?.isEligible ).toBeUndefined();
+		} );
+
+		// Unpublished drafts render nothing for customers, so the test email
+		// must use the file template too — not the draft content.
+		it.each( [
+			[ 'no post', { post_id: '', postStatus: null } ],
+			[ 'an unpublished draft', { post_id: '55', postStatus: 'draft' } ],
+		] )(
+			'sends by email type when the email has %s',
+			( _label, overrides ) => {
+				renderTestModal( { ...emailType, ...overrides } );
+
+				expect( useSendTestEmailMock ).toHaveBeenCalledWith(
+					{
+						endpoint: 'editor',
+						postId: null,
+						emailType: 'WC_Email_New_Order',
+						emailTypeId: 'new_order',
+					},
+					'email_listing'
+				);
+			}
 		);
 	} );
 } );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx
index 9d806f747ce..f17553335b4 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx
@@ -10,7 +10,7 @@
 /**
  * External dependencies
  */
-import { render } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';

 /**
  * Internal dependencies
@@ -34,18 +34,53 @@ jest.mock( '@wordpress/components', () => ( {
 			<div>{ children }</div>
 		),
 	} ),
-	Button: ( { children }: { children: React.ReactNode } ) => (
-		<button>{ children }</button>
-	),
+	Button: ( {
+		children,
+		onClick,
+		disabled,
+		href,
+	}: {
+		children: React.ReactNode;
+		onClick?: () => void;
+		disabled?: boolean;
+		href?: string;
+	} ) =>
+		href ? (
+			<a href={ href }>{ children }</a>
+		) : (
+			<button onClick={ onClick } disabled={ disabled }>
+				{ children }
+			</button>
+		),
 } ) );

 jest.mock( '../settings-email-listing-listview', () => ( {
 	ListView: () => <div data-testid="listview" />,
 } ) );

+// The slotfill imports `recreateEmailPostRequest` from the data module, which
+// transitively pulls in `@wordpress/core-data`; mock it out — these tests only
+// exercise the Tracks instrumentation, payload normalization, and the
+// "Edit template" lazy-creation flow.
+jest.mock( '../settings-email-listing-data', () => ( {
+	recreateEmailPostRequest: jest.fn(),
+} ) );
+
+const createErrorNoticeMock = jest.fn();
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	dispatch: () => ( { createErrorNotice: createErrorNoticeMock } ),
+} ) );
+
+jest.mock( '@woocommerce/settings', () => ( {
+	getAdminLink: ( path: string ) => `https://example.com/wp-admin/${ path }`,
+} ) );
+
 const baseEmail: EmailType = {
 	id: 'new-order',
 	post_id: '123',
+	file_template_preview_url: null,
 	title: 'New order',
 	description: '',
 	enabled: true,
@@ -80,6 +115,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 			<EmailListingFill
 				emailTypes={ [ baseEmail, eligibleEmail ] }
 				editTemplateUrl={ null }
+				emailTemplateId={ null }
 			/>
 		);

@@ -99,6 +135,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 			<EmailListingFill
 				emailTypes={ [ eligibleEmail ] }
 				editTemplateUrl={ null }
+				emailTemplateId={ null }
 			/>
 		);
 		unmount();
@@ -106,6 +143,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 			<EmailListingFill
 				emailTypes={ [ eligibleEmail ] }
 				editTemplateUrl={ null }
+				emailTemplateId={ null }
 			/>
 		);

@@ -124,6 +162,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 				<EmailListingFill
 					emailTypes={ [ eligibleEmail ] }
 					editTemplateUrl={ null }
+					emailTemplateId={ null }
 				/>
 			);

@@ -138,6 +177,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 			<EmailListingFill
 				emailTypes={ [ baseEmail, baseEmail ] }
 				editTemplateUrl={ null }
+				emailTemplateId={ null }
 			/>
 		);

@@ -204,6 +244,7 @@ describe( 'normalizeEmailTypePayload — regression for eligible_count=0', () =>
 			<EmailListingFill
 				emailTypes={ [ rawAsEmailType ] }
 				editTemplateUrl={ null }
+				emailTemplateId={ null }
 			/>
 		);

@@ -232,6 +273,7 @@ describe( 'normalizeEmailTypePayload — regression for eligible_count=0', () =>
 			<EmailListingFill
 				emailTypes={ [ normalized ] }
 				editTemplateUrl={ null }
+				emailTemplateId={ null }
 			/>
 		);

@@ -265,3 +307,117 @@ describe( 'normalizeEmailTypePayload — regression for eligible_count=0', () =>
 		expect( normalized.templateStatus ).toBeNull();
 	} );
 } );
+
+describe( 'EditTemplateButton — lazy post creation', () => {
+	const TEMPLATE_ID = 'my-theme//wooemailtemplate';
+
+	const { recreateEmailPostRequest } = jest.requireMock(
+		'../settings-email-listing-data'
+	) as { recreateEmailPostRequest: jest.Mock };
+
+	const originalLocation = window.location;
+
+	beforeAll( () => {
+		// jsdom throws on real navigation; replace location with a writable stub.
+		Object.defineProperty( window, 'location', {
+			writable: true,
+			value: { ...originalLocation, href: '' },
+		} );
+	} );
+
+	afterAll( () => {
+		Object.defineProperty( window, 'location', {
+			writable: true,
+			value: originalLocation,
+		} );
+	} );
+
+	beforeEach( () => {
+		recordEventMock.mockClear();
+		recreateEmailPostRequest.mockReset();
+		createErrorNoticeMock.mockClear();
+		window.sessionStorage.clear();
+		window.location.href = '';
+	} );
+
+	it( 'links directly to the template editor when editTemplateUrl is provided', () => {
+		render(
+			<EmailListingFill
+				emailTypes={ [ baseEmail ] }
+				editTemplateUrl="https://example.com/wp-admin/site-editor.php"
+				emailTemplateId={ TEMPLATE_ID }
+			/>
+		);
+
+		expect(
+			screen.getByRole( 'link', { name: 'Edit template' } )
+		).toHaveAttribute(
+			'href',
+			'https://example.com/wp-admin/site-editor.php'
+		);
+	} );
+
+	it( 'renders no button when there is no URL and no template id', () => {
+		render(
+			<EmailListingFill
+				emailTypes={ [ baseEmail ] }
+				editTemplateUrl={ null }
+				emailTemplateId={ null }
+			/>
+		);
+
+		expect( screen.queryByText( 'Edit template' ) ).not.toBeInTheDocument();
+	} );
+
+	it( 'lazily creates the post and navigates to the editor with the template param', async () => {
+		recreateEmailPostRequest.mockResolvedValue( {
+			message: 'ok',
+			post_id: '42',
+		} );
+
+		render(
+			<EmailListingFill
+				emailTypes={ [ baseEmail ] }
+				editTemplateUrl={ null }
+				emailTemplateId={ TEMPLATE_ID }
+			/>
+		);
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: 'Edit template' } )
+		);
+
+		await waitFor( () => {
+			expect( window.location.href ).toBe(
+				`https://example.com/wp-admin/post.php?post=42&action=edit&template=${ encodeURIComponent(
+					TEMPLATE_ID
+				) }`
+			);
+		} );
+		expect( recreateEmailPostRequest ).toHaveBeenCalledWith( baseEmail.id );
+	} );
+
+	it( 'shows an error notice and re-enables the button when post creation fails', async () => {
+		recreateEmailPostRequest.mockResolvedValue( null );
+
+		render(
+			<EmailListingFill
+				emailTypes={ [ baseEmail ] }
+				editTemplateUrl={ null }
+				emailTemplateId={ TEMPLATE_ID }
+			/>
+		);
+
+		fireEvent.click(
+			screen.getByRole( 'button', { name: 'Edit template' } )
+		);
+
+		await waitFor( () => {
+			expect( createErrorNoticeMock ).toHaveBeenCalled();
+		} );
+		expect( window.location.href ).toBe( '' );
+		expect(
+			screen.getByRole( 'button', { name: 'Edit template' } )
+		).toBeEnabled();
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
index 567ce2d91cd..e2304980724 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-update-cell.test.tsx
@@ -47,6 +47,7 @@ const baseEmail: EmailType = {
 	templateVersion: null,
 	currentVersion: null,
 	wasBackfilled: false,
+	file_template_preview_url: null,
 };

 describe( '<UpdatesCell>', () => {
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-send-test.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-send-test.test.tsx
index d605781f9f8..c2cbb222cde 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-send-test.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-send-test.test.tsx
@@ -62,6 +62,7 @@ const editorTarget: SendTestEmailTarget = {
 	endpoint: 'editor',
 	postId: 123,
 	emailType: 'WC_Email_New_Order',
+	emailTypeId: 'new_order',
 };

 const settingsTarget: SendTestEmailTarget = {
@@ -103,6 +104,34 @@ describe( 'useSendTestEmail + SendTestEmailForm', () => {
 		expect( sendButton ).toBeEnabled();
 	} );

+	it( 'editor target without a post: posts the email type so the server renders the file template', async () => {
+		apiFetchMock.mockResolvedValue( { success: true, result: true } );
+
+		render(
+			<Harness
+				target={ { ...editorTarget, postId: null } }
+				source="email_listing"
+			/>
+		);
+
+		enterEmailAndSend( 'merchant@example.com' );
+
+		await waitFor( () =>
+			expect(
+				screen.getByText( 'Test email sent successfully!' )
+			).toBeInTheDocument()
+		);
+
+		expect( apiFetchMock ).toHaveBeenCalledWith( {
+			path: '/woocommerce-email-editor/v1/send_preview_email',
+			method: 'POST',
+			data: {
+				email: 'merchant@example.com',
+				emailType: 'new_order',
+			},
+		} );
+	} );
+
 	it( 'editor target: posts the post ID to the email editor endpoint and shows the success notice', async () => {
 		apiFetchMock.mockResolvedValue( { success: true, result: true } );

diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
index 396b4f2708d..a6af737692a 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
@@ -47,6 +47,35 @@ const emailListingNonce = () => {
 	return getAdminSetting( 'email_listing_nonce' );
 };

+/**
+ * Request creation of the post backing an email type (a draft with the
+ * file template content, or the existing post when one is already there).
+ * Standalone so surfaces outside the listing hook (e.g. the "Edit template"
+ * button) can lazily create a post before navigating to the editor.
+ */
+export const recreateEmailPostRequest = async (
+	emailId: string
+): Promise< EmailListingRecreateEmailPostResponse | null > => {
+	try {
+		const response: EmailListingRecreateEmailPostResponse = await apiFetch(
+			{
+				path: `wc-admin-email/settings/email/listing/recreate-email-post?nonce=${ emailListingNonce() }`,
+				method: 'POST',
+				data: { email_id: emailId },
+			}
+		);
+		return response;
+	} catch ( e ) {
+		const wpError = e as WPError;
+		// eslint-disable-next-line no-console
+		console.error(
+			'[WooCommerce Admin] Error recreating email post: ',
+			wpError
+		);
+		return null;
+	}
+};
+
 /**
  * Hook providing transactional emails enriched by woo_email post data for DataViews component.
  */
@@ -65,11 +94,18 @@ export const useTransactionalEmails = (
 	}, [ emailTypesData ] );

 	const validPostIds = Array.from( postIdsMap.values() ).filter( Boolean );
-	const emailPosts = useEntityRecords( 'postType', 'woo_email', {
-		include: validPostIds.join( ',' ),
-		per_page: -1,
-		status: 'any',
-	} ) as { records: Post[] };
+	const emailPosts = useEntityRecords(
+		'postType',
+		'woo_email',
+		{
+			include: validPostIds.join( ',' ),
+			per_page: -1,
+			status: 'any',
+		},
+		// With lazy post creation most emails have no post; an empty `include`
+		// would fetch every woo_email post, so skip the request entirely.
+		{ enabled: validPostIds.length > 0 }
+	) as { records: Post[] };

 	const { updateAndPersistSettingsForGroup } = useDispatch( settingsStore );

@@ -127,6 +163,8 @@ export const useTransactionalEmails = (
 				return {
 					...emailType,
 					link: post?.link || '',
+					postStatus:
+						( post as { status?: string } | null )?.status ?? null,
 					status: status as EmailStatus,
 					templateStatus,
 					templateVersion,
@@ -332,23 +370,12 @@ export const useTransactionalEmails = (
 	);

 	const recreateEmailPost = useCallback(
-		async ( emailId: string ) => {
-			try {
-				const response: EmailListingRecreateEmailPostResponse =
-					await apiFetch( {
-						path: `wc-admin-email/settings/email/listing/recreate-email-post?nonce=${ emailListingNonce() }`,
-						method: 'POST',
-						data: { email_id: emailId },
-					} );
-				updateEmailPostIdInState( emailId, response?.post_id || '' );
-			} catch ( e ) {
-				const wpError = e as WPError;
-				// eslint-disable-next-line no-console
-				console.error(
-					'[WooCommerce Admin] Error recreating email post: ',
-					wpError
-				);
-			}
+		async (
+			emailId: string
+		): Promise< EmailListingRecreateEmailPostResponse | null > => {
+			const response = await recreateEmailPostRequest( emailId );
+			updateEmailPostIdInState( emailId, response?.post_id || '' );
+			return response;
 		},
 		[ updateEmailPostIdInState ]
 	);
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
index ce687efe3ba..3850f740091 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
@@ -2,6 +2,7 @@
  * External dependencies
  */
 import { useState, useMemo } from '@wordpress/element';
+import { dispatch } from '@wordpress/data';
 import { pencil, external } from '@wordpress/icons';
 import { Icon } from '@wordpress/components';
 import { getAdminLink } from '@woocommerce/settings';
@@ -23,24 +24,45 @@ import {
 	useSendTestEmail,
 } from './settings-email-send-test';

+/**
+ * Send-test form for a listing row. A published post renders the saved
+ * content; any other state (no post, unpublished scratchpad) sends by email
+ * type and the server renders the file template — in both cases the test
+ * email matches what customers receive.
+ */
 const SendTestEmailModalContent = ( {
-	postId,
-	emailClassName,
+	email,
 	onClose,
 }: {
-	postId: number;
-	emailClassName: string;
+	email: EmailType;
 	onClose: () => void;
 } ) => {
-	const { email, setEmail, isSending, notice, noticeType, sendEmail } =
-		useSendTestEmail(
-			{ endpoint: 'editor', postId, emailType: emailClassName },
-			'email_listing'
-		);
+	const parsedPostId = parseInt( email.post_id, 10 );
+	const postId =
+		email.postStatus === 'publish' && Number.isFinite( parsedPostId )
+			? parsedPostId
+			: null;
+
+	const {
+		email: recipient,
+		setEmail,
+		isSending,
+		notice,
+		noticeType,
+		sendEmail,
+	} = useSendTestEmail(
+		{
+			endpoint: 'editor',
+			postId,
+			emailType: email.email_class_name,
+			emailTypeId: email.id,
+		},
+		'email_listing'
+	);

 	return (
 		<SendTestEmailForm
-			email={ email }
+			email={ recipient }
 			onEmailChange={ setEmail }
 			isSending={ isSending }
 			notice={ notice }
@@ -173,7 +195,7 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 				label: __( 'Edit', 'woocommerce' ),
 				icon: <Icon icon={ pencil } />,
 				supportsBulk: false,
-				callback: ( items: EmailType[] ) => {
+				callback: async ( items: EmailType[] ) => {
 					const email = items[ 0 ];
 					if ( email.post_id ) {
 						window.location.href = getAdminLink(
@@ -181,13 +203,25 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 								email.post_id
 							) }&action=edit`
 						);
-					} else {
+						return;
+					}
+					// Lazily create the post (a draft with the file
+					// template content) and open it in the editor.
+					const response = await recreateEmailPost( email.id );
+					if ( response?.post_id ) {
 						window.location.href = getAdminLink(
-							`admin.php?page=wc-settings&tab=email&section=${ encodeURIComponent(
-								email.email_key
-							) }`
+							`post.php?post=${ encodeURIComponent(
+								response.post_id
+							) }&action=edit`
 						);
+						return;
 					}
+					void dispatch( 'core/notices' ).createErrorNotice(
+						__(
+							'Could not prepare the email for editing. Please try again.',
+							'woocommerce'
+						)
+					);
 				},
 			},
 			{
@@ -195,21 +229,33 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 				label: __( 'Preview', 'woocommerce' ),
 				icon: <Icon icon={ external } />,
 				supportsBulk: false,
+				// A published post previews through its permalink (the saved
+				// content customers receive). Any other state — no post, or an
+				// unpublished draft — previews the file template via the admin
+				// preview page, matching what customers receive until the
+				// email is saved.
 				callback: ( items: EmailType[] ) => {
-					window.open( items[ 0 ].link );
+					const email = items[ 0 ];
+					const isPublished =
+						!! email.post_id && email.postStatus === 'publish';
+					const previewUrl = isPublished
+						? email.link
+						: email.file_template_preview_url;
+					if ( previewUrl ) {
+						window.open( previewUrl );
+					}
 				},
-				isEligible: ( item: EmailType ) => !! item.post_id,
+				isEligible: ( item: EmailType ) =>
+					( !! item.post_id &&
+						item.postStatus === 'publish' &&
+						!! item.link ) ||
+					!! item.file_template_preview_url,
 				isPrimary: true,
 			},
 			{
 				id: 'test',
 				label: __( 'Send test email', 'woocommerce' ),
 				supportsBulk: false,
-				// The editor's send_preview_email endpoint renders the
-				// woo_email post, so a numeric post ID is required — rows
-				// without one offer the "Recreate email post" action instead.
-				isEligible: ( item: EmailType ) =>
-					Number.isFinite( parseInt( item.post_id, 10 ) ),
 				modalHeader: __( 'Send a test email', 'woocommerce' ),
 				RenderModal: ( {
 					items,
@@ -219,8 +265,7 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 					closeModal?: () => void;
 				} ) => (
 					<SendTestEmailModalContent
-						postId={ parseInt( items[ 0 ].post_id, 10 ) }
-						emailClassName={ items[ 0 ].email_class_name }
+						email={ items[ 0 ] }
 						onClose={ closeModal ?? ( () => {} ) }
 					/>
 				),
@@ -241,17 +286,6 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 					);
 				},
 			},
-			{
-				id: 'recreate-email-post',
-				label: __( 'Recreate email post', 'woocommerce' ),
-				disabled: false,
-				supportsBulk: false,
-				isEligible: ( item: EmailType ) => ! item?.post_id,
-				callback: ( items: EmailType[] ) => {
-					void recreateEmailPost( items[ 0 ].id );
-					return true;
-				},
-			},
 		],
 		[ updateEmailEnabledStatus, recreateEmailPost ]
 	);
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
index da0b2826971..bf970e6ebdb 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
@@ -3,8 +3,10 @@
  */
 import { createSlotFill, Button } from '@wordpress/components';
 import { registerPlugin } from '@wordpress/plugins';
-import { useEffect } from '@wordpress/element';
+import { useEffect, useState } from '@wordpress/element';
+import { dispatch } from '@wordpress/data';
 import { __ } from '@wordpress/i18n';
+import { getAdminLink } from '@woocommerce/settings';
 import { recordEvent } from '@woocommerce/tracks';

 /**
@@ -12,6 +14,7 @@ import { recordEvent } from '@woocommerce/tracks';
  */
 import { SETTINGS_SLOT_FILL_CONSTANT } from '~/settings/settings-slots';
 import { ListView } from './settings-email-listing-listview';
+import { recreateEmailPostRequest } from './settings-email-listing-data';
 import { shouldShowReviewUpdate } from './settings-email-listing-update-state';
 import { VIEWED_FROM_EMAIL_LIST } from '../wp-admin-scripts/email-editor-integration/tracks/build-shared-payload';

@@ -42,11 +45,20 @@ export type EmailType = {
 	email_key: string;
 	email_class_name: string;
 	post_id: string;
+	/** Null for emails not registered for the block editor. */
+	file_template_preview_url: string | null;
 	recipients: Recipients;
 	enabled: boolean;
 	manual: boolean;
 	link?: string;
 	status?: EmailStatus;
+	/**
+	 * Status of the backing `woo_email` post (`publish`, `draft`, `auto-draft`,
+	 * …), projected from the `wp/v2/woo_email` REST enrichment in
+	 * {@link useTransactionalEmails}. Null while unresolved or when no post
+	 * exists — with lazy post creation most emails have no post until edited.
+	 */
+	postStatus?: string | null;
 	templateStatus: TemplateStatus | null;
 	templateVersion: string | null;
 	/**
@@ -80,10 +92,81 @@ const { Fill } = createSlotFill( SETTINGS_SLOT_FILL_CONSTANT );
  */
 const LIST_VIEWED_DEDUP_SESSION_KEY = 'wc_email_update_list_viewed';

+/**
+ * "Edit template" entry point. The template editor opens through an email
+ * post's editor session (`post.php?post=…&template=…`), so a post is always
+ * required. With an existing post the server-built URL is used directly;
+ * otherwise a post is created on click for `templateSessionEmailTypeId` —
+ * any email type works as the session host, the listing passes its first row.
+ */
+const EditTemplateButton = ( {
+	editTemplateUrl,
+	emailTemplateId,
+	templateSessionEmailTypeId,
+}: {
+	editTemplateUrl: string | null;
+	emailTemplateId: string | null;
+	templateSessionEmailTypeId: string | null;
+} ) => {
+	const [ isCreatingPost, setIsCreatingPost ] = useState( false );
+
+	if ( editTemplateUrl ) {
+		return (
+			<Button
+				variant="primary"
+				href={ editTemplateUrl }
+				className="woocommerce-email-listing-edit-template-button"
+			>
+				{ __( 'Edit template', 'woocommerce' ) }
+			</Button>
+		);
+	}
+
+	if ( ! emailTemplateId || ! templateSessionEmailTypeId ) {
+		return null;
+	}
+
+	const handleClick = async () => {
+		setIsCreatingPost( true );
+		const response = await recreateEmailPostRequest(
+			templateSessionEmailTypeId
+		);
+		const postId = parseInt( response?.post_id ?? '', 10 );
+		if ( Number.isInteger( postId ) && postId > 0 ) {
+			window.location.href = getAdminLink(
+				`post.php?post=${ postId }&action=edit&template=${ encodeURIComponent(
+					emailTemplateId
+				) }`
+			);
+			return;
+		}
+		setIsCreatingPost( false );
+		void dispatch( 'core/notices' ).createErrorNotice(
+			__(
+				'Could not prepare the email template for editing. Please try again.',
+				'woocommerce'
+			)
+		);
+	};
+
+	return (
+		<Button
+			variant="primary"
+			isBusy={ isCreatingPost }
+			disabled={ isCreatingPost }
+			onClick={ handleClick }
+			className="woocommerce-email-listing-edit-template-button"
+		>
+			{ __( 'Edit template', 'woocommerce' ) }
+		</Button>
+	);
+};
+
 export const EmailListingFill: React.FC< {
 	emailTypes: EmailType[];
 	editTemplateUrl: string | null;
-} > = ( { emailTypes, editTemplateUrl } ) => {
+	emailTemplateId: string | null;
+} > = ( { emailTypes, editTemplateUrl, emailTemplateId } ) => {
 	// Fire one aggregate `_list_viewed` per session covering the entire list.
 	// Tracking per-row creates one event per visible cell (~20+ on a default
 	// install) per page load with limited analytical lift over a single
@@ -131,15 +214,11 @@ export const EmailListingFill: React.FC< {
 						'woocommerce'
 					) }
 				</p>
-				{ editTemplateUrl && (
-					<Button
-						variant="primary"
-						href={ editTemplateUrl }
-						className="woocommerce-email-listing-edit-template-button"
-					>
-						{ __( 'Edit template', 'woocommerce' ) }
-					</Button>
-				) }
+				<EditTemplateButton
+					editTemplateUrl={ editTemplateUrl }
+					emailTemplateId={ emailTemplateId }
+					templateSessionEmailTypeId={ emailTypes[ 0 ]?.id ?? null }
+				/>
 			</div>
 			<ListView emailTypes={ emailTypes } />
 		</Fill>
@@ -196,6 +275,9 @@ export const registerSettingsEmailListingFill = () => {
 	const editTemplateUrl = slotElement.getAttribute(
 		'data-edit-template-url'
 	);
+	const emailTemplateId = slotElement.getAttribute(
+		'data-email-template-id'
+	);
 	let emailTypes: EmailType[] = [];
 	try {
 		const parsed = JSON.parse( emailTypesData || '' );
@@ -212,6 +294,7 @@ export const registerSettingsEmailListingFill = () => {
 			<EmailListingFill
 				emailTypes={ emailTypes }
 				editTemplateUrl={ editTemplateUrl }
+				emailTemplateId={ emailTemplateId }
 			/>
 		),
 	} );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
index c14b46ec9af..67f66700627 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-update-cell.story.tsx
@@ -18,6 +18,7 @@ const baseEmail: EmailType = {
 	templateVersion: null,
 	currentVersion: null,
 	wasBackfilled: false,
+	file_template_preview_url: null,
 };

 export const CoreUpdatedCustomized = () => (
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-send-test.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-send-test.tsx
index ebb9009577e..e522f32d812 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-send-test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-send-test.tsx
@@ -105,13 +105,21 @@ export type SendTestEmailSource = 'email_preview' | 'email_listing';
  *
  * - `settings`: the wc-admin-email send-preview endpoint, driven by the exact
  *   WC_Email class name (`emailType`). Used by the legacy email preview page.
- * - `editor`: the email editor's send_preview_email endpoint, driven by the
- *   `woo_email` post ID — the exact pipeline the block email editor's own
- *   "Send a test email" modal uses. `emailType` is only used for Tracks.
+ * - `editor`: the email editor's send_preview_email endpoint — the exact
+ *   pipeline the block email editor's own "Send a test email" modal uses.
+ *   Driven by the `woo_email` post ID when the email has a published post;
+ *   without one, `emailTypeId` (the WC_Email id) is sent instead and the
+ *   server renders the file template — matching what customers receive.
+ *   `emailType` (the class name) is only used for Tracks.
  */
 export type SendTestEmailTarget =
 	| { endpoint: 'settings'; emailType: string }
-	| { endpoint: 'editor'; postId: number; emailType: string };
+	| {
+			endpoint: 'editor';
+			postId: number | null;
+			emailType: string;
+			emailTypeId: string;
+	  };

 /**
  * State and send logic for the "Send a test email" flow, shared between the
@@ -138,7 +146,9 @@ export const useSendTestEmail = (
 				await apiFetch( {
 					path: '/woocommerce-email-editor/v1/send_preview_email',
 					method: 'POST',
-					data: { email, postId: target.postId },
+					data: target.postId
+						? { email, postId: target.postId }
+						: { email, emailType: target.emailTypeId },
 				} );

 				setNotice(
diff --git a/plugins/woocommerce/client/admin/client/typings/index.d.ts b/plugins/woocommerce/client/admin/client/typings/index.d.ts
index d6b19b0768f..56a5a0db937 100644
--- a/plugins/woocommerce/client/admin/client/typings/index.d.ts
+++ b/plugins/woocommerce/client/admin/client/typings/index.d.ts
@@ -7,6 +7,42 @@ declare module '@woocommerce/settings' {
 			typeof val !== 'undefined' ? val : fb
 	): T;
 }
+declare module '@wordpress/keyboard-shortcuts' {
+	// The package ships no type declarations; declare the minimal surface in use.
+	export type ShortcutKeyCombination = {
+		modifier?: string;
+		character: string;
+	};
+	export declare const store: import('@wordpress/data').StoreDescriptor<
+		import('@wordpress/data').ReduxStoreConfig<
+			unknown,
+			{
+				registerShortcut: ( shortcut: {
+					name: string;
+					category: string;
+					description?: string;
+					keyCombination: ShortcutKeyCombination;
+				} ) => unknown;
+				unregisterShortcut: ( name: string ) => unknown;
+			},
+			{
+				getShortcutKeyCombination: (
+					state: unknown,
+					name: string
+				) => ShortcutKeyCombination | null;
+				getShortcutDescription: (
+					state: unknown,
+					name: string
+				) => string | undefined;
+			}
+		>
+	>;
+	export declare function useShortcut(
+		name: string,
+		callback: ( event: { preventDefault: () => void } ) => void,
+		options?: { isDisabled?: boolean }
+	): void;
+}
 declare module '@wordpress/components/build/ui' {
 	// Typescript seems unable to resolve this correctly by default, so we need to re-export it in our type defs.
 	export * from '@wordpress/components/build-types/ui';
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/save-button.test.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/save-button.test.tsx
new file mode 100644
index 00000000000..c78e54274dd
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/save-button.test.tsx
@@ -0,0 +1,430 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { store as editorStore } from '@wordpress/editor';
+
+/**
+ * Internal dependencies
+ */
+import { SaveButton, registerWooEmailSaveButton } from '../save-button';
+
+// ---- @wordpress/data mock -------------------------------------------------
+//
+// Drive the component's selectors from a mutable `state` object; each test
+// sets the fields it cares about before rendering. Dispatchers are plain
+// jest.fn()s asserted on directly.
+const state = {
+	isSaving: false,
+	postStatus: 'draft',
+	isDirty: false,
+	hasNonPostEntityChanges: false,
+	dirtyEntityRecords: [] as {
+		kind: string;
+		name: string;
+		key: string | number;
+	}[],
+	currentPostId: 5,
+	// Status of the post entity record as the wrap-editor filter sees it
+	// (null = record not loaded yet).
+	entityRecordStatus: null as string | null,
+	// Key combination registered for core/editor/save (null = core's
+	// EditorKeyboardShortcutsRegister has not run yet).
+	coreSaveKeyCombination: null as {
+		modifier: string;
+		character: string;
+	} | null,
+};
+
+const mockEditPost = jest.fn();
+const mockSavePost = jest.fn();
+const mockSaveEditedEntityRecord = jest.fn();
+const mockRegisterShortcut = jest.fn();
+const mockUnregisterShortcut = jest.fn();
+
+// The useShortcut callbacks registered during render, keyed by shortcut name.
+type SaveShortcutEvent = { preventDefault: jest.Mock };
+const shortcutHandlers: Record< string, ( event: SaveShortcutEvent ) => void > =
+	{};
+
+const mockStoreSelect = ( store: unknown ) => {
+	if ( store === editorStore ) {
+		return {
+			isSavingPost: () => state.isSaving,
+			getEditedPostAttribute: ( attribute: string ) =>
+				attribute === 'status' ? state.postStatus : undefined,
+			isEditedPostDirty: () => state.isDirty,
+			hasNonPostEntityChanges: () => state.hasNonPostEntityChanges,
+			getCurrentPostId: () => state.currentPostId,
+			getCurrentPostType: () => 'woo_email',
+		};
+	}
+	if ( store === jest.requireMock( '@wordpress/keyboard-shortcuts' ).store ) {
+		return {
+			getShortcutKeyCombination: ( name: string ) =>
+				name === 'core/editor/save'
+					? state.coreSaveKeyCombination
+					: null,
+			getShortcutDescription: ( name: string ) =>
+				name === 'core/editor/save' ? 'Save your changes.' : undefined,
+		};
+	}
+	return {
+		__experimentalGetDirtyEntityRecords: () => state.dirtyEntityRecords,
+		// Keyed off the arguments so a wrong kind/name/key lookup in the
+		// component surfaces as a missing record instead of passing silently.
+		getEntityRecord: ( kind: string, name: string, key: number ) =>
+			kind === 'postType' &&
+			name === 'woo_email' &&
+			key === state.currentPostId &&
+			state.entityRecordStatus
+				? { status: state.entityRecordStatus }
+				: undefined,
+	};
+};
+
+jest.mock( '@wordpress/data', () => ( {
+	useSelect: ( callback: ( select: unknown ) => unknown ) =>
+		callback( mockStoreSelect ),
+	useDispatch: ( store: unknown ) => {
+		if ( store === jest.requireMock( '@wordpress/editor' ).store ) {
+			return { editPost: mockEditPost, savePost: mockSavePost };
+		}
+		if (
+			store === jest.requireMock( '@wordpress/keyboard-shortcuts' ).store
+		) {
+			return {
+				registerShortcut: mockRegisterShortcut,
+				unregisterShortcut: mockUnregisterShortcut,
+			};
+		}
+		return { saveEditedEntityRecord: mockSaveEditedEntityRecord };
+	},
+	select: ( store: unknown ) => mockStoreSelect( store ),
+} ) );
+
+jest.mock( '@wordpress/editor', () => ( {
+	store: { name: 'core/editor' },
+} ) );
+
+jest.mock( '@wordpress/keyboard-shortcuts', () => ( {
+	store: { name: 'core/keyboard-shortcuts' },
+	useShortcut: (
+		name: string,
+		callback: ( event: { preventDefault: () => void } ) => void
+	) => {
+		shortcutHandlers[ name ] = callback;
+	},
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+	Button: ( {
+		children,
+		onClick,
+		disabled,
+		className,
+		'aria-disabled': ariaDisabled,
+	}: {
+		children: ReactNode;
+		onClick?: () => void;
+		disabled?: boolean;
+		className?: string;
+		'aria-disabled'?: boolean;
+	} ) => (
+		<button
+			onClick={ onClick }
+			disabled={ disabled }
+			className={ className }
+			aria-disabled={ ariaDisabled }
+		>
+			{ children }
+		</button>
+	),
+} ) );
+
+jest.mock( '@wordpress/core-data', () => ( {
+	store: { name: 'core' },
+} ) );
+
+describe( 'SaveButton', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		state.isSaving = false;
+		state.postStatus = 'draft';
+		state.isDirty = false;
+		state.hasNonPostEntityChanges = false;
+		state.dirtyEntityRecords = [];
+		state.currentPostId = 5;
+		state.entityRecordStatus = null;
+		state.coreSaveKeyCombination = { modifier: 'primary', character: 's' };
+		Object.keys( shortcutHandlers ).forEach(
+			( key ) => delete shortcutHandlers[ key ]
+		);
+	} );
+
+	it( 'renders a button labeled "Save"', () => {
+		render( <SaveButton /> );
+
+		expect(
+			screen.getByRole( 'button', { name: 'Save' } )
+		).toBeInTheDocument();
+	} );
+
+	// The email editor package's DOM tracking records save clicks via the
+	// `.editor-post-publish-button` selector and checks `aria-disabled`;
+	// dropping either silently kills the `header_save_button_clicked` event.
+	it( 'carries the telemetry contract: core publish-button class and aria-disabled', () => {
+		render( <SaveButton /> );
+
+		const button = screen.getByRole( 'button', { name: 'Save' } );
+		expect( button ).toHaveClass( 'editor-post-publish-button' );
+		expect( button ).toHaveAttribute( 'aria-disabled', 'false' );
+	} );
+
+	// auto-draft covers legacy stray posts created outside the lazy flow.
+	it.each( [ [ 'draft' ], [ 'auto-draft' ] ] )(
+		'is enabled for an unpublished (%s) post even when not dirty',
+		( status ) => {
+			state.postStatus = status;
+			state.isDirty = false;
+
+			render( <SaveButton /> );
+
+			expect(
+				screen.getByRole( 'button', { name: 'Save' } )
+			).toBeEnabled();
+		}
+	);
+
+	it( 'is disabled while a save is already in flight', () => {
+		state.postStatus = 'draft';
+		state.isSaving = true;
+
+		render( <SaveButton /> );
+
+		expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeDisabled();
+	} );
+
+	it( 'is disabled when the post is published and not dirty', () => {
+		state.postStatus = 'publish';
+		state.isDirty = false;
+
+		render( <SaveButton /> );
+
+		expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeDisabled();
+	} );
+
+	it( 'publishes and saves an unpublished post on click', () => {
+		state.postStatus = 'draft';
+
+		render( <SaveButton /> );
+		fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
+
+		expect( mockEditPost ).toHaveBeenCalledWith( { status: 'publish' } );
+		expect( mockSavePost ).toHaveBeenCalled();
+	} );
+
+	it( 'saves a published post on click without re-publishing it', () => {
+		state.postStatus = 'publish';
+		state.isDirty = true;
+
+		render( <SaveButton /> );
+		fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
+
+		expect( mockEditPost ).not.toHaveBeenCalled();
+		expect( mockSavePost ).toHaveBeenCalled();
+	} );
+
+	it( 'is enabled for a published, non-dirty post when non-post entities have changes', () => {
+		state.postStatus = 'publish';
+		state.isDirty = false;
+		state.hasNonPostEntityChanges = true;
+
+		render( <SaveButton /> );
+
+		expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeEnabled();
+	} );
+
+	describe( 'save shortcut takeover', () => {
+		const makeKeydownEvent = (): SaveShortcutEvent => ( {
+			preventDefault: jest.fn(),
+		} );
+
+		it( 'replaces the core save shortcut with its own registration', () => {
+			render( <SaveButton /> );
+
+			expect( mockUnregisterShortcut ).toHaveBeenCalledWith(
+				'core/editor/save'
+			);
+			expect( mockRegisterShortcut ).toHaveBeenCalledWith(
+				expect.objectContaining( {
+					name: 'woocommerce/email-editor/save',
+					keyCombination: { modifier: 'primary', character: 's' },
+				} )
+			);
+		} );
+
+		it( 'does not unregister anything before core registered its shortcut', () => {
+			// EditorKeyboardShortcutsRegister registers core/editor/save in its
+			// own mount effect — the takeover must wait for the store, not
+			// race the mount order.
+			state.coreSaveKeyCombination = null;
+
+			render( <SaveButton /> );
+
+			expect( mockUnregisterShortcut ).not.toHaveBeenCalled();
+			expect( mockRegisterShortcut ).not.toHaveBeenCalled();
+		} );
+
+		it( 'takes over again when core re-registers while mounted', () => {
+			const { rerender } = render( <SaveButton /> );
+			mockRegisterShortcut.mockClear();
+			mockUnregisterShortcut.mockClear();
+
+			// The store reports core/editor/save as registered again (e.g. a
+			// remounted EditorKeyboardShortcutsRegister); a rerender must
+			// repeat the takeover.
+			rerender( <SaveButton /> );
+
+			expect( mockUnregisterShortcut ).toHaveBeenCalledWith(
+				'core/editor/save'
+			);
+			expect( mockRegisterShortcut ).toHaveBeenCalledWith(
+				expect.objectContaining( {
+					name: 'woocommerce/email-editor/save',
+				} )
+			);
+		} );
+
+		it( 'publishes and saves on the shortcut like a button click', () => {
+			state.postStatus = 'draft';
+
+			render( <SaveButton /> );
+			const event = makeKeydownEvent();
+			shortcutHandlers[ 'woocommerce/email-editor/save' ]( event );
+
+			expect( event.preventDefault ).toHaveBeenCalled();
+			expect( mockEditPost ).toHaveBeenCalledWith( {
+				status: 'publish',
+			} );
+			expect( mockSavePost ).toHaveBeenCalled();
+		} );
+
+		it( 'still prevents the browser save dialog but does not save while disabled', () => {
+			state.isSaving = true;
+
+			render( <SaveButton /> );
+			const event = makeKeydownEvent();
+			shortcutHandlers[ 'woocommerce/email-editor/save' ]( event );
+
+			expect( event.preventDefault ).toHaveBeenCalled();
+			expect( mockSavePost ).not.toHaveBeenCalled();
+		} );
+
+		it( 'restores the core save shortcut on unmount', () => {
+			const { unmount } = render( <SaveButton /> );
+			mockRegisterShortcut.mockClear();
+			mockUnregisterShortcut.mockClear();
+
+			unmount();
+
+			expect( mockUnregisterShortcut ).toHaveBeenCalledWith(
+				'woocommerce/email-editor/save'
+			);
+			expect( mockRegisterShortcut ).toHaveBeenCalledWith(
+				expect.objectContaining( {
+					name: 'core/editor/save',
+					keyCombination: { modifier: 'primary', character: 's' },
+					description: 'Save your changes.',
+				} )
+			);
+		} );
+	} );
+
+	it( 'saves dirty non-post entities on click but not the post entity record', () => {
+		state.postStatus = 'publish';
+		state.isDirty = true;
+		state.currentPostId = 5;
+		state.dirtyEntityRecords = [
+			{ kind: 'postType', name: 'woo_email', key: 5 },
+			{ kind: 'root', name: 'globalStyles', key: 1 },
+		];
+
+		render( <SaveButton /> );
+		fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
+
+		expect( mockSaveEditedEntityRecord ).toHaveBeenCalledTimes( 1 );
+		expect( mockSaveEditedEntityRecord ).toHaveBeenCalledWith(
+			'root',
+			'globalStyles',
+			1,
+			{}
+		);
+		expect( mockSaveEditedEntityRecord ).not.toHaveBeenCalledWith(
+			'postType',
+			'woo_email',
+			5,
+			{}
+		);
+	} );
+} );
+
+describe( 'registerWooEmailSaveButton', () => {
+	// The wrap-editor filter must only inject the custom button while the
+	// post is unpublished; published posts keep core's stock save flow
+	// (including the multi-entity save panel).
+	const getWrappedEditor = () => {
+		registerWooEmailSaveButton();
+		const MockEditor = ( {
+			customSaveButton,
+		}: {
+			customSaveButton?: ReactNode;
+		} ) => <div>{ customSaveButton ?? <span>core save flow</span> }</div>;
+
+		const { applyFilters } = require( '@wordpress/hooks' );
+		return applyFilters(
+			'woocommerce_email_editor_wrap_editor_component',
+			MockEditor
+		) as React.ComponentType< Record< string, unknown > >;
+	};
+
+	beforeEach( () => {
+		state.entityRecordStatus = null;
+	} );
+
+	it.each( [ [ 'auto-draft' ], [ 'draft' ] ] )(
+		'injects the custom save button for an unpublished (%s) post',
+		( status ) => {
+			state.entityRecordStatus = status;
+			const Wrapped = getWrappedEditor();
+
+			render( <Wrapped postId={ 5 } postType="woo_email" /> );
+
+			expect(
+				screen.getByRole( 'button', { name: 'Save' } )
+			).toBeInTheDocument();
+		}
+	);
+
+	it( 'keeps the core save flow for a published post', () => {
+		state.entityRecordStatus = 'publish';
+		const Wrapped = getWrappedEditor();
+
+		render( <Wrapped postId={ 5 } postType="woo_email" /> );
+
+		expect( screen.getByText( 'core save flow' ) ).toBeInTheDocument();
+		expect(
+			screen.queryByRole( 'button', { name: 'Save' } )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'keeps the core save flow while the post record is not loaded yet', () => {
+		state.entityRecordStatus = null;
+		const Wrapped = getWrappedEditor();
+
+		render( <Wrapped postId={ 5 } postType="woo_email" /> );
+
+		expect( screen.getByText( 'core save flow' ) ).toBeInTheDocument();
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
index aaf5558227d..4dff1538957 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
@@ -5,7 +5,6 @@
  */
 import { dispatch } from '@wordpress/data';
 import { addFilter, addAction } from '@wordpress/hooks';
-import { __ } from '@wordpress/i18n';
 import { registerPlugin } from '@wordpress/plugins';
 import {
 	initializeEditor,
@@ -16,6 +15,7 @@ import {
  * Internal dependencies
  */
 import { NAME_SPACE } from './constants';
+import { registerWooEmailSaveButton } from './save-button';
 import { modifyTemplateSidebar } from './templates';
 import { modifySidebar } from './sidebar_settings';
 import { registerEmailValidationRules } from './email-validation';
@@ -30,10 +30,6 @@ import {
 import './style.scss';
 import './update-banner.scss';

-addFilter( 'woocommerce_email_editor_send_button_label', NAME_SPACE, () =>
-	__( 'Save email', 'woocommerce' )
-);
-
 addFilter(
 	'woocommerce_email_editor_check_sending_method_configuration_link',
 	NAME_SPACE,
@@ -86,6 +82,9 @@ registerIntegrationStore();
 modifySidebar();
 modifyTemplateSidebar();
 registerEmailValidationRules();
+// Replace the editor's Publish/Save button with a Save button that publishes
+// lazily created email posts in the background (WOOPLUG-6171).
+registerWooEmailSaveButton();

 // Register the review-update plugin (RSM-143). Mounts the review drawer
 // into the email editor — its open / close state is driven by the
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/save-button.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/save-button.tsx
new file mode 100644
index 00000000000..19ad22c7409
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/save-button.tsx
@@ -0,0 +1,244 @@
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { useSelect, useDispatch, select as dataSelect } from '@wordpress/data';
+import { useEffect, useRef } from '@wordpress/element';
+import { addFilter } from '@wordpress/hooks';
+import { __ } from '@wordpress/i18n';
+import { store as editorStore } from '@wordpress/editor';
+import { store as coreDataStore } from '@wordpress/core-data';
+import {
+	useShortcut,
+	store as keyboardShortcutsStore,
+	type ShortcutKeyCombination,
+} from '@wordpress/keyboard-shortcuts';
+
+/**
+ * Internal dependencies
+ */
+import { NAME_SPACE } from './constants';
+
+const SAVE_SHORTCUT_NAME = 'woocommerce/email-editor/save';
+
+/**
+ * Take over the editor's save shortcut (Cmd/Ctrl+S) while the custom Save
+ * button is mounted, so the shortcut publishes exactly like the button.
+ *
+ * Core's handler saves a draft without publishing — with the publish step
+ * hidden behind the custom button, that leaves content invisible to sending.
+ * Every handler registered for a shortcut fires on a key match, so adding a
+ * second one would double-save; instead `core/editor/save` is unregistered
+ * (its handler stops matching) and the key combination is re-registered
+ * under an own name. Unmounting restores core's registration for the
+ * published-post phase, where the stock save flow takes over again.
+ *
+ * @param onSave     Save handler shared with the button.
+ * @param isDisabled Whether saving is currently disabled.
+ */
+function useSaveShortcutTakeover( onSave: () => void, isDisabled: boolean ) {
+	const coreShortcut = useSelect(
+		( select ) => ( {
+			keyCombination: select(
+				keyboardShortcutsStore
+			).getShortcutKeyCombination(
+				'core/editor/save'
+			) as ShortcutKeyCombination | null,
+			description: select(
+				keyboardShortcutsStore
+			).getShortcutDescription( 'core/editor/save' ) as
+				| string
+				| undefined,
+		} ),
+		[]
+	);
+	const { registerShortcut, unregisterShortcut } = useDispatch(
+		keyboardShortcutsStore
+	);
+	const stashedShortcut = useRef< {
+		keyCombination: ShortcutKeyCombination;
+		description: string | undefined;
+	} | null >( null );
+
+	// Driven by the store, not mount order: EditorKeyboardShortcutsRegister
+	// registers `core/editor/save` in its own mount effect, so a plain mount
+	// effect here could run earlier and unregister nothing. Re-runs whenever
+	// core's registration reappears while the button stays mounted, so the
+	// takeover cannot be undone by a re-registration.
+	useEffect( () => {
+		if ( ! coreShortcut.keyCombination ) {
+			return;
+		}
+		if ( ! stashedShortcut.current ) {
+			stashedShortcut.current = {
+				keyCombination: coreShortcut.keyCombination,
+				description: coreShortcut.description,
+			};
+		}
+		void unregisterShortcut( 'core/editor/save' );
+		void registerShortcut( {
+			name: SAVE_SHORTCUT_NAME,
+			category: 'global',
+			description: __( 'Save your changes.', 'woocommerce' ),
+			keyCombination: coreShortcut.keyCombination,
+		} );
+	}, [ coreShortcut, registerShortcut, unregisterShortcut ] );
+
+	useEffect( () => {
+		return () => {
+			if ( ! stashedShortcut.current ) {
+				return;
+			}
+			void unregisterShortcut( SAVE_SHORTCUT_NAME );
+			void registerShortcut( {
+				name: 'core/editor/save',
+				category: 'global',
+				description: stashedShortcut.current.description,
+				keyCombination: stashedShortcut.current.keyCombination,
+			} );
+			stashedShortcut.current = null;
+		};
+	}, [ registerShortcut, unregisterShortcut ] );
+
+	useShortcut(
+		SAVE_SHORTCUT_NAME,
+		( event: { preventDefault: () => void } ) => {
+			event.preventDefault();
+			if ( isDisabled ) {
+				return;
+			}
+			onSave();
+		}
+	);
+}
+
+/**
+ * Replacement for the editor's Publish/Save button.
+ *
+ * With lazy post creation, opening an email creates a draft that stays
+ * invisible to rendering. Every explicit save must make the content live —
+ * matching how saving an email always behaved — so the first save publishes
+ * the post in the background instead of surfacing WordPress's draft/publish
+ * flow.
+ */
+export function SaveButton() {
+	const { isSaving, postStatus, isDirty } = useSelect(
+		( select ) => ( {
+			isSaving: select( editorStore ).isSavingPost(),
+			postStatus:
+				select( editorStore ).getEditedPostAttribute( 'status' ),
+			isDirty:
+				select( editorStore ).isEditedPostDirty() ||
+				select( editorStore ).hasNonPostEntityChanges(),
+		} ),
+		[]
+	);
+	const { editPost, savePost } = useDispatch( editorStore );
+	const { saveEditedEntityRecord } = useDispatch( coreDataStore );
+
+	// An unpublished post must stay savable even without edits so the user can
+	// accept the file template defaults as-is.
+	const isDisabled = isSaving || ( postStatus === 'publish' && ! isDirty );
+
+	const onClick = () => {
+		const currentPostId = dataSelect( editorStore ).getCurrentPostId();
+		const currentPostType = dataSelect( editorStore ).getCurrentPostType();
+		const dirtyRecords = dataSelect(
+			coreDataStore
+		).__experimentalGetDirtyEntityRecords() as {
+			kind: string;
+			name: string;
+			key: string | number;
+		}[];
+
+		if ( postStatus !== 'publish' ) {
+			void editPost( { status: 'publish' } );
+		}
+		void savePost();
+
+		// Persist other dirty entities (e.g. global styles) the way the
+		// entities-saved-states panel would; the post itself is handled by
+		// savePost() above.
+		dirtyRecords
+			.filter(
+				( record ) =>
+					! (
+						record.kind === 'postType' &&
+						record.name === currentPostType &&
+						record.key === currentPostId
+					)
+			)
+			.forEach( ( record ) => {
+				void saveEditedEntityRecord(
+					record.kind,
+					record.name,
+					record.key,
+					{}
+				);
+			} );
+	};
+
+	useSaveShortcutTakeover( onClick, isDisabled );
+
+	return (
+		<Button
+			variant="primary"
+			// The core classes are load-bearing: the email editor package's
+			// DOM tracking records `header_save_button_clicked` for clicks on
+			// `.editor-post-publish-button` (see the package's
+			// events/dom-tracking.ts and the editor-tracking-selectors e2e
+			// canary), and its guard also requires the `aria-disabled`
+			// attribute below.
+			className="editor-post-publish-button editor-post-publish-button__button"
+			onClick={ onClick }
+			isBusy={ isSaving }
+			disabled={ isDisabled }
+			aria-disabled={ isDisabled }
+		>
+			{ __( 'Save', 'woocommerce' ) }
+		</Button>
+	);
+}
+
+/**
+ * Injects the save button into the email editor via the wrap-editor filter.
+ * Must run before `initializeEditor()`.
+ *
+ * The custom button is only used while the email post is unpublished — its
+ * sole job is to publish the lazily created scratchpad in the background on
+ * the first save. Once the post is published, core's stock save flow takes
+ * over again, restoring the multi-entity save panel ("Are you ready to
+ * save?") when e.g. template changes are pending alongside content changes.
+ */
+export function registerWooEmailSaveButton() {
+	addFilter(
+		'woocommerce_email_editor_wrap_editor_component',
+		`${ NAME_SPACE }/save-button`,
+		( EditorComponent: React.ComponentType< Record< string, unknown > > ) =>
+			function EditorWithWooSaveButton(
+				props: Record< string, unknown >
+			) {
+				const postStatus = useSelect(
+					( select ) =>
+						(
+							select( coreDataStore ).getEntityRecord(
+								'postType',
+								props.postType as string,
+								props.postId as string | number
+							) as { status?: string } | undefined
+						 )?.status,
+					[ props.postType, props.postId ]
+				);
+				const isUnpublished = !! postStatus && postStatus !== 'publish';
+
+				return (
+					<EditorComponent
+						{ ...props }
+						customSaveButton={
+							isUnpublished ? <SaveButton /> : undefined
+						}
+					/>
+				);
+			}
+	);
+}
diff --git a/plugins/woocommerce/client/admin/package.json b/plugins/woocommerce/client/admin/package.json
index 07afb0e54ed..83f38627774 100644
--- a/plugins/woocommerce/client/admin/package.json
+++ b/plugins/woocommerce/client/admin/package.json
@@ -95,6 +95,7 @@
 		"@wordpress/i18n": "catalog:wp-min",
 		"@wordpress/icons": "catalog:wp-bundled",
 		"@wordpress/interface": "catalog:wp-bundled",
+		"@wordpress/keyboard-shortcuts": "catalog:wp-min",
 		"@wordpress/keycodes": "catalog:wp-min",
 		"@wordpress/media-utils": "catalog:wp-min",
 		"@wordpress/notices": "catalog:wp-min",
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
index b91c98c3249..3a4780f96e2 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Internal\Email\EmailFont;
 use Automattic\WooCommerce\Internal\Email\EmailStyleSync;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
 use Automattic\WooCommerce\Internal\Features\FeaturesController;
@@ -595,10 +596,11 @@ class WC_Settings_Emails extends WC_Settings_Page {
 			'https://wordpress.org/plugins/wp-mail-logging/',
 			'https://woocommerce.com/document/email-faq'
 		);
-		$email_post_manager   = WCTransactionalEmailPostsManager::get_instance();
-		$emails               = WC()->mailer()->get_emails();
-		$email_types          = array();
-		$post_id_for_template = null;
+		$email_post_manager     = WCTransactionalEmailPostsManager::get_instance();
+		$emails                 = WC()->mailer()->get_emails();
+		$email_types            = array();
+		$post_id_for_template   = null;
+		$block_editor_email_ids = WCTransactionalEmails::get_transactional_emails();
 		foreach ( $emails as $email_key => $email ) {
 			$post_id     = $email_post_manager->get_email_template_post_id( $email->id );
 			$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email->id );
@@ -619,20 +621,25 @@ class WC_Settings_Emails extends WC_Settings_Page {
 			$template_version = $post_id ? (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) : '';
 			$was_backfilled   = $post_id ? (bool) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, true ) : false;

+			$file_template_preview_url = in_array( $email->id, $block_editor_email_ids, true )
+				? wp_nonce_url( admin_url( '?preview_woo_block_email=true&email_id=' . $email->id ), 'preview-woo-block-email' )
+				: null;
+
 			$email_types[] = array(
-				'title'            => $email->get_title(),
-				'description'      => $email->get_description(),
-				'id'               => $email->id,
-				'email_key'        => strtolower( $email_key ),
-				'email_class_name' => get_class( $email ),
-				'post_id'          => $post_id,
-				'enabled'          => $email->is_enabled(),
-				'manual'           => $email->is_manual(),
-				'current_version'  => '' !== $current_version ? $current_version : null,
-				'template_status'  => '' !== $template_status ? $template_status : null,
-				'template_version' => '' !== $template_version ? $template_version : null,
-				'was_backfilled'   => $was_backfilled,
-				'recipients'       => array(
+				'title'                     => $email->get_title(),
+				'description'               => $email->get_description(),
+				'id'                        => $email->id,
+				'email_key'                 => strtolower( $email_key ),
+				'email_class_name'          => get_class( $email ),
+				'post_id'                   => $post_id,
+				'file_template_preview_url' => $file_template_preview_url,
+				'enabled'                   => $email->is_enabled(),
+				'manual'                    => $email->is_manual(),
+				'current_version'           => '' !== $current_version ? $current_version : null,
+				'template_status'           => '' !== $template_status ? $template_status : null,
+				'template_version'          => '' !== $template_version ? $template_version : null,
+				'was_backfilled'            => $was_backfilled,
+				'recipients'                => array(
 					'to'  => $email->is_customer_email() ? __( 'Customers', 'woocommerce' ) : $email->get_recipient(),
 					'cc'  => $email->get_cc_recipient(),
 					'bcc' => $email->get_bcc_recipient(),
@@ -644,10 +651,13 @@ class WC_Settings_Emails extends WC_Settings_Page {
 				$post_id_for_template = $post_id;
 			}
 		}
-		// Create URL for email editor template mode.
+		// The email editor's template mode opens through an email post's
+		// editor session, so the URL requires a post ID. When no post exists,
+		// the URL stays null and the client creates a post on demand, building
+		// the URL itself from the template ID passed below.
+		$email_template_id = get_stylesheet() . '//' . WooEmailTemplate::TEMPLATE_SLUG;
 		$edit_template_url = null;
 		if ( $post_id_for_template ) {
-			$email_template_id = get_stylesheet() . '//' . WooEmailTemplate::TEMPLATE_SLUG;
 			$edit_template_url = admin_url( 'post.php?post=' . $post_id_for_template . '&action=edit&template=' . $email_template_id );
 		}

@@ -656,6 +666,7 @@ class WC_Settings_Emails extends WC_Settings_Page {
 			id="wc_settings_email_listing_slotfill" class="wc-settings-prevent-change-event woocommerce-email-listing-listview"
 			data-email-types="<?php echo esc_attr( wp_json_encode( $email_types ) ); ?>"
 			data-edit-template-url="<?php echo esc_attr( $edit_template_url ); ?>"
+			data-email-template-id="<?php echo esc_attr( $email_template_id ); ?>"
 		>
 			<div style="
 			display: flex;
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 755c6e07ec7..564ffa721cf 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -344,6 +344,7 @@ class WC_Install {
 		),
 		'11.1.0'   => array(
 			'wc_update_1110_delete_dashboard_outofstock_count_transient',
+			'wc_update_1110_cleanup_block_email_posts',
 		),
 	);

diff --git a/plugins/woocommerce/includes/wc-deprecated-functions.php b/plugins/woocommerce/includes/wc-deprecated-functions.php
index 855b60c3b25..02b280d5ad4 100644
--- a/plugins/woocommerce/includes/wc-deprecated-functions.php
+++ b/plugins/woocommerce/includes/wc-deprecated-functions.php
@@ -90,7 +90,7 @@ function wc_deprecated_hook( $hook, $version, $replacement = null, $message = nu
  * When catching an exception, this allows us to log it if unexpected.
  *
  * @since 3.3.0
- * @param Exception $exception_object The exception object.
+ * @param Throwable $exception_object The exception (or error) object.
  * @param string    $function The function which threw exception.
  * @param array     $args The args passed to the function.
  * @return void
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 5adbd8a6951..820dab3caa9 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -30,6 +30,7 @@ use Automattic\WooCommerce\Internal\AssignDefaultCategory;
 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
 use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
 use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailPostsCleanup;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
 use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
@@ -3593,3 +3594,16 @@ function wc_update_1100_enable_point_of_sale_feature() {
 function wc_update_1110_delete_dashboard_outofstock_count_transient() {
 	delete_transient( ProductUtil::OUTOFSTOCK_COUNT_TRANSIENT );
 }
+
+/**
+ * Delete never-customized block email posts so those emails render from the
+ * file templates again (picking up template updates and the current site
+ * locale). Customized posts are kept untouched. See WOOPLUG-6171.
+ *
+ * @since 11.1.0
+ *
+ * @return bool Always false (one-shot migration).
+ */
+function wc_update_1110_cleanup_block_email_posts(): bool {
+	return WCEmailPostsCleanup::run();
+}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index a659efc043f..37f35b09bcd 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -55857,12 +55857,6 @@ parameters:
 			count: 1
 			path: src/Internal/Admin/Emails/EmailListingRestController.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\Admin\\Emails\\EmailListingRestController\:\:initialize_template_generator\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/Admin/Emails/EmailListingRestController.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Internal\\Admin\\Emails\\EmailListingRestController\:\:recreate_email_post\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
 			identifier: missingType.generics
@@ -62136,30 +62130,6 @@ parameters:
 			count: 1
 			path: src/Internal/EmailEditor/PersonalizationTags/StoreTagsProvider.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\EmailEditor\\WCTransactionalEmails\\WCTransactionalEmailPostsGenerator\:\:generate_email_templates\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\EmailEditor\\WCTransactionalEmails\\WCTransactionalEmailPostsGenerator\:\:generate_initial_email_templates\(\) should return bool but empty return statement found\.$#'
-			identifier: return.empty
-			count: 1
-			path: src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\EmailEditor\\WCTransactionalEmails\\WCTransactionalEmailPostsGenerator\:\:init_default_transactional_emails\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\EmailEditor\\WCTransactionalEmails\\WCTransactionalEmailPostsGenerator\:\:initialize\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			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
@@ -62208,12 +62178,6 @@ parameters:
 			count: 1
 			path: src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Internal\\EmailEditor\\WCTransactionalEmails\\WCTransactionalEmails\:\:init_email_templates\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
-
 		-
 			message: '#^@param string \$feature_id does not accept actual type of parameter\: int\<min, \-1\>\|int\<1, max\>\|non\-falsy\-string\.$#'
 			identifier: parameter.phpDocType
@@ -69900,12 +69864,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Schemas/ExtendSchema.php

-		-
-			message: '#^Parameter \#1 \$exception_object of function wc_caught_exception expects Exception, Throwable given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/StoreApi/Schemas/ExtendSchema.php
-
 		-
 			message: '#^Variable \$data might not be defined\.$#'
 			identifier: variable.undefined
diff --git a/plugins/woocommerce/src/Internal/Admin/Emails/EmailListingRestController.php b/plugins/woocommerce/src/Internal/Admin/Emails/EmailListingRestController.php
index 0dd747a3cda..aea0c765cf7 100644
--- a/plugins/woocommerce/src/Internal/Admin/Emails/EmailListingRestController.php
+++ b/plugins/woocommerce/src/Internal/Admin/Emails/EmailListingRestController.php
@@ -3,9 +3,13 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\Admin\Emails;

+use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Internal\RestApiControllerBase;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailScratchpadRefresher;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
 use WP_Error;
 use WP_REST_Request;

@@ -21,6 +25,21 @@ class EmailListingRestController extends RestApiControllerBase {
 	 */
 	const NONCE_KEY = 'email-listing-nonce';

+	/**
+	 * Option storing the WC version for which `woo_email` rewrite rules were last flushed.
+	 *
+	 * Flushing rebuilds WordPress's persisted permalink routing table (the
+	 * `rewrite_rules` option) so it includes the `woo_email` rules; email post
+	 * permalinks (used by the listing Preview action) 404 until that happens
+	 * once after the post type is registered. Rebuilding is expensive, so it
+	 * runs only when this option doesn't match the current WC version.
+	 *
+	 * @var string
+	 *
+	 * @since 11.1.0
+	 */
+	const REWRITE_FLUSH_OPTION = 'woocommerce_email_editor_rewrites_flushed';
+
 	/**
 	 * The root namespace for the JSON REST API endpoints.
 	 *
@@ -51,26 +70,35 @@ class EmailListingRestController extends RestApiControllerBase {
 		return 'wc-admin-email-listing';
 	}

+	/**
+	 * Scratchpad refresher instance.
+	 *
+	 * @var WCEmailScratchpadRefresher
+	 */
+	private $scratchpad_refresher;
+
 	/**
 	 * The constructor.
 	 */
 	public function __construct() {
 		$this->email_template_generator = new WCTransactionalEmailPostsGenerator();
+		$this->scratchpad_refresher     = new WCEmailScratchpadRefresher();
 	}

 	/**
 	 * Perform the initialization.
+	 *
+	 * @deprecated 11.1.0 The template generator no longer needs priming; email posts are created lazily. No-op, will be removed in a future version.
+	 * @return void
 	 */
 	public function initialize_template_generator() {
-		$this->email_template_generator->init_default_transactional_emails();
+		wc_deprecated_function( __METHOD__, '11.1.0' );
 	}

 	/**
 	 * Register the REST API endpoints handled by this controller.
 	 */
 	public function register_routes() {
-		$this->initialize_template_generator();
-
 		register_rest_route(
 			$this->route_namespace,
 			'/' . $this->rest_base . '/recreate-email-post',
@@ -168,36 +196,112 @@ class EmailListingRestController extends RestApiControllerBase {
 	/**
 	 * Handle the POST /settings/email/listing/recreate-email-post.
 	 *
+	 * Returns the post for the given email type, creating a draft with the
+	 * file template content when none exists yet. This follows the WordPress
+	 * Site Editor pattern: posts are only created when the user opens the editor,
+	 * and only become the rendering source once published.
+	 *
 	 * @param WP_REST_Request $request The received request.
 	 * @return array|WP_Error Request response or an error.
 	 */
 	public function recreate_email_post( WP_REST_Request $request ) {
-		$email_id = $request->get_param( 'email_id' );
+		$email_id     = $request->get_param( 'email_id' );
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();

-		$generated_post_id = '';
+		$this->maybe_flush_rewrite_rules();

-		try {
-			$generated_post_id = $this->email_template_generator->generate_email_template_if_not_exists( $email_id );
-		} catch ( \Exception $e ) {
+		// A mapped, non-trashed post already exists (the email was saved before).
+		$existing_post = $post_manager->get_email_post( $email_id );
+		if ( $existing_post && 'trash' !== $existing_post->post_status ) {
+			return array(
+				// translators: %s: WooCommerce transactional email ID.
+				'message' => sprintf( __( 'Email post already exists for %s.', 'woocommerce' ), $email_id ),
+				'post_id' => (string) $existing_post->ID,
+			);
+		}
+
+		$email = $post_manager->get_email_by_id( (string) $email_id );
+		if ( ! $email ) {
 			return new WP_Error(
 				'woocommerce_rest_email_post_generation_failed',
-				// translators: %s: Error message.
-				sprintf( __( 'Error generating email post. Error: %s.', 'woocommerce' ), $e->getMessage() ),
+				// translators: %s: WooCommerce transactional email ID.
+				sprintf( __( 'Error generating email post. Email type "%s" is not registered.', 'woocommerce' ), $email_id ),
 				array( 'status' => 500 )
 			);
 		}

-		if ( $generated_post_id ) {
+		// Reuse an existing editing scratchpad. When it was never edited, refresh
+		// its content in place so it reflects the current file template (the post
+		// ID stays stable — another admin may have the editor open on it).
+		$scratchpad = $this->find_unpublished_post_for_email_type( $email_id );
+		if ( $scratchpad ) {
+			$this->scratchpad_refresher->maybe_refresh( $scratchpad, $email );
+
 			return array(
 				// translators: %s: WooCommerce transactional email ID.
-				'message' => sprintf( __( 'Email post generated for %s.', 'woocommerce' ), $email_id ),
-				'post_id' => (string) $generated_post_id,
+				'message' => sprintf( __( 'Email draft already exists for %s.', 'woocommerce' ), $email_id ),
+				'post_id' => (string) $scratchpad->ID,
+			);
+		}
+
+		try {
+			$post_id = $this->email_template_generator->create_draft( $email );
+		} catch ( \Exception $e ) {
+			return new WP_Error(
+				'woocommerce_rest_email_post_generation_failed',
+				// translators: %s: Error message.
+				sprintf( __( 'Error generating email post. Error: %s.', 'woocommerce' ), $e->getMessage() ),
+				array( 'status' => 500 )
 			);
 		}
-		return new WP_Error(
-			'woocommerce_rest_email_post_generation_error',
-			__( 'Error unable to generate email post.', 'woocommerce' ),
-			array( 'status' => 500 )
+
+		return array(
+			// translators: %s: WooCommerce transactional email ID.
+			'message' => sprintf( __( 'Email draft created for %s.', 'woocommerce' ), $email_id ),
+			'post_id' => (string) $post_id,
 		);
 	}
+
+	/**
+	 * Find an existing unpublished post (editing scratchpad) for the given email type.
+	 *
+	 * Scratchpads are created as drafts. The `auto-draft` status is matched as
+	 * well so the lookup keeps working when scratchpad creation switches to
+	 * auto-drafts (planned once the Gutenberg auto-draft title blanking is
+	 * fixed upstream) — including during the transition, when a site can hold
+	 * a mix of both.
+	 *
+	 * @param string $email_id The email type identifier.
+	 * @return \WP_Post|null The post if found, null otherwise.
+	 */
+	private function find_unpublished_post_for_email_type( string $email_id ): ?\WP_Post {
+		$posts = get_posts(
+			array(
+				'post_type'      => Integration::EMAIL_POST_TYPE,
+				'post_status'    => array( 'auto-draft', 'draft' ),
+				'meta_key'       => WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Scoped to a handful of unpublished woo_email posts.
+				'meta_value'     => $email_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Scoped to a handful of unpublished woo_email posts.
+				'posts_per_page' => 1,
+				'orderby'        => 'ID',
+				'order'          => 'DESC',
+			)
+		);
+
+		return $posts[0] ?? null;
+	}
+
+	/**
+	 * Flush rewrite rules once per WC version so `woo_email` permalinks work.
+	 *
+	 * Replaces the flush that used to happen during bulk post generation.
+	 */
+	private function maybe_flush_rewrite_rules(): void {
+		$wc_version = (string) Constants::get_constant( 'WC_VERSION' );
+		if ( get_option( self::REWRITE_FLUSH_OPTION ) === $wc_version ) {
+			return;
+		}
+
+		flush_rewrite_rules();
+		update_option( self::REWRITE_FLUSH_OPTION, $wc_version, false );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/BlockEmailRenderer.php b/plugins/woocommerce/src/Internal/EmailEditor/BlockEmailRenderer.php
index b107025065e..25b1166fdea 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/BlockEmailRenderer.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/BlockEmailRenderer.php
@@ -6,6 +6,9 @@ namespace Automattic\WooCommerce\Internal\EmailEditor;
 use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
 use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
 use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Renderer as EmailRenderer;
+use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;

 /**
@@ -43,6 +46,16 @@ class BlockEmailRenderer {
 	 */
 	private $template_manager;

+	/**
+	 * Request-scoped cache of file template content, keyed by email type and
+	 * locale. The canonical content is type-level (personalization happens
+	 * later), so repeated sends of the same email type in one request — e.g.
+	 * a bulk order status update — compute it once.
+	 *
+	 * @var array<string, string>
+	 */
+	private $file_template_content_cache = array();
+
 	/**
 	 * Constructor.
 	 */
@@ -72,23 +85,49 @@ class BlockEmailRenderer {
 	 */
 	public function maybe_render_block_email( \WC_Email $wc_email ): ?string {
 		$email_post = $this->get_email_post_by_wc_email( $wc_email );
+
+		// Without a published post, the file template is the source of truth
+		// (WP Site Editor pattern). This method runs for every WC_Email when
+		// the block email feature is enabled, so the fallback is limited to
+		// emails opted in via the
+		// `woocommerce_transactional_emails_for_block_editor` filter — for any
+		// other email the template resolution would silently degrade to the
+		// generic default block content. Those keep the classic pipeline.
+		$file_template_content = '';
 		if ( ! $email_post ) {
-			return null;
+			if ( ! in_array( $wc_email->id, WCTransactionalEmails::get_transactional_emails(), true ) ) {
+				return null;
+			}
+
+			// The canonical content (including the `woocommerce_email_content_post_data`
+			// filter) is used, so what is sent matches both the editor scratchpad
+			// content and the hash the cleanup migration compared against before
+			// deleting a post. Keyed by locale as well: plugins may switch it
+			// per recipient, and the markup contains translated strings.
+			$cache_key = $wc_email->id . ':' . get_locale();
+			if ( ! isset( $this->file_template_content_cache[ $cache_key ] ) ) {
+				$this->file_template_content_cache[ $cache_key ] = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $wc_email );
+			}
+			$file_template_content = $this->file_template_content_cache[ $cache_key ];
+			if ( '' === $file_template_content ) {
+				return null;
+			}
 		}

 		$woo_content = $this->woo_content_processor->get_woo_content( $wc_email );
-		return $this->render_block_email( $email_post, $woo_content, $wc_email );
+		return $this->render_block_email( $email_post, $file_template_content, $woo_content, $wc_email );
 	}

 	/**
-	 * Maybe render block-based email content.
+	 * Render block email content from the saved post or the file template markup.
 	 *
-	 * @param \WP_Post  $email_post Email post.
-	 * @param string    $woo_content WooCommerce email content.
-	 * @param \WC_Email $wc_email WooCommerce email.
-	 * @return string Modified email content
+	 * @param \WP_Post|null $email_post Email post, or null to render from the file template content.
+	 * @param string        $file_template_content File template block markup, used when no post is given.
+	 * @param string        $woo_content WooCommerce email content.
+	 * @param \WC_Email     $wc_email WooCommerce email.
+	 * @return string|null Rendered email content, or null when rendering fails.
 	 */
-	private function render_block_email( \WP_Post $email_post, string $woo_content, \WC_Email $wc_email ): ?string {
+	private function render_block_email( ?\WP_Post $email_post, string $file_template_content, string $woo_content, \WC_Email $wc_email ): ?string {
 		try {
 			// Set email context before rendering so blocks can access it.
 			$filter_callback = function ( $context = array() ) use ( $wc_email ) {
@@ -96,18 +135,28 @@ class BlockEmailRenderer {
 			};
 			add_filter( 'woocommerce_email_editor_rendering_email_context', $filter_callback, 10, 1 );

-			$subject             = $wc_email->get_subject(); // We will get subject from $email_post after we add it to the editor.
-			$preheader           = $wc_email->get_preheader();
-			$rendered_email_data = $this->renderer->render( $email_post, $subject, $preheader, 'en' );
-			$personalized_email  = $this->personalizer->personalize_content( $rendered_email_data['html'] );
-			$rendered_email      = str_replace( self::WOO_EMAIL_CONTENT_PLACEHOLDER, $woo_content, $personalized_email );
+			$subject   = $wc_email->get_subject();
+			$preheader = $wc_email->get_preheader();
+
+			if ( $email_post ) {
+				$rendered_email_data = $this->renderer->render( $email_post, $subject, $preheader, 'en' );
+			} else {
+				$rendered_email_data = $this->renderer->render_from_content( $file_template_content, ( new WooEmailTemplate() )->get_slug(), $subject, $preheader, 'en' );
+			}
+
+			$personalized_email = $this->personalizer->personalize_content( $rendered_email_data['html'] );
+			$rendered_email     = str_replace( self::WOO_EMAIL_CONTENT_PLACEHOLDER, $woo_content, $personalized_email );

 			// Remove the filter after rendering to prevent context leakage.
 			remove_filter( 'woocommerce_email_editor_rendering_email_context', $filter_callback );

 			add_filter( 'woocommerce_email_styles', array( $this->woo_content_processor, 'prepare_css' ), 10, 2 );
 			return $rendered_email;
-		} catch ( \Exception $e ) {
+		} catch ( \Throwable $e ) {
+			// Catching \Throwable on purpose: this is the last safety net
+			// before sending, and an \Error (e.g. a TypeError from an
+			// unresolvable template) must also fall back to the classic
+			// pipeline instead of fataling the request that triggered the email.
 			wc_caught_exception( $e, __METHOD__, array( $email_post, $woo_content, $wc_email ) );
 			// Remove the filter in case of exception.
 			if ( isset( $filter_callback ) ) {
@@ -120,11 +169,21 @@ class BlockEmailRenderer {
 	/**
 	 * Get the email post for a given WC_Email.
 	 *
+	 * Only published posts are used for rendering: unpublished states
+	 * (auto-draft, draft, trash) are editing scratchpads or removed content
+	 * and must not affect outgoing emails.
+	 *
 	 * @param \WC_Email $email WooCommerce email.
 	 * @return \WP_Post|null
 	 */
 	private function get_email_post_by_wc_email( \WC_Email $email ): ?\WP_Post {
-		return $this->template_manager->get_email_post( $email->id );
+		$email_post = $this->template_manager->get_email_post( $email->id );
+
+		if ( $email_post && 'publish' !== $email_post->post_status ) {
+			return null;
+		}
+
+		return $email_post;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index 84b803c4efe..fbc0854c22c 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -6,9 +6,14 @@ namespace Automattic\WooCommerce\Internal\EmailEditor;

 use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
 use Automattic\WooCommerce\EmailEditor\Engine\Dependency_Check;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Renderer;
+use Automattic\WooCommerce\EmailEditor\Engine\Send_Preview_Email;
 use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailPatterns\PatternsController;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplatesController;
+use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailScratchpadRefresher;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
@@ -55,6 +60,16 @@ class Integration {
 	 */
 	private EmailApiController $email_api_controller;

+	/**
+	 * Email type of the postless send-preview currently being handled.
+	 *
+	 * Set while {@see self::send_preview_email_for_email_type()} runs so the
+	 * personalizer-context filter can resolve the email without a post.
+	 *
+	 * @var string
+	 */
+	private string $sending_preview_for_email_type = '';
+
 	/**
 	 * The WC_Email instance.
 	 *
@@ -148,7 +163,15 @@ class Integration {
 		add_action( 'woocommerce_email_editor_send_preview_email_before_wp_mail', array( $this, 'send_preview_email_before_wp_mail' ), 10 );
 		add_action( 'woocommerce_email_editor_send_preview_email_after_wp_mail', array( $this, 'send_preview_email_after_wp_mail' ), 10 );
 		add_filter( 'woocommerce_email_editor_send_preview_email_subject', array( $this, 'update_email_subject_for_send_preview_email' ), 10, 2 );
+		// Postless send-preview: the listing "Send test email" action sends
+		// `emailType` instead of `postId` for emails without a published post.
+		// Priority 10 runs before the package's post-based handler (11).
+		add_filter( 'woocommerce_email_editor_send_preview_email', array( $this, 'send_preview_email_for_email_type' ), 10, 1 );
+		add_filter( 'woocommerce_email_editor_send_preview_email_without_post_permission', array( $this, 'authorize_postless_send_preview' ), 10, 2 );
+		// Listing Preview page for emails rendering from the file template.
+		add_action( 'admin_init', array( $this, 'render_block_email_preview_page' ) );
 		add_action( 'rest_api_init', array( $this->email_api_controller, 'register_routes' ) );
+		add_action( 'transition_post_status', array( $this, 'save_email_mapping_on_publish' ), 10, 3 );
 		// Priority 11 ensures the email editor's `init` bootstrap (default priority 10)
 		// has registered the `woo_email` post type before we register meta against it.
 		add_action( 'init', array( WCEmailTemplateDivergenceDetector::class, 'register_meta' ), 11 );
@@ -243,12 +266,39 @@ class Integration {
 	public function replace_editor( $replace, $post ) {
 		$current_screen = get_current_screen();
 		if ( self::EMAIL_POST_TYPE === $post->post_type && $current_screen ) {
+			$this->maybe_refresh_scratchpad( $post );
 			$this->editor_page_renderer->render();
 			return true;
 		}
 		return $replace;
 	}

+	/**
+	 * Refresh a never-edited scratchpad from the current file template when the
+	 * editor opens directly (bookmark, browser refresh) — the listing Edit flow
+	 * refreshes through the REST endpoint before redirecting here.
+	 *
+	 * @param WP_Post $post Post being opened in the editor.
+	 */
+	private function maybe_refresh_scratchpad( $post ): void {
+		if ( ! in_array( $post->post_status, array( 'auto-draft', 'draft' ), true ) ) {
+			return;
+		}
+
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();
+		$email_type   = $post_manager->get_email_type_from_post_id( $post->ID );
+		if ( ! is_string( $email_type ) || '' === $email_type ) {
+			return;
+		}
+
+		$email = $post_manager->get_email_by_id( $email_type );
+		if ( ! $email ) {
+			return;
+		}
+
+		( new WCEmailScratchpadRefresher() )->maybe_refresh( $post, $email );
+	}
+
 	/**
 	 * Delete the email template associated with the email editor post when the post is permanently deleted.
 	 *
@@ -268,7 +318,49 @@ class Integration {
 			return;
 		}

-		$post_manager->delete_email_template( $email_type );
+		// Only clear the mapping when it points at the post being deleted —
+		// the email type can also resolve for unpublished scratchpad posts
+		// whose type maps to a different (live) post.
+		$post_manager->delete_email_template( $email_type, (int) $post_id );
+	}
+
+	/**
+	 * Save the email type → post ID mapping when a `woo_email` post is published.
+	 *
+	 * Lazily created posts (drafts) carry only the `_wc_email_type` meta;
+	 * the mapping that makes a post the rendering source for its email type is
+	 * written here, on the first transition to `publish`. Until then the file
+	 * template remains the source of truth.
+	 *
+	 * @param string   $new_status New post status.
+	 * @param string   $old_status Old post status.
+	 * @param \WP_Post $post       Post object.
+	 *
+	 * @since 11.1.0
+	 */
+	public function save_email_mapping_on_publish( $new_status, $old_status, $post ): void {
+		if ( ! $post instanceof \WP_Post || self::EMAIL_POST_TYPE !== $post->post_type ) {
+			return;
+		}
+
+		if ( 'publish' !== $new_status || 'publish' === $old_status ) {
+			return;
+		}
+
+		$email_type = get_post_meta( $post->ID, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, true );
+		if ( empty( $email_type ) || ! is_string( $email_type ) ) {
+			return;
+		}
+
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();
+
+		// Only map registered email types — the meta could carry an arbitrary
+		// string on imported or programmatically created posts.
+		if ( ! $post_manager->get_email_by_id( $email_type ) ) {
+			return;
+		}
+
+		$post_manager->save_email_template_post_id( $email_type, $post->ID );
 	}

 	/**
@@ -363,8 +455,13 @@ class Integration {
 	 * @return array The updated personalizer context.
 	 */
 	public function update_send_preview_email_personalizer_context( $context ) {
-		$post_manager  = WCTransactionalEmailPostsManager::get_instance();
-		$email_id      = $post_manager->get_email_type_from_post_id( get_the_ID() );
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();
+		$email_id     = $post_manager->get_email_type_from_post_id( get_the_ID() );
+		if ( ! $email_id && '' !== $this->sending_preview_for_email_type ) {
+			// Postless send-preview: there is no post to resolve the email
+			// from, the type comes from the request instead.
+			$email_id = $this->sending_preview_for_email_type;
+		}
 		$email_type    = $email_id ? $post_manager->get_email_type_class_name_from_email_id( $email_id ) : EmailPreview::DEFAULT_EMAIL_TYPE;
 		$email_preview = wc_get_container()->get( EmailPreview::class );

@@ -382,6 +479,188 @@ class Integration {
 		return $personalizer->prepare_context_data( $context, $email );
 	}

+	/**
+	 * Send a preview email rendered from the file template when the request
+	 * carries an email type instead of a post ID.
+	 *
+	 * Runs before the email editor package's post-based handler; requests with
+	 * a post ID pass through untouched. The rendered content is the canonical
+	 * file template — exactly what customers receive while the email has no
+	 * published post.
+	 *
+	 * @param array|bool $data Send-preview request data, or a bool when a previous handler already sent the email.
+	 * @return array|bool True/false when handled here (email sent / send failed); the unchanged data otherwise.
+	 * @throws \InvalidArgumentException When the recipient address or email type is invalid, or the email has no template content.
+	 */
+	public function send_preview_email_for_email_type( $data ) {
+		if ( ! is_array( $data ) || ! empty( $data['postId'] ) ) {
+			return $data;
+		}
+
+		$email_type = isset( $data['emailType'] ) ? sanitize_text_field( (string) $data['emailType'] ) : '';
+		if ( '' === $email_type ) {
+			return $data;
+		}
+
+		$recipient = isset( $data['email'] ) ? sanitize_email( (string) $data['email'] ) : '';
+		if ( ! is_email( $recipient ) ) {
+			throw new \InvalidArgumentException( 'Invalid email address' );
+		}
+
+		$preview = $this->render_preview_html_for_email_type( $email_type );
+
+		$send_preview = Email_Editor_Container::container()->get( Send_Preview_Email::class );
+
+		return $send_preview->send_email( $recipient, $preview['subject'], $preview['html'] );
+	}
+
+	/**
+	 * Render the file-template preview of an email type — the content customers
+	 * receive while the email has no published post — with the preview order
+	 * context applied and personalization tags resolved.
+	 *
+	 * Shared by the postless send-test handler and the listing Preview page.
+	 *
+	 * @param string $email_type The email type identifier (e.g. `customer_processing_order`).
+	 * @return array{subject: string, html: string} The preview subject and full HTML document.
+	 * @throws \InvalidArgumentException When the email type is invalid or has no template content.
+	 */
+	public function render_preview_html_for_email_type( string $email_type ): array {
+		$email = WCTransactionalEmailPostsManager::get_instance()->get_email_by_id( $email_type );
+		if ( ! $email instanceof \WC_Email || ! in_array( $email_type, WCTransactionalEmails::get_transactional_emails(), true ) ) {
+			throw new \InvalidArgumentException( 'Unknown email type' );
+		}
+
+		$content = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+		if ( '' === $content ) {
+			throw new \InvalidArgumentException( 'The email has no template content' );
+		}
+
+		$container    = Email_Editor_Container::container();
+		$renderer     = $container->get( Renderer::class );
+		$send_preview = $container->get( Send_Preview_Email::class );
+
+		$subject = $this->get_preview_subject_for_email_type( $email_type, $email );
+
+		add_filter( 'woocommerce_email_editor_rendering_email_context', array( $send_preview, 'add_preview_context' ) );
+		try {
+			$rendered = $renderer->render_from_content(
+				$content,
+				( new WooEmailTemplate() )->get_slug(),
+				$subject,
+				__( 'Preview', 'woocommerce' ),
+				get_bloginfo( 'language' )
+			);
+		} finally {
+			remove_filter( 'woocommerce_email_editor_rendering_email_context', array( $send_preview, 'add_preview_context' ) );
+		}
+
+		$html = $this->update_email_preview_data( $rendered['html'], $email_type );
+
+		$this->sending_preview_for_email_type = $email_type;
+		try {
+			$html = $send_preview->set_personalize_content( $html );
+		} finally {
+			$this->sending_preview_for_email_type = '';
+		}
+
+		return array(
+			'subject' => $subject,
+			'html'    => $html,
+		);
+	}
+
+	/**
+	 * Render the listing Preview page for an email without a published post.
+	 *
+	 * Mirrors the `preview_woocommerce_mail` admin page pattern: a nonce-gated
+	 * query-param handler that echoes the full preview HTML document. Used by
+	 * the settings listing's Preview action for emails whose rendering source
+	 * is the file template (no post, or an unpublished draft).
+	 */
+	public function render_block_email_preview_page(): void {
+		if ( ! isset( $_GET['preview_woo_block_email'] ) ) {
+			return;
+		}
+
+		// Verifies the `_wpnonce` query arg that `wp_nonce_url()` appended to
+		// the listing payload's preview URL; dies when missing or invalid.
+		check_admin_referer( 'preview-woo-block-email' );
+
+		if ( ! current_user_can( 'manage_woocommerce' ) ) {
+			wp_die( esc_html__( 'You do not have permission to preview emails.', 'woocommerce' ) );
+		}
+
+		$email_type = isset( $_GET['email_id'] ) ? sanitize_text_field( wp_unslash( $_GET['email_id'] ) ) : '';
+
+		try {
+			$preview = $this->render_preview_html_for_email_type( $email_type );
+		} catch ( \InvalidArgumentException $e ) {
+			wp_die( esc_html__( 'This email cannot be previewed.', 'woocommerce' ) );
+		}
+
+		echo $preview['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Full HTML document produced by the email renderer; escaping would break it.
+		exit;
+	}
+
+	/**
+	 * Authorize postless send-preview requests for registered block emails.
+	 *
+	 * The package rejects requests without a post ID by default; grant them for
+	 * shop managers when the requested email type is registered for the block
+	 * editor.
+	 *
+	 * @param bool             $allowed Current authorization.
+	 * @param \WP_REST_Request $request The send-preview REST request.
+	 * @return bool Whether the request is authorized.
+	 *
+	 * @phpstan-param \WP_REST_Request<array{emailType?: string}> $request
+	 */
+	public function authorize_postless_send_preview( $allowed, $request ) {
+		if ( $allowed ) {
+			return $allowed;
+		}
+
+		if ( ! current_user_can( 'manage_woocommerce' ) ) {
+			return false;
+		}
+
+		$email_type = sanitize_text_field( (string) $request->get_param( 'emailType' ) );
+
+		return '' !== $email_type && in_array( $email_type, WCTransactionalEmails::get_transactional_emails(), true );
+	}
+
+	/**
+	 * Build the preview subject for an email type, mirroring the post-based
+	 * preview subject (placeholders resolved against the preview order).
+	 *
+	 * @param string    $email_type The email type identifier.
+	 * @param \WC_Email $email      The email instance, used for fallbacks.
+	 * @return string The preview subject.
+	 */
+	private function get_preview_subject_for_email_type( string $email_type, \WC_Email $email ): string {
+		$class_name = WCTransactionalEmailPostsManager::get_instance()->get_email_type_class_name_from_email_id( $email_type );
+		if ( ! empty( $class_name ) ) {
+			try {
+				$email_preview = wc_get_container()->get( EmailPreview::class );
+				$email_preview->set_email_type( $class_name );
+				return $email_preview->get_subject();
+			} catch ( \Throwable $e ) {
+				// Fall through to the WC_Email fallbacks below.
+				unset( $e );
+			}
+		}
+
+		// Third-party get_subject() implementations may assume send-time state
+		// (e.g. Bookings dereferences its booking object), so it must only run
+		// guarded; the title is the always-safe last resort.
+		try {
+			return (string) $email->get_subject();
+		} catch ( \Throwable $e ) {
+			return (string) $email->get_title();
+		}
+	}
+
 	/**
 	 * Filter email preview data used when previewing the email in new tab.
 	 *
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailPostsCleanup.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailPostsCleanup.php
new file mode 100644
index 00000000000..94635e352d4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailPostsCleanup.php
@@ -0,0 +1,226 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\Logger;
+
+/**
+ * One-time cleanup of never-customized `woo_email` posts (WOOPLUG-6171).
+ *
+ * With file templates as the rendering source of truth until an email is
+ * edited and saved, stored copies that were bulk-generated on initialization
+ * and never touched by a merchant only freeze outdated content. This
+ * migration hard-deletes them (and their option mapping) so the affected
+ * emails fall back to the file template — picking up template updates and the
+ * site's current locale. Customized posts are left untouched and are stamped
+ * with the `_wc_email_type` meta that lazily created posts carry.
+ *
+ * Runs once per site via WooCommerce's db-updates pipeline (see
+ * {@see \WC_Install::$db_updates}); the `woocommerce_db_version` fence
+ * guarantees single execution and re-runs converge because deleted mappings
+ * are gone. A single synchronous pass is sufficient — the post set is bounded
+ * by the number of registered transactional emails.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 11.1.0
+ */
+class WCEmailPostsCleanup {
+	/**
+	 * Transient formerly used to gate bulk post generation; retired with it.
+	 *
+	 * @var string
+	 */
+	const LEGACY_GENERATION_TRANSIENT = 'wc_email_editor_initial_templates_generated';
+
+	/**
+	 * Entry point for the db-update callback.
+	 *
+	 * Always returns `false` (one-shot), matching the contract
+	 * {@see \WC_Install::run_update_callback_end()} expects.
+	 *
+	 * @param Email_Editor_Logger_Interface|null $logger Logger to report to; defaults to the WooCommerce logger.
+	 * @return bool Always false.
+	 *
+	 * @since 11.1.0
+	 */
+	public static function run( ?Email_Editor_Logger_Interface $logger = null ): bool {
+		$logger = $logger ?? new Logger( wc_get_logger() );
+
+		$posts_manager = WCTransactionalEmailPostsManager::get_instance();
+		$emails_by_id  = $posts_manager->get_emails_by_id();
+
+		$deleted = 0;
+		$kept    = 0;
+
+		foreach ( self::fetch_email_post_mappings() as $mapping ) {
+			try {
+				$option_name = (string) $mapping->option_name;
+				$post_id     = (int) $mapping->option_value;
+				$email_type  = self::email_type_from_option_name( $option_name );
+
+				// The SQL LIKE match is loose (each `_` is a single-char
+				// wildcard); never touch options that only resemble the
+				// mapping shape — they belong to someone else.
+				if ( null === $email_type ) {
+					continue;
+				}
+
+				if ( $post_id <= 0 ) {
+					delete_option( $option_name );
+					continue;
+				}
+
+				$post = get_post( $post_id );
+				if ( ! $post instanceof \WP_Post || Integration::EMAIL_POST_TYPE !== $post->post_type ) {
+					// Orphaned mapping.
+					delete_option( $option_name );
+					continue;
+				}
+
+				// A trashed copy no longer affects rendering; treat it as "revert to
+				// default". The email editor UI never trashes woo_email posts (its
+				// delete action permanently deletes via the
+				// `woocommerce_email_editor_trash_modal_should_permanently_delete`
+				// filter), so a trashed post slipped past that guard out-of-band.
+				if ( 'trash' === $post->post_status ) {
+					if ( wp_delete_post( $post->ID, true ) ) {
+						delete_option( $option_name );
+						++$deleted;
+					}
+					continue;
+				}
+
+				$email = $emails_by_id[ $email_type ] ?? null;
+				if ( ! $email instanceof \WC_Email ) {
+					// Cannot compute the canonical content (e.g. extension deactivated);
+					// keep the post — it keeps rendering from the DB as before.
+					update_post_meta( $post->ID, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, $email_type );
+					++$kept;
+					continue;
+				}
+
+				if ( self::was_never_customized( $post, $email ) ) {
+					// When the deletion fails (or a `pre_delete_post` filter
+					// short-circuits it with a falsy value), keep the mapping —
+					// otherwise the post would linger published while the email
+					// falls back to the file template.
+					if ( ! wp_delete_post( $post->ID, true ) ) {
+						continue;
+					}
+					// `before_delete_post` also removes the mapping when the email
+					// editor feature is active; delete the option defensively for
+					// the case it isn't.
+					delete_option( $option_name );
+					++$deleted;
+				} else {
+					update_post_meta( $post->ID, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, $email_type );
+					++$kept;
+				}
+			} catch ( \Throwable $e ) {
+				$logger->error(
+					sprintf(
+						'Email posts cleanup failed for mapping %s: %s',
+						(string) $mapping->option_name,
+						$e->getMessage()
+					),
+					array(
+						'option_name' => (string) $mapping->option_name,
+						'context'     => 'email_posts_cleanup',
+					)
+				);
+				continue;
+			}
+		}
+
+		delete_transient( self::LEGACY_GENERATION_TRANSIENT );
+
+		$posts_manager->clear_caches();
+
+		$logger->info(
+			sprintf( 'Email posts cleanup finished: %d never-customized post(s) deleted, %d customized post(s) kept.', $deleted, $kept ),
+			array( 'context' => 'email_posts_cleanup' )
+		);
+
+		return false;
+	}
+
+	/**
+	 * Decide whether a stored email post was never customized by a merchant.
+	 *
+	 * True when any of the following independent signals holds:
+	 * - the content matches the canonical core render recomputed right now;
+	 * - the content still matches the sync source hash stamped at creation or
+	 *   backfill time (untouched even though core moved on — deleting is
+	 *   equivalent to what the auto-applier would do, just cheaper);
+	 * - no valid source hash exists (email not covered by the sync registry)
+	 *   and the GMT creation/modification timestamps show no edit ever happened.
+	 *
+	 * The stored `_wc_email_template_status` meta is deliberately not trusted
+	 * on its own — it can be stale (sweeps only run after upgrades) or absent.
+	 *
+	 * @param \WP_Post  $post  The stored email post.
+	 * @param \WC_Email $email The registered email instance.
+	 * @return bool True when the post can safely fall back to the file template.
+	 */
+	private static function was_never_customized( \WP_Post $post, \WC_Email $email ): bool {
+		$post_hash = sha1( (string) $post->post_content );
+
+		$canonical_hash = sha1( WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ) );
+		if ( $post_hash === $canonical_hash ) {
+			return true;
+		}
+
+		$stored_hash = get_post_meta( $post->ID, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+		if ( is_string( $stored_hash ) && 1 === preg_match( '/^[0-9a-f]{40}$/', $stored_hash ) ) {
+			return $post_hash === $stored_hash;
+		}
+
+		// GMT columns only: the local pair is computed with the site offset at
+		// write time, so a timezone change between creation and an edit can
+		// make it match coincidentally — and any extra signal here widens a
+		// hard-delete decision.
+		return $post->post_date_gmt === $post->post_modified_gmt;
+	}
+
+	/**
+	 * Fetch all email type → post ID option mappings straight from the database.
+	 *
+	 * Bypasses the posts manager caches on purpose: this is a migration and
+	 * must observe the persisted state.
+	 *
+	 * @return \stdClass[] Rows with `option_name` and `option_value`.
+	 */
+	private static function fetch_email_post_mappings(): array {
+		global $wpdb;
+
+		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_name ASC",
+				WCTransactionalEmailPostsManager::WC_OPTION_NAME
+			)
+		);
+		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+
+		return is_array( $rows ) ? $rows : array();
+	}
+
+	/**
+	 * Derive the email type from a mapping option name.
+	 *
+	 * @param string $option_name Option name, e.g. `woocommerce_email_templates_customer_new_account_post_id`.
+	 * @return string|null The email type, e.g. `customer_new_account`, or null when
+	 *                     the name doesn't match the mapping shape exactly.
+	 */
+	private static function email_type_from_option_name( string $option_name ): ?string {
+		if ( 1 !== preg_match( '/^woocommerce_email_templates_(.+)_post_id$/', $option_name, $matches ) ) {
+			return null;
+		}
+
+		return $matches[1];
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailScratchpadRefresher.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailScratchpadRefresher.php
new file mode 100644
index 00000000000..5cf4739e886
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailScratchpadRefresher.php
@@ -0,0 +1,112 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
+
+/**
+ * Refreshes never-edited editing scratchpads (unpublished `woo_email` posts)
+ * from the current file template, so the editor always opens on the content
+ * customers would receive. Edited scratchpads are never touched.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 11.1.0
+ *
+ * @internal
+ */
+class WCEmailScratchpadRefresher {
+
+	/**
+	 * Refresh the scratchpad from the current file template when it was never
+	 * edited by the user.
+	 *
+	 * @param \WP_Post  $scratchpad The unpublished scratchpad post.
+	 * @param \WC_Email $email      The email instance.
+	 */
+	public function maybe_refresh( \WP_Post $scratchpad, \WC_Email $email ): void {
+		if ( ! $this->was_never_edited( $scratchpad ) ) {
+			return;
+		}
+
+		$this->refresh_content( $scratchpad, $email );
+	}
+
+	/**
+	 * Check whether an unpublished email post was never edited by the user.
+	 *
+	 * Prefers the source hash stamped at creation and refresh (content
+	 * untouched when it still matches); the timestamp fallback only applies to
+	 * posts without a valid hash, e.g. stray unpublished posts created outside
+	 * the lazy-creation flow.
+	 *
+	 * @param \WP_Post $post The post to check.
+	 * @return bool True when the post content was never edited.
+	 */
+	private function was_never_edited( \WP_Post $post ): bool {
+		$stored_hash = get_post_meta( $post->ID, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+		if ( is_string( $stored_hash ) && 1 === preg_match( '/^[0-9a-f]{40}$/', $stored_hash ) ) {
+			return sha1( (string) $post->post_content ) === $stored_hash;
+		}
+
+		return $post->post_date_gmt === $post->post_modified_gmt;
+	}
+
+	/**
+	 * Refresh the scratchpad's content and title to the current file template.
+	 *
+	 * The title is system-owned, so it moves with the content. Keeps the
+	 * sync meta baseline in step with the new content so a later
+	 * `was_never_edited()` check still recognizes the post as untouched.
+	 *
+	 * @param \WP_Post  $scratchpad The unpublished scratchpad post.
+	 * @param \WC_Email $email      The email instance.
+	 */
+	private function refresh_content( \WP_Post $scratchpad, \WC_Email $email ): void {
+		$post_data       = WCTransactionalEmailPostsGenerator::build_filtered_post_data( (string) $email->id, $email );
+		$canonical       = (string) ( $post_data['post_content'] ?? '' );
+		$canonical_title = (string) ( $post_data['post_title'] ?? '' );
+
+		if ( '' === $canonical || ( $canonical === $scratchpad->post_content && $canonical_title === $scratchpad->post_title ) ) {
+			return;
+		}
+
+		$updated = wp_update_post(
+			array(
+				'ID'            => $scratchpad->ID,
+				'post_content'  => $canonical,
+				'post_title'    => $canonical_title,
+				// The explicit empty value makes core skip template handling
+				// entirely, leaving the stored `_wp_page_template` meta as is.
+				// With the key omitted, wp_update_post() would re-inject the
+				// stored template (WP_Post::to_array()) and fail with "Invalid
+				// page template." whenever the email template is not
+				// registered — it only is while the editor package is
+				// bootstrapped.
+				'page_template' => '',
+			),
+			true
+		);
+
+		if ( is_wp_error( $updated ) ) {
+			return;
+		}
+
+		$saved_post = get_post( $scratchpad->ID );
+		$saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : $canonical;
+
+		// Restamped for every email, not only sync-registry ones: the update
+		// above bumped `post_modified`, so without a matching hash the
+		// timestamp fallback in `was_never_edited()` would treat this
+		// scratchpad as edited forever and this refresh would never run again.
+		update_post_meta( $scratchpad->ID, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( $saved_body ) );
+
+		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email->id );
+		if ( null !== $sync_config ) {
+			update_post_meta( $scratchpad->ID, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, (string) $sync_config['version'] );
+			update_post_meta( $scratchpad->ID, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, gmdate( 'Y-m-d H:i:s' ) );
+			update_post_meta( $scratchpad->ID, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, $canonical );
+		}
+
+		$scratchpad->post_content = $saved_body;
+		$scratchpad->post_title   = $canonical_title;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
index 54dbf137d4b..c11e38e1ad8 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
@@ -4,7 +4,6 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;

-use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Internal\EmailEditor\Integration;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
 use Automattic\WooCommerce\Utilities\StringUtil;
@@ -19,94 +18,6 @@ use Automattic\WooCommerce\Utilities\StringUtil;
  * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
  */
 class WCTransactionalEmailPostsGenerator {
-	/**
-	 * WooCommerce Email Template Manager instance.
-	 *
-	 * @var WCTransactionalEmailPostsManager
-	 */
-	private $template_manager;
-
-	/**
-	 * Default templates.
-	 *
-	 * @var array<string, \WC_Email>
-	 */
-	private $default_templates = array();
-
-	/**
-	 * Transient name.
-	 *
-	 * @var string
-	 */
-	private $transient_name = 'wc_email_editor_initial_templates_generated';
-
-	/**
-	 * Constructor.
-	 *
-	 * Initializes the WCTransactionalEmailPostsGenerator by setting up the template manager.
-	 */
-	public function __construct() {
-		$this->template_manager = WCTransactionalEmailPostsManager::get_instance();
-	}
-
-	/**
-	 * Initialize the email template generator.
-	 *
-	 * This function initializes the email template generator by loading the default templates
-	 * and generating initial email templates if needed.
-	 *
-	 * @internal
-	 */
-	public function initialize() {
-		if ( Constants::get_constant( 'WC_VERSION' ) === get_transient( $this->transient_name ) ) {
-			// if templates are already generated, we don't need to run this function again.
-			return true;
-		}
-
-		$this->init_default_transactional_emails();
-		$this->generate_initial_email_templates();
-	}
-
-	/**
-	 * Initialize the default WooCommerce Transactional Emails.
-	 *
-	 * This function initializes the default templates for the core transactional emails.
-	 * It fetches all the emails from WooCommerce and filters them to include only the core transactional emails.
-	 */
-	public function init_default_transactional_emails() {
-		if ( ! empty( $this->default_templates ) ) {
-			// If the default templates are already initialized, we don't need to run this function again.
-			return;
-		}
-
-		$core_transactional_emails = WCTransactionalEmails::get_transactional_emails();
-
-		$wc_emails = \WC_Emails::instance();
-		/**
-		 * WooCommerce Transactional Emails instance.
-		 *
-		 * @var \WC_Email[]
-		 */
-		$email_types = $wc_emails->get_emails();
-
-		// Filter the emails to include only the core transactional emails.
-		$email_types = array_filter(
-			$email_types,
-			function ( $email ) use ( $core_transactional_emails ) {
-				return in_array( $email->id, $core_transactional_emails, true );
-			}
-		);
-
-		$this->default_templates = array_reduce(
-			$email_types,
-			function ( $acc, $email ) {
-				$acc[ $email->id ] = $email;
-				return $acc;
-			},
-			array()
-		);
-	}
-
 	/**
 	 * Resolve the block template name for the given email.
 	 *
@@ -218,6 +129,11 @@ class WCTransactionalEmailPostsGenerator {
 		/**
 		 * Filter the email template HTML.
 		 *
+		 * Runs wherever the file template is rendered: in admin (post creation,
+		 * divergence detection) and on the email send path (file-first
+		 * rendering) — including front-end, cron, and CLI requests. Callbacks
+		 * must not assume admin context.
+		 *
 		 * @param string    $template_html The email template HTML.
 		 * @param \WC_Email $email The email object.
 		 * @since 10.7.0
@@ -227,109 +143,19 @@ class WCTransactionalEmailPostsGenerator {
 		return is_string( $filtered_template_html ) ? $filtered_template_html : $template_html;
 	}

-	/**
-	 * Generate initial email templates.
-	 *
-	 * This function generates the initial email templates for the core transactional emails.
-	 * It checks if the templates are already generated and if not, it generates them.
-	 *
-	 * @return bool True if the templates are generated, false otherwise.
-	 */
-	public function generate_initial_email_templates() {
-		$core_transactional_emails = WCTransactionalEmails::get_transactional_emails();
-
-		$templates_to_generate = array();
-		foreach ( $core_transactional_emails as $email_type ) {
-			if ( empty( $this->template_manager->get_email_template_post_id( $email_type ) ) ) {
-				$templates_to_generate[] = $email_type;
-			}
-		}
-
-		if ( empty( $templates_to_generate ) ) {
-			return;
-		}
-
-		$result = $this->generate_email_templates( $templates_to_generate );
-
-		if ( is_wp_error( $result ) ) {
-			return false;
-		}
-
-		set_transient( $this->transient_name, Constants::get_constant( 'WC_VERSION' ), WEEK_IN_SECONDS );
-
-		// Flush rewrite rules to ensure the new templates are loaded.
-		flush_rewrite_rules();
-
-		return true;
-	}
-
-	/**
-	 * Generate email template if it doesn't exist.
-	 *
-	 * This function generates an email template if it doesn't exist.
-	 *
-	 * @param string $email_type The email type.
-	 * @return int The post ID of the generated template.
-	 * @throws \Exception When post creation fails.
-	 */
-	public function generate_email_template_if_not_exists( $email_type ) {
-		$email_data = $this->default_templates[ $email_type ];
-
-		if ( $this->template_manager->get_email_template_post_id( $email_type ) || empty( $email_data ) ) {
-			return $this->template_manager->get_email_template_post_id( $email_type );
-		}
-
-		return $this->generate_single_template( $email_type, $email_data );
-	}
-
-	/**
-	 * Generate email templates.
-	 *
-	 * This function generates the email templates for the given email types.
-	 *
-	 * @param array $templates_to_generate The email types to generate.
-	 */
-	public function generate_email_templates( $templates_to_generate ) {
-		global $wpdb;
-
-		$core_emails = array_filter(
-			$this->default_templates,
-			function ( $email_id ) use ( $templates_to_generate ) {
-				return in_array( $email_id, $templates_to_generate, true );
-			},
-			ARRAY_FILTER_USE_KEY
-		);
-
-		if ( empty( $core_emails ) ) {
-			return false;
-		}
-
-		// Start transaction.
-		$wpdb->query( 'START TRANSACTION' );
-
-		try {
-			foreach ( $core_emails as $email_type => $email_data ) {
-				$this->generate_single_template( $email_type, $email_data );
-			}
-
-			$wpdb->query( 'COMMIT' );
-			return true;
-
-		} catch ( \Exception $e ) {
-			$wpdb->query( 'ROLLBACK' );
-			return new \WP_Error( 'email_generation_failed', $e->getMessage() );
-		}
-	}
-
 	/**
 	 * Build the `wp_insert_post()` payload for a given email and apply the
 	 * `woocommerce_email_content_post_data` filter.
 	 *
 	 * Extracted so the generator and the divergence detector observe the exact
 	 * same pre-insert post payload, guaranteeing by construction that the hash
-	 * stamped in {@see self::generate_single_template()} and the hash recomputed
+	 * stamped in {@see self::create_draft()} and the hash recomputed
 	 * in `WCEmailTemplateDivergenceDetector` hash identical input.
 	 *
+	 * Note: a `post_status` returned by the filter is not honored on the
+	 * creation path — {@see self::create_draft()} forces `draft`
+	 * because the status is system-owned (only published posts are rendered).
+	 *
 	 * @param string    $email_type The email type identifier (e.g. `customer_processing_order`).
 	 * @param \WC_Email $email      The transactional email instance.
 	 * @return array The post data array after the `woocommerce_email_content_post_data` filter runs.
@@ -355,6 +181,12 @@ class WCTransactionalEmailPostsGenerator {
 		 * Allows third-party integrators to modify the post data (title, content, meta, etc.)
 		 * before the email content post is created.
 		 *
+		 * Besides post creation, this also runs whenever the canonical file
+		 * template content is computed — including the email send path
+		 * (front-end, cron, CLI), where only `post_content` from the filtered
+		 * array is used. On the creation path `post_status` is system-owned
+		 * and not honored. Callbacks must not assume admin context.
+		 *
 		 * @since 10.5.0
 		 * @param array     $post_data  The post data array to be used for wp_insert_post().
 		 * @param string    $email_type The email type identifier (e.g., 'customer_processing_order').
@@ -386,28 +218,43 @@ class WCTransactionalEmailPostsGenerator {
 	}

 	/**
-	 * Generate a single email template.
+	 * Create a draft email post for the given email.
 	 *
-	 * This function generates a single email template post and sets its postmeta association.
+	 * The draft is the editing scratchpad created when a user opens the
+	 * email editor for an email type that has no saved post yet. It stays
+	 * invisible to rendering (only published posts are used) and links to its
+	 * email type solely via the `_wc_email_type` meta — the option mapping is
+	 * written when the post is published, see
+	 * `Integration::save_email_mapping_on_publish()`.
 	 *
-	 * @param string    $email_type    The email type.
-	 * @param \WC_Email $email_data The transactional email data.
-	 * @return int The post ID of the generated template.
+	 * @param \WC_Email $email The transactional email instance.
+	 * @return int The post ID of the created draft.
 	 * @throws \Exception When post creation fails.
+	 *
+	 * @since 11.1.0
 	 */
-	private function generate_single_template( $email_type, $email_data ) {
-		$post_data = self::build_filtered_post_data( (string) $email_type, $email_data );
-
-		// Sync meta stamp for emails participating in template update propagation.
-		// VERSION + LAST_SYNCED_AT are filter-independent and can ride on `meta_input`
-		// during the insert. SOURCE_HASH must reflect the post_content WordPress
-		// actually persisted (post-`content_save_pre` filter chain), so we stamp it
-		// after the insert returns and re-fetch the post to hash its saved content.
-		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_data->id );
+	public function create_draft( \WC_Email $email ): int {
+		$email_type = (string) $email->id;
+		$post_data  = self::build_filtered_post_data( $email_type, $email );
+
+		// The status is system-owned: it must stay `draft` so the post is
+		// ignored by rendering until published, regardless of what the
+		// `woocommerce_email_content_post_data` filter returns. A regular draft
+		// is used instead of an auto-draft because the editor treats auto-draft
+		// titles as placeholders and blanks them.
+		$post_data['post_status'] = 'draft';
+
+		if ( ! isset( $post_data['meta_input'] ) || ! is_array( $post_data['meta_input'] ) ) {
+			$post_data['meta_input'] = array();
+		}
+		$post_data['meta_input'][ WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY ] = $email_type;
+
+		// Version + last-synced meta only apply to emails participating in
+		// template update propagation. Their values don't depend on what
+		// WordPress persists, so they can be written as part of the insert
+		// (unlike the source hash below, which must match the saved content).
+		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email_type );
 		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'][ WCEmailTemplateDivergenceDetector::VERSION_META_KEY ]          = (string) $sync_config['version'];
 			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY ]   = gmdate( 'Y-m-d H:i:s' );
 			$post_data['meta_input'][ WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY ] = (string) ( $post_data['post_content'] ?? '' );
@@ -419,23 +266,129 @@ class WCTransactionalEmailPostsGenerator {
 			throw new \Exception( esc_html( $post_id->get_error_message() ) );
 		}

-		if ( null !== $sync_config ) {
-			$saved_post = get_post( $post_id );
-			$saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : (string) ( $post_data['post_content'] ?? '' );
-			update_post_meta(
-				(int) $post_id,
-				WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
-				sha1( $saved_body )
-			);
-			// Freshly generated posts match canonical core by construction.
-			update_post_meta(
-				(int) $post_id,
-				WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
-				WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC
-			);
+		// The source hash is stamped for every draft — also for emails outside
+		// the sync registry — because `was_never_edited()` checks rely on it;
+		// the timestamp fallback breaks once a refresh or autosave touches
+		// `post_modified`. It must reflect the post_content WordPress actually
+		// persisted (post-`content_save_pre` filter chain), so it is stamped
+		// after the insert returns, hashing the saved content.
+		$saved_post = get_post( $post_id );
+		$saved_body = $saved_post instanceof \WP_Post ? (string) $saved_post->post_content : (string) ( $post_data['post_content'] ?? '' );
+		update_post_meta(
+			(int) $post_id,
+			WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+			sha1( $saved_body )
+		);
+		// Freshly created posts match canonical core by construction.
+		update_post_meta(
+			(int) $post_id,
+			WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC
+		);
+
+		return (int) $post_id;
+	}
+
+	/**
+	 * Initialize the email template generator.
+	 *
+	 * @deprecated 11.1.0 Email posts are created lazily when the user opens the editor; there is no initialization step anymore. No-op, will be removed in a future version.
+	 * @return void
+	 */
+	public function initialize() {
+		wc_deprecated_function( __METHOD__, '11.1.0' );
+	}
+
+	/**
+	 * Initialize the default WooCommerce Transactional Emails.
+	 *
+	 * @deprecated 11.1.0 Email posts are created lazily when the user opens the editor; default templates are no longer pre-loaded. No-op, will be removed in a future version.
+	 * @return void
+	 */
+	public function init_default_transactional_emails() {
+		wc_deprecated_function( __METHOD__, '11.1.0' );
+	}
+
+	/**
+	 * Generate initial email templates.
+	 *
+	 * @deprecated 11.1.0 Email posts are no longer bulk-generated; file templates are the rendering source until an email is customized and saved. No-op, will be removed in a future version.
+	 * @return bool Always false.
+	 */
+	public function generate_initial_email_templates() {
+		wc_deprecated_function( __METHOD__, '11.1.0' );
+		return false;
+	}
+
+	/**
+	 * Generate email templates.
+	 *
+	 * @deprecated 11.1.0 Email posts are no longer bulk-generated; file templates are the rendering source until an email is customized and saved. No-op, will be removed in a future version.
+	 * @param array $templates_to_generate The email types to generate.
+	 * @return bool Always false.
+	 */
+	public function generate_email_templates( $templates_to_generate ) {
+		unset( $templates_to_generate );
+		wc_deprecated_function( __METHOD__, '11.1.0' );
+		return false;
+	}
+
+	/**
+	 * Generate email template if it doesn't exist.
+	 *
+	 * @deprecated 11.1.0 Email posts are created lazily as drafts when the user opens the editor and become the rendering source when published. This method now creates a published post directly and will be removed in a future version.
+	 * @param string $email_type The email type.
+	 * @return int|false The post ID, or false when the email type is not registered or the post could not be created or published.
+	 */
+	public function generate_email_template_if_not_exists( $email_type ) {
+		wc_deprecated_function( __METHOD__, '11.1.0' );
+
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();
+
+		// Reuse the mapped post only when it still exists and isn't trashed —
+		// a stale mapping (post deleted or trashed out-of-band) must fall
+		// through to creating a fresh post, mirroring the recreate endpoint.
+		$existing_post = $post_manager->get_email_post( $email_type );
+		if ( $existing_post && 'trash' !== $existing_post->post_status ) {
+			return $existing_post->ID;
+		}
+
+		$email = $post_manager->get_email_by_id( (string) $email_type );
+		if ( ! $email ) {
+			return false;
+		}
+
+		// Preserve the method's original int|false contract: it never threw,
+		// so a post-creation failure must surface as false, not an exception.
+		try {
+			$post_id = $this->create_draft( $email );
+		} catch ( \Exception $e ) {
+			return false;
+		}
+
+		$updated = wp_update_post(
+			array(
+				'ID'            => $post_id,
+				'post_status'   => 'publish',
+				// An empty value makes core skip page template handling,
+				// leaving the meta as created. Omitting the key would not
+				// help: wp_update_post() fills it from the stored meta and
+				// then fails validation when the email template is not
+				// registered in the current request.
+				'page_template' => '',
+			),
+			true
+		);
+
+		// Callers expect a published, mapped, render-ready post. When
+		// publishing fails, don't map the leftover draft (the renderer
+		// ignores unpublished posts); the editor flow reuses it as the
+		// scratchpad for this email type when the user opens the editor.
+		if ( is_wp_error( $updated ) || 0 === $updated ) {
+			return false;
 		}

-		$this->template_manager->save_email_template_post_id( $email_type, $post_id );
+		$post_manager->save_email_template_post_id( $email_type, $post_id );

 		return $post_id;
 	}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.php
index 7a561462b2a..6b3fc829ad7 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.php
@@ -10,6 +10,17 @@ namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
 class WCTransactionalEmailPostsManager {
 	const WC_OPTION_NAME = 'woocommerce_email_templates_%_post_id';

+	/**
+	 * Post meta key storing the email type on lazily created `woo_email` posts.
+	 *
+	 * Unpublished posts (auto-draft/draft) have no option mapping yet, so the
+	 * meta is the only way to resolve their email type.
+	 *
+	 * @var string
+	 * @since 11.1.0
+	 */
+	const EMAIL_TYPE_META_KEY = '_wc_email_type';
+
 	/**
 	 * Cache group for email template lookups.
 	 *
@@ -45,6 +56,13 @@ class WCTransactionalEmailPostsManager {
 	 */
 	private $email_class_name_cache = array();

+	/**
+	 * Whether all mapping options were already loaded into the options cache during this request.
+	 *
+	 * @var bool
+	 */
+	private $mapping_options_primed = false;
+
 	/**
 	 * Gets the singleton instance of the class.
 	 *
@@ -173,7 +191,21 @@ class WCTransactionalEmailPostsManager {
 		);

 		if ( empty( $option_name ) ) {
-			return null;
+			// Unpublished posts (auto-draft/draft) have no option mapping yet; fall back to post meta.
+			// The result is intentionally NOT stored in the shared caches:
+			// `get_email_template_post_id()` reverse-searches the in-memory cache
+			// and must only ever see mapped posts, and the object cache has no
+			// invalidation for scratchpad deletion. Repeated calls are cheap via
+			// the post meta cache.
+			$email_type = get_post_meta( $post_id, self::EMAIL_TYPE_META_KEY, true );
+			if ( empty( $email_type ) || ! is_string( $email_type ) ) {
+				// Cache the full miss in memory so repeated lookups within the
+				// request don't rerun the options LIKE scan.
+				$this->post_id_to_email_type_cache[ $post_id ] = null;
+				return null;
+			}
+
+			return $email_type;
 		}

 		$email_type = $this->get_email_type_from_option_name( $option_name );
@@ -238,6 +270,8 @@ class WCTransactionalEmailPostsManager {
 			return $post_id_from_cache;
 		}

+		$this->maybe_prime_mapping_option_caches();
+
 		$option_name = $this->get_option_name( $email_type );
 		$post_id     = get_option( $option_name );

@@ -254,9 +288,14 @@ class WCTransactionalEmailPostsManager {
 	/**
 	 * Deletes the post ID for a specific email template type.
 	 *
-	 * @param string $email_type The type of email template e.g. 'customer_new_account' from the WC_Email->id property.
+	 * @param string   $email_type      The type of email template e.g. 'customer_new_account' from the WC_Email->id property.
+	 * @param int|null $only_if_post_id When given, the mapping is only deleted if it points at this post ID.
+	 *                                  Guards against deleting the mapping of a different post for the same
+	 *                                  email type (e.g. when an unpublished scratchpad post is deleted while
+	 *                                  another post is mapped). Compared against the persisted option value.
+	 *                                  Added in 11.1.0.
 	 */
-	public function delete_email_template( $email_type ) {
+	public function delete_email_template( $email_type, $only_if_post_id = null ) {
 		$option_name = $this->get_option_name( $email_type );
 		$post_id     = get_option( $option_name );

@@ -264,6 +303,10 @@ class WCTransactionalEmailPostsManager {
 			return;
 		}

+		if ( null !== $only_if_post_id && (int) $post_id !== (int) $only_if_post_id ) {
+			return;
+		}
+
 		delete_option( $option_name );

 		// Invalidate cache.
@@ -326,6 +369,27 @@ class WCTransactionalEmailPostsManager {
 		return str_replace( '%', $email_type, self::WC_OPTION_NAME );
 	}

+	/**
+	 * Prime the option caches for all known email type mappings in one query.
+	 *
+	 * With lazy post creation most mappings don't exist, and without priming
+	 * each lookup of a missing mapping costs one SELECT on sites without a
+	 * persistent object cache. Runs once per request.
+	 */
+	private function maybe_prime_mapping_option_caches(): void {
+		if ( $this->mapping_options_primed || ! function_exists( 'wp_prime_option_caches' ) ) {
+			return;
+		}
+		$this->mapping_options_primed = true;
+
+		wp_prime_option_caches(
+			array_map(
+				array( $this, 'get_option_name' ),
+				WCTransactionalEmails::get_transactional_emails()
+			)
+		);
+	}
+
 	/**
 	 * Gets the email type from the option name.
 	 *
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
index d86edb516b1..6670076b56e 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmails.php
@@ -41,29 +41,17 @@ class WCTransactionalEmails {
 		'new_order',
 	);

-	/**
-	 * Email template generator instance.
-	 *
-	 * @var WCTransactionalEmailPostsGenerator
-	 */
-	private $email_template_generator;
-
-	/**
-	 * Constructor.
-	 *
-	 * Initializes the WCTransactionalEmailPostsGenerator by setting up the template generator.
-	 */
-	public function __construct() {
-		$this->email_template_generator = new WCTransactionalEmailPostsGenerator();
-	}
-
 	/**
 	 * Initialize the class.
 	 *
+	 * Email posts are no longer generated on initialization. They are created
+	 * lazily (as drafts) when a user opens the email editor for a specific
+	 * email type, following the WordPress Site Editor pattern where file
+	 * templates are the source of truth until the user edits and saves.
+	 *
 	 * @internal
 	 */
 	final public function init() {
-		add_action( 'current_screen', array( $this, 'init_email_templates' ), 50 );
 	}

 	/**
@@ -106,27 +94,11 @@ class WCTransactionalEmails {

 	/**
 	 * Initialize email templates on WooCommerce admin pages.
+	 *
+	 * @deprecated 11.1.0 Email posts are no longer generated on admin page loads; they are created lazily when the user opens the editor. No-op, will be removed in a future version.
+	 * @return void
 	 */
 	public function init_email_templates() {
-		if ( ! function_exists( 'wc_get_screen_ids' ) ) {
-			return;
-		}
-
-		$screen = get_current_screen();
-
-		$wc_screen_ids = array_merge(
-			wc_get_screen_ids(),
-			array(
-				'woocommerce_page_wc-admin',
-				'edit-woo_email',
-			)
-		);
-
-		if ( ! $screen || ! in_array( $screen->id, $wc_screen_ids, true ) ) {
-			return;
-		}
-
-		// run only on WooCommerce admin pages.
-		$this->email_template_generator->initialize();
+		wc_deprecated_function( __METHOD__, '11.1.0' );
 	}
 }
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php b/plugins/woocommerce/tests/e2e/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
index 008642f1b16..d8747f58823 100644
--- a/plugins/woocommerce/tests/e2e/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
+++ b/plugins/woocommerce/tests/e2e/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
@@ -189,8 +189,8 @@ class REST_Controller {
 	}

 	/**
-	 * Delete the woo_email post for the given email type, clear template manager state +
-	 * transient, then regenerate synchronously.
+	 * Delete the woo_email post for the given email type, clear template manager state,
+	 * then recreate a published post from the file template synchronously.
 	 *
 	 * @param WP_REST_Request $request The REST request. Expects `email_id` route parameter.
 	 * @return WP_REST_Response
@@ -207,19 +207,37 @@ class REST_Controller {

 		$manager->delete_email_template( $email_id );

-		delete_transient( 'wc_email_editor_initial_templates_generated' );
+		$email = $manager->get_email_by_id( $email_id );
+		if ( ! $email instanceof \WC_Email ) {
+			return new WP_REST_Response(
+				array( 'error' => "Unknown email_id {$email_id}" ),
+				404
+			);
+		}

 		$generator = new \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-		$new_post_id = (int) $generator->generate_email_template_if_not_exists( $email_id );

-		if ( $new_post_id <= 0 ) {
+		try {
+			$new_post_id = $generator->create_draft( $email );
+		} catch ( \Exception $e ) {
 			return new WP_REST_Response(
-				array( 'error' => "Failed to regenerate woo_email post for {$email_id}" ),
+				array( 'error' => "Failed to regenerate woo_email post for {$email_id}: " . $e->getMessage() ),
 				500
 			);
 		}

+		wp_update_post(
+			array(
+				'ID'          => $new_post_id,
+				'post_status' => 'publish',
+			)
+		);
+
+		// The Integration transition_post_status hook writes the mapping on publish
+		// in this live environment; save it explicitly as well so this endpoint's
+		// callers can rely on the mapping regardless of hook registration order.
+		$manager->save_email_template_post_id( $email_id, $new_post_id );
+
 		return new WP_REST_Response( array( 'post_id' => $new_post_id ), 200 );
 	}

diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/helpers/enable-email-editor-feature.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/helpers/enable-email-editor-feature.ts
index 3b366be87b7..1a97c09754c 100644
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/helpers/enable-email-editor-feature.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/helpers/enable-email-editor-feature.ts
@@ -48,7 +48,8 @@ export const disableEmailEditor = async ( baseURL: string ) =>
 	setEmailEditorFeatureFlag( baseURL, 'no' );

 /**
- * Delete an email post.
+ * Delete an email post, reverting the email to render from its file
+ * template. The editor creates a fresh post on the next edit.
  *
  * @param {string} baseURL The base URL.
  * @param {string} pageId  The page ID.
@@ -65,13 +66,6 @@ export const deleteEmailPost = async ( baseURL: string, pageId: string ) => {
 	await apiClient.delete(
 		`${ WP_API_PATH }/woo_email/${ pageId }?force=true`
 	);
-
-	// clear the transient. It will force post regeneration.
-	await deleteOption(
-		request,
-		baseURL,
-		'_transient_wc_email_editor_initial_templates_generated'
-	);
 };

 /**
diff --git a/plugins/woocommerce/tests/e2e/tests/email/editor-tracking-selectors.spec.ts b/plugins/woocommerce/tests/e2e/tests/email/editor-tracking-selectors.spec.ts
index d8670686d3c..c862e2498cb 100644
--- a/plugins/woocommerce/tests/e2e/tests/email/editor-tracking-selectors.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email/editor-tracking-selectors.spec.ts
@@ -7,7 +7,7 @@ import { test, expect, request } from '@playwright/test';
  * Internal dependencies
  */
 import { setOption } from '../../utils/options';
-import { getWooEmails } from '../../utils/email';
+import { accessTheEmailEditor } from '../../utils/email';
 import { ADMIN_STATE_PATH } from '../../playwright.config';

 const setFeatureFlag = async ( baseURL: string | undefined, value: string ) => {
@@ -40,13 +40,9 @@ test.describe( 'WooCommerce Email Editor Tracking Selectors', () => {
 	} ) => {
 		await setFeatureFlag( baseURL, 'yes' );

-		// Navigate to WooCommerce Email Settings page to generate email posts
-		await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=email' );
-		const emails = await getWooEmails();
-
-		await page.goto(
-			`wp-admin/post.php?post=${ emails.data[ 0 ].id }&action=edit`
-		);
+		// Open an email through the listing — with lazy post creation the
+		// Edit action creates the post on demand before opening the editor.
+		await accessTheEmailEditor( page, 'New order' );

 		// Check that the Editor is present
 		const editorLocator = page.locator( '#woocommerce-email-editor' );
diff --git a/plugins/woocommerce/tests/e2e/tests/email/order-emails-block-editor.spec.ts b/plugins/woocommerce/tests/e2e/tests/email/order-emails-block-editor.spec.ts
new file mode 100644
index 00000000000..06daf70cbef
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/email/order-emails-block-editor.spec.ts
@@ -0,0 +1,221 @@
+/**
+ * External dependencies
+ */
+import { faker } from '@faker-js/faker';
+import { request } from '@playwright/test';
+import {
+	createClient,
+	WC_API_PATH,
+	WP_API_PATH,
+} from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+import { expect, test as baseTest } from '../../fixtures/fixtures';
+import { admin } from '../../test-data/data';
+import { setOption } from '../../utils/options';
+import { accessTheEmailEditor, expectEmail } from '../../utils/email';
+
+/**
+ * End-to-end coverage for file-first block email rendering (WOOPLUG-6171):
+ * with the block email editor enabled, a transactional email renders from its
+ * file template until the merchant edits AND saves it — a draft scratchpad
+ * (even one carrying unsaved edits) never affects what customers receive.
+ *
+ * Uses the customer "Processing order" email; delivery is asserted through the
+ * WP Mail Logging inbox like the classic `order-emails.spec.ts`.
+ */
+
+const EMAIL_LISTING_TITLE = 'Order confirmation';
+const EMAIL_TYPE = 'customer_processing_order';
+const SUBJECT_REGEX = /Your .+ order has been received!/;
+// Footer text unique to the `wooemailtemplate` block template — proves the
+// email was rendered through the block pipeline, not the classic one.
+const BLOCK_TEMPLATE_FOOTER = 'All Rights Reserved';
+const DRAFT_MARKER = 'WOOPLUG6171_DRAFT_ONLY_MARKER';
+
+const test = baseTest.extend( {
+	storageState: ADMIN_STATE_PATH,
+} );
+
+test.describe.configure( { mode: 'serial' } );
+
+const createAdminApiClient = ( baseURL: string ) =>
+	createClient( baseURL, {
+		type: 'basic',
+		username: admin.username,
+		password: admin.password,
+	} );
+
+const deleteEmailTypePosts = async ( baseURL: string ) => {
+	const apiClient = createAdminApiClient( baseURL );
+	const posts = await apiClient.get( `${ WP_API_PATH }/woo_email`, {
+		status: 'publish,draft',
+		per_page: 100,
+	} );
+	for ( const post of posts.data ) {
+		if ( ( post.slug as string ).startsWith( EMAIL_TYPE ) ) {
+			await apiClient.delete( `${ WP_API_PATH }/woo_email/${ post.id }`, {
+				force: true,
+			} );
+		}
+	}
+};
+
+const orderIds: number[] = [];
+
+test.beforeAll( async ( { baseURL } ) => {
+	await setOption(
+		request,
+		baseURL,
+		'woocommerce_feature_block_email_editor_enabled',
+		'yes'
+	);
+	// Start from a clean slate in case another spec left a post behind.
+	await deleteEmailTypePosts( baseURL );
+} );
+
+test.afterAll( async ( { baseURL } ) => {
+	const apiClient = createAdminApiClient( baseURL );
+	for ( const orderId of orderIds ) {
+		await apiClient.delete( `${ WC_API_PATH }/orders/${ orderId }`, {
+			force: true,
+		} );
+	}
+	// Delete posts while the feature is still enabled so the
+	// `before_delete_post` hook also clears the email type → post mapping.
+	await deleteEmailTypePosts( baseURL );
+	await setOption(
+		request,
+		baseURL,
+		'woocommerce_feature_block_email_editor_enabled',
+		'no'
+	);
+} );
+
+/**
+ * Create a processing order (which sends the customer email) and open its
+ * logged email in the WP Mail Logging modal.
+ *
+ * @param {import('@playwright/test').Page} page    The Playwright page.
+ * @param {*}                               restApi The REST API client fixture.
+ * @return {Promise<import('@playwright/test').FrameLocator>} Locator of the logged email body frame.
+ */
+const triggerOrderEmailAndOpenLog = async ( page, restApi ) => {
+	const customerEmail = faker.internet.exampleEmail();
+	const orderResponse = await restApi.post( `${ WC_API_PATH }/orders`, {
+		status: 'processing',
+		billing: { email: customerEmail },
+	} );
+	orderIds.push( orderResponse.data.id );
+
+	const emailRow = await expectEmail( page, customerEmail, SUBJECT_REGEX );
+	await emailRow.getByRole( 'button', { name: 'View log' } ).click();
+
+	const modalContent = page.locator(
+		'#wp-mail-logging-modal-content-body-content'
+	);
+	await expect(
+		modalContent.getByText( `Receiver ${ customerEmail }` )
+	).toBeVisible();
+
+	return modalContent.locator( 'iframe' ).contentFrame();
+};
+
+test( 'uncustomized email is sent from the file template', async ( {
+	page,
+	restApi,
+} ) => {
+	const emailBody = await triggerOrderEmailAndOpenLog( page, restApi );
+
+	await expect( emailBody.locator( 'body' ) ).toContainText(
+		'is now being processed'
+	);
+	await expect( emailBody.locator( 'body' ) ).toContainText(
+		BLOCK_TEMPLATE_FOOTER
+	);
+} );
+
+test( 'draft edits do not affect sent emails until saved', async ( {
+	page,
+	restApi,
+} ) => {
+	// Opening the editor lazily creates the draft scratchpad with the file
+	// template content.
+	await accessTheEmailEditor( page, EMAIL_LISTING_TITLE );
+	await expect(
+		page
+			.locator( 'iframe[name="editor-canvas"]' )
+			.contentFrame()
+			.getByText( 'Thank you for your order' )
+	).toBeVisible();
+
+	// Write an edit into the draft via REST — a deterministic stand-in for
+	// the editor's remote autosave (which fires on a 60s interval).
+	const drafts = await restApi.get( `${ WP_API_PATH }/woo_email`, {
+		status: 'draft',
+		context: 'edit',
+		per_page: 100,
+	} );
+	const draft = drafts.data.find( ( post ) =>
+		( post.slug as string ).startsWith( EMAIL_TYPE )
+	);
+	expect( draft ).toBeTruthy();
+	await restApi.post( `${ WP_API_PATH }/woo_email/${ draft.id }`, {
+		content: `${ draft.content.raw }\n<!-- wp:paragraph --><p>${ DRAFT_MARKER }</p><!-- /wp:paragraph -->`,
+	} );
+
+	const emailBody = await triggerOrderEmailAndOpenLog( page, restApi );
+
+	await expect( emailBody.locator( 'body' ) ).toContainText(
+		BLOCK_TEMPLATE_FOOTER
+	);
+	await expect( emailBody.locator( 'body' ) ).not.toContainText(
+		DRAFT_MARKER
+	);
+} );
+
+test( 'saved email is sent from the customized post', async ( {
+	page,
+	restApi,
+} ) => {
+	// The editor reuses the edited draft (edits must survive reopening).
+	await accessTheEmailEditor( page, EMAIL_LISTING_TITLE );
+	await expect(
+		page
+			.locator( 'iframe[name="editor-canvas"]' )
+			.contentFrame()
+			.getByText( DRAFT_MARKER )
+	).toBeVisible();
+
+	// Save publishes the draft in the background, making it the rendering
+	// source.
+	await page.getByRole( 'button', { name: 'Save', exact: true } ).click();
+
+	const drafts = await restApi.get( `${ WP_API_PATH }/woo_email`, {
+		status: 'publish,draft',
+		per_page: 100,
+	} );
+	const post = drafts.data.find( ( item ) =>
+		( item.slug as string ).startsWith( EMAIL_TYPE )
+	);
+	expect( post ).toBeTruthy();
+	await expect
+		.poll(
+			async () => {
+				const response = await restApi.get(
+					`${ WP_API_PATH }/woo_email/${ post.id }`,
+					{ context: 'edit' }
+				);
+				return response.data.status;
+			},
+			{ timeout: 20000 }
+		)
+		.toBe( 'publish' );
+
+	const emailBody = await triggerOrderEmailAndOpenLog( page, restApi );
+
+	await expect( emailBody.locator( 'body' ) ).toContainText( DRAFT_MARKER );
+} );
diff --git a/plugins/woocommerce/tests/e2e/tests/email/settings-email-listing.spec.ts b/plugins/woocommerce/tests/e2e/tests/email/settings-email-listing.spec.ts
index ce1c1e2250a..385ba49af17 100644
--- a/plugins/woocommerce/tests/e2e/tests/email/settings-email-listing.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email/settings-email-listing.spec.ts
@@ -118,4 +118,32 @@ test.describe( 'WooCommerce Email Settings List View', () => {
 		// Add 1 to account for header row
 		await expect( rows ).toHaveCount( 2 );
 	} );
+
+	test( 'Preview action renders the file template for emails without a saved post', async ( {
+		page,
+		baseURL,
+	} ) => {
+		await setBlockEmailEditorFeatureFlag( baseURL, 'yes' );
+
+		await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=email' );
+		const listViewLocator = page.locator(
+			'.woocommerce-email-listing-listview'
+		);
+		await expect( listViewLocator ).toBeVisible();
+
+		// A row no other spec creates a post for, so it renders from the file
+		// template and the Preview action must use the admin preview page.
+		const row = listViewLocator.locator( 'tr', {
+			hasText: 'Order on hold',
+		} );
+		const popupPromise = page.waitForEvent( 'popup' );
+		await row.getByRole( 'button', { name: 'Preview' } ).click();
+		const popup = await popupPromise;
+
+		await expect( popup ).toHaveURL( /preview_woo_block_email/ );
+		// The wooemailtemplate chrome proves the block pipeline rendered it.
+		await expect( popup.locator( 'body' ) ).toContainText(
+			'All Rights Reserved'
+		);
+	} );
 } );
diff --git a/plugins/woocommerce/tests/e2e/utils/email.ts b/plugins/woocommerce/tests/e2e/utils/email.ts
index 4e38b2be6ea..5727dcc27ce 100644
--- a/plugins/woocommerce/tests/e2e/utils/email.ts
+++ b/plugins/woocommerce/tests/e2e/utils/email.ts
@@ -2,14 +2,11 @@
  * External dependencies
  */
 import type { Page } from '@playwright/test';
-import { createClient, WP_API_PATH } from '@woocommerce/e2e-utils-playwright';

 /**
  * Internal dependencies
  */
 import { expect } from '../fixtures/fixtures';
-import { admin } from '../test-data/data';
-import playwrightConfig from '../playwright.config';

 /**
  * Check that an email exists in the WP Mail Logging plugin Email Log page. WP Mail Logging plugin must be installed.
@@ -80,18 +77,6 @@ export async function expectEmailContent(
 	);
 }

-export async function getWooEmails( params: any ) {
-	const apiClient = createClient( playwrightConfig.use.baseURL, {
-		type: 'basic',
-		username: admin.username,
-		password: admin.password,
-	} );
-	const emails = await apiClient.get( `${ WP_API_PATH }/woo_email`, {
-		...params,
-	} );
-	return emails;
-}
-
 /**
  * Access the email editor and using the WooCommerce settings page.
  * Note: Ensure the block email editor feature flag is already enabled.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Emails/EmailListingRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Emails/EmailListingRestControllerTest.php
new file mode 100644
index 00000000000..83315dcd5c7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Emails/EmailListingRestControllerTest.php
@@ -0,0 +1,482 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin\Emails;
+
+use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\Admin\Emails\EmailListingRestController;
+use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+use WC_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Tests for the recreate-email-post endpoint of the EmailListingRestController class.
+ */
+class EmailListingRestControllerTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Email type used throughout the tests. Registered by core and covered by the sync registry.
+	 *
+	 * @var string
+	 */
+	const EMAIL_ID = 'customer_processing_order';
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var EmailListingRestController
+	 */
+	private $sut;
+
+	/**
+	 * Transactional email post manager singleton.
+	 *
+	 * @var WCTransactionalEmailPostsManager
+	 */
+	private WCTransactionalEmailPostsManager $posts_manager;
+
+	/**
+	 * Keys of WC_Email stubs injected into WC_Emails::$emails, for teardown.
+	 *
+	 * @var string[]
+	 */
+	private array $injected_email_keys = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+
+		// Eagerly boot \WC_Emails so registered transactional emails resolve.
+		\WC_Emails::instance();
+
+		$this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+		$this->posts_manager->clear_caches();
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		// Pre-seed the flush marker so tests don't pay for flush_rewrite_rules().
+		update_option( EmailListingRestController::REWRITE_FLUSH_OPTION, (string) Constants::get_constant( 'WC_VERSION' ), false );
+
+		$this->sut = new EmailListingRestController();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	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_email_block_template_html' );
+		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+		$this->posts_manager->clear_caches();
+		WCEmailTemplateSyncRegistry::reset_cache();
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should create a draft with email type, template and sync metas, without writing the option mapping.
+	 */
+	public function test_creates_draft_with_metas_and_sync_stamps(): void {
+		$response = $this->call_recreate_email_post( self::EMAIL_ID );
+
+		$this->assertIsArray( $response );
+		$this->assertArrayHasKey( 'message', $response );
+		$this->assertIsString( $response['message'] );
+		$this->assertArrayHasKey( 'post_id', $response );
+		$this->assertIsString( $response['post_id'] );
+
+		$post = get_post( (int) $response['post_id'] );
+		$this->assertInstanceOf( \WP_Post::class, $post );
+		$this->assertSame( 'draft', $post->post_status, 'The scratchpad must be created as a draft' );
+		$this->assertSame(
+			self::EMAIL_ID,
+			get_post_meta( $post->ID, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, true ),
+			'The draft must carry the email type meta'
+		);
+		$this->assertSame(
+			( new WooEmailTemplate() )->get_slug(),
+			get_post_meta( $post->ID, '_wp_page_template', true ),
+			'The draft must carry the email template slug meta'
+		);
+
+		$this->assertSame(
+			sha1( (string) $post->post_content ),
+			get_post_meta( $post->ID, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+			'The source hash must match the persisted post content'
+		);
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			get_post_meta( $post->ID, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
+		$this->assertNotSame(
+			'',
+			(string) get_post_meta( $post->ID, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+			'Registry-covered emails must be stamped with the template version'
+		);
+
+		$this->assertFalse(
+			get_option( 'woocommerce_email_templates_' . self::EMAIL_ID . '_post_id' ),
+			'The option mapping must not be written for a draft — only publishing writes it'
+		);
+	}
+
+	/**
+	 * An edited scratchpad is reused as-is, never refreshed: refreshing would
+	 * destroy the merchant's unsaved work. Customers are unaffected — drafts
+	 * never render (sends use the file template until the post is published),
+	 * and after publishing, core template updates surface through the regular
+	 * update-propagation flow.
+	 *
+	 * @testdox Should reuse the same scratchpad post when it was edited since creation.
+	 */
+	public function test_second_call_reuses_scratchpad_after_edit(): void {
+		$first_response = $this->call_recreate_email_post( self::EMAIL_ID );
+		$this->assertIsArray( $first_response );
+		$post_id = (int) $first_response['post_id'];
+
+		$edited_content = '<!-- wp:paragraph --><p>MERCHANT_EDITED_MARKER</p><!-- /wp:paragraph -->';
+		wp_update_post(
+			array(
+				'ID'           => $post_id,
+				'post_content' => $edited_content,
+			)
+		);
+
+		$second_response = $this->call_recreate_email_post( self::EMAIL_ID );
+
+		$this->assertIsArray( $second_response );
+		$this->assertSame( (string) $post_id, $second_response['post_id'], 'An edited scratchpad must be reused, keeping the same post ID' );
+
+		$post = get_post( $post_id );
+		$this->assertInstanceOf( \WP_Post::class, $post );
+		$this->assertStringContainsString( 'MERCHANT_EDITED_MARKER', $post->post_content, 'The edited content must survive the second call' );
+	}
+
+	/**
+	 * @testdox Should refresh a never-edited scratchpad in place so it reflects the current file template.
+	 */
+	public function test_second_call_refreshes_untouched_scratchpad_in_place(): void {
+		$first_response = $this->call_recreate_email_post( self::EMAIL_ID );
+		$this->assertIsArray( $first_response );
+		$first_post_id = (int) $first_response['post_id'];
+
+		// The file template moves between the two calls; a never-edited scratchpad must pick that up.
+		add_filter(
+			'woocommerce_email_block_template_html',
+			static function ( $template_html ) {
+				return $template_html . "\n<!-- wp:paragraph --><p>FRESH_TEMPLATE_MARKER</p><!-- /wp:paragraph -->";
+			}
+		);
+		// The title is system-owned and must move with the content; the same
+		// post-data filter that customizes creation also drives the refresh.
+		add_filter(
+			'woocommerce_email_content_post_data',
+			static function ( $post_data ) {
+				$post_data['post_title'] = 'Fresh Template Title';
+				return $post_data;
+			}
+		);
+
+		// Simulate a template-version bump since creation: the refresh must
+		// restamp the version meta along with the content.
+		update_post_meta( $first_post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, '0.0.1' );
+
+		$second_response = $this->call_recreate_email_post( self::EMAIL_ID );
+
+		$this->assertIsArray( $second_response );
+		$second_post_id = (int) $second_response['post_id'];
+
+		// The post ID stays stable — another admin may have the editor open on it.
+		$this->assertSame( $first_post_id, $second_post_id, 'A never-edited scratchpad must be reused under the same post ID' );
+
+		$refreshed_post = get_post( $second_post_id );
+		$this->assertInstanceOf( \WP_Post::class, $refreshed_post );
+		$this->assertStringContainsString( 'FRESH_TEMPLATE_MARKER', $refreshed_post->post_content, 'The refreshed scratchpad must contain the current file template content' );
+		$this->assertSame( 'Fresh Template Title', $refreshed_post->post_title, 'The system-owned title must be refreshed along with the content' );
+
+		// The refresh must move the sync baseline along with the content, so the
+		// scratchpad still counts as never-edited on subsequent calls.
+		$stored_hash = get_post_meta( $second_post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+		$this->assertSame( sha1( (string) $refreshed_post->post_content ), $stored_hash, 'The source hash must be restamped for the refreshed content' );
+
+		$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( self::EMAIL_ID );
+		$this->assertNotNull( $sync_config );
+		$this->assertSame(
+			(string) $sync_config['version'],
+			get_post_meta( $second_post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+			'The template version must be restamped along with the refreshed content'
+		);
+
+		// The refresh passes `page_template => ''` so core skips template
+		// handling; this guards against a core behavior change that would
+		// clear the association on an empty value.
+		$this->assertSame(
+			( new WooEmailTemplate() )->get_slug(),
+			get_post_meta( $second_post_id, '_wp_page_template', true ),
+			'The template meta must survive a content refresh'
+		);
+	}
+
+	/**
+	 * @testdox Should refresh the scratchpad when only the title changed, leaving the content identical.
+	 */
+	public function test_second_call_refreshes_scratchpad_on_title_only_change(): void {
+		$first_response = $this->call_recreate_email_post( self::EMAIL_ID );
+		$this->assertIsArray( $first_response );
+		$first_post_id = (int) $first_response['post_id'];
+
+		// Only the title moves — e.g. a plugin update renaming the email; the
+		// content-equality early return must not skip the refresh.
+		add_filter(
+			'woocommerce_email_content_post_data',
+			static function ( $post_data ) {
+				$post_data['post_title'] = 'Renamed Email Title';
+				return $post_data;
+			}
+		);
+
+		$second_response = $this->call_recreate_email_post( self::EMAIL_ID );
+		$this->assertIsArray( $second_response );
+
+		$refreshed_post = get_post( $first_post_id );
+		$this->assertSame( 'Renamed Email Title', $refreshed_post->post_title, 'A title-only change must still refresh the scratchpad' );
+	}
+
+	/**
+	 * An email is outside the sync registry when its block template file has no
+	 * parseable `@version` header — typically a third-party email that opted in
+	 * via `woocommerce_transactional_emails_for_block_editor` without adopting
+	 * the version-header convention. Such emails get no template-version meta
+	 * and no update propagation, but scratchpad handling must work the same.
+	 *
+	 * @testdox Should preserve merchant edits on a scratchpad for an email outside the sync registry.
+	 */
+	public function test_non_sync_email_edited_scratchpad_is_preserved(): void {
+		$email_id = 'wc_test_listing_email_no_version';
+		$this->register_non_sync_email( $email_id );
+
+		$first_response = $this->call_recreate_email_post( $email_id );
+		$this->assertIsArray( $first_response );
+		$post_id = (int) $first_response['post_id'];
+
+		$edited_content = '<!-- wp:paragraph --><p>NON_SYNC_EDITED_MARKER</p><!-- /wp:paragraph -->';
+		wp_update_post(
+			array(
+				'ID'           => $post_id,
+				'post_content' => $edited_content,
+			)
+		);
+
+		$second_response = $this->call_recreate_email_post( $email_id );
+
+		$this->assertIsArray( $second_response );
+		$this->assertSame( (string) $post_id, $second_response['post_id'] );
+		$this->assertStringContainsString(
+			'NON_SYNC_EDITED_MARKER',
+			(string) get_post( $post_id )->post_content,
+			'A refresh must never overwrite merchant edits, also for emails outside the sync registry'
+		);
+	}
+
+	/**
+	 * @testdox Should keep refreshing an untouched scratchpad for an email outside the sync registry across repeated calls.
+	 */
+	public function test_non_sync_email_untouched_scratchpad_is_refreshed_repeatedly(): void {
+		$email_id = 'wc_test_listing_email_no_version';
+		$this->register_non_sync_email( $email_id );
+
+		$first_response = $this->call_recreate_email_post( $email_id );
+		$this->assertIsArray( $first_response );
+		$post_id = (int) $first_response['post_id'];
+
+		// A refresh bumps `post_modified`, so recognizing the scratchpad as
+		// untouched afterwards depends on the source hash being stamped for
+		// non-registry emails too. Two refresh rounds pin that.
+		foreach ( array( 'FIRST_REFRESH_MARKER', 'SECOND_REFRESH_MARKER' ) as $marker ) {
+			remove_all_filters( 'woocommerce_email_block_template_html' );
+			add_filter(
+				'woocommerce_email_block_template_html',
+				static function ( $template_html ) use ( $marker ) {
+					return $template_html . "\n<!-- wp:paragraph --><p>" . $marker . '</p><!-- /wp:paragraph -->';
+				}
+			);
+
+			$response = $this->call_recreate_email_post( $email_id );
+
+			$this->assertIsArray( $response );
+			$this->assertSame( (string) $post_id, $response['post_id'] );
+			$this->assertStringContainsString(
+				$marker,
+				(string) get_post( $post_id )->post_content,
+				'An untouched non-sync scratchpad must pick up the current file template on every open'
+			);
+		}
+	}
+
+	/**
+	 * @testdox Should return the existing post ID when a published mapped post exists.
+	 */
+	public function test_returns_existing_published_mapped_post_id(): void {
+		$email = $this->posts_manager->get_email_by_id( self::EMAIL_ID );
+		$this->assertInstanceOf( \WC_Email::class, $email );
+
+		$generator = new WCTransactionalEmailPostsGenerator();
+		$post_id   = $generator->create_draft( $email );
+		wp_update_post(
+			array(
+				'ID'          => $post_id,
+				'post_status' => 'publish',
+			)
+		);
+		// The mapping is normally written by the publish transition hook; write it explicitly
+		// because Integration::register_hooks() did not run in this test.
+		$this->posts_manager->save_email_template_post_id( self::EMAIL_ID, $post_id );
+
+		$response = $this->call_recreate_email_post( self::EMAIL_ID );
+
+		$this->assertIsArray( $response );
+		$this->assertSame( (string) $post_id, $response['post_id'], 'A published mapped post must be returned as-is' );
+		$this->assertIsString( $response['message'] );
+	}
+
+	/**
+	 * @testdox Should stamp the rewrite-flush option with the current WC version when it is missing.
+	 */
+	public function test_endpoint_stamps_rewrite_flush_option(): void {
+		delete_option( EmailListingRestController::REWRITE_FLUSH_OPTION );
+
+		$response = $this->call_recreate_email_post( self::EMAIL_ID );
+		$this->assertIsArray( $response );
+
+		$this->assertSame(
+			(string) Constants::get_constant( 'WC_VERSION' ),
+			get_option( EmailListingRestController::REWRITE_FLUSH_OPTION ),
+			'The endpoint must flush rewrite rules once per WC version and stamp the option'
+		);
+	}
+
+	/**
+	 * @testdox Should create a fresh draft when the mapped post was trashed.
+	 */
+	public function test_trashed_mapped_post_gets_fresh_draft(): void {
+		$email = $this->posts_manager->get_email_by_id( self::EMAIL_ID );
+		$this->assertInstanceOf( \WC_Email::class, $email );
+
+		$generator = new WCTransactionalEmailPostsGenerator();
+		$post_id   = $generator->create_draft( $email );
+		wp_update_post(
+			array(
+				'ID'          => $post_id,
+				'post_status' => 'publish',
+			)
+		);
+		$this->posts_manager->save_email_template_post_id( self::EMAIL_ID, $post_id );
+		wp_trash_post( $post_id );
+
+		$response = $this->call_recreate_email_post( self::EMAIL_ID );
+
+		$this->assertIsArray( $response );
+		$this->assertNotSame( (string) $post_id, $response['post_id'], 'A trashed mapped post must not be returned' );
+
+		$new_post = get_post( (int) $response['post_id'] );
+		$this->assertInstanceOf( \WP_Post::class, $new_post );
+		$this->assertSame( 'draft', $new_post->post_status, 'A fresh draft must be created instead of the trashed post' );
+	}
+
+	/**
+	 * @testdox Should return an error when the email type is not registered.
+	 */
+	public function test_returns_error_for_unregistered_email_type(): void {
+		$response = $this->call_recreate_email_post( 'this_email_type_does_not_exist' );
+
+		$this->assertWPError( $response );
+		$this->assertSame( 'woocommerce_rest_email_post_generation_failed', $response->get_error_code() );
+	}
+
+	/**
+	 * @testdox Deprecated initialize_template_generator() is a no-op that triggers a deprecation notice.
+	 */
+	public function test_deprecated_initialize_template_generator_is_noop(): void {
+		$this->setExpectedDeprecated( EmailListingRestController::class . '::initialize_template_generator' );
+
+		$this->sut->initialize_template_generator();
+	}
+
+	/**
+	 * Inject a WC_Email stub whose block template lacks a parseable @version
+	 * header, so the email is absent from the sync registry.
+	 *
+	 * @param string $email_id Email ID to inject.
+	 * @return \WC_Email The injected stub.
+	 */
+	private function register_non_sync_email( string $email_id ): \WC_Email {
+		$stub = $this->getMockBuilder( \WC_Email::class )
+			->disableOriginalConstructor()
+			->getMock();
+		$stub->method( 'get_title' )->willReturn( 'Third-party listing test email' );
+		$stub->method( 'get_description' )->willReturn( 'Fixture email without a parseable @version header.' );
+		$stub->id             = $email_id;
+		$stub->template_base  = dirname( __DIR__, 2 ) . '/EmailEditor/WCTransactionalEmails/fixtures/';
+		$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;
+			}
+		);
+
+		WCEmailTemplateSyncRegistry::reset_cache();
+
+		return $stub;
+	}
+
+	/**
+	 * Call the recreate_email_post handler with a crafted request.
+	 *
+	 * @param string $email_id The email ID request parameter.
+	 * @return array|\WP_Error The handler response.
+	 */
+	private function call_recreate_email_post( string $email_id ) {
+		$request = new WP_REST_Request( 'POST', '/wc-admin-email/settings/email/listing/recreate-email-post' );
+		$request->set_param( 'email_id', $email_id );
+
+		return $this->sut->recreate_email_post( $request );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php
index 58a5f98e13b..8aafd9e47d8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php
@@ -16,6 +16,24 @@ use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransact
  * Tests for the BlockEmailRenderer class.
  */
 class BlockEmailRendererTest extends \WC_Unit_Test_Case {
+	/**
+	 * Fake email IDs the tests register for the block editor. Only registered
+	 * emails get the file-template fallback in `maybe_render_block_email()`.
+	 *
+	 * @var string[]
+	 */
+	private const FAKE_BLOCK_EDITOR_EMAIL_IDS = array(
+		'email_without_mapping',
+		'email_with_empty_template',
+		// Own id: the renderer instance (and so its request-scoped cache) is
+		// shared across tests in the process, and the memoization test needs
+		// a cold cache.
+		'memoized_email',
+		'unpublished_draft_email',
+		'unpublished_auto_draft_email',
+		'trashed_email',
+	);
+
 	/**
 	 * @var BlockEmailRenderer $block_email_renderer
 	 */
@@ -54,18 +72,21 @@ class BlockEmailRendererTest extends \WC_Unit_Test_Case {
 		}

 		add_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+		add_filter( 'woocommerce_transactional_emails_for_block_editor', array( $this, 'register_fake_block_editor_email_ids' ) );
 		wc_get_container()->get( Package::class )->init();
 		wc_get_container()->get( Integration::class )->initialize();
 		Email_Editor_Container::container()->get( Bootstrap::class )->initialize();
 		$this->personalizer = Email_Editor_Container::container()->get( Personalizer::class );

+		// Published on purpose: only published posts are used for rendering,
+		// unpublished states fall back to the file template.
 		$this->email_post = $this->factory()->post->create_and_get(
 			array(
 				'post_title'   => 'Test email',
 				'post_name'    => 'test_email',
 				'post_type'    => Integration::EMAIL_POST_TYPE,
 				'post_content' => $this->email_post_content,
-				'post_status'  => 'draft',
+				'post_status'  => 'publish',
 			)
 		);

@@ -106,11 +127,252 @@ class BlockEmailRendererTest extends \WC_Unit_Test_Case {
 		$this->assertStringContainsString( 'customer@test.com', $rendered_email );
 	}

+	/**
+	 * @testdox Should render non-null HTML from the file template when no post mapping exists.
+	 */
+	public function testItRendersFromFileTemplateWhenNoPostMappingExists(): void {
+		$this->skip_if_unsupported_environment();
+
+		$test_woo_content = 'Test Woo Content';
+		$wc_mail_mock     = $this->create_wc_email_mock( 'email_without_mapping', $test_woo_content );
+
+		$this->personalizer->set_context(
+			array(
+				'wc_email'        => $wc_mail_mock,
+				'recipient_email' => $wc_mail_mock->get_recipient(),
+			)
+		);
+
+		$rendered_email = $this->block_email_renderer->maybe_render_block_email( $wc_mail_mock );
+
+		$this->assertNotNull( $rendered_email, 'Rendering must fall back to the file template when no post mapping exists' );
+		$this->assertStringContainsString( $test_woo_content, $rendered_email, 'The Woo content placeholder must be replaced in the file template output' );
+		$this->assertStringNotContainsString( BlockEmailRenderer::WOO_EMAIL_CONTENT_PLACEHOLDER, $rendered_email, 'The raw placeholder must not leak into the rendered email' );
+		// Only the `wooemailtemplate` template renders this footer, proving the
+		// synthetic post was rendered through the explicitly passed template slug.
+		$this->assertStringContainsString( '. All Rights Reserved.', $rendered_email, 'The wooemailtemplate footer must be present, proving the template slug was passed through for the synthetic post' );
+	}
+
+	/**
+	 * @testdox Should compute the file template content once for repeated sends of the same email type.
+	 */
+	public function testItComputesFileTemplateContentOnceForRepeatedSends(): void {
+		$this->skip_if_unsupported_environment();
+
+		$wc_mail_mock = $this->create_wc_email_mock( 'memoized_email', 'Test Woo Content' );
+
+		$this->personalizer->set_context(
+			array(
+				'wc_email'        => $wc_mail_mock,
+				'recipient_email' => $wc_mail_mock->get_recipient(),
+			)
+		);
+
+		$compute_count = 0;
+		$count_filter  = function ( $post_data ) use ( &$compute_count ) {
+			++$compute_count;
+			return $post_data;
+		};
+		add_filter( 'woocommerce_email_content_post_data', $count_filter );
+
+		try {
+			$first  = $this->block_email_renderer->maybe_render_block_email( $wc_mail_mock );
+			$second = $this->block_email_renderer->maybe_render_block_email( $wc_mail_mock );
+		} finally {
+			remove_filter( 'woocommerce_email_content_post_data', $count_filter );
+		}
+
+		// Full renders are intentionally not compared: block supports generate
+		// unique `wp-elements-*` class names per render pass (non-deterministic
+		// on WP 7.1+), so only the memoized input is asserted, via the counter.
+		$this->assertNotNull( $first );
+		$this->assertNotNull( $second );
+		$this->assertSame( 1, $compute_count, 'The canonical file template content must be computed once per request for a given email type' );
+	}
+
+	/**
+	 * @testdox Should return null instead of an empty-bodied email when the file template content is empty.
+	 */
+	public function testItReturnsNullWhenFileTemplateContentIsEmpty(): void {
+		$this->skip_if_unsupported_environment();
+
+		$wc_mail_mock = $this->create_wc_email_mock( 'email_with_empty_template', 'Test Woo Content' );
+
+		// Simulates an unresolvable/empty template (even the default block
+		// content fallback yields nothing) — the last safety net before
+		// sending must hand back null so the classic pipeline takes over.
+		add_filter( 'woocommerce_email_block_template_html', '__return_empty_string' );
+
+		try {
+			$this->assertNull(
+				$this->block_email_renderer->maybe_render_block_email( $wc_mail_mock ),
+				'An empty file template must yield null, not an empty-bodied email'
+			);
+		} finally {
+			remove_filter( 'woocommerce_email_block_template_html', '__return_empty_string' );
+		}
+	}
+
+	/**
+	 * @testdox Should return null for an email that is not registered for the block editor.
+	 */
+	public function testItReturnsNullForEmailNotRegisteredForBlockEditor(): void {
+		$this->skip_if_unsupported_environment();
+
+		// On purpose NOT registered via the `woocommerce_transactional_emails_for_block_editor` filter.
+		$wc_mail_mock = $this->create_wc_email_mock( 'unregistered_third_party_email', 'Test Woo Content' );
+
+		$this->assertNull(
+			$this->block_email_renderer->maybe_render_block_email( $wc_mail_mock ),
+			'Emails not registered for the block editor must not get the file-template fallback'
+		);
+	}
+
+	/**
+	 * @testdox Should fall back to the file template when the mapped post is an unpublished editing scratchpad.
+	 * @dataProvider provide_unpublished_statuses
+	 *
+	 * @param string $post_status Unpublished post status to test.
+	 */
+	public function testItFallsBackToFileTemplateWhenMappedPostIsUnpublished( string $post_status ): void {
+		$this->skip_if_unsupported_environment();
+
+		$email_id = 'unpublished_' . str_replace( '-', '_', $post_status ) . '_email';
+		$marker   = 'UNPUBLISHED_DB_POST_MARKER';
+
+		$unpublished_post = $this->factory()->post->create_and_get(
+			array(
+				'post_title'   => 'Unpublished email',
+				'post_type'    => Integration::EMAIL_POST_TYPE,
+				'post_content' => $this->build_marker_post_content( $marker ),
+				'post_status'  => $post_status,
+			)
+		);
+		WCTransactionalEmailPostsManager::get_instance()->save_email_template_post_id( $email_id, $unpublished_post->ID );
+
+		$test_woo_content = 'Test Woo Content';
+		$wc_mail_mock     = $this->create_wc_email_mock( $email_id, $test_woo_content );
+
+		$this->personalizer->set_context(
+			array(
+				'wc_email'        => $wc_mail_mock,
+				'recipient_email' => $wc_mail_mock->get_recipient(),
+			)
+		);
+
+		$rendered_email = $this->block_email_renderer->maybe_render_block_email( $wc_mail_mock );
+
+		$this->assertNotNull( $rendered_email, 'Rendering must fall back to the file template for unpublished posts' );
+		$this->assertStringNotContainsString( $marker, $rendered_email, 'Content of an unpublished post must not be used for rendering' );
+		$this->assertStringContainsString( $test_woo_content, $rendered_email, 'The Woo content placeholder must be replaced in the file template output' );
+	}
+
+	/**
+	 * Unpublished post statuses that must not be used as the rendering source.
+	 *
+	 * @return array<string, array{0: string}>
+	 */
+	public function provide_unpublished_statuses(): array {
+		return array(
+			'draft'      => array( 'draft' ),
+			'auto-draft' => array( 'auto-draft' ),
+		);
+	}
+
+	/**
+	 * @testdox Should fall back to the file template when the mapped post is trashed.
+	 */
+	public function testItFallsBackToFileTemplateWhenMappedPostIsTrashed(): void {
+		$this->skip_if_unsupported_environment();
+
+		$email_id = 'trashed_email';
+		$marker   = 'TRASHED_DB_POST_MARKER';
+
+		$trashed_post = $this->factory()->post->create_and_get(
+			array(
+				'post_title'   => 'Trashed email',
+				'post_type'    => Integration::EMAIL_POST_TYPE,
+				'post_content' => $this->build_marker_post_content( $marker ),
+				'post_status'  => 'publish',
+			)
+		);
+		WCTransactionalEmailPostsManager::get_instance()->save_email_template_post_id( $email_id, $trashed_post->ID );
+
+		wp_trash_post( $trashed_post->ID );
+
+		$test_woo_content = 'Test Woo Content';
+		$wc_mail_mock     = $this->create_wc_email_mock( $email_id, $test_woo_content );
+
+		$this->personalizer->set_context(
+			array(
+				'wc_email'        => $wc_mail_mock,
+				'recipient_email' => $wc_mail_mock->get_recipient(),
+			)
+		);
+
+		$rendered_email = $this->block_email_renderer->maybe_render_block_email( $wc_mail_mock );
+
+		$this->assertNotNull( $rendered_email, 'Rendering must fall back to the file template when the mapped post is trashed' );
+		$this->assertStringNotContainsString( $marker, $rendered_email, 'Content of a trashed post must not be used for rendering' );
+		$this->assertStringContainsString( $test_woo_content, $rendered_email, 'The Woo content placeholder must be replaced in the file template output' );
+	}
+
+	/**
+	 * Build a WC_Email mock whose block template resolves to the default block content file.
+	 *
+	 * @param string $email_id    The email ID assigned to the mock.
+	 * @param string $woo_content The Woo content the mock returns.
+	 * @return \WC_Email&\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private function create_wc_email_mock( string $email_id, string $woo_content ) {
+		$wc_mail_mock                 = $this->createMock( \WC_Email::class );
+		$wc_mail_mock->id             = $email_id;
+		$wc_mail_mock->template_block = 'emails/block/default-block-content.php';
+		$wc_mail_mock->method( 'get_recipient' )->willReturn( 'customer@test.com' );
+		$wc_mail_mock->method( 'get_title' )->willReturn( 'Mock email title' );
+		$wc_mail_mock->method( 'get_subject' )->willReturn( 'Test Woo Email' );
+		$wc_mail_mock->method( 'get_preheader' )->willReturn( 'Test Woo Preheader' );
+		$wc_mail_mock->method( 'get_content_html' )->willReturn( $woo_content );
+		$wc_mail_mock->method( 'get_block_editor_email_template_content' )->willReturn( $woo_content );
+		// The real file template contains store personalization tags whose
+		// callbacks read these from the WC_Email instance.
+		$wc_mail_mock->method( 'get_from_address' )->willReturn( 'store@test.com' );
+		$wc_mail_mock->method( 'get_from_name' )->willReturn( 'Test Store' );
+
+		return $wc_mail_mock;
+	}
+
+	/**
+	 * Build block post content carrying a distinctive marker paragraph.
+	 *
+	 * @param string $marker Distinctive marker string.
+	 * @return string Post content.
+	 */
+	private function build_marker_post_content( string $marker ): string {
+		return '<!-- wp:paragraph --><p>' . $marker . '</p><!-- /wp:paragraph -->
+
+<!-- wp:woocommerce/email-content {"lock":{"move":false,"remove":true}} -->
+<div class="wp-block-woocommerce-email-content">##WOO_CONTENT##</div>
+<!-- /wp:woocommerce/email-content -->';
+	}
+
+	/**
+	 * Register the fake email IDs used by the tests for the block editor.
+	 *
+	 * @param string[] $emails Registered transactional email IDs.
+	 * @return string[]
+	 */
+	public function register_fake_block_editor_email_ids( array $emails ): array {
+		return array_merge( $emails, self::FAKE_BLOCK_EDITOR_EMAIL_IDS );
+	}
+
 	/**
 	 * Cleanup after test.
 	 */
 	public function tearDown(): void {
 		parent::tearDown();
+		remove_filter( 'woocommerce_transactional_emails_for_block_editor', array( $this, 'register_fake_block_editor_email_ids' ) );
+		WCTransactionalEmailPostsManager::get_instance()->clear_caches();
 		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index 125d3936c8e..b3a9a76a882 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -69,7 +69,6 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 		delete_option( 'woocommerce_' . $this->email_type . '_settings' );
 		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
 		WCEmailTemplateSyncRegistry::reset_cache();
-		delete_transient( 'wc_email_editor_initial_templates_generated' );
 	}

 	/**
@@ -352,16 +351,9 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 	public function test_reset_response_overwrites_post_content_and_stamps_sync_meta(): void {
 		$email_type = 'customer_new_account';

-		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-
-		$post_manager = WCTransactionalEmailPostsManager::get_instance();
-		$post_manager->clear_caches();
-		$post_manager->delete_email_template( $email_type );
 		WCEmailTemplateSyncRegistry::reset_cache();

-		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
-		$this->assertIsInt( $post_id );
+		$post_id = $this->create_published_email_post( $email_type );

 		// Simulate merchant customisation that diverges from the core render.
 		wp_update_post(
@@ -453,15 +445,7 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 	public function test_reset_response_resets_content_without_meta_for_non_sync_enabled_email(): void {
 		$email_type = 'customer_new_account';

-		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-
-		$post_manager = WCTransactionalEmailPostsManager::get_instance();
-		$post_manager->clear_caches();
-		$post_manager->delete_email_template( $email_type );
-
-		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
-		$this->assertIsInt( $post_id );
+		$post_id = $this->create_published_email_post( $email_type );

 		// Capture meta stamped at generation time so we can assert it is unchanged after reset.
 		$baseline_version     = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true );
@@ -535,16 +519,9 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 	public function test_reset_response_returns_wp_error_when_wp_update_post_fails(): void {
 		$email_type = 'customer_new_account';

-		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-
-		$post_manager = WCTransactionalEmailPostsManager::get_instance();
-		$post_manager->clear_caches();
-		$post_manager->delete_email_template( $email_type );
 		WCEmailTemplateSyncRegistry::reset_cache();

-		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
-		$this->assertIsInt( $post_id );
+		$post_id = $this->create_published_email_post( $email_type );

 		$pre_call_content = (string) get_post( $post_id )->post_content;
 		$pre_call_meta    = array(
@@ -598,17 +575,10 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 	public function test_change_summary_route_returns_structured_payload(): void {
 		$email_type = 'customer_new_account';

-		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-
-		$post_manager = WCTransactionalEmailPostsManager::get_instance();
-		$post_manager->clear_caches();
-		$post_manager->delete_email_template( $email_type );
 		WCEmailTemplateSyncRegistry::reset_cache();
 		WCEmailTemplateChangeSummary::reset_cache();

-		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
-		$this->assertIsInt( $post_id );
+		$post_id = $this->create_published_email_post( $email_type );

 		// Diverge the post from the canonical render so the summary has something to say.
 		wp_update_post(
@@ -678,17 +648,10 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 	public function test_apply_route_returns_merged_content_and_revision_id(): void {
 		$email_type = 'customer_new_account';

-		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-
-		$post_manager = WCTransactionalEmailPostsManager::get_instance();
-		$post_manager->clear_caches();
-		$post_manager->delete_email_template( $email_type );
 		WCEmailTemplateSyncRegistry::reset_cache();
 		WCEmailTemplateChangeSummary::reset_cache();

-		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
-		$this->assertIsInt( $post_id );
+		$post_id = $this->create_published_email_post( $email_type );

 		// Diverge the post so the change-summary has something to apply.
 		wp_update_post(
@@ -747,17 +710,10 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 	public function test_undo_route_restores_pre_apply_content(): void {
 		$email_type = 'customer_new_account';

-		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-
-		$post_manager = WCTransactionalEmailPostsManager::get_instance();
-		$post_manager->clear_caches();
-		$post_manager->delete_email_template( $email_type );
 		WCEmailTemplateSyncRegistry::reset_cache();
 		WCEmailTemplateChangeSummary::reset_cache();

-		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
-		$this->assertIsInt( $post_id );
+		$post_id = $this->create_published_email_post( $email_type );

 		$pre_apply_content = "<!-- wp:paragraph -->\n<p>Merchant edit.</p>\n<!-- /wp:paragraph -->";
 		wp_update_post(
@@ -796,16 +752,9 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 	public function test_undo_route_returns_410_when_no_snapshot(): void {
 		$email_type = 'customer_new_account';

-		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-
-		$post_manager = WCTransactionalEmailPostsManager::get_instance();
-		$post_manager->clear_caches();
-		$post_manager->delete_email_template( $email_type );
 		WCEmailTemplateSyncRegistry::reset_cache();

-		$post_id = $generator->generate_email_template_if_not_exists( $email_type );
-		$this->assertIsInt( $post_id );
+		$post_id = $this->create_published_email_post( $email_type );

 		$request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/undo' );
 		$request->set_param( 'id', $post_id );
@@ -864,6 +813,36 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
 		$this->assertArrayHasKey( 'GET', $methods, 'Change-summary endpoint must accept GET.' );
 	}

+	/**
+	 * Helper: create a published, mapped `woo_email` post for the given email type.
+	 *
+	 * Mirrors the production flow: a draft is created from the file template
+	 * and then published. The Integration `transition_post_status` hook is not
+	 * registered in this suite, so the email_type => post_id mapping is written
+	 * explicitly.
+	 *
+	 * @param string $email_type Email type ID (e.g. 'customer_new_account').
+	 * @return int The published post ID.
+	 */
+	private function create_published_email_post( string $email_type ): int {
+		$post_manager = WCTransactionalEmailPostsManager::get_instance();
+		$post_manager->clear_caches();
+
+		$email = $post_manager->get_email_by_id( $email_type );
+		$this->assertInstanceOf( \WC_Email::class, $email, "Email type {$email_type} must be registered with WC_Emails." );
+
+		$post_id = ( new WCTransactionalEmailPostsGenerator() )->create_draft( $email );
+		wp_update_post(
+			array(
+				'ID'          => $post_id,
+				'post_status' => 'publish',
+			)
+		);
+		$post_manager->save_email_template_post_id( $email_type, $post_id );
+
+		return $post_id;
+	}
+
 	/**
 	 * Helper: resolve a WC_Email instance by email type ID.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php
new file mode 100644
index 00000000000..29d58c81132
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php
@@ -0,0 +1,589 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor;
+
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\Package;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Integration class.
+ */
+class IntegrationTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Integration
+	 */
+	private $sut;
+
+	/**
+	 * Transactional email post manager singleton.
+	 *
+	 * @var WCTransactionalEmailPostsManager
+	 */
+	private WCTransactionalEmailPostsManager $posts_manager;
+
+	/**
+	 * Keys of WC_Email stubs injected into WC_Emails::$emails, for teardown.
+	 *
+	 * @var string[]
+	 */
+	private array $injected_email_keys = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		add_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+		wc_get_container()->get( Package::class )->init();
+
+		$this->sut           = wc_get_container()->get( Integration::class );
+		$this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+		$this->posts_manager->clear_caches();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	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();
+		}
+
+		$this->posts_manager->clear_caches();
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should write the email type mapping when an auto-draft transitions to publish.
+	 */
+	public function test_auto_draft_to_publish_writes_mapping(): void {
+		$post = $this->create_woo_email_post( 'customer_processing_order' );
+
+		$this->sut->save_email_mapping_on_publish( 'publish', 'auto-draft', $post );
+
+		$this->assertSame(
+			$post->ID,
+			(int) get_option( 'woocommerce_email_templates_customer_processing_order_post_id' ),
+			'Publishing an auto-draft must write the option mapping'
+		);
+	}
+
+	/**
+	 * @testdox Should write the email type mapping when a draft transitions to publish.
+	 */
+	public function test_draft_to_publish_writes_mapping(): void {
+		$post = $this->create_woo_email_post( 'customer_completed_order', 'draft' );
+
+		$this->sut->save_email_mapping_on_publish( 'publish', 'draft', $post );
+
+		$this->assertSame(
+			$post->ID,
+			(int) get_option( 'woocommerce_email_templates_customer_completed_order_post_id' ),
+			'Publishing a draft must write the option mapping'
+		);
+	}
+
+	/**
+	 * @testdox Should not touch an existing mapping on a publish-to-publish transition.
+	 */
+	public function test_publish_to_publish_leaves_existing_mapping_untouched(): void {
+		$other_post_id = $this->factory()->post->create();
+		$this->posts_manager->save_email_template_post_id( 'customer_new_account', $other_post_id );
+
+		$post = $this->create_woo_email_post( 'customer_new_account', 'publish' );
+
+		$this->sut->save_email_mapping_on_publish( 'publish', 'publish', $post );
+
+		$this->assertSame(
+			$other_post_id,
+			(int) get_option( 'woocommerce_email_templates_customer_new_account_post_id' ),
+			'A publish-to-publish transition (post update) must not rewrite the mapping'
+		);
+	}
+
+	/**
+	 * @testdox Should ignore posts of other post types.
+	 */
+	public function test_non_woo_email_post_is_ignored(): void {
+		$post = $this->factory()->post->create_and_get( array( 'post_status' => 'draft' ) );
+		update_post_meta( $post->ID, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, 'customer_note' );
+
+		$this->sut->save_email_mapping_on_publish( 'publish', 'draft', $post );
+
+		$this->assertFalse(
+			get_option( 'woocommerce_email_templates_customer_note_post_id' ),
+			'Posts of other post types must not produce a mapping'
+		);
+	}
+
+	/**
+	 * @testdox Should not write a mapping when the email type meta is not a registered WC_Email id.
+	 */
+	public function test_unregistered_email_type_meta_does_not_write_mapping(): void {
+		$post = $this->create_woo_email_post( 'not_a_registered_email_type' );
+
+		$this->sut->save_email_mapping_on_publish( 'publish', 'auto-draft', $post );
+
+		$this->assertFalse(
+			get_option( 'woocommerce_email_templates_not_a_registered_email_type_post_id' ),
+			'An unregistered email type in the meta must not produce a mapping'
+		);
+	}
+
+	/**
+	 * @testdox Should ignore woo_email posts without the email type meta.
+	 */
+	public function test_post_without_email_type_meta_is_ignored(): void {
+		$post = $this->factory()->post->create_and_get(
+			array(
+				'post_type'   => Integration::EMAIL_POST_TYPE,
+				'post_status' => 'auto-draft',
+			)
+		);
+
+		$this->sut->save_email_mapping_on_publish( 'publish', 'auto-draft', $post );
+
+		$this->assertNull(
+			$this->posts_manager->get_email_type_from_post_id( $post->ID, true ),
+			'A woo_email post without the email type meta must not produce a mapping'
+		);
+	}
+
+	/**
+	 * @testdox Should write the mapping through the transition_post_status hook when a post is published.
+	 */
+	public function test_mapping_written_via_transition_hook_on_publish(): void {
+		$this->sut->initialize();
+
+		$post = $this->create_woo_email_post( 'new_order' );
+
+		wp_update_post(
+			array(
+				'ID'          => $post->ID,
+				'post_status' => 'publish',
+			)
+		);
+
+		$this->assertSame(
+			$post->ID,
+			(int) get_option( 'woocommerce_email_templates_new_order_post_id' ),
+			'Publishing via wp_update_post must write the mapping through the transition_post_status hook'
+		);
+	}
+
+	/**
+	 * @testdox Postless send-preview renders the file template and mails it to the recipient.
+	 */
+	public function test_send_preview_for_email_type_sends_file_template_render(): void {
+		$this->bootstrap_email_editor();
+		$this->skip_if_unsupported_environment();
+
+		reset_phpmailer_instance();
+
+		$result = $this->sut->send_preview_email_for_email_type(
+			array(
+				'email'     => 'preview-recipient@example.com',
+				'emailType' => 'customer_processing_order',
+			)
+		);
+
+		$this->assertTrue( $result );
+
+		$mailer = tests_retrieve_phpmailer_instance();
+		$sent   = $mailer->get_sent();
+		$this->assertNotFalse( $sent, 'A preview email must be sent' );
+		$this->assertSame( 'preview-recipient@example.com', $sent->to[0][0] );
+		$this->assertStringContainsString( 'is now being processed', (string) $sent->body, 'The body must contain the file template content' );
+		$this->assertStringContainsString( 'All Rights Reserved', (string) $sent->body, 'The body must be rendered through the email template chrome' );
+	}
+
+	/**
+	 * @testdox Send-preview requests with a post ID (or already handled) pass through untouched.
+	 */
+	public function test_send_preview_passes_post_requests_through(): void {
+		$data = array(
+			'email'  => 'a@example.com',
+			'postId' => 123,
+		);
+
+		$this->assertSame( $data, $this->sut->send_preview_email_for_email_type( $data ) );
+		$this->assertTrue( $this->sut->send_preview_email_for_email_type( true ) );
+	}
+
+	/**
+	 * @testdox Postless send-preview throws for an unregistered email type.
+	 */
+	public function test_send_preview_for_unknown_email_type_throws(): void {
+		$this->expectException( \InvalidArgumentException::class );
+
+		$this->sut->send_preview_email_for_email_type(
+			array(
+				'email'     => 'a@example.com',
+				'emailType' => 'this_type_is_not_registered',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Postless send-preview throws for an invalid recipient address.
+	 */
+	public function test_send_preview_with_invalid_recipient_throws(): void {
+		$this->expectException( \InvalidArgumentException::class );
+
+		$this->sut->send_preview_email_for_email_type(
+			array(
+				'email'     => 'not-an-email',
+				'emailType' => 'customer_processing_order',
+			)
+		);
+	}
+
+	/**
+	 * @testdox Postless send-preview permission requires manage_woocommerce and a registered email type.
+	 */
+	public function test_postless_send_preview_permission(): void {
+		$request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/send_preview_email' );
+		$request->set_param( 'emailType', 'customer_processing_order' );
+
+		$admin_id = $this->factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin_id );
+		$this->assertTrue( $this->sut->authorize_postless_send_preview( false, $request ), 'A shop manager may send a postless preview for a registered email type' );
+
+		$request->set_param( 'emailType', 'this_type_is_not_registered' );
+		$this->assertFalse( $this->sut->authorize_postless_send_preview( false, $request ), 'Unregistered email types must be rejected' );
+
+		$subscriber_id = $this->factory()->user->create( array( 'role' => 'subscriber' ) );
+		wp_set_current_user( $subscriber_id );
+		$request->set_param( 'emailType', 'customer_processing_order' );
+		$this->assertFalse( $this->sut->authorize_postless_send_preview( false, $request ), 'Users without manage_woocommerce must be rejected' );
+	}
+
+	/**
+	 * Third-party get_subject() implementations may assume send-time state and
+	 * throw outside a real send (e.g. WooCommerce Bookings dereferences its
+	 * booking object). The preview must survive that and fall back to the title.
+	 *
+	 * @testdox Postless send-preview survives a get_subject() that throws outside a send.
+	 */
+	public function test_send_preview_survives_get_subject_throwing(): void {
+		$this->bootstrap_email_editor();
+		$this->skip_if_unsupported_environment();
+
+		$email_id = 'wc_test_booking_style_email';
+		$this->register_third_party_email( $email_id );
+
+		reset_phpmailer_instance();
+
+		$result = $this->sut->send_preview_email_for_email_type(
+			array(
+				'email'     => 'preview-recipient@example.com',
+				'emailType' => $email_id,
+			)
+		);
+
+		$this->assertTrue( $result );
+
+		$mailer = tests_retrieve_phpmailer_instance();
+		$sent   = $mailer->get_sent();
+		$this->assertNotFalse( $sent, 'A preview email must be sent despite get_subject() throwing' );
+		$this->assertSame( 'Booking style email', $sent->subject, 'The email title must be used as the subject fallback' );
+	}
+
+	/**
+	 * @testdox Should refresh a never-edited scratchpad from the file template when the editor opens directly.
+	 */
+	public function test_editor_open_refreshes_untouched_scratchpad(): void {
+		$this->bootstrap_email_editor();
+		$this->skip_if_unsupported_environment();
+
+		$post_id = $this->create_scratchpad( 'customer_processing_order' );
+
+		$append_marker = static function ( $template_html ) {
+			return $template_html . "\n<!-- wp:paragraph --><p>FRESH_TEMPLATE_MARKER</p><!-- /wp:paragraph -->";
+		};
+		add_filter( 'woocommerce_email_block_template_html', $append_marker );
+
+		try {
+			$this->invoke_maybe_refresh_scratchpad( $post_id );
+		} finally {
+			remove_filter( 'woocommerce_email_block_template_html', $append_marker );
+		}
+
+		$refreshed = get_post( $post_id );
+		$this->assertStringContainsString( 'FRESH_TEMPLATE_MARKER', $refreshed->post_content, 'Opening the editor must refresh an untouched scratchpad to the current file template' );
+		$this->assertSame(
+			sha1( (string) $refreshed->post_content ),
+			get_post_meta( $post_id, \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+			'The source hash must be restamped so the scratchpad still counts as never-edited'
+		);
+	}
+
+	/**
+	 * @testdox Should not touch an edited scratchpad when the editor opens directly.
+	 */
+	public function test_editor_open_leaves_edited_scratchpad_untouched(): void {
+		$this->bootstrap_email_editor();
+		$this->skip_if_unsupported_environment();
+
+		$post_id        = $this->create_scratchpad( 'customer_completed_order' );
+		$edited_content = get_post( $post_id )->post_content . "\n<!-- wp:paragraph --><p>MERCHANT_EDIT</p><!-- /wp:paragraph -->";
+		wp_update_post(
+			array(
+				'ID'            => $post_id,
+				'post_content'  => $edited_content,
+				'page_template' => '',
+			)
+		);
+
+		$append_marker = static function ( $template_html ) {
+			return $template_html . "\n<!-- wp:paragraph --><p>FRESH_TEMPLATE_MARKER</p><!-- /wp:paragraph -->";
+		};
+		add_filter( 'woocommerce_email_block_template_html', $append_marker );
+
+		try {
+			$this->invoke_maybe_refresh_scratchpad( $post_id );
+		} finally {
+			remove_filter( 'woocommerce_email_block_template_html', $append_marker );
+		}
+
+		$this->assertSame( $edited_content, get_post( $post_id )->post_content, 'An edited scratchpad must never be refreshed' );
+	}
+
+	/**
+	 * @testdox Should not touch a published email post when the editor opens directly.
+	 */
+	public function test_editor_open_leaves_published_post_untouched(): void {
+		$this->bootstrap_email_editor();
+		$this->skip_if_unsupported_environment();
+
+		$post_id = $this->create_scratchpad( 'customer_new_account' );
+		wp_update_post(
+			array(
+				'ID'            => $post_id,
+				'post_status'   => 'publish',
+				'page_template' => '',
+			)
+		);
+		$published_content = get_post( $post_id )->post_content;
+
+		$append_marker = static function ( $template_html ) {
+			return $template_html . "\n<!-- wp:paragraph --><p>FRESH_TEMPLATE_MARKER</p><!-- /wp:paragraph -->";
+		};
+		add_filter( 'woocommerce_email_block_template_html', $append_marker );
+
+		try {
+			$this->invoke_maybe_refresh_scratchpad( $post_id );
+		} finally {
+			remove_filter( 'woocommerce_email_block_template_html', $append_marker );
+		}
+
+		$this->assertSame( $published_content, get_post( $post_id )->post_content, 'A published post must never be refreshed' );
+	}
+
+	/**
+	 * Create a draft scratchpad for a core transactional email.
+	 *
+	 * @param string $email_id Core transactional email ID.
+	 * @return int The scratchpad post ID.
+	 */
+	private function create_scratchpad( string $email_id ): int {
+		$email = $this->posts_manager->get_email_by_id( $email_id );
+		$this->assertNotNull( $email, 'The core transactional email must resolve' );
+
+		return ( new \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator() )->create_draft( $email );
+	}
+
+	/**
+	 * Invoke the private editor-open refresh hook with a fresh post object.
+	 *
+	 * @param int $post_id The post to open.
+	 */
+	private function invoke_maybe_refresh_scratchpad( int $post_id ): void {
+		$method = new \ReflectionMethod( Integration::class, 'maybe_refresh_scratchpad' );
+		$method->setAccessible( true );
+		$method->invoke( $this->sut, get_post( $post_id ) );
+	}
+
+	/**
+	 * Inject a WC_Email stub mimicking a third-party email whose get_subject()
+	 * requires send-time state, and opt it into the block editor.
+	 *
+	 * @param string $email_id Email ID to inject.
+	 * @return \WC_Email The injected stub.
+	 */
+	private function register_third_party_email( string $email_id ): \WC_Email {
+		$stub = $this->getMockBuilder( \WC_Email::class )
+			->disableOriginalConstructor()
+			->onlyMethods( array( 'get_title', 'get_description', 'get_subject' ) )
+			->getMock();
+		$stub->method( 'get_title' )->willReturn( 'Booking style email' );
+		$stub->method( 'get_description' )->willReturn( 'Fixture email whose subject requires send-time state.' );
+		$stub->method( 'get_subject' )->willThrowException( new \Error( 'Call to a member function get_order() on null' ) );
+		$stub->id             = $email_id;
+		$stub->template_base  = __DIR__ . '/WCTransactionalEmails/fixtures/';
+		$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;
+			}
+		);
+
+		return $stub;
+	}
+
+	/**
+	 * @testdox Preview HTML for an email type renders the file template with the preview context applied.
+	 */
+	public function test_render_preview_html_for_email_type_returns_full_document(): void {
+		$this->bootstrap_email_editor();
+		$this->skip_if_unsupported_environment();
+
+		$preview = $this->sut->render_preview_html_for_email_type( 'customer_processing_order' );
+
+		$this->assertNotSame( '', $preview['subject'] );
+		$this->assertStringContainsString( 'is now being processed', $preview['html'], 'The preview must contain the file template content' );
+		$this->assertStringContainsString( 'All Rights Reserved', $preview['html'], 'The preview must be rendered through the email template chrome' );
+		$this->assertStringNotContainsString( 'WOO_CONTENT', $preview['html'], 'The Woo content placeholder must be replaced with preview content' );
+	}
+
+	/**
+	 * @testdox Preview HTML rendering throws for an unregistered email type.
+	 */
+	public function test_render_preview_html_for_unknown_email_type_throws(): void {
+		$this->expectException( \InvalidArgumentException::class );
+
+		$this->sut->render_preview_html_for_email_type( 'this_type_is_not_registered' );
+	}
+
+	/**
+	 * @testdox The preview page dies on a missing or invalid nonce.
+	 */
+	public function test_preview_page_requires_valid_nonce(): void {
+		$_GET['preview_woo_block_email'] = 'true';
+		$_GET['email_id']                = 'customer_processing_order';
+
+		$this->expectException( \WPDieException::class );
+
+		$this->sut->render_block_email_preview_page();
+	}
+
+	/**
+	 * @testdox The preview page dies for users without manage_woocommerce.
+	 */
+	public function test_preview_page_requires_manage_woocommerce(): void {
+		$subscriber_id = $this->factory()->user->create( array( 'role' => 'subscriber' ) );
+		wp_set_current_user( $subscriber_id );
+
+		$_GET['preview_woo_block_email'] = 'true';
+		$_GET['email_id']                = 'customer_processing_order';
+		$_REQUEST['_wpnonce']            = wp_create_nonce( 'preview-woo-block-email' );
+
+		$this->expectException( \WPDieException::class );
+		$this->expectExceptionMessage( 'permission' );
+
+		$this->sut->render_block_email_preview_page();
+	}
+
+	/**
+	 * @testdox The preview page dies for an unregistered email type.
+	 */
+	public function test_preview_page_dies_for_unknown_email_type(): void {
+		$admin_id = $this->factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin_id );
+
+		$_GET['preview_woo_block_email'] = 'true';
+		$_GET['email_id']                = 'this_type_is_not_registered';
+		$_REQUEST['_wpnonce']            = wp_create_nonce( 'preview-woo-block-email' );
+
+		$this->expectException( \WPDieException::class );
+		$this->expectExceptionMessage( 'cannot be previewed' );
+
+		$this->sut->render_block_email_preview_page();
+	}
+
+	/**
+	 * Initialize the email editor package so block templates resolve during rendering.
+	 */
+	private function bootstrap_email_editor(): void {
+		// setUp's add_option() no-ops when a previous tearDown left the option
+		// at 'no'; the editor bootstrap requires the feature to be enabled.
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+		wc_get_container()->get( Integration::class )->initialize();
+
+		// The DI container runs TemplatesController::init() only on first
+		// instantiation; when an earlier test resolved it, the WP test
+		// framework's per-test filter restoration removed its registration
+		// hook, so re-add it for the cached instance.
+		$templates_controller = wc_get_container()->get( \Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplatesController::class );
+		if ( false === has_filter( 'woocommerce_email_editor_register_templates', array( $templates_controller, 'register_templates' ) ) ) {
+			$templates_controller->init();
+		}
+		\Automattic\WooCommerce\EmailEditor\Email_Editor_Container::container()->get( \Automattic\WooCommerce\EmailEditor\Bootstrap::class )->initialize();
+	}
+
+	/**
+	 * Skip the test when the environment doesn't meet the editor's requirements.
+	 */
+	private function skip_if_unsupported_environment(): void {
+		$dependency_check = \Automattic\WooCommerce\EmailEditor\Email_Editor_Container::container()->get( \Automattic\WooCommerce\EmailEditor\Engine\Dependency_Check::class );
+		if ( ! $dependency_check->are_dependencies_met() ) {
+			$this->markTestSkipped( 'The test environment does not fulfill minimal requirements for the block email editor.' );
+		}
+	}
+
+	/**
+	 * Create a `woo_email` post carrying the email type meta.
+	 *
+	 * @param string $email_type  Email type to stamp into the meta.
+	 * @param string $post_status Post status. Defaults to `auto-draft`.
+	 * @return \WP_Post
+	 */
+	private function create_woo_email_post( string $email_type, string $post_status = 'auto-draft' ): \WP_Post {
+		$post = $this->factory()->post->create_and_get(
+			array(
+				'post_title'  => 'Email post for ' . $email_type,
+				'post_type'   => Integration::EMAIL_POST_TYPE,
+				'post_status' => $post_status,
+			)
+		);
+		update_post_meta( $post->ID, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, $email_type );
+
+		return $post;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailPostsCleanupTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailPostsCleanupTest.php
new file mode 100644
index 00000000000..638a69ed06d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailPostsCleanupTest.php
@@ -0,0 +1,521 @@
+<?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\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailPostsCleanup;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+
+/**
+ * Tests for the WOOPLUG-6171 one-shot email posts cleanup migration.
+ */
+class WCEmailPostsCleanupTest extends \WC_Unit_Test_Case {
+	/**
+	 * Absolute path to the fixtures directory.
+	 *
+	 * @var string
+	 */
+	private string $fixtures_base;
+
+	/**
+	 * Keys injected into \WC_Emails::$emails during the current test.
+	 *
+	 * @var string[]
+	 */
+	private array $injected_email_keys = array();
+
+	/**
+	 * Transactional email post manager singleton.
+	 *
+	 * @var WCTransactionalEmailPostsManager
+	 */
+	private WCTransactionalEmailPostsManager $posts_manager;
+
+	/**
+	 * Setup test case.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+
+		// Eagerly boot \WC_Emails so the \WC_Email class is autoloaded before any
+		// test reflects on it via getMockBuilder() / onlyMethods().
+		\WC_Emails::instance();
+
+		$this->fixtures_base = __DIR__ . '/fixtures/';
+		$this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+		$this->posts_manager->clear_caches();
+	}
+
+	/**
+	 * Cleanup after test.
+	 */
+	public function tearDown(): void {
+		$this->cleanup_injected_emails();
+
+		remove_all_filters( 'woocommerce_email_block_template_html' );
+
+		$this->posts_manager->clear_caches();
+		delete_transient( WCEmailPostsCleanup::LEGACY_GENERATION_TRANSIENT );
+		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should delete a published post whose content matches the current canonical render, together with its mapping.
+	 */
+	public function test_deletes_post_matching_current_canonical_render(): void {
+		$email_id = 'wc_cleanup_canonical';
+		$email    = $this->register_fixture_email( $email_id );
+
+		$canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+		// Edited timestamps on purpose: the canonical match must win regardless of timestamps.
+		$post_id = $this->create_mapped_email_post( $email_id, $canonical, false );
+
+		$result = WCEmailPostsCleanup::run();
+
+		$this->assertFalse( $result, 'run() must return false (one-shot).' );
+		$this->assertNull( get_post( $post_id ), 'A never-customized post must be hard-deleted' );
+		$this->assertFalse( get_option( $this->option_name( $email_id ) ), 'The option mapping must be deleted with the post' );
+	}
+
+	/**
+	 * @testdox Should delete a post still matching its stored source hash even when the canonical render moved on.
+	 */
+	public function test_deletes_post_matching_stored_source_hash_when_canonical_moved(): void {
+		$email_id = 'wc_cleanup_hash';
+		$email    = $this->register_fixture_email( $email_id );
+
+		$canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+		$post_id   = $this->create_mapped_email_post( $email_id, $canonical, false );
+		// Hash the persisted content (as the generator does) so the stamp matches what WordPress actually saved.
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( (string) get_post( $post_id )->post_content ) );
+
+		// Core template moves after the post was stamped: canonical no longer matches
+		// the stored content, but the source hash still does — untouched by the merchant.
+		add_filter(
+			'woocommerce_email_block_template_html',
+			static function ( $template_html ) {
+				return $template_html . "\n<!-- wp:paragraph --><p>Core template moved on.</p><!-- /wp:paragraph -->";
+			}
+		);
+
+		WCEmailPostsCleanup::run();
+
+		$this->assertNull( get_post( $post_id ), 'A post still matching its stored source hash must be deleted' );
+		$this->assertFalse( get_option( $this->option_name( $email_id ) ) );
+	}
+
+	/**
+	 * @testdox Should delete a never-edited post without sync meta based on identical creation and modification timestamps.
+	 */
+	public function test_deletes_timestamp_never_edited_post_without_sync_meta(): void {
+		$email_id = 'wc_cleanup_timestamps';
+		$this->register_fixture_email( $email_id );
+
+		$legacy_body = "<!-- wp:paragraph -->\n<p>Legacy content from an older core version.</p>\n<!-- /wp:paragraph -->";
+		$post_id     = $this->create_mapped_email_post( $email_id, $legacy_body, true );
+
+		WCEmailPostsCleanup::run();
+
+		$this->assertNull( get_post( $post_id ), 'A post with identical creation/modification timestamps and no sync meta must be deleted' );
+		$this->assertFalse( get_option( $this->option_name( $email_id ) ) );
+	}
+
+	/**
+	 * @testdox Should keep a customized post, its mapping, and stamp the email type meta.
+	 */
+	public function test_keeps_customized_post_and_stamps_email_type_meta(): void {
+		$email_id = 'wc_cleanup_customized';
+		$this->register_fixture_email( $email_id );
+
+		$merchant_body = "<!-- wp:paragraph -->\n<p>Merchant-authored customisations must survive the cleanup.</p>\n<!-- /wp:paragraph -->";
+		$post_id       = $this->create_mapped_email_post( $email_id, $merchant_body, false );
+
+		WCEmailPostsCleanup::run();
+
+		$post = get_post( $post_id );
+		$this->assertInstanceOf( \WP_Post::class, $post, 'A customized post must be kept' );
+		$this->assertSame( $merchant_body, $post->post_content, 'Customized content must not be touched' );
+		$this->assertSame(
+			$email_id,
+			get_post_meta( $post_id, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, true ),
+			'Kept posts must be stamped with the email type meta'
+		);
+		$this->assertSame(
+			$post_id,
+			(int) get_option( $this->option_name( $email_id ) ),
+			'The mapping of a kept post must be preserved'
+		);
+	}
+
+	/**
+	 * @testdox Should keep a customized sync-covered post even when its timestamps claim it was never edited.
+	 */
+	public function test_keeps_customized_sync_covered_post_despite_never_edited_timestamps(): void {
+		$email_id = 'wc_cleanup_hash_customized';
+		$this->register_fixture_email( $email_id );
+
+		$merchant_body = "<!-- wp:paragraph -->\n<p>Merchant content diverging from the stamped source hash.</p>\n<!-- /wp:paragraph -->";
+		// Never-edited timestamps on purpose: the hash verdict must win over the timestamp signal.
+		$post_id = $this->create_mapped_email_post( $email_id, $merchant_body, true );
+		// Valid source hash that does not match the stored content — the merchant edited after stamping.
+		update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, sha1( 'content the post no longer matches' ) );
+
+		// The canonical render moves too, so a canonical match cannot save the post either.
+		add_filter(
+			'woocommerce_email_block_template_html',
+			static function ( $template_html ) {
+				return $template_html . "\n<!-- wp:paragraph --><p>Core template moved on.</p><!-- /wp:paragraph -->";
+			}
+		);
+
+		WCEmailPostsCleanup::run();
+
+		$post = get_post( $post_id );
+		$this->assertInstanceOf( \WP_Post::class, $post, 'A sync-covered post whose content diverged from the source hash must be kept' );
+		$this->assertSame( $merchant_body, $post->post_content, 'Customized content must not be touched' );
+		$this->assertSame(
+			$email_id,
+			get_post_meta( $post_id, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, true ),
+			'Kept posts must be stamped with the email type meta'
+		);
+		$this->assertSame(
+			$post_id,
+			(int) get_option( $this->option_name( $email_id ) ),
+			'The mapping of a kept post must be preserved'
+		);
+	}
+
+	/**
+	 * The local date pair is computed with the site offset at write time, so a
+	 * timezone change between creation and an edit can make it match
+	 * coincidentally on an edited post. Only the GMT pair may vouch for
+	 * "never edited".
+	 *
+	 * @testdox Should keep an edited post whose local timestamps coincide while GMT timestamps differ.
+	 */
+	public function test_keeps_post_with_coincidentally_matching_local_timestamps(): void {
+		$email_id = 'wc_cleanup_tz_change';
+		$this->register_fixture_email( $email_id );
+
+		$merchant_body = '<!-- wp:paragraph --><p>Edited after a timezone change.</p><!-- /wp:paragraph -->';
+		$post_id       = $this->create_mapped_email_post( $email_id, $merchant_body, false );
+
+		// GMT pair differs (the real edit signal); make the local pair match.
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_date'     => '2023-01-01 12:00:00',
+				'post_modified' => '2023-01-01 12:00:00',
+			),
+			array( 'ID' => $post_id )
+		);
+		clean_post_cache( $post_id );
+
+		WCEmailPostsCleanup::run();
+
+		$this->assertInstanceOf(
+			\WP_Post::class,
+			get_post( $post_id ),
+			'An edited post must be kept even when its local timestamps coincide'
+		);
+	}
+
+	/**
+	 * @testdox Should keep the post and stamp the email type meta when the email type is not registered.
+	 */
+	public function test_keeps_post_for_unresolvable_email_type(): void {
+		$email_id = 'wc_cleanup_unregistered';
+
+		// No fixture email registered for this type on purpose.
+		$post_id = $this->create_mapped_email_post( $email_id, '<!-- wp:paragraph --><p>Content.</p><!-- /wp:paragraph -->', true );
+
+		WCEmailPostsCleanup::run();
+
+		$this->assertInstanceOf( \WP_Post::class, get_post( $post_id ), 'A post for an unresolvable email type must be kept' );
+		$this->assertSame(
+			$email_id,
+			get_post_meta( $post_id, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, true )
+		);
+		$this->assertSame( $post_id, (int) get_option( $this->option_name( $email_id ) ) );
+	}
+
+	/**
+	 * @testdox Should delete orphaned mappings pointing at missing posts or posts of other types.
+	 */
+	public function test_deletes_orphaned_mappings(): void {
+		update_option( $this->option_name( 'wc_cleanup_orphan' ), 999999 );
+
+		$regular_post_id = $this->factory()->post->create();
+		update_option( $this->option_name( 'wc_cleanup_wrong_type' ), $regular_post_id );
+
+		WCEmailPostsCleanup::run();
+
+		$this->assertFalse( get_option( $this->option_name( 'wc_cleanup_orphan' ) ), 'A mapping without a post must be deleted' );
+		$this->assertFalse( get_option( $this->option_name( 'wc_cleanup_wrong_type' ) ), 'A mapping pointing at a non-woo_email post must be deleted' );
+		$this->assertInstanceOf( \WP_Post::class, get_post( $regular_post_id ), 'The non-woo_email post itself must not be deleted' );
+	}
+
+	/**
+	 * A corrupt mapping value casts to a non-positive post ID; it must be
+	 * removed without ever reaching get_post(), which reads the global $post
+	 * when given 0.
+	 *
+	 * @testdox Should delete mappings with corrupt values without resolving the global post.
+	 */
+	public function test_deletes_mappings_with_corrupt_values(): void {
+		update_option( $this->option_name( 'wc_cleanup_corrupt' ), 'not-a-post-id' );
+		update_option( $this->option_name( 'wc_cleanup_negative' ), -5 );
+
+		$global_post_id = $this->create_mapped_email_post( 'wc_cleanup_global', '<!-- wp:paragraph --><p>Customized content.</p><!-- /wp:paragraph -->', false );
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulating a request with a global post to prove the cleanup never resolves it for corrupt mappings.
+		$GLOBALS['post'] = get_post( $global_post_id );
+		delete_option( $this->option_name( 'wc_cleanup_global' ) );
+
+		try {
+			WCEmailPostsCleanup::run();
+		} finally {
+			unset( $GLOBALS['post'] );
+		}
+
+		$this->assertFalse( get_option( $this->option_name( 'wc_cleanup_corrupt' ) ), 'A mapping with a non-numeric value must be deleted' );
+		$this->assertFalse( get_option( $this->option_name( 'wc_cleanup_negative' ) ), 'A mapping with a negative value must be deleted' );
+		$this->assertInstanceOf( \WP_Post::class, get_post( $global_post_id ), 'The global post must never be resolved and deleted in place of a corrupt mapping' );
+	}
+
+	/**
+	 * @testdox Should hard-delete a trashed post and its mapping.
+	 */
+	public function test_deletes_trashed_post_and_mapping(): void {
+		$email_id = 'wc_cleanup_trashed';
+
+		$post_id = $this->create_mapped_email_post( $email_id, '<!-- wp:paragraph --><p>Trashed content.</p><!-- /wp:paragraph -->', false );
+		wp_trash_post( $post_id );
+
+		WCEmailPostsCleanup::run();
+
+		$this->assertNull( get_post( $post_id ), 'A trashed post must be hard-deleted' );
+		$this->assertFalse( get_option( $this->option_name( $email_id ) ), 'The mapping of a trashed post must be deleted' );
+	}
+
+	/**
+	 * @testdox Should delete the legacy bulk-generation transient.
+	 */
+	public function test_deletes_legacy_generation_transient(): void {
+		set_transient( WCEmailPostsCleanup::LEGACY_GENERATION_TRANSIENT, 'yes' );
+
+		WCEmailPostsCleanup::run();
+
+		$this->assertFalse( get_transient( WCEmailPostsCleanup::LEGACY_GENERATION_TRANSIENT ) );
+	}
+
+	/**
+	 * @testdox Should be a clean no-op on a second run.
+	 */
+	public function test_second_run_is_a_clean_noop(): void {
+		$email_id = 'wc_cleanup_rerun_kept';
+		$this->register_fixture_email( $email_id );
+
+		$merchant_body = '<!-- wp:paragraph --><p>Customized content kept across runs.</p><!-- /wp:paragraph -->';
+		$kept_post_id  = $this->create_mapped_email_post( $email_id, $merchant_body, false );
+
+		$deleted_email_id = 'wc_cleanup_rerun_orphan';
+		update_option( $this->option_name( $deleted_email_id ), 999999 );
+
+		$this->assertFalse( WCEmailPostsCleanup::run() );
+		$this->assertFalse( WCEmailPostsCleanup::run(), 'A second run must also return false' );
+
+		$post = get_post( $kept_post_id );
+		$this->assertInstanceOf( \WP_Post::class, $post, 'The kept post must survive a second run' );
+		$this->assertSame( $merchant_body, $post->post_content );
+		$this->assertSame( $kept_post_id, (int) get_option( $this->option_name( $email_id ) ) );
+		$this->assertFalse( get_option( $this->option_name( $deleted_email_id ) ), 'A mapping deleted by the first run must stay deleted' );
+	}
+
+	/**
+	 * @testdox Should log and continue with the remaining mappings when processing one mapping throws.
+	 */
+	public function test_continues_after_throwing_mapping_and_logs_error(): void {
+		// Sorted before the healthy mapping (mappings are processed in
+		// option_name order), so the throw must not abort the rest of the run.
+		$throwing_email_id = 'wc_cleanup_aa_throws';
+		$healthy_email_id  = 'wc_cleanup_zz_healthy';
+		$this->register_fixture_email( $throwing_email_id );
+		$healthy_email = $this->register_fixture_email( $healthy_email_id );
+
+		$throwing_post_id = $this->create_mapped_email_post( $throwing_email_id, '<!-- wp:paragraph --><p>Content.</p><!-- /wp:paragraph -->', true );
+		$healthy_post_id  = $this->create_mapped_email_post( $healthy_email_id, WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $healthy_email ), false );
+
+		// The canonical render for the broken email throws (e.g. a fataling
+		// third-party filter); render_block_template_html() does not catch
+		// filter exceptions.
+		add_filter(
+			'woocommerce_email_block_template_html',
+			static function ( $template_html, $email ) use ( $throwing_email_id ) {
+				if ( $email instanceof \WC_Email && $throwing_email_id === $email->id ) {
+					throw new \RuntimeException( 'Broken third-party template filter.' );
+				}
+				return $template_html;
+			},
+			10,
+			2
+		);
+
+		$logger = $this->createMock( Email_Editor_Logger_Interface::class );
+		$logger->expects( $this->once() )
+			->method( 'error' )
+			->with( $this->stringContains( $this->option_name( $throwing_email_id ) ) );
+
+		WCEmailPostsCleanup::run( $logger );
+
+		$this->assertInstanceOf( \WP_Post::class, get_post( $throwing_post_id ), 'The mapping that threw must be left untouched' );
+		$this->assertNull( get_post( $healthy_post_id ), 'Mappings after the throwing one must still be processed' );
+		$this->assertFalse( get_option( $this->option_name( $healthy_email_id ) ) );
+	}
+
+	/**
+	 * @testdox Should not delete options that only resemble the mapping shape via LIKE wildcards.
+	 */
+	public function test_ignores_options_only_resembling_the_mapping_shape(): void {
+		// `_` is a single-character wildcard in SQL LIKE, so this dashed
+		// third-party option matches the scan pattern but is not a mapping.
+		$lookalike_option = 'woocommerce-email-templates-foo-post-id';
+		update_option( $lookalike_option, 999999 );
+
+		WCEmailPostsCleanup::run();
+
+		$this->assertSame(
+			999999,
+			(int) get_option( $lookalike_option ),
+			'Options merely matching the LIKE pattern must never be deleted'
+		);
+
+		delete_option( $lookalike_option );
+	}
+
+	/**
+	 * Build a WC_Email stub backed by the fixture template and inject it into
+	 * \WC_Emails::$emails so the cleanup can resolve it by email ID.
+	 *
+	 * @param string $email_id Email ID to assign to the stub.
+	 * @return \WC_Email Registered fixture email instance.
+	 */
+	private function register_fixture_email( string $email_id ): \WC_Email {
+		$stub = $this->getMockBuilder( \WC_Email::class )
+			->disableOriginalConstructor()
+			->onlyMethods( array( 'get_title', 'get_description' ) )
+			->getMock();
+		$stub->method( 'get_title' )->willReturn( 'Fixture email for cleanup tests' );
+		$stub->method( 'get_description' )->willReturn( 'Fixture email used to cover WOOPLUG-6171 cleanup scenarios.' );
+		$stub->id             = $email_id;
+		$stub->template_base  = $this->fixtures_base;
+		$stub->template_block = 'block/third-party-with-version.php';
+		$stub->template_plain = null;
+
+		$class_key = 'WC_Test_Email_' . $email_id;
+
+		$emails_container = \WC_Emails::instance();
+		$reflection       = new \ReflectionClass( $emails_container );
+		$property         = $reflection->getProperty( 'emails' );
+		$property->setAccessible( true );
+		$current               = $property->getValue( $emails_container );
+		$current[ $class_key ] = $stub;
+		$property->setValue( $emails_container, $current );
+
+		$this->injected_email_keys[] = $class_key;
+
+		return $stub;
+	}
+
+	/**
+	 * Create a mapped `woo_email` post with controlled timestamps.
+	 *
+	 * @param string $email_id     Email ID to link the post to via the manager option.
+	 * @param string $post_content Post content.
+	 * @param bool   $never_edited When true, creation and modification timestamps are identical.
+	 * @return int The created post ID.
+	 */
+	private function create_mapped_email_post( string $email_id, string $post_content, bool $never_edited ): int {
+		$created_at  = '2023-01-01 00:00:00';
+		$modified_at = $never_edited ? $created_at : '2024-06-15 12:34:56';
+
+		$inserted = wp_insert_post(
+			array(
+				'post_type'         => Integration::EMAIL_POST_TYPE,
+				'post_status'       => 'publish',
+				'post_title'        => 'Cleanup fixture for ' . $email_id,
+				'post_content'      => $post_content,
+				'post_date'         => $created_at,
+				'post_date_gmt'     => $created_at,
+				'post_modified'     => $modified_at,
+				'post_modified_gmt' => $modified_at,
+			),
+			true
+		);
+
+		if ( is_wp_error( $inserted ) ) {
+			throw new \RuntimeException( 'wp_insert_post failed: ' . esc_html( $inserted->get_error_message() ) );
+		}
+
+		$post_id = (int) $inserted;
+		$this->assertGreaterThan( 0, $post_id );
+
+		// wp_insert_post overwrites post_modified* with `now` — force the timestamps via the DB.
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_date'         => $created_at,
+				'post_date_gmt'     => $created_at,
+				'post_modified'     => $modified_at,
+				'post_modified_gmt' => $modified_at,
+			),
+			array( 'ID' => $post_id )
+		);
+		clean_post_cache( $post_id );
+
+		$this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
+		return $post_id;
+	}
+
+	/**
+	 * Build the mapping option name for an email type.
+	 *
+	 * @param string $email_id The email type.
+	 * @return string Option name.
+	 */
+	private function option_name( string $email_id ): string {
+		return 'woocommerce_email_templates_' . $email_id . '_post_id';
+	}
+
+	/**
+	 * Remove any stubs we injected into \WC_Emails::$emails during the test.
+	 */
+	private function cleanup_injected_emails(): void {
+		if ( empty( $this->injected_email_keys ) ) {
+			return;
+		}
+
+		$emails_container = \WC_Emails::instance();
+		$reflection       = new \ReflectionClass( $emails_container );
+		$property         = $reflection->getProperty( 'emails' );
+		$property->setAccessible( true );
+		$current = $property->getValue( $emails_container );
+		foreach ( $this->injected_email_keys as $key ) {
+			unset( $current[ $key ] );
+		}
+		$property->setValue( $emails_container, $current );
+		$this->injected_email_keys = array();
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
index c59cb56f79f..cd2269c0ab7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
@@ -1140,17 +1140,23 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
 	 * @return int The generated post ID.
 	 */
 	private function generate_stamped_post( string $email_id ): int {
-		$this->register_fixture_email( $email_id );
+		$email = $this->register_fixture_email( $email_id );

 		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-		$this->posts_manager->delete_email_template( $email_id );
+		$post_id   = $generator->create_draft( $email );

-		$post_id = $generator->generate_email_template_if_not_exists( $email_id );
-
-		$this->assertIsInt( $post_id );
 		$this->assertGreaterThan( 0, $post_id );

+		wp_update_post(
+			array(
+				'ID'          => $post_id,
+				'post_status' => 'publish',
+			)
+		);
+		// Integration's transition_post_status hook is not registered in the
+		// unit-test bootstrap, so write the email_type => post_id mapping explicitly.
+		$this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
 		return $post_id;
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
index ee3ebe4ccbc..a95f43602c0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -655,17 +655,23 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
 	 * @return int The generated post ID.
 	 */
 	private function generate_stamped_post( string $email_id ): int {
-		$this->register_fixture_email( $email_id );
+		$email = $this->register_fixture_email( $email_id );

 		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-		$this->posts_manager->delete_email_template( $email_id );
-
-		$post_id = $generator->generate_email_template_if_not_exists( $email_id );
+		$post_id   = $generator->create_draft( $email );

-		$this->assertIsInt( $post_id );
 		$this->assertGreaterThan( 0, $post_id );

+		wp_update_post(
+			array(
+				'ID'          => $post_id,
+				'post_status' => 'publish',
+			)
+		);
+		// Integration's transition_post_status hook is not registered in the
+		// unit-test bootstrap, so write the email_type => post_id mapping explicitly.
+		$this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
 		// Sanity check: RSM-137 stamped all three sync meta keys.
 		$this->assertNotSame( '', (string) get_post_meta( $post_id, '_wc_email_template_source_hash', true ) );
 		$this->assertNotSame( '', (string) get_post_meta( $post_id, '_wc_email_template_version', true ) );
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php
index 8f229b86835..6032c14aa11 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php
@@ -333,17 +333,23 @@ class WCEmailTemplateSyncTrackerTest extends \WC_Unit_Test_Case {
 	 * @return int The generated post ID.
 	 */
 	private function generate_stamped_post( string $email_id ): int {
-		$this->register_fixture_email( $email_id );
+		$email = $this->register_fixture_email( $email_id );

 		$generator = new WCTransactionalEmailPostsGenerator();
-		$generator->init_default_transactional_emails();
-		$this->posts_manager->delete_email_template( $email_id );
+		$post_id   = $generator->create_draft( $email );

-		$post_id = $generator->generate_email_template_if_not_exists( $email_id );
-
-		$this->assertIsInt( $post_id );
 		$this->assertGreaterThan( 0, $post_id );

+		wp_update_post(
+			array(
+				'ID'          => $post_id,
+				'post_status' => 'publish',
+			)
+		);
+		// Integration's transition_post_status hook is not registered in the
+		// unit-test bootstrap, so write the email_type => post_id mapping explicitly.
+		$this->posts_manager->save_email_template_post_id( $email_id, $post_id );
+
 		return $post_id;
 	}

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 18138884f35..286d28a356a 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,7 @@ declare( strict_types=1 );

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

+use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
@@ -50,35 +51,12 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {

 		// 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.
+		// and would otherwise leak stale mappings whose backing posts were rolled back.
 		$this->template_manager->clear_caches();

 		WCEmailTemplateSyncRegistry::reset_cache();
 	}

-	/**
-	 * Test that init sets up the transient.
-	 */
-	public function testInitSetsUpTransient(): void {
-		delete_transient( 'wc_email_editor_initial_templates_generated' );
-
-		$this->email_generator->initialize();
-
-		$this->assertEquals( WOOCOMMERCE_VERSION, get_transient( 'wc_email_editor_initial_templates_generated' ) );
-	}
-
-	/**
-	 * Test that init doesn't run if transient exists.
-	 */
-	public function testInitDoesNotRunIfTransientExists(): void {
-		set_transient( 'wc_email_editor_initial_templates_generated', WOOCOMMERCE_VERSION, WEEK_IN_SECONDS );
-
-		$result = $this->email_generator->initialize();
-
-		$this->assertTrue( $result );
-	}
-
 	/**
 	 * Test that get_email_template prioritizes template_block property.
 	 */
@@ -118,64 +96,80 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * Test that generate_email_template_if_not_exists generates template.
+	 * @testdox Should create a draft post carrying the email type and page template meta.
 	 */
-	public function testGenerateEmailTemplateIfNotExistsGeneratesTemplate(): void {
+	public function test_create_draft_creates_draft_with_identity_meta(): void {
 		$email_type = 'customer_new_account';
-		$email      = $this->createMock( \WC_Email::class );
-		$email->id  = $email_type;
+		$email      = $this->template_manager->get_email_by_id( $email_type );
+		$this->assertInstanceOf( \WC_Email::class, $email );

-		$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 );
+		$post_id = $this->email_generator->create_draft( $email );

-		$this->assertIsInt( $post_id );
 		$this->assertGreaterThan( 0, $post_id );
+		$this->assertSame( 'draft', get_post_status( $post_id ), 'Lazily created email posts must stay drafts until published.' );
+		$this->assertSame(
+			$email_type,
+			(string) get_post_meta( $post_id, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, true ),
+			'_wc_email_type meta must link the draft to its email type.'
+		);
+		$this->assertSame(
+			( new WooEmailTemplate() )->get_slug(),
+			(string) get_post_meta( $post_id, '_wp_page_template', true ),
+			'_wp_page_template meta must point at the Woo email template.'
+		);
 	}

 	/**
-	 * Test that generate_email_templates generates multiple templates.
+	 * @testdox Should not write the email_type to post_id option mapping when creating a draft.
 	 */
-	public function testGenerateEmailTemplatesGeneratesMultipleTemplates(): void {
-		$templates_to_generate = array( 'customer_new_account', 'customer_completed_order' );
+	public function test_create_draft_does_not_write_option_mapping(): void {
+		$email_type = 'customer_new_account';
+		$email      = $this->template_manager->get_email_by_id( $email_type );
+		$this->assertInstanceOf( \WC_Email::class, $email );

-		$this->email_generator->init_default_transactional_emails();
-		foreach ( $templates_to_generate as $email_type ) {
-			// Delete the email template association if it exists.
-			$this->template_manager->delete_email_template( $email_type );
-		}
-		$result = $this->email_generator->generate_email_templates( $templates_to_generate );
+		$post_id = $this->email_generator->create_draft( $email );

-		$this->assertTrue( $result );
-		foreach ( $templates_to_generate as $email_type ) {
-			$this->assertNotFalse( get_option( 'woocommerce_email_templates_' . $email_type . '_post_id' ) );
-		}
+		$this->assertGreaterThan( 0, $post_id );
+		$this->assertEmpty(
+			$this->template_manager->get_email_template_post_id( $email_type ),
+			'The option mapping must only be written on publish, not when the draft is created.'
+		);
 	}

 	/**
-	 * Test that generate_email_templates returns false when no templates are generated.
+	 * @testdox Should force draft status even when the content-post-data filter sets publish.
 	 */
-	public function testGenerateEmailTemplatesReturnsFalseWhenNoTemplatesAreGenerated(): void {
-		$templates_to_generate = array( 'invalid_email_type' );
+	public function test_create_draft_forces_draft_status_over_filter(): void {
+		add_filter(
+			'woocommerce_email_content_post_data',
+			static function ( array $post_data ): array {
+				$post_data['post_status'] = 'publish';
+				return $post_data;
+			}
+		);

-		$this->email_generator->init_default_transactional_emails();
-		$result = $this->email_generator->generate_email_templates( $templates_to_generate );
+		$email = $this->template_manager->get_email_by_id( 'customer_new_account' );
+		$this->assertInstanceOf( \WC_Email::class, $email );
+
+		$post_id = $this->email_generator->create_draft( $email );

-		$this->assertFalse( $result );
+		$this->assertGreaterThan( 0, $post_id );
+		$this->assertSame(
+			'draft',
+			get_post_status( $post_id ),
+			'The post status is system-owned and must not be overridable by the woocommerce_email_content_post_data filter.'
+		);
 	}

 	/**
-	 * Core email is stamped with all three sync meta keys, and the hash is self-consistent with post_content.
+	 * Core email is stamped with all 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 );
+	public function test_core_email_is_stamped_with_all_sync_meta_keys(): void {
+		$email = $this->template_manager->get_email_by_id( 'customer_new_account' );
+		$this->assertInstanceOf( \WC_Email::class, $email );

-		$post_id = $this->email_generator->generate_email_template_if_not_exists( $email_type );
+		$post_id = $this->email_generator->create_draft( $email );

-		$this->assertIsInt( $post_id );
 		$this->assertGreaterThan( 0, $post_id );

 		$version   = (string) get_post_meta( $post_id, '_wc_email_template_version', true );
@@ -197,20 +191,23 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 			$synced_at,
 			'_wc_email_last_synced_at should be a GMT MySQL-format timestamp.'
 		);
+
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+			'Freshly created posts must be stamped in_sync.'
+		);
 	}

 	/**
-	 * @testdox Should stamp _wc_email_template_last_core_render meta with the canonical post_content at generation time.
+	 * @testdox Should stamp _wc_email_template_last_core_render meta with the canonical post_content at creation time.
 	 */
-	public function test_generation_stamps_last_core_render_meta(): void {
-		$email_type = 'customer_on_hold_order';
-
-		$this->email_generator->init_default_transactional_emails();
-		$this->template_manager->delete_email_template( $email_type );
+	public function test_create_draft_stamps_last_core_render_meta(): void {
+		$email = $this->template_manager->get_email_by_id( 'customer_on_hold_order' );
+		$this->assertInstanceOf( \WC_Email::class, $email );

-		$post_id = $this->email_generator->generate_email_template_if_not_exists( $email_type );
+		$post_id = $this->email_generator->create_draft( $email );

-		$this->assertIsInt( $post_id );
 		$this->assertGreaterThan( 0, $post_id );

 		$stored_render = (string) get_post_meta(
@@ -222,18 +219,9 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 		$this->assertNotSame(
 			'',
 			$stored_render,
-			'_wc_email_template_last_core_render should be populated at generation time.'
+			'_wc_email_template_last_core_render should be populated at creation time.'
 		);

-		$email = null;
-		foreach ( \WC_Emails::instance()->get_emails() as $candidate ) {
-			if ( $candidate instanceof \WC_Email && $candidate->id === $email_type ) {
-				$email = $candidate;
-				break;
-			}
-		}
-		$this->assertInstanceOf( \WC_Email::class, $email );
-
 		$this->assertSame(
 			WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ),
 			$stored_render,
@@ -243,25 +231,32 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {

 	/**
 	 * 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.
+	 * header are absent from the sync registry: they get no version/synced-at meta, but the
+	 * source hash is still stamped — `was_never_edited()` checks depend on it for every email.
 	 */
-	public function test_email_absent_from_registry_is_not_stamped(): void {
+	public function test_email_absent_from_registry_gets_hash_but_no_version_meta(): void {
 		$email_id = 'wc_test_email_no_version';
-		$this->register_third_party_email_without_version( $email_id );
+		$email    = $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 );
+		$post_id = $this->email_generator->create_draft( $email );

-		$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 ) );
+
+		$post_content = (string) get_post( $post_id )->post_content;
+		$this->assertSame(
+			sha1( $post_content ),
+			(string) get_post_meta( $post_id, '_wc_email_template_source_hash', true ),
+			'The source hash must be stamped even for emails outside the sync registry.'
+		);
+		$this->assertSame(
+			WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+			(string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+		);
 	}

 	/**
@@ -312,8 +307,9 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 	 * WC_Emails::$emails and opt it in via the block-editor filter.
 	 *
 	 * @param string $email_id Email ID to inject.
+	 * @return \WC_Email The injected stub.
 	 */
-	private function register_third_party_email_without_version( string $email_id ): void {
+	private function register_third_party_email_without_version( string $email_id ): \WC_Email {
 		$stub = $this->getMockBuilder( \WC_Email::class )
 			->disableOriginalConstructor()
 			->getMock();
@@ -345,6 +341,78 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 				return $emails;
 			}
 		);
+
+		return $stub;
+	}
+
+	/**
+	 * @testdox Deprecated generation methods are no-ops that trigger a deprecation notice.
+	 */
+	public function test_deprecated_generation_methods_are_noops(): void {
+		$generator_class = WCTransactionalEmailPostsGenerator::class;
+		$this->setExpectedDeprecated( $generator_class . '::initialize' );
+		$this->setExpectedDeprecated( $generator_class . '::init_default_transactional_emails' );
+		$this->setExpectedDeprecated( $generator_class . '::generate_initial_email_templates' );
+		$this->setExpectedDeprecated( $generator_class . '::generate_email_templates' );
+
+		$this->email_generator->initialize();
+		$this->email_generator->init_default_transactional_emails();
+		$this->assertFalse( $this->email_generator->generate_initial_email_templates() );
+		$this->assertFalse( $this->email_generator->generate_email_templates( array( 'customer_processing_order' ) ) );
+
+		$this->assertFalse(
+			$this->template_manager->get_email_template_post_id( 'customer_processing_order' ),
+			'The deprecated no-ops must not create posts or mappings'
+		);
+	}
+
+	/**
+	 * @testdox Deprecated generate_email_template_if_not_exists() creates a published, mapped post and is idempotent.
+	 */
+	public function test_deprecated_generate_email_template_if_not_exists_creates_published_mapped_post(): void {
+		$this->setExpectedDeprecated( WCTransactionalEmailPostsGenerator::class . '::generate_email_template_if_not_exists' );
+
+		$post_id = $this->email_generator->generate_email_template_if_not_exists( 'customer_processing_order' );
+
+		$this->assertIsInt( $post_id );
+		$post = get_post( $post_id );
+		$this->assertInstanceOf( \WP_Post::class, $post );
+		$this->assertSame( 'publish', $post->post_status );
+		$this->assertSame( $post_id, $this->template_manager->get_email_template_post_id( 'customer_processing_order' ) );
+
+		$this->assertSame(
+			$post_id,
+			$this->email_generator->generate_email_template_if_not_exists( 'customer_processing_order' ),
+			'A second call must return the existing post instead of creating another one'
+		);
+
+		$this->assertFalse(
+			$this->email_generator->generate_email_template_if_not_exists( 'this_email_type_is_not_registered' ),
+			'Unregistered email types must not create posts'
+		);
+	}
+
+	/**
+	 * @testdox Deprecated generate_email_template_if_not_exists() replaces a stale mapping pointing at a deleted post.
+	 */
+	public function test_deprecated_generate_email_template_if_not_exists_replaces_stale_mapping(): void {
+		$this->setExpectedDeprecated( WCTransactionalEmailPostsGenerator::class . '::generate_email_template_if_not_exists' );
+
+		// Stale mapping: the post behind it no longer exists.
+		$this->template_manager->save_email_template_post_id( 'customer_processing_order', 999999 );
+
+		$post_id = $this->email_generator->generate_email_template_if_not_exists( 'customer_processing_order' );
+
+		$this->assertIsInt( $post_id );
+		$this->assertNotSame( 999999, $post_id, 'A stale mapping must not be returned as a usable post ID' );
+		$post = get_post( $post_id );
+		$this->assertInstanceOf( \WP_Post::class, $post );
+		$this->assertSame( 'publish', $post->post_status );
+		$this->assertSame(
+			$post_id,
+			$this->template_manager->get_email_template_post_id( 'customer_processing_order' ),
+			'The stale mapping must be replaced with the fresh post'
+		);
 	}

 	/**
@@ -365,11 +433,11 @@ class WCTransactionalEmailPostsGeneratorTest extends \WC_Unit_Test_Case {
 		}

 		remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+		remove_all_filters( 'woocommerce_email_content_post_data' );

 		WCEmailTemplateSyncRegistry::reset_cache();

 		parent::tearDown();
 		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
-		delete_transient( 'wc_email_editor_initial_templates_generated' );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManagerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManagerTest.php
index 286ddf64d01..fb04138bee9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManagerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManagerTest.php
@@ -422,6 +422,44 @@ class WCTransactionalEmailPostsManagerTest extends \WC_Unit_Test_Case {
 		$this->assertFalse( wp_cache_get( $cache_key, WCTransactionalEmailPostsManager::CACHE_GROUP ) );
 	}

+	/**
+	 * @testdox Should fall back to the _wc_email_type post meta when no option mapping exists.
+	 */
+	public function testGetEmailTypeFromPostIdFallsBackToEmailTypeMeta(): void {
+		$post_id = $this->factory->post->create();
+		update_post_meta( $post_id, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, 'meta_fallback_email' );
+
+		$result = $this->template_manager->get_email_type_from_post_id( $post_id );
+
+		$this->assertEquals( 'meta_fallback_email', $result, 'Unpublished posts without an option mapping must resolve via the email type meta' );
+	}
+
+	/**
+	 * @testdox Should not feed the meta fallback result into the mapping caches.
+	 */
+	public function testGetEmailTypeFromPostIdDoesNotCacheMetaFallbackResult(): void {
+		$post_id = $this->factory->post->create();
+		update_post_meta( $post_id, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY, 'meta_uncached_email' );
+
+		$this->assertEquals( 'meta_uncached_email', $this->template_manager->get_email_type_from_post_id( $post_id ) );
+
+		// The fallback result must NOT poison the forward lookup:
+		// `get_email_template_post_id()` reverse-searches the in-memory cache
+		// and must only ever see mapped posts.
+		$this->assertEmpty(
+			$this->template_manager->get_email_template_post_id( 'meta_uncached_email' ),
+			'A meta-resolved scratchpad must not appear as the mapped post for its email type'
+		);
+
+		// Deleting the meta immediately changes the result — nothing was cached.
+		delete_post_meta( $post_id, WCTransactionalEmailPostsManager::EMAIL_TYPE_META_KEY );
+
+		$this->assertNull(
+			$this->template_manager->get_email_type_from_post_id( $post_id, true ),
+			'The meta fallback result must not be served from a cache after the meta was deleted'
+		);
+	}
+
 	/**
 	 * Cleanup after test.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailsTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailsTest.php
index 0b7cd16181a..f97fc8d51ed 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailsTest.php
@@ -5,43 +5,17 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;

 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
-use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;

 /**
  * Tests for the WCTransactionalEmails class.
  */
 class WCTransactionalEmailsTest extends \WC_Unit_Test_Case {
-	/**
-	 * @var WCTransactionalEmails $transactional_emails
-	 */
-	private WCTransactionalEmails $transactional_emails;
-
-	/**
-	 * @var WCTransactionalEmailPostsGenerator|\PHPUnit\Framework\MockObject\MockObject
-	 */
-	private $mock_email_generator;
-
 	/**
 	 * Setup test case.
 	 */
 	public function setUp(): void {
 		parent::setUp();
 		add_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
-
-		// Create a mock for the email generator.
-		$this->mock_email_generator = $this->getMockBuilder( WCTransactionalEmailPostsGenerator::class )
-			->disableOriginalConstructor()
-			->onlyMethods( array( 'initialize' ) )
-			->getMock();
-
-		// Create a reflection of the WCTransactionalEmails class.
-		$reflection = new \ReflectionClass( WCTransactionalEmails::class );
-
-		// Create an instance and set the mocked generator.
-		$this->transactional_emails = new WCTransactionalEmails();
-		$property                   = $reflection->getProperty( 'email_template_generator' );
-		$property->setAccessible( true );
-		$property->setValue( $this->transactional_emails, $this->mock_email_generator );
 	}

 	/**
@@ -77,29 +51,31 @@ class WCTransactionalEmailsTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * Test that init_email_templates is not called on non-WooCommerce admin pages.
+	 * Test that get_core_transactional_emails returns the unfiltered core list.
 	 */
-	public function testInitEmailTemplatesNotCalledOnNonWooCommercePages(): void {
-		set_current_screen( 'front' );
+	public function testGetCoreTransactionalEmailsIgnoresBlockEditorFilter(): void {
+		add_filter(
+			'woocommerce_transactional_emails_for_block_editor',
+			function ( $emails ) {
+				$emails[] = 'custom_email';
+				return $emails;
+			}
+		);

-		// Set expectation that initialize should not be called.
-		$this->mock_email_generator->expects( $this->never() )
-			->method( 'initialize' );
+		$emails = WCTransactionalEmails::get_core_transactional_emails();

-		$this->transactional_emails->init_email_templates();
+		$this->assertIsArray( $emails );
+		$this->assertContains( 'customer_new_account', $emails );
+		$this->assertNotContains( 'custom_email', $emails );
 	}

 	/**
-	 * Test that init_email_templates is called on WooCommerce admin pages.
+	 * @testdox Deprecated init_email_templates() is a no-op that triggers a deprecation notice.
 	 */
-	public function testInitEmailTemplatesCalledOnWooCommercePages(): void {
-		set_current_screen( 'woocommerce_page_wc-admin' );
-
-		// Set expectation that initialize should be called exactly once.
-		$this->mock_email_generator->expects( $this->once() )
-			->method( 'initialize' );
+	public function testDeprecatedInitEmailTemplatesIsNoop(): void {
+		$this->setExpectedDeprecated( WCTransactionalEmails::class . '::init_email_templates' );

-		$this->transactional_emails->init_email_templates();
+		( new WCTransactionalEmails() )->init_email_templates();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/Emails/EmailsSettingsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/Emails/EmailsSettingsControllerTest.php
index d0807aa2a4e..fe905bf0ae2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/Emails/EmailsSettingsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/Emails/EmailsSettingsControllerTest.php
@@ -12,6 +12,7 @@ namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Settings\Email
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Emails\Controller;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Emails\Schema\EmailsSettingsSchema;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
 use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
 use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
 use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
@@ -155,9 +156,17 @@ class EmailsSettingsControllerTest extends WC_Unit_Test_Case {
 		WC_Emails::instance()->init();
 		$this->email = WC_Emails::instance()->emails['WC_Email_Customer_Completed_Order'];

-		// Generate transactional email template posts.
+		// Create a published, mapped email post for the sample email (posts are
+		// created lazily now, so only the email under test gets one).
 		$email_generator = new WCTransactionalEmailPostsGenerator();
-		$email_generator->initialize();
+		$sample_post_id  = $email_generator->create_draft( $this->email );
+		wp_update_post(
+			array(
+				'ID'          => $sample_post_id,
+				'post_status' => 'publish',
+			)
+		);
+		WCTransactionalEmailPostsManager::get_instance()->save_email_template_post_id( self::SAMPLE_EMAIL_ID, $sample_post_id );
 	}

 	/**
@@ -181,8 +190,9 @@ class EmailsSettingsControllerTest extends WC_Unit_Test_Case {
 				}
 			}

-			// Clean up email template posts transient.
-			delete_transient( 'wc_email_editor_initial_templates_generated' );
+			// The DB rolls back between tests but the posts manager singleton's
+			// in-memory cache does not — clear it so stale mappings don't leak.
+			WCTransactionalEmailPostsManager::get_instance()->clear_caches();
 			$this->clear_rest_server();
 			unset( $this->server, $this->controller );
 		} finally {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0cdbeef698e..cc8a700d4b2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3011,6 +3011,9 @@ importers:
       '@wordpress/interface':
         specifier: catalog:wp-bundled
         version: 9.18.5(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.4.0)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@14.16.1)
+      '@wordpress/keyboard-shortcuts':
+        specifier: catalog:wp-min
+        version: 5.33.1(react@18.3.1)
       '@wordpress/keycodes':
         specifier: catalog:wp-min
         version: 4.33.1