Commit 8f46e9035ff for woocommerce

commit 8f46e9035ffc4c6706b61a369b6c09dcfb1175c4
Author: Mike Jolley <mike.jolley@me.com>
Date:   Thu Nov 12 13:40:36 2026 +0000

    Fix email addresses being reformatted on block theme order pages (#70665)

    * Keep order emails and addresses unchanged by wptexturize in block themes

    Block themes texturize the whole template after blocks render, which
    changed emails such as o'neil--shop@48x17.com on order pages. On the
    order received and view order pages, skip <address> content, and
    render the Summary block email in an <address>.

    * Move texturize exclusions into Internal\Orders\TexturizeExclusions

diff --git a/plugins/woocommerce/changelog/66192-fix-order-email-texturize b/plugins/woocommerce/changelog/66192-fix-order-email-texturize
new file mode 100644
index 00000000000..c0e3da0667b
--- /dev/null
+++ b/plugins/woocommerce/changelog/66192-fix-order-email-texturize
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show customer emails and addresses exactly as entered on order pages in block themes.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/order-confirmation/summary/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/order-confirmation/summary/style.scss
index db9e9d444df..c02c83bb07e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/order-confirmation/summary/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/order-confirmation/summary/style.scss
@@ -25,6 +25,12 @@
 				font-weight: inherit;
 				display: block;
 			}
+
+			// The email value is an <address>, which browsers show in italics.
+			> address.wc-block-order-confirmation-summary-list-item__value {
+				font-style: inherit;
+				margin: 0;
+			}
 		}

 		@media (max-width: #{$break-small}) {
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index fb7a1347049..73a00a5bc38 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -454,6 +454,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Orders\UnpaidOrderTimeoutMarker::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Orders\ProductSales::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\Orders\TexturizeExclusions::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\POS\POSController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\StockNotifications\StockNotifications::class )->register();
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Summary.php b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Summary.php
index 3560292ab02..b978d083989 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Summary.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Summary.php
@@ -32,13 +32,27 @@ class Summary extends AbstractOrderConfirmationBlock {
 		$content .= $this->render_summary_row( __( 'Order #:', 'woocommerce' ), $order->get_order_number() );
 		$content .= $this->render_summary_row( __( 'Date:', 'woocommerce' ), wc_format_datetime( $order->get_date_created() ) );
 		$content .= $this->render_summary_row( __( 'Total:', 'woocommerce' ), $order->get_formatted_order_total() );
-		$content .= $this->render_summary_row( __( 'Email:', 'woocommerce' ), $order->get_billing_email() );
+		$content .= $this->render_email_row( (string) $order->get_billing_email() );
 		$content .= $this->render_summary_row( __( 'Payment:', 'woocommerce' ), $order->get_payment_method_title() );
 		$content .= '</ul>';

 		return $content;
 	}

