Commit d2f02158b58 for woocommerce

commit d2f02158b588b236b5c31e73a9d510d3f51887e4
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date:   Mon Oct 12 11:56:02 2026 +0200

    [Email Editor] Stop building the mailer on woocommerce_init and restore the classic Edit fallback (#69538)

    * Resolve the preview "From" email lazily in the email editor integration

    Integration::init_hooks() ran on woocommerce_init, which fires at init
    priority 0, and called WC_Emails::instance()->get_emails() just to keep
    one WC_Email around for the preview send "From" filters. Building the
    mailer that early applied woocommerce_email_classes once, before features
    hooked on init (Back in Stock Notifications at priority 1) added their
    emails, and the singleton is never rebuilt. With the block email editor
    on, those emails were missing from the settings list and never sent.

    The preview callbacks now resolve the email on first use, so the editor
    no longer decides when the mailer gets built. Sends already build it on
    demand, matching the behavior with the editor disabled.

    * Restore the classic settings fallback for the email listing Edit action

    The block email listing's Edit action lost its fallback when posts became
    lazily created: with no post it now always asks the REST API to create
    one, and that endpoint rejects emails that are not registered for the
    block editor. Classic emails such as Back in Stock Notifications, and
    any extension email that does not opt in, ended on "Could not prepare
    the email for editing" with nowhere to configure them.

    The listing payload now carries a block_editor_supported flag per row,
    and the Edit action sends unsupported emails to their classic per-email
    settings section, as it did before lazy creation.

diff --git a/plugins/woocommerce/changelog/fix-wooplug-7935-email-editor-lazy-mailer b/plugins/woocommerce/changelog/fix-wooplug-7935-email-editor-lazy-mailer
new file mode 100644
index 00000000000..0d25faa075f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7935-email-editor-lazy-mailer
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show and send emails registered late on init, such as Back in stock notifications, when the block email editor is enabled.
diff --git a/plugins/woocommerce/changelog/fix-wooplug-7935-email-listing-classic-edit b/plugins/woocommerce/changelog/fix-wooplug-7935-email-listing-classic-edit
new file mode 100644
index 00000000000..f8c18cfc725
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7935-email-listing-classic-edit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Open the classic settings page from the "Edit" action for emails the block email editor does not support, instead of showing an error.
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 4538bf239aa..62ecd083c8a 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
@@ -75,6 +75,7 @@ const emailType: EmailType = {
 	email_key: 'wc_email_new_order',
 	email_class_name: 'WC_Email_New_Order',
 	post_id: '123',
+	block_editor_supported: true,
 	file_template_preview_url:
 		'https://example.test/wp-admin/?preview_woo_block_email=true&email_id=new_order&_wpnonce=abc',
 	recipients: {
@@ -161,6 +162,27 @@ describe( 'ListView', () => {
 			);
 		} );

+		it( 'navigates to the classic settings section for emails the block editor does not support', async () => {
+			const classicEmail = {
+				...emailType,
+				id: 'customer_stock_notification_verify',
+				email_key: 'wc_email_customer_stock_notification_verify',
+				post_id: '',
+				postStatus: null,
+				block_editor_supported: false,
+				file_template_preview_url: null,
+			};
+			render( <ListView emailTypes={ [ classicEmail ] } /> );
+
+			await getAction( 'edit' )?.callback?.( [ classicEmail ] );
+
+			expect( mockRecreateEmailPost ).not.toHaveBeenCalled();
+			expect( window.location.href ).toBe(
+				'https://example.test/wp-admin/admin.php?page=wc-settings&tab=email&section=wc_email_customer_stock_notification_verify'
+			);
+			expect( mockCreateErrorNotice ).not.toHaveBeenCalled();
+		} );
+
 		it( 'does not navigate and surfaces an error notice when the post could not be created', async () => {
 			mockRecreateEmailPost.mockResolvedValue( null );
 			const emailWithoutPost = {
@@ -205,6 +227,7 @@ describe( 'ListView', () => {
 					...emailType,
 					post_id: '',
 					postStatus: null,
+					block_editor_supported: true,
 					file_template_preview_url: null,
 				} )
 			).toBe( false );
@@ -215,6 +238,7 @@ describe( 'ListView', () => {
 					...emailType,
 					postStatus: 'publish',
 					link: 'https://example.test/?woo_email=new-order',
+					block_editor_supported: true,
 					file_template_preview_url: null,
 				} )
 			).toBe( true );
@@ -223,6 +247,7 @@ describe( 'ListView', () => {
 					...emailType,
 					postStatus: 'publish',
 					link: '',
+					block_editor_supported: true,
 					file_template_preview_url: null,
 				} )
 			).toBe( false );
@@ -230,6 +255,7 @@ describe( 'ListView', () => {
 				isEligible?.( {
 					...emailType,
 					postStatus: 'draft',
+					block_editor_supported: true,
 					file_template_preview_url: null,
 				} )
 			).toBe( false );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill-load-error.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill-load-error.test.tsx
index 32e8800b623..0709d360f6f 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill-load-error.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill-load-error.test.tsx
@@ -56,6 +56,7 @@ jest.mock( '@woocommerce/settings', () => ( {
 const email: EmailType = {
 	id: 'new-order',
 	post_id: '123',
+	block_editor_supported: true,
 	file_template_preview_url: null,
 	title: 'New order',
 	description: '',
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 d9ec660a397..db4e421a4f1 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
@@ -80,6 +80,7 @@ jest.mock( '@woocommerce/settings', () => ( {
 const baseEmail: EmailType = {
 	id: 'new-order',
 	post_id: '123',
+	block_editor_supported: true,
 	file_template_preview_url: null,
 	title: 'New order',
 	description: '',
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 e2304980724..ce93e409710 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,
+	block_editor_supported: true,
 	file_template_preview_url: null,
 };

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 3850f740091..e852665b82b 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
@@ -205,6 +205,16 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 						);
 						return;
 					}
+					// Classic emails have no block template to edit; their
+					// settings live on the per-email section page.
+					if ( ! email.block_editor_supported ) {
+						window.location.href = getAdminLink(
+							`admin.php?page=wc-settings&tab=email&section=${ encodeURIComponent(
+								email.email_key
+							) }`
+						);
+						return;
+					}
 					// Lazily create the post (a draft with the file
 					// template content) and open it in the editor.
 					const response = await recreateEmailPost( email.id );
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 220a7cf7f1e..02f5248b9c4 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
@@ -80,6 +80,12 @@ export type EmailType = {
 	email_key: string;
 	email_class_name: string;
 	post_id: string;
+	/**
+	 * False for emails not registered for the block editor (classic
+	 * `WC_Email`s without a block template). Those are configured on the
+	 * classic per-email settings section instead.
+	 */
+	block_editor_supported: boolean;
 	/** Null for emails not registered for the block editor. */
 	file_template_preview_url: string | null;
 	recipients: Recipients;
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 67f66700627..98969fe2784 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,
+	block_editor_supported: true,
 	file_template_preview_url: null,
 };

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 36836fe3b5a..1af77e29045 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
@@ -621,7 +621,8 @@ 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 )
+			$block_editor_supported    = in_array( $email->id, $block_editor_email_ids, true );
+			$file_template_preview_url = $block_editor_supported
 				? wp_nonce_url( admin_url( '?preview_woo_block_email=true&email_id=' . $email->id ), 'preview-woo-block-email' )
 				: null;

@@ -632,6 +633,7 @@ class WC_Settings_Emails extends WC_Settings_Page {
 				'email_key'                 => strtolower( $email_key ),
 				'email_class_name'          => get_class( $email ),
 				'post_id'                   => $post_id,
+				'block_editor_supported'    => $block_editor_supported,
 				'file_template_preview_url' => $file_template_preview_url,
 				'enabled'                   => $email->is_enabled(),
 				'manual'                    => $email->is_manual(),
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index a2a2f02b454..b61584792eb 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -71,11 +71,12 @@ class Integration {
 	private string $sending_preview_for_email_type = '';

 	/**
-	 * The WC_Email instance.
+	 * Email used to resolve the "From" address and name for preview sends. Resolved lazily
+	 * by get_wc_email_instance().
 	 *
-	 * @var \WC_Email
+	 * @var \WC_Email|null
 	 */
-	private \WC_Email $wc_email_instance;
+	private ?\WC_Email $wc_email_instance = null;

 	/**
 	 * Whether the email editor page has been rendered.
@@ -146,15 +147,6 @@ class Integration {
 		$this->editor_page_renderer    = $container->get( PageRenderer::class );
 		$this->template_api_controller = $container->get( TemplateApiController::class );
 		$this->email_api_controller    = $container->get( EmailApiController::class );
-
-		// Using any email class to get the instance.
-		$registered_emails = \WC_Emails::instance()->get_emails();
-		if ( isset( $registered_emails['WC_Email_New_Order'] ) ) {
-			$this->wc_email_instance = $registered_emails['WC_Email_New_Order'];
-		} else {
-			$first_email_key         = array_key_first( $registered_emails );
-			$this->wc_email_instance = $registered_emails[ $first_email_key ];
-		}
 	}

 	/**
@@ -722,8 +714,12 @@ class Integration {
 	 * @return void
 	 */
 	public function send_preview_email_before_wp_mail() {
-		add_filter( 'wp_mail_from', array( $this->wc_email_instance, 'get_from_address' ) );
-		add_filter( 'wp_mail_from_name', array( $this->wc_email_instance, 'get_from_name' ) );
+		$email = $this->get_wc_email_instance();
+		if ( ! $email ) {
+			return;
+		}
+		add_filter( 'wp_mail_from', array( $email, 'get_from_address' ) );
+		add_filter( 'wp_mail_from_name', array( $email, 'get_from_name' ) );
 	}

 	/**
@@ -733,8 +729,30 @@ class Integration {
 	 * @return void
 	 */
 	public function send_preview_email_after_wp_mail() {
-		remove_filter( 'wp_mail_from', array( $this->wc_email_instance, 'get_from_address' ) );
-		remove_filter( 'wp_mail_from_name', array( $this->wc_email_instance, 'get_from_name' ) );
+		$email = $this->get_wc_email_instance();
+		if ( ! $email ) {
+			return;
+		}
+		remove_filter( 'wp_mail_from', array( $email, 'get_from_address' ) );
+		remove_filter( 'wp_mail_from_name', array( $email, 'get_from_name' ) );
+	}
+
+	/**
+	 * Get a registered email whose "From" address and name apply to preview sends.
+	 *
+	 * Resolved on first use, not during `woocommerce_init`: building `WC_Emails` that early
+	 * applies `woocommerce_email_classes` before features hooked on `init` register their
+	 * emails, and the mailer is never rebuilt. Any registered email works, since the "From"
+	 * settings are shared across all of them.
+	 *
+	 * @return \WC_Email|null Null when no email is registered.
+	 */
+	private function get_wc_email_instance(): ?\WC_Email {
+		if ( null === $this->wc_email_instance ) {
+			$registered_emails       = \WC_Emails::instance()->get_emails();
+			$this->wc_email_instance = $registered_emails['WC_Email_New_Order'] ?? $registered_emails[ array_key_first( $registered_emails ) ] ?? null;
+		}
+		return $this->wc_email_instance;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-emails-test.php b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-emails-test.php
index fc4c9a18e25..c9c899e8adc 100644
--- a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-emails-test.php
+++ b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-emails-test.php
@@ -384,6 +384,46 @@ class WC_Settings_Emails_Test extends WC_Settings_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox The block emails listing payload flags whether each email can open in the block editor.
+	 */
+	public function test_block_emails_listing_payload_flags_block_editor_support(): void {
+		$classic_email = new class() extends WC_Email {
+			/**
+			 * Constructor.
+			 */
+			public function __construct() {
+				$this->id    = 'classic_test_email';
+				$this->title = 'Classic test email';
+				parent::__construct();
+			}
+		};
+
+		// Inject straight into the registry: WC_Emails::init() only appends, so
+		// re-running it would not remove the stub again.
+		$mailer          = WC()->mailer();
+		$emails_property = new ReflectionProperty( WC_Emails::class, 'emails' );
+		$emails_property->setAccessible( true );
+		$original_emails = $emails_property->getValue( $mailer );
+		$emails_property->setValue( $mailer, array_merge( $original_emails, array( 'WC_Email_Classic_Test' => $classic_email ) ) );
+
+		try {
+			ob_start();
+			( new WC_Settings_Emails() )->email_notification_setting_block_emails();
+			$html = ob_get_clean();
+		} finally {
+			$emails_property->setValue( $mailer, $original_emails );
+		}
+
+		$this->assertSame( 1, preg_match( '/data-email-types="([^"]*)"/', $html, $matches ), 'Listing mount point should carry the email types payload' );
+		$rows_by_id = array_column( json_decode( html_entity_decode( $matches[1], ENT_QUOTES ), true ), null, 'id' );
+
+		$this->assertTrue( $rows_by_id['new_order']['block_editor_supported'] );
+		$this->assertNotNull( $rows_by_id['new_order']['file_template_preview_url'] );
+		$this->assertFalse( $rows_by_id['classic_test_email']['block_editor_supported'] );
+		$this->assertNull( $rows_by_id['classic_test_email']['file_template_preview_url'] );
+	}
+
 	/**
 	 * @testDox When the current section is the name of an existing email, 'output' invokes that email's 'admin_options' method.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php
index 4187589a968..70449cfbca6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/IntegrationTest.php
@@ -637,6 +637,45 @@ class IntegrationTest extends WC_Unit_Test_Case {
 		$this->assertFalse( $integration->replace_editor( false, $ordinary_post ), 'Posts of other types must keep the default editor after the guard trips' );
 	}

+	/**
+	 * @testdox Initializing the integration does not build the WC_Emails mailer, so emails registered later on init are still listed.
+	 */
+	public function test_initialize_does_not_build_the_mailer(): void {
+		$instance_property = new \ReflectionProperty( \WC_Emails::class, 'instance' );
+		$instance_property->setAccessible( true );
+		$previous_instance = $instance_property->getValue();
+
+		try {
+			$instance_property->setValue( null, null );
+
+			$this->sut->initialize();
+
+			$this->assertNull(
+				$instance_property->getValue(),
+				'Integration::initialize() must not build WC_Emails: it runs on woocommerce_init, before features hooked on init register their emails'
+			);
+		} finally {
+			$instance_property->setValue( null, $previous_instance );
+		}
+	}
+
+	/**
+	 * @testdox Preview sends use the configured "From" address and name while wp_mail runs, and restore the defaults afterwards.
+	 */
+	public function test_preview_send_applies_and_restores_from_filters(): void {
+		$new_order = \WC_Emails::instance()->get_emails()['WC_Email_New_Order'];
+
+		$this->sut->send_preview_email_before_wp_mail();
+
+		$this->assertSame( $new_order->get_from_address(), apply_filters( 'wp_mail_from', 'placeholder@example.com' ) );
+		$this->assertSame( $new_order->get_from_name(), apply_filters( 'wp_mail_from_name', 'Placeholder' ) );
+
+		$this->sut->send_preview_email_after_wp_mail();
+
+		$this->assertSame( 'placeholder@example.com', apply_filters( 'wp_mail_from', 'placeholder@example.com' ) );
+		$this->assertSame( 'Placeholder', apply_filters( 'wp_mail_from_name', 'Placeholder' ) );
+	}
+
 	/**
 	 * Create a `woo_email` post carrying the email type meta.
 	 *