Commit d652b0821a3 for woocommerce

commit d652b0821a37f506cdab56aa90c99d0d5f0e5e7b
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Wed Nov 11 08:13:23 2026 +0100

    Add the Preformatted block to the email editor (#70784)

    * Add the Preformatted block to the email editor

    Merchants could not use preformatted text in emails. The block now
    renders through the shared text renderer, keeps its line breaks and
    indentation, and defaults to a monospace font that a chosen font can
    replace.

    * Keep preformatted lines whole in plain text emails

    Each text node inside a pre element was trimmed and put on its own
    line, so a link or bold word split one line into several.

    * Share the Preformatted default font with the email editor

    The editor showed the browser's monospace font while sent emails used
    Courier New. Blocks with a background also get the same padding in
    the editor as in the email.

    * Convert typed personalization tags in the Preformatted block

    Paragraphs, headings and list items already convert a tag typed by
    hand; Preformatted text was sent with the literal tag.

    * Keep indentation after line break tags in plain text emails

    Editing a Preformatted block with a personalization tag can save its
    line breaks as br tags, which lost the next line's indentation in the
    plain text version. Also mark the editor change as a minor release.

    * Keep the cursor after a typed personalization tag

    Converting a tag shortens the content, so the cursor offset pointed
    past its end and the next characters landed at the block's start.

    * Show bold text as bold across the email editor

    The editor's text weight is 300, so a strong tag only reached 400 and
    looked like normal text. One rule now covers every block and replaces
    the workaround that only covered tables.

    * Only move the cursor when a personalization tag was converted

    The cursor update ran on every content change and collapsed the
    selection, so applying bold to selected text lost the selection.

    * Keep the first preformatted line's indentation in plain text

    The newline opening a pre element was not armored, so clean-up removed
    the first line's leading spaces while later lines kept theirs.

    * Apply the bold text weight in sent emails too

    With site style sync, a theme such as Twenty Twenty-Five gives emails a
    text weight of 300, so bold text in sent emails looked normal. The rule
    now lives in the shared stylesheet that the renderer inlines.

    * Check the whole bold declaration in the sent email test

    A substring check also accepted font-weight: bolder, which is the
    weight that renders as normal text.

    * Build the multipart plain text from the block email

    With the block email editor on, multipart emails still took their plain
    text part from the legacy plain template, so it could miss content the
    merchant added in the editor. It now comes from the sent block email.

    * Drop the stored block email after building the plain text part

    It held the rendered email, with customer and order data, for the rest
    of the request. Also word the changelog for merchants.

    * Build the multipart plain text from the filtered email HTML

    Content added or removed by the woocommerce_mail_content filter now
    reaches the plain text part as well. The tests send the email through
    send() and read the plain text part from the sent message.

    * Build the multipart plain text from the final mail body

    Reading the body PHPMailer is about to send covers every filter that can
    change the message, including wp_mail, instead of only the WooCommerce
    one. Typing the mailer parameter removes three PHPStan baseline entries.

diff --git a/packages/js/email-editor/changelog/add-wooplug-3865-preformatted-personalization-tags b/packages/js/email-editor/changelog/add-wooplug-3865-preformatted-personalization-tags
new file mode 100644
index 00000000000..48cd3bf83ce
--- /dev/null
+++ b/packages/js/email-editor/changelog/add-wooplug-3865-preformatted-personalization-tags
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Convert personalization tags typed into the Preformatted block in the email editor.
diff --git a/packages/js/email-editor/changelog/fix-personalization-tag-caret b/packages/js/email-editor/changelog/fix-personalization-tag-caret
new file mode 100644
index 00000000000..cc9a15678d7
--- /dev/null
+++ b/packages/js/email-editor/changelog/fix-personalization-tag-caret
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep the cursor after a personalization tag typed by hand in the email editor.
diff --git a/packages/js/email-editor/src/blocks/core/rich-text.tsx b/packages/js/email-editor/src/blocks/core/rich-text.tsx
index 6c0e2aa99a1..89d48dd57e2 100644
--- a/packages/js/email-editor/src/blocks/core/rich-text.tsx
+++ b/packages/js/email-editor/src/blocks/core/rich-text.tsx
@@ -7,9 +7,12 @@ import type {
 	RichTextFormat as WPFormat,
 } from '@wordpress/rich-text/build-types/types';
 import { __ } from '@wordpress/i18n';
-import { BlockControls } from '@wordpress/block-editor';
+import {
+	BlockControls,
+	store as blockEditorStore,
+} from '@wordpress/block-editor';
 import { ToolbarButton, ToolbarGroup } from '@wordpress/components';
-import { useSelect } from '@wordpress/data';
+import { dispatch, select, useSelect } from '@wordpress/data';
 import { useCallback, useState } from '@wordpress/element';
 import { createHigherOrderComponent } from '@wordpress/compose';
 import * as React from '@wordpress/element';
@@ -19,6 +22,7 @@ import * as React from '@wordpress/element';
  */
 import {
 	getElementRange,
+	getOffsetAfterTagReplacement,
 	renameTagComment,
 	insertPersonalizationTag,
 	replacePersonalizationTagsWithHTMLComments,
@@ -209,7 +213,7 @@ function extendRichTextFormats() {

 const personalizationTagsLiveContentUpdate = createHigherOrderComponent(
 	( BlockEdit ) => ( props ) => {
-		const { attributes, setAttributes, name } = props;
+		const { attributes, setAttributes, name, clientId } = props;
 		const { content } = attributes;

 		// Fetch the personalization tags list
@@ -239,18 +243,40 @@ const personalizationTagsLiveContentUpdate = createHigherOrderComponent(
 						...newAttributes,
 						content: replacedContent,
 					} );
+
+					// RichText selected the end of the typed tag, which the shorter content no longer reaches.
+					const { clientId: selectedId, offset } =
+						select( blockEditorStore ).getSelectionStart();
+					if (
+						replacedContent !== newAttributes.content &&
+						selectedId === clientId &&
+						offset !== undefined
+					) {
+						const newOffset = getOffsetAfterTagReplacement(
+							newAttributes.content,
+							replacedContent,
+							offset
+						);
+						void dispatch( blockEditorStore ).selectionChange(
+							clientId,
+							'content',
+							newOffset,
+							newOffset
+						);
+					}
 				} else {
 					setAttributes( newAttributes );
 				}
 			},
-			[ list, setAttributes ]
+			[ clientId, list, setAttributes ]
 		);

 		// Only process supported blocks
 		if (
 			name === 'core/paragraph' ||
 			name === 'core/heading' ||
-			name === 'core/list-item'
+			name === 'core/list-item' ||
+			name === 'core/preformatted'
 		) {
 			return (
 				<BlockEdit
diff --git a/packages/js/email-editor/src/blocks/core/test/rich-text-caret.spec.tsx b/packages/js/email-editor/src/blocks/core/test/rich-text-caret.spec.tsx
new file mode 100644
index 00000000000..ab71a417195
--- /dev/null
+++ b/packages/js/email-editor/src/blocks/core/test/rich-text-caret.spec.tsx
@@ -0,0 +1,92 @@
+import * as React from 'react';
+
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { dispatch, select, useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { activatePersonalizationTagsReplacing } from '../rich-text';
+import { addFilterForEmail } from '../../../config-tools/filters';
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	select: jest.fn(),
+	dispatch: jest.fn(),
+	useSelect: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/block-editor', () => ( {
+	store: {},
+	BlockControls: () => null,
+} ) );
+
+jest.mock( '../../../store', () => ( { storeName: 'email-editor' } ) );
+
+jest.mock( '../../../events', () => ( { recordEvent: jest.fn() } ) );
+
+jest.mock( '../../../config-tools/filters', () => ( {
+	addFilterForEmail: jest.fn(),
+} ) );
+
+const TAG = '[woocommerce/customer-first-name]';
+
+describe( 'personalizationTagsLiveContentUpdate caret', () => {
+	const selectionChange = jest.fn();
+	let setContent: ( content: string ) => void;
+
+	beforeEach( () => {
+		selectionChange.mockClear();
+		( useSelect as jest.Mock ).mockReturnValue( [
+			{ token: TAG, name: 'First name' },
+		] );
+		( select as jest.Mock ).mockReturnValue( {
+			getFormatTypeForBareElement: () => undefined,
+			getFormatTypeForClassName: () => undefined,
+			getSelectionStart: () => ( {
+				clientId: 'block',
+				attributeKey: 'content',
+				offset: 39,
+			} ),
+		} );
+		( dispatch as jest.Mock ).mockReturnValue( { selectionChange } );
+
+		activatePersonalizationTagsReplacing();
+		const withLiveUpdate = ( addFilterForEmail as jest.Mock ).mock
+			.calls[ 0 ][ 2 ];
+		const Edit = withLiveUpdate(
+			( props: { setAttributes: ( a: object ) => void } ) => {
+				setContent = ( content ) => props.setAttributes( { content } );
+				return null;
+			}
+		);
+		render(
+			<Edit
+				name="core/paragraph"
+				clientId="block"
+				attributes={ { content: '' } }
+				setAttributes={ jest.fn() }
+			/>
+		);
+	} );
+
+	it( 'moves the caret when a typed tag is converted', () => {
+		setContent( `Hello ${ TAG }` );
+
+		expect( selectionChange ).toHaveBeenCalledWith(
+			'block',
+			'content',
+			7,
+			7
+		);
+	} );
+
+	it( 'leaves the selection alone when no tag is converted', () => {
+		setContent( '<strong>Hello</strong> world' );
+
+		expect( selectionChange ).not.toHaveBeenCalled();
+	} );
+} );
diff --git a/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts b/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts
index 8329b0e1cff..7ab2085b314 100644
--- a/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts
+++ b/packages/js/email-editor/src/components/personalization-tags/rich-text-utils.ts
@@ -215,7 +215,24 @@ const insertPersonalizationTag = (
 	} as RichTextValue;
 };

+/**
+ * Return where a caret belongs after typed tags became comments. Each tag then counts as one character.
+ *
+ * @param original The content with the tag as plain text.
+ * @param replaced The content with the tag as a comment.
+ * @param offset   The caret offset in the original content.
+ */
+const getOffsetAfterTagReplacement = (
+	original: unknown,
+	replaced: unknown,
+	offset: number
+): number =>
+	offset -
+	( create( { html: String( original ) } ).text.length -
+		create( { html: String( replaced ) } ).text.length );
+
 export {
+	getOffsetAfterTagReplacement,
 	getCursorPosition,
 	getElementRange,
 	insertPersonalizationTag,
diff --git a/packages/js/email-editor/src/components/personalization-tags/test/rich-text-utils.spec.ts b/packages/js/email-editor/src/components/personalization-tags/test/rich-text-utils.spec.ts
index 8273aac9518..baeae9e2554 100644
--- a/packages/js/email-editor/src/components/personalization-tags/test/rich-text-utils.spec.ts
+++ b/packages/js/email-editor/src/components/personalization-tags/test/rich-text-utils.spec.ts
@@ -15,6 +15,7 @@ import {
 import {
 	getCursorPosition,
 	insertPersonalizationTag,
+	getOffsetAfterTagReplacement,
 } from '../rich-text-utils';

 const TAG = '[woocommerce/customer-first-name]';
@@ -320,3 +321,18 @@ describe( 'getCursorPosition', () => {
 		expect( getCursorPosition( ref ) ).toEqual( { start: 2, end: 2 } );
 	} );
 } );
+
+describe( 'getOffsetAfterTagReplacement', () => {
+	it( 'moves the offset back by the characters the tag no longer takes', () => {
+		const typed = 'Hello [woocommerce/customer-first-name]';
+		const replaced = 'Hello <!--[woocommerce/customer-first-name]-->';
+
+		expect(
+			getOffsetAfterTagReplacement( typed, replaced, typed.length )
+		).toBe( 'Hello '.length + 1 );
+	} );
+
+	it( 'keeps the offset when nothing was replaced', () => {
+		expect( getOffsetAfterTagReplacement( 'Hello', 'Hello', 3 ) ).toBe( 3 );
+	} );
+} );
diff --git a/packages/php/email-editor/changelog/add-wooplug-3865-preformatted-block b/packages/php/email-editor/changelog/add-wooplug-3865-preformatted-block
new file mode 100644
index 00000000000..633609d0d89
--- /dev/null
+++ b/packages/php/email-editor/changelog/add-wooplug-3865-preformatted-block
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the Preformatted block to the email editor, keeping line breaks and indentation in sent emails.
diff --git a/packages/php/email-editor/changelog/fix-email-editor-bold-text b/packages/php/email-editor/changelog/fix-email-editor-bold-text
new file mode 100644
index 00000000000..a7f5407206e
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-email-editor-bold-text
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show bold text as bold in the email editor and in sent emails when the email uses a light text weight.
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/content.css b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/content.css
index 075bdb0b117..97e89947718 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/content.css
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/content.css
@@ -25,6 +25,11 @@ p {
 	margin: 0;
 }

+pre {
+	margin: 0;
+	white-space: pre-wrap;
+}
+
 h1,
 h2,
 h3,
diff --git a/packages/php/email-editor/src/Engine/Renderer/class-html2text.php b/packages/php/email-editor/src/Engine/Renderer/class-html2text.php
index d4af046e0ac..b37805eb894 100644
--- a/packages/php/email-editor/src/Engine/Renderer/class-html2text.php
+++ b/packages/php/email-editor/src/Engine/Renderer/class-html2text.php
@@ -361,7 +361,7 @@ class Html2Text {
 			// Replace whitespace characters with a space (equivalent to \s).
 			if ( $in_pre ) {
 				// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
-				$text = "\n" . trim( self::render_text( $node->wholeText ), "\n\r\t " ) . "\n";
+				$text = self::render_text( $node->wholeText );

 				// Remove trailing whitespace only.
 				$result = preg_replace( "/[ \t]*\n/im", "\n", $text );
@@ -418,11 +418,15 @@ class Html2Text {
 			case 'h6':
 			case 'ol':
 			case 'ul':
-			case 'pre':
 				// Add two newlines.
 				$output = "\n\n";
 				break;

+			case 'pre':
+				// Add two newlines. The second is armored like newlines inside pre, to keep the first line's indentation.
+				$output = "\n\r";
+				break;
+
 			case 'td':
 			case 'th':
 				// Add tab char to separate table fields.
@@ -535,8 +539,8 @@ class Html2Text {
 				break;

 			case 'br':
-				// Add one line.
-				$output .= "\n";
+				// Add one line. Inside pre it is armored like other newlines, to keep the next line's indentation.
+				$output .= $in_pre ? "\r" : "\n";
 				break;

 			case 'div':
diff --git a/packages/php/email-editor/src/Engine/content-editor.css b/packages/php/email-editor/src/Engine/content-editor.css
index f08becc07e3..0f29f2e78b1 100644
--- a/packages/php/email-editor/src/Engine/content-editor.css
+++ b/packages/php/email-editor/src/Engine/content-editor.css
@@ -1,6 +1,7 @@
 /*
  * Styles for the email editor.
  */
+
 /*
  * Flex layout used for buttons block for email editor.
  */
@@ -82,13 +83,6 @@
 	text-align: center;
 }

-/* Ensure bold formatting shows up in table cells and captions in the editor */
-.wp-block-table td strong,
-.wp-block-table th strong,
-.wp-block-table figcaption strong {
-	font-weight: bold;
-}
-
 /*
  * Embed block enhancements
  */
diff --git a/packages/php/email-editor/src/Engine/content-shared.css b/packages/php/email-editor/src/Engine/content-shared.css
index c772f3e6bd8..32eee0ecc02 100644
--- a/packages/php/email-editor/src/Engine/content-shared.css
+++ b/packages/php/email-editor/src/Engine/content-shared.css
@@ -10,10 +10,21 @@ h2.has-background,
 h3.has-background,
 h4.has-background,
 h5.has-background,
-h6.has-background {
+h6.has-background,
+pre.has-background {
 	padding: 16px 24px;
 }

+pre {
+	font-family: 'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace;
+}
+
+/* With a light text weight such as 300, the browser's "bolder" gives 400 and looks like normal text. */
+strong,
+b {
+	font-weight: bold;
+}
+
 /* Specific padding for list elements */
 ul.has-background,
 ol.has-background {
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-text.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-text.php
index 51183679e1a..f498d4294e8 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-text.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-text.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
 use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;

 /**
- * This renderer covers both core/paragraph, core/heading and core/site-title blocks.
+ * This renderer covers the core/paragraph, core/heading, core/preformatted and core/site-title blocks.
  */
 class Text extends Abstract_Block_Renderer {
 	/**
diff --git a/packages/php/email-editor/src/Integrations/Core/class-initializer.php b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
index 2d66b0735e1..3ba6d656e91 100644
--- a/packages/php/email-editor/src/Integrations/Core/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/Core/class-initializer.php
@@ -54,6 +54,7 @@ class Initializer {
 		'core/list',
 		'core/list-item',
 		'core/paragraph',
+		'core/preformatted',
 		'core/quote',
 		'core/separator',
 		'core/spacer',
@@ -238,6 +239,7 @@ class Initializer {
 		switch ( $block_name ) {
 			case 'core/heading':
 			case 'core/paragraph':
+			case 'core/preformatted':
 			case 'core/site-title':
 			case 'core/post-title':
 				$renderer = new Text();
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 7c56fd5f47e..aea36b59823 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
@@ -1080,4 +1080,26 @@ class Content_Renderer_Test extends \Email_Editor_Integration_Test_Case {
 		}
 		return null;
 	}
+
+	/**
+	 * Test bold text gets an explicit weight, so it stays bold when the email's text weight is light.
+	 */
+	public function testItRendersBoldTextWithExplicitWeight(): void {
+		$email_post_id = $this->factory->post->create(
+			array(
+				'post_content' => '<!-- wp:paragraph --><p>Plain <strong>Bold text</strong></p><!-- /wp:paragraph -->',
+			)
+		);
+		$this->assertIsInt( $email_post_id );
+		$email_post = get_post( $email_post_id );
+		$this->assertInstanceOf( \WP_Post::class, $email_post );
+
+		$template          = new \WP_Block_Template();
+		$template->id      = 'template-id';
+		$template->content = '<!-- wp:post-content /-->';
+		$content           = $this->renderer->render( $email_post, $template );
+
+		$this->assertSame( 1, preg_match( '/<strong[^>]*>Bold text<\/strong>/', $content, $strong ) );
+		$this->assertMatchesRegularExpression( '/style="[^"]*font-weight:\s*bold\s*(;|")/', $strong[0] );
+	}
 }
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Preformatted_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Preformatted_Test.php
new file mode 100644
index 00000000000..69cbde9344b
--- /dev/null
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Preformatted_Test.php
@@ -0,0 +1,124 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare(strict_types = 1);
+namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Content_Renderer;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Initializer;
+
+/**
+ * Integration test for the core/preformatted block, through the whole content renderer.
+ */
+class Preformatted_Test extends \Email_Editor_Integration_Test_Case {
+	/**
+	 * Original registration of the core/preformatted block.
+	 *
+	 * @var \WP_Block_Type
+	 */
+	private \WP_Block_Type $original_block;
+
+	/**
+	 * Set up the test with the block registered the way the email editor registers it.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->di_container->get( Email_Editor::class )->initialize();
+		$initializer = $this->di_container->get( Initializer::class );
+		$initializer->initialize();
+
+		$registry       = \WP_Block_Type_Registry::get_instance();
+		$original_block = $registry->get_registered( 'core/preformatted' );
+		$this->assertInstanceOf( \WP_Block_Type::class, $original_block );
+		$this->original_block = $original_block;
+
+		$settings = $initializer->update_block_settings( array_merge( get_object_vars( $original_block ), array( 'name' => 'core/preformatted' ) ) );
+		$registry->unregister( 'core/preformatted' );
+		$registry->register( 'core/preformatted', $settings );
+	}
+
+	/**
+	 * Restore the original block registration.
+	 */
+	public function tearDown(): void {
+		$registry = \WP_Block_Type_Registry::get_instance();
+		$registry->unregister( 'core/preformatted' );
+		$registry->register( $this->original_block );
+		parent::tearDown();
+	}
+
+	/**
+	 * Test the block keeps its line breaks and indentation, wraps long lines, and has no margin.
+	 */
+	public function testItKeepsWhitespaceAndUsesEmailSafeStyles(): void {
+		$html = $this->render_block( '<!-- wp:preformatted --><pre class="wp-block-preformatted">first line' . "\n\t" . 'indented  line</pre><!-- /wp:preformatted -->' );
+
+		$this->assertStringContainsString( "first line\n\tindented  line</pre>", $html );
+		$style = $this->get_pre_element( $html )->getAttribute( 'style' );
+		$this->assertStringContainsString( 'white-space: pre-wrap', $style );
+		$this->assertStringContainsString( 'margin: 0', $style );
+		$this->assertStringContainsString( 'font-family:', $style );
+		$this->assertStringContainsString( 'monospace', $style );
+	}
+
+	/**
+	 * Test the settings available in the editor reach the email.
+	 */
+	public function testItAppliesBlockSettings(): void {
+		$rendered = $this->render_block( '<!-- wp:preformatted {"style":{"color":{"text":"#ff0000","background":"#00ff00"},"typography":{"fontSize":"22px"},"spacing":{"padding":{"top":"11px","bottom":"12px","left":"13px","right":"14px"}}}} --><pre class="wp-block-preformatted has-text-color has-background" style="color:#ff0000;background-color:#00ff00;font-size:22px;padding-top:11px;padding-right:14px;padding-bottom:12px;padding-left:13px">Text</pre><!-- /wp:preformatted -->' );
+
+		$this->assertStringContainsString( 'email-text-block', $rendered );
+		$this->assertMatchesRegularExpression( '/<td[^>]*email-text-block[^>]*style="[^"]*background-color: #00ff00/', $rendered );
+		$this->assertMatchesRegularExpression( '/<td[^>]*email-text-block[^>]*style="[^"]*font-size: 22px/', $rendered );
+		$this->assertMatchesRegularExpression( '/<td[^>]*email-text-block[^>]*style="[^"]*padding-top: 11px/', $rendered );
+		$this->assertStringContainsString( 'color: #ff0000', $this->get_pre_element( $rendered )->getAttribute( 'style' ) );
+	}
+
+	/**
+	 * Test a font family picked in the editor replaces the default monospace font.
+	 */
+	public function testItAppliesAChosenFontFamily(): void {
+		$style = $this->get_pre_element( $this->render_block( '<!-- wp:preformatted {"fontFamily":"georgia"} --><pre class="wp-block-preformatted has-georgia-font-family">Text</pre><!-- /wp:preformatted -->' ) )->getAttribute( 'style' );
+
+		$this->assertStringContainsString( 'Georgia', $style );
+		$this->assertStringNotContainsString( 'monospace', $style );
+	}
+
+	/**
+	 * Render an email with the given content and return the HTML.
+	 *
+	 * @param string $content Block markup.
+	 */
+	private function render_block( string $content ): string {
+		$email_post_id = $this->factory->post->create( array( 'post_content' => $content ) );
+		$this->assertIsInt( $email_post_id );
+		$email_post = get_post( $email_post_id );
+		$this->assertInstanceOf( \WP_Post::class, $email_post );
+
+		$template          = new \WP_Block_Template();
+		$template->id      = 'template-id';
+		$template->content = '<!-- wp:post-content /-->';
+
+		return $this->di_container->get( Content_Renderer::class )->render( $email_post, $template );
+	}
+
+	/**
+	 * Return the pre element of the rendered HTML.
+	 *
+	 * @param string $html Rendered HTML.
+	 */
+	private function get_pre_element( string $html ): \DOMElement {
+		$document = new \DOMDocument();
+		libxml_use_internal_errors( true );
+		$document->loadHTML( '<?xml encoding="utf-8" ?>' . $html );
+		libxml_clear_errors();
+		$pre = $document->getElementsByTagName( 'pre' )->item( 0 );
+		$this->assertInstanceOf( \DOMElement::class, $pre );
+		return $pre;
+	}
+}
diff --git a/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php b/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php
index d4193fc1d53..b9dcda51e21 100644
--- a/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/Renderer/Html2Text_Test.php
@@ -29,6 +29,29 @@ class Html2Text_Test extends \Email_Editor_Unit_Test {
 		$this->assertEquals( $expected, $result );
 	}

+	/**
+	 * Test inline formatting does not split a preformatted line, and later lines keep their indentation
+	 */
+	public function test_preformatted_text_keeps_lines_and_indentation(): void {
+		$html = "<p>Before</p><pre>one <a href=\"https://example.com\">link</a> <em>two</em> three\n\tfour</pre>";
+
+		$this->assertEquals( "Before\n\none [link](https://example.com) two three\n\tfour", Html2Text::convert( $html ) );
+	}
+
+	/**
+	 * Test a line break tag inside preformatted text keeps the next line's indentation
+	 */
+	public function test_preformatted_line_break_tag_keeps_indentation(): void {
+		$this->assertEquals( "one\n    two", Html2Text::convert( '<pre>one<br>    two</pre>' ) );
+	}
+
+	/**
+	 * Test the first preformatted line keeps its indentation too
+	 */
+	public function test_preformatted_first_line_keeps_indentation(): void {
+		$this->assertEquals( "Before\n\n  first\n  second\n\nAfter", Html2Text::convert( "<p>Before</p><pre>  first\n  second</pre><p>After</p>" ) );
+	}
+
 	/**
 	 * Test anchor links with drop_links option
 	 */
diff --git a/plugins/woocommerce/changelog/fix-block-email-multipart-plain-text b/plugins/woocommerce/changelog/fix-block-email-multipart-plain-text
new file mode 100644
index 00000000000..beaadc7cf48
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-block-email-multipart-plain-text
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show the content edited in the block email editor in the plain text version of multipart emails.
diff --git a/plugins/woocommerce/includes/emails/class-wc-email.php b/plugins/woocommerce/includes/emails/class-wc-email.php
index 17f7a5b2fdf..786e61d0fdc 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email.php
@@ -6,6 +6,7 @@
  */

 use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Html2Text;
 use Automattic\WooCommerce\Internal\Email\EmailHeaders;
 use Automattic\WooCommerce\Internal\EmailEditor\BlockEmailRenderer;
 use Automattic\WooCommerce\Internal\EmailEditor\TransactionalEmailPersonalizer;
@@ -296,6 +297,13 @@ class WC_Email extends WC_Settings_API {
 	 */
 	public $block_email_editor_enabled;

+	/**
+	 * Whether the email being sent is a block email, whose plain text part is built from its HTML.
+	 *
+	 * @var bool
+	 */
+	private $is_block_email = false;
+
 	/**
 	 * Whether Cc/Bcc recipients can be configured for this email.
 	 *
@@ -371,23 +379,24 @@ class WC_Email extends WC_Settings_API {
 	/**
 	 * Handle multipart mail.
 	 *
-	 * @param  PHPMailer $mailer PHPMailer object.
-	 * @return PHPMailer
+	 * @param  \PHPMailer\PHPMailer\PHPMailer $mailer PHPMailer object.
+	 * @return \PHPMailer\PHPMailer\PHPMailer
 	 */
 	public function handle_multipart( $mailer ) {
 		if ( ! $this->sending ) {
 			return $mailer;
 		}

+		$alt_body = '';
 		if ( 'multipart' === $this->get_email_type() ) {
-			$mailer->AltBody = wordwrap( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
-				preg_replace( $this->plain_search, $this->plain_replace, wp_strip_all_tags( $this->get_content_plain() ) )
-			);
-		} else {
-			$mailer->AltBody = ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+			$alt_body = $this->is_block_email
+				? Html2Text::convert( $mailer->Body, array( 'ignore_errors' => true ) ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+				: wordwrap( preg_replace( $this->plain_search, $this->plain_replace, wp_strip_all_tags( $this->get_content_plain() ) ) );
 		}
+		$mailer->AltBody = $alt_body; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase

-		$this->sending = false;
+		$this->is_block_email = false;
+		$this->sending        = false;
 		return $mailer;
 	}

@@ -911,7 +920,8 @@ class WC_Email extends WC_Settings_API {
 	public function get_content() {
 		$this->sending = true;

-		$block_email_content = $this->get_block_email_html_content();
+		$block_email_content  = $this->get_block_email_html_content();
+		$this->is_block_email = (bool) $block_email_content;
 		if ( $block_email_content ) {
 			$this->email_type = 'plain' === $this->email_type ? 'html' : $this->email_type;
 			return $block_email_content;
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 07bc0d7bc91..9a1a12d6f0f 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -18481,13 +18481,7 @@ parameters:
 			path: includes/emails/class-wc-email.php

 		-
-			message: '#^Access to property \$AltBody on an unknown class PHPMailer\.$#'
-			identifier: class.notFound
-			count: 4
-			path: includes/emails/class-wc-email.php
-
-		-
-			message: '#^Action callback returns PHPMailer but should not return anything\.$#'
+			message: '#^Action callback returns PHPMailer\\PHPMailer\\PHPMailer but should not return anything\.$#'
 			identifier: return.void
 			count: 1
 			path: includes/emails/class-wc-email.php
@@ -18528,12 +18522,6 @@ parameters:
 			count: 1
 			path: includes/emails/class-wc-email.php

-		-
-			message: '#^Method WC_Email\:\:handle_multipart\(\) has invalid return type PHPMailer\.$#'
-			identifier: class.notFound
-			count: 1
-			path: includes/emails/class-wc-email.php
-
 		-
 			message: '#^Method WC_Email\:\:init_form_fields\(\) has no return type specified\.$#'
 			identifier: missingType.return
@@ -18630,12 +18618,6 @@ parameters:
 			count: 3
 			path: includes/emails/class-wc-email.php

-		-
-			message: '#^Parameter \$mailer of method WC_Email\:\:handle_multipart\(\) has invalid type PHPMailer\.$#'
-			identifier: class.notFound
-			count: 1
-			path: includes/emails/class-wc-email.php
-
 		-
 			message: '#^Variable \$template in empty\(\) always exists and is not falsy\.$#'
 			identifier: empty.variable
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php
index b695e0bd8f5..f54f724b5c1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/BlockEmailRendererTest.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
 use Automattic\WooCommerce\Internal\EmailEditor\BlockEmailRenderer;
 use Automattic\WooCommerce\Internal\EmailEditor\Integration;
 use Automattic\WooCommerce\Internal\EmailEditor\Package;
+use Automattic\WooCommerce\Internal\EmailEditor\TransactionalEmailPersonalizer;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
 /**
  * Tests for the BlockEmailRenderer class.
@@ -422,6 +423,115 @@ class BlockEmailRendererTest extends \WC_Unit_Test_Case {
 		update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
 	}

+	/**
+	 * @testdox Multipart emails take their plain text part from the block email, not the legacy plain template.
+	 */
+	public function testMultipartPlainTextComesFromTheBlockEmail(): void {
+		$this->skip_if_unsupported_environment();
+
+		$alt_body = $this->send_multipart_test_email( true );
+
+		$this->assertStringContainsString( 'Test Paragraph.', $alt_body );
+		$this->assertStringContainsString( 'Order details', $alt_body );
+		$this->assertStringNotContainsString( 'Legacy plain content', $alt_body );
+	}
+
+	/**
+	 * @testdox The multipart plain text includes content added by the woocommerce_mail_content and wp_mail filters.
+	 */
+	public function testMultipartPlainTextIncludesFilteredContent(): void {
+		$this->skip_if_unsupported_environment();
+		$add_content = static function ( $content ) {
+			return str_replace( '</body>', '<p>Added by filter</p></body>', $content );
+		};
+		$add_to_mail = static function ( $atts ) {
+			$atts['message'] = str_replace( '</body>', '<p>Added by wp_mail</p></body>', $atts['message'] );
+			return $atts;
+		};
+		add_filter( 'woocommerce_mail_content', $add_content );
+		add_filter( 'wp_mail', $add_to_mail );
+
+		$alt_body = $this->send_multipart_test_email( true );
+		remove_filter( 'woocommerce_mail_content', $add_content );
+		remove_filter( 'wp_mail', $add_to_mail );
+
+		$this->assertStringContainsString( 'Added by filter', $alt_body );
+		$this->assertStringContainsString( 'Added by wp_mail', $alt_body );
+	}
+
+	/**
+	 * @testdox Multipart emails keep the legacy plain template when the block email editor is off.
+	 */
+	public function testMultipartPlainTextUsesLegacyTemplateWithoutBlockEmails(): void {
+		$alt_body = $this->send_multipart_test_email( false );
+
+		$this->assertStringContainsString( 'Legacy plain content', $alt_body );
+		$this->assertStringNotContainsString( 'Test Paragraph.', $alt_body );
+	}
+
+	/**
+	 * Send the test email as a multipart email and return its plain text part.
+	 *
+	 * @param bool $block_email_editor_enabled Whether the email uses the block email editor.
+	 */
+	private function send_multipart_test_email( bool $block_email_editor_enabled ): string {
+		$email = new class() extends \WC_Email {
+			/**
+			 * Set up the test email.
+			 */
+			public function __construct() {
+				$this->id             = 'test_email';
+				$this->template_block = 'emails/block/default-block-content.php';
+				parent::__construct();
+				$this->email_type = 'multipart';
+			}
+
+			/**
+			 * Legacy plain template content.
+			 */
+			public function get_content_plain() {
+				return 'Legacy plain content';
+			}
+
+			/**
+			 * Legacy HTML template content.
+			 */
+			public function get_content_html() {
+				return 'Legacy HTML content';
+			}
+
+			/**
+			 * Order details the block email shows.
+			 */
+			public function get_block_editor_email_template_content() {
+				return 'Order details';
+			}
+		};
+		// The feature flag is read when the email is built, so set what the constructor would.
+		$email->block_email_editor_enabled = $block_email_editor_enabled;
+		$email->personalizer               = wc_get_container()->get( TransactionalEmailPersonalizer::class );
+		$this->personalizer->set_context(
+			array(
+				'wc_email'        => $email,
+				'recipient_email' => 'customer@test.com',
+			)
+		);
+
+		reset_phpmailer_instance();
+		$email->send( 'customer@test.com', 'Test subject', $email->get_content(), '', array() );
+
+		// The plain text part is cleared after sending, so read it from the sent message.
+		$sent  = tests_retrieve_phpmailer_instance()->get_sent();
+		$parts = preg_split( '/^--b\d=_\S+\r?$/m', $sent->body );
+		foreach ( $parts as $part ) {
+			if ( false !== stripos( $part, 'Content-Type: text/plain' ) ) {
+				return quoted_printable_decode( $part );
+			}
+		}
+
+		return '';
+	}
+
 	/**
 	 * Skip test if the environment doesn't fulfill minimal requirements.
 	 */