+	/**
+	 * Render the email row. The value is an `<address>`, which wptexturize() skips on order pages.
+	 *
+	 * @param string $email Billing email.
+	 * @return string
+	 */
+	private function render_email_row( string $email ): string {
+		if ( '' === $email ) {
+			return '';
+		}
+
+		return '<li class="wc-block-order-confirmation-summary-list-item"><span class="wc-block-order-confirmation-summary-list-item__key">' . esc_html__( 'Email:', 'woocommerce' ) . '</span> <address class="wc-block-order-confirmation-summary-list-item__value">' . esc_html( $email ) . '</address></li>';
+	}
+
 	/**
 	 * Render row in the order summary.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Orders/TexturizeExclusions.php b/plugins/woocommerce/src/Internal/Orders/TexturizeExclusions.php
new file mode 100644
index 00000000000..050327aa025
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Orders/TexturizeExclusions.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * TexturizeExclusions class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Orders;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+/**
+ * Stops wptexturize() changing customer details, such as emails like `o'neil--shop@48x17.com`.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 11.4.0
+ */
+class TexturizeExclusions implements RegisterHooksInterface {
+
+	/**
+	 * Register hooks and filters.
+	 */
+	public function register(): void {
+		add_action( 'template_redirect', array( $this, 'skip_address_texturize_on_order_pages' ) );
+	}
+
+	/**
+	 * Add `address` to the tags wptexturize() skips on the order received and view order pages.
+	 *
+	 * Block themes texturize the whole template HTML, including the customer details.
+	 *
+	 * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
+	 */
+	public function skip_address_texturize_on_order_pages(): void {
+		if ( wp_is_block_theme() && ( is_order_received_page() || is_view_order_page() ) ) {
+			add_filter( 'no_texturize_tags', array( $this, 'add_address_tag' ) );
+		}
+	}
+
+	/**
+	 * Add `address` to the tags wptexturize() skips.
+	 *
+	 * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
+	 *
+	 * @param mixed $tags HTML element names.
+	 * @return mixed
+	 */
+	public function add_address_tag( $tags ) {
+		if ( ! is_array( $tags ) || in_array( 'address', $tags, true ) ) {
+			return $tags;
+		}
+
+		$tags[] = 'address';
+		return $tags;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/SummaryTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/SummaryTest.php
new file mode 100644
index 00000000000..1c0ed137ba0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/SummaryTest.php
@@ -0,0 +1,63 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\OrderConfirmation;
+
+use Automattic\WooCommerce\Blocks\BlockTypes\OrderConfirmation\Summary as SummaryBlock;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Order Confirmation Summary block.
+ */
+class SummaryTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox The billing email renders as an escaped address element.
+	 */
+	public function test_billing_email_renders_in_address(): void {
+		$order = wc_create_order();
+		$order->set_billing_email( "o'neil--shop@48x17.com" );
+		$order->save();
+
+		$html = $this->render( $order );
+
+		$this->assertStringContainsString(
+			'<address class="wc-block-order-confirmation-summary-list-item__value">o&#039;neil--shop@48x17.com</address>',
+			$html,
+			'The email value should be an escaped address element, which wptexturize() skips on order pages.'
+		);
+	}
+
+	/**
+	 * @testdox Without a billing email, the block renders no email row.
+	 */
+	public function test_no_email_row_without_email(): void {
+		$order = wc_create_order();
+		$order->set_billing_email( '' );
+		$order->save();
+
+		$this->assertStringNotContainsString( 'Email:', $this->render( $order ), 'The email row should not render.' );
+	}
+
+	/**
+	 * Render the block content with full view permission.
+	 *
+	 * @param WC_Order $order Order to render.
+	 * @return string
+	 */
+	private function render( WC_Order $order ): string {
+		$sut = new class() extends SummaryBlock {
+			// phpcs:ignore Squiz.Commenting.FunctionComment.Missing
+			public function __construct() {
+			}
+
+			// phpcs:ignore Squiz.Commenting.FunctionComment.Missing
+			public function render_content_proxy( $order, $permission ) {
+				return $this->render_content( $order, $permission );
+			}
+		};
+
+		return $sut->render_content_proxy( $order, 'full' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Orders/TexturizeExclusionsTest.php b/plugins/woocommerce/tests/php/src/Internal/Orders/TexturizeExclusionsTest.php
new file mode 100644
index 00000000000..4a051561942
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Orders/TexturizeExclusionsTest.php
@@ -0,0 +1,139 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Orders;
+
+use Automattic\WooCommerce\Internal\Orders\TexturizeExclusions;
+use WC_Helper_Order;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the TexturizeExclusions class.
+ *
+ * @covers \Automattic\WooCommerce\Internal\Orders\TexturizeExclusions
+ */
+class TexturizeExclusionsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var TexturizeExclusions
+	 */
+	private $sut;
+
+	/**
+	 * Active theme before each test, restored in tearDown.
+	 *
+	 * @var string
+	 */
+	private $original_theme;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->original_theme = get_stylesheet();
+		switch_theme( 'twentytwentyfour' );
+		$this->sut = new TexturizeExclusions();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		switch_theme( $this->original_theme );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox On the order received and view order pages, address content is not texturized, while other text is.
+	 *
+	 * @testWith ["order-received"]
+	 *           ["view-order"]
+	 *
+	 * @param string $endpoint Order page endpoint.
+	 */
+	public function test_skips_address_texturize_on_order_pages( string $endpoint ): void {
+		$this->go_to_order_page( $endpoint, WC_Helper_Order::create_order() );
+
+		$this->sut->skip_address_texturize_on_order_pages();
+		$text = $this->texturize_to_text( "<address>o'neil--shop@48x17.com</address><p>3x4 -- done</p>" );
+
+		$this->assertStringContainsString( "o'neil--shop@48x17.com", $text, 'Address content should not be texturized.' );
+		$this->assertStringContainsString( '3×4 — done', $text, 'Text outside the address should still be texturized.' );
+	}
+
+	/**
+	 * @testdox Address content is texturized as usual on other pages.
+	 */
+	public function test_texturizes_address_on_other_pages(): void {
+		$this->go_to( get_permalink( self::factory()->post->create( array( 'post_type' => 'page' ) ) ) );
+
+		$this->sut->skip_address_texturize_on_order_pages();
+
+		$this->assertStringNotContainsString(
+			"o'neil--shop@48x17.com",
+			$this->texturize_to_text( "<address>o'neil--shop@48x17.com</address>" ),
+			'Address content should be texturized.'
+		);
+	}
+
+	/**
+	 * @testdox Address content is texturized as usual on order pages in classic themes.
+	 */
+	public function test_texturizes_address_on_order_pages_in_classic_themes(): void {
+		switch_theme( 'storefront' );
+		$this->assertFalse( wp_is_block_theme(), 'The test requires a classic theme.' );
+		$this->go_to_order_page( 'view-order', WC_Helper_Order::create_order() );
+
+		$this->sut->skip_address_texturize_on_order_pages();
+
+		$this->assertStringNotContainsString(
+			"o'neil--shop@48x17.com",
+			$this->texturize_to_text( "<address>o'neil--shop@48x17.com</address>" ),
+			'Address content should be texturized.'
+		);
+	}
+
+	/**
+	 * @testdox On the view order page, the customer details template shows the billing email and address as entered, after kses and wptexturize().
+	 */
+	public function test_customer_details_template_survives_texturize_on_view_order_page(): void {
+		$order = WC_Helper_Order::create_order();
+		$order->set_billing_email( "o'neil--shop@48x17.com" );
+		$order->set_billing_address_1( '12--14 Main St' );
+		$order->save();
+		$this->go_to_order_page( 'view-order', $order );
+
+		$this->sut->skip_address_texturize_on_order_pages();
+		$text = $this->texturize_to_text( wp_kses_post( wc_get_template_html( 'order/order-details-customer.php', array( 'order' => $order ) ) ) );
+
+		$this->assertStringContainsString( "o'neil--shop@48x17.com", $text, 'The billing email should not be texturized.' );
+		$this->assertStringContainsString( '12--14 Main St', $text, 'The billing address should not be texturized.' );
+	}
+
+	/**
+	 * Load an order endpoint page, so the WooCommerce conditional tags match.
+	 *
+	 * @param string   $endpoint Order page endpoint.
+	 * @param WC_Order $order    Order to view.
+	 */
+	private function go_to_order_page( string $endpoint, WC_Order $order ): void {
+		$page_id = self::factory()->post->create( array( 'post_type' => 'page' ) );
+		update_option( 'order-received' === $endpoint ? 'woocommerce_checkout_page_id' : 'woocommerce_myaccount_page_id', $page_id );
+
+		$this->go_to( add_query_arg( $endpoint, $order->get_id(), get_permalink( $page_id ) ) );
+	}
+
+	/**
+	 * Texturize HTML and return its decoded text.
+	 *
+	 * @param string $html HTML to texturize.
+	 * @return string
+	 */
+	private function texturize_to_text( string $html ): string {
+		return html_entity_decode( wp_strip_all_tags( wptexturize( $html ) ), ENT_QUOTES | ENT_HTML5 );
+	}
+}