Commit 752c2af961f for woocommerce

commit 752c2af961fbb31c094b52e66709f2f41859856f
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Tue Sep 22 13:46:23 2026 +0200

    Sanitize customer names in email Reply-to headers and Store API addresses (#68802)

    * Sanitize names used in email Reply-to headers

    Admin order emails built the Reply-to header from the billing name as
    stored on the order. Names saved through the Store API can contain line
    breaks and commas, which wp_mail treats as header and address
    separators, so the header could break apart.

    The billing name is now run through sanitize_text_field with commas
    removed, the email through sanitize_email, and the header is skipped
    when either ends up empty. The store Reply-to name gets line breaks
    and commas removed too, without changing the public getters.

    * Sanitize Store API address text fields like classic checkout

    Classic checkout runs every posted address field through wc_clean, but
    the Store API address schema only applied the REST schema sanitizer
    and wp_kses. Names and other free-text address fields could be saved
    with line breaks and other characters the classic flow removes.

    Core address fields now also go through sanitize_text_field, so both
    checkouts store the same values. Additional checkout fields keep using
    their own field sanitizer and are not changed.

    * Remove commas from withdrawal request Reply-to name

    The withdrawal request email puts the name from the customer's form
    into the Reply-to header. wp_mail splits Reply-to on commas, so a name
    like "Smith, Jr." broke into two entries. Commas are now removed, the
    same way the admin order emails handle billing names.

    * Keep Store API billing email out of text field sanitizing

    The billing address schema already cleans the email with
    sanitize_email. Running sanitize_text_field first removed percent
    sequences, which changed valid addresses such as user%41b@example.com.

    * Check Reply-to names against empty strings, not falsy values

    A billing name of "0" was dropped from the admin order Reply-to header
    because PHP treats "0" as false. The withdrawal request email checked
    the name before removing commas, so a name of only commas gave an
    empty display name. Both now check the cleaned value against ''.

    * Add changelog entry for Reply-to and Store API address sanitizing

    * Use string functions instead of regexes in Reply-to header tests

    The repo guidelines prefer plain string operations over regular
    expressions. The helpers that split headers and pick out the Reply-to
    line now use str_replace, explode, strpos and substr. The assertions
    are unchanged, and the tests still fail when the header fixes are
    reverted.

    * Drop redundant option cleanup from Reply-to header tests

    WP_UnitTestCase runs each test in a transaction that is rolled back
    afterwards and flushes the object cache, so option changes never reach
    later tests. The manual delete_option calls were not needed and read as
    if they wiped settings that existed before the test.

    * Only build the admin order Reply-to header for order objects

    The admin order branch of get_headers() called billing getters on any
    truthy $this->object. It now requires a WC_Order, so another value
    there no longer triggers a fatal error, and the three PHPStan baseline
    entries about calling those getters on object|true are gone. Tests now
    mock WC_Order and cover a non-order object.

    * Share Reply-to name cleanup between email classes

    The admin order emails and the withdrawal request email cleaned the
    Reply-to name with the same inline expression. Both now call a
    protected WC_Email::sanitize_reply_to_name() helper. It has no type
    declarations, so extensions that subclass WC_Email with a method of the
    same name keep loading.

    * Move Reply-to name cleanup to an internal email helper

    The shared cleanup was a protected method on WC_Email. Extensions
    subclass WC_Email, and one that already has a private, static or typed
    method with the same name would fail to load. The cleanup now lives in
    the internal EmailHeaders class, which nothing inherits from.

    * Remove line breaks after the text field filter runs

    sanitize_text_field() ends with its own filter, so a callback can hand
    back a value that still holds line breaks. The Reply-to name helper now
    removes them after that call and trims the result.

    * Simplify Reply-to header test assertions

    Both test files carried a copy of the same helper that cut the Reply-to
    line out of the headers. The tests now assert on the expected line with
    assertStringContainsString, and the withdrawal name cases share one
    test with a data set each.

    * Check the address and clean every Reply-to name the same way

    The admin order header accepted any non-empty result from
    sanitize_email, which ends in a filter like sanitize_text_field does,
    so it now also requires is_email. The store reply-to and from-name
    paths had their own weaker cleanup that kept tag-like text; they now
    use the shared helper, guard on the cleaned name, and read the from
    address and name once.

diff --git a/plugins/woocommerce/changelog/fix-WOO6-158-sanitize-email-reply-to-names b/plugins/woocommerce/changelog/fix-WOO6-158-sanitize-email-reply-to-names
new file mode 100644
index 00000000000..2709b9ab4bc
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-WOO6-158-sanitize-email-reply-to-names
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep customer names used in email Reply-to headers on a single line and sanitize Store API address fields like classic checkout.
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php b/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php
index 769aee16b06..41e2ea64d4c 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-order-withdrawal-requested.php
@@ -5,6 +5,7 @@
  * @package WooCommerce\Emails
  */

+use Automattic\WooCommerce\Internal\Email\EmailHeaders;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\Emails\OrderWithdrawalEmailDataFormatter;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -158,8 +159,10 @@ if ( ! class_exists( 'WC_Email_Order_Withdrawal_Requested', false ) ) :
 			$name    = $this->formatter->get_customer_name( $this->withdrawal_data );
 			$email   = $this->withdrawal_data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] ?? '';

-			if ( '' !== $name && is_email( $email ) ) {
-				$headers .= 'Reply-to: ' . sanitize_text_field( $name ) . ' <' . sanitize_email( $email ) . ">\r\n";
+			$cleaned_name = EmailHeaders::sanitize_reply_to_name( $name );
+
+			if ( '' !== $cleaned_name && is_email( $email ) ) {
+				$headers .= 'Reply-to: ' . $cleaned_name . ' <' . sanitize_email( $email ) . ">\r\n";
 			}

 			if ( FeaturesUtil::feature_is_enabled( 'email_improvements' ) ) {
diff --git a/plugins/woocommerce/includes/emails/class-wc-email.php b/plugins/woocommerce/includes/emails/class-wc-email.php
index 7c027ab5e15..8cf6036cf50 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\Internal\Email\EmailHeaders;
 use Automattic\WooCommerce\Internal\EmailEditor\BlockEmailRenderer;
 use Automattic\WooCommerce\Internal\EmailEditor\TransactionalEmailPersonalizer;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -687,8 +688,13 @@ class WC_Email extends WC_Settings_API {

 		// For order notification emails sent to admin, always use customer's billing email as reply-to.
 		if ( in_array( $this->id, array( 'new_order', 'cancelled_order', 'failed_order' ), true ) ) {
-			if ( $this->object && $this->object->get_billing_email() && ( $this->object->get_billing_first_name() || $this->object->get_billing_last_name() ) ) {
-				$header .= 'Reply-to: ' . $this->object->get_billing_first_name() . ' ' . $this->object->get_billing_last_name() . ' <' . $this->object->get_billing_email() . ">\r\n";
+			if ( $this->object instanceof WC_Order ) {
+				$reply_to_name  = EmailHeaders::sanitize_reply_to_name( $this->object->get_billing_first_name() . ' ' . $this->object->get_billing_last_name() );
+				$reply_to_email = sanitize_email( $this->object->get_billing_email() );
+
+				if ( '' !== $reply_to_name && is_email( $reply_to_email ) ) {
+					$header .= 'Reply-to: ' . $reply_to_name . ' <' . $reply_to_email . ">\r\n";
+				}
 			}
 		} else {
 			// Check if custom reply-to is enabled and configured for non-admin notification emails.
@@ -697,10 +703,20 @@ class WC_Email extends WC_Settings_API {
 			$reply_to_name    = $this->get_reply_to_name();

 			if ( $reply_to_enabled && ! empty( $reply_to_address ) && is_email( $reply_to_address ) ) {
-				$reply_to_name = ! empty( $reply_to_name ) ? $reply_to_name : $this->get_from_name();
-				$header       .= 'Reply-to: ' . $reply_to_name . ' <' . $reply_to_address . ">\r\n";
-			} elseif ( $this->get_from_address() && $this->get_from_name() ) {
-				$header .= 'Reply-to: ' . $this->get_from_name() . ' <' . $this->get_from_address() . ">\r\n";
+				$reply_to_name = EmailHeaders::sanitize_reply_to_name( $reply_to_name );
+
+				if ( '' === $reply_to_name ) {
+					$reply_to_name = EmailHeaders::sanitize_reply_to_name( $this->get_from_name() );
+				}
+
+				$header .= 'Reply-to: ' . $reply_to_name . ' <' . $reply_to_address . ">\r\n";
+			} else {
+				$from_address = $this->get_from_address();
+				$from_name    = EmailHeaders::sanitize_reply_to_name( $this->get_from_name() );
+
+				if ( $from_address && '' !== $from_name ) {
+					$header .= 'Reply-to: ' . $from_name . ' <' . $from_address . ">\r\n";
+				}
 			}
 		}

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index a3f80491f94..d108826542f 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -18751,24 +18751,6 @@ parameters:
 			count: 1
 			path: includes/emails/class-wc-email.php

-		-
-			message: '#^Cannot call method get_billing_email\(\) on object\|true\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: includes/emails/class-wc-email.php
-
-		-
-			message: '#^Cannot call method get_billing_first_name\(\) on object\|true\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: includes/emails/class-wc-email.php
-
-		-
-			message: '#^Cannot call method get_billing_last_name\(\) on object\|true\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: includes/emails/class-wc-email.php
-
 		-
 			message: '#^Method WC_Email\:\:admin_actions\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Email/EmailHeaders.php b/plugins/woocommerce/src/Internal/Email/EmailHeaders.php
new file mode 100644
index 00000000000..864d4797d86
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Email/EmailHeaders.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * EmailHeaders class file
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Email;
+
+/**
+ * Helper class for building safe email headers.
+ *
+ * @internal Just for internal use.
+ * @since 11.3.0
+ */
+final class EmailHeaders {
+
+	/**
+	 * Clean a customer-provided name for use in a Reply-to header.
+	 *
+	 * Line breaks are collapsed to spaces and commas removed, since wp_mail()
+	 * splits Reply-to values on commas. The line break removal runs again
+	 * after sanitize_text_field(), since its 'sanitize_text_field' filter
+	 * runs last and could reintroduce them.
+	 *
+	 * @since 11.3.0
+	 * @param string $name Name to clean.
+	 * @return string
+	 */
+	public static function sanitize_reply_to_name( string $name ): string {
+		$name = str_replace( array( "\r", "\n" ), ' ', sanitize_text_field( $name ) );
+		return trim( str_replace( ',', '', $name ) );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
index 8d0063b5bfb..4b1595cd17a 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
@@ -138,6 +138,11 @@ abstract class AbstractAddressSchema extends AbstractSchema {
 						break;
 					default:
 						$carry[ $key ] = rest_sanitize_value_from_schema( $address[ $key ], $schema[ $key ], $key );
+						// Additional fields are sanitized separately below, via sanitize_field().
+						// Email is excluded because its own schema sanitizer already applies sanitize_email().
+						if ( 'email' !== $key && ! $this->additional_fields_controller->is_field( $key ) && is_string( $carry[ $key ] ) ) {
+							$carry[ $key ] = sanitize_text_field( $carry[ $key ] );
+						}
 						break;
 				}
 				if ( $this->additional_fields_controller->is_field( $key ) ) {
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-withdrawal-requested-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-withdrawal-requested-test.php
new file mode 100644
index 00000000000..b65bca364b8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-order-withdrawal-requested-test.php
@@ -0,0 +1,66 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
+
+/**
+ * WC_Email_Order_Withdrawal_Requested::get_headers() Reply-to tests.
+ *
+ * @covers WC_Email_Order_Withdrawal_Requested::get_headers
+ */
+class WC_Email_Order_Withdrawal_Requested_Test extends \WC_Unit_Test_Case {
+
+	/**
+	 * Load up the email class since it isn't loaded by default.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$bootstrap = \WC_Unit_Tests_Bootstrap::instance();
+		require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email.php';
+		require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-order-withdrawal-requested.php';
+	}
+
+	/**
+	 * Build a withdrawal email instance with the given submitter name and email.
+	 *
+	 * @param string $first_name Submitter first name.
+	 * @param string $last_name  Submitter last name.
+	 * @param string $email      Submitter email.
+	 * @return WC_Email_Order_Withdrawal_Requested
+	 */
+	private function make_email( string $first_name, string $last_name, string $email ): WC_Email_Order_Withdrawal_Requested {
+		$wc_email                  = new WC_Email_Order_Withdrawal_Requested();
+		$wc_email->withdrawal_data = array(
+			OrderWithdrawalFormProcessor::FIELD_FIRST_NAME => $first_name,
+			OrderWithdrawalFormProcessor::FIELD_LAST_NAME  => $last_name,
+			OrderWithdrawalFormProcessor::FIELD_EMAIL      => $email,
+		);
+
+		return $wc_email;
+	}
+
+	/**
+	 * @testWith ["Smith,", "Jr.", "Reply-to: Smith Jr. <guest@example.com>\r\n"]
+	 *           ["x@evil.test,", "Bob", "Reply-to: x@evil.test Bob <guest@example.com>\r\n"]
+	 *           ["Jane", "Doe", "Reply-to: Jane Doe <guest@example.com>\r\n"]
+	 *
+	 * @param string $first_name     Submitter first name.
+	 * @param string $last_name      Submitter last name.
+	 * @param string $reply_to_line  Expected Reply-to header line.
+	 */
+	public function test_submitter_name_reply_to_line( string $first_name, string $last_name, string $reply_to_line ): void {
+		$email = $this->make_email( $first_name, $last_name, 'guest@example.com' );
+
+		$this->assertStringContainsString( $reply_to_line, $email->get_headers() );
+	}
+
+	/**
+	 * @testdox A name made only of a comma produces no Reply-to line.
+	 */
+	public function test_comma_only_submitter_name_produces_no_reply_to(): void {
+		$email = $this->make_email( ',', '', 'guest@example.com' );
+
+		$this->assertStringNotContainsString( 'Reply-to:', $email->get_headers() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-reply-to-header-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-reply-to-header-test.php
new file mode 100644
index 00000000000..0d7ae0bdaac
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-reply-to-header-test.php
@@ -0,0 +1,323 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * WC_Email::get_headers() Reply-to tests.
+ *
+ * @covers WC_Email::get_headers
+ */
+class WC_Email_Reply_To_Header_Test extends \WC_Unit_Test_Case {
+
+	/**
+	 * Load up the email classes since they aren't loaded by default, and
+	 * make sure the legacy header format is used regardless of the
+	 * environment's feature flag defaults.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		update_option( 'woocommerce_feature_email_improvements_enabled', 'no' );
+
+		$bootstrap = \WC_Unit_Tests_Bootstrap::instance();
+		require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email.php';
+		require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-new-order.php';
+		require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-cancelled-order.php';
+		require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-failed-order.php';
+		require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-customer-processing-order.php';
+	}
+
+	/**
+	 * Split header text into individual lines, so newline variants used to
+	 * smuggle extra headers are all normalized the same way.
+	 *
+	 * @param string $headers Raw header string.
+	 * @return array
+	 */
+	private function split_headers( string $headers ): array {
+		$normalized = str_replace( array( "\r\n", "\r" ), array( "\n", "\n" ), $headers );
+		return explode( "\n", $normalized );
+	}
+
+	/**
+	 * Data provider of admin order emails and line-break separators used to
+	 * try to inject an extra header via the billing name.
+	 *
+	 * @return array
+	 */
+	public function admin_order_email_and_separator_provider(): array {
+		$emails = array(
+			'new_order'       => WC_Email_New_Order::class,
+			'cancelled_order' => WC_Email_Cancelled_Order::class,
+			'failed_order'    => WC_Email_Failed_Order::class,
+		);
+
+		$cases = array();
+		foreach ( $emails as $email_id => $email_class ) {
+			// "\r" alone is a regression-only case: wp_mail splits headers on "\n", so this could pass even without the fix.
+			foreach ( array( "\r\n", "\n", "\r" ) as $separator_label => $separator ) {
+				$cases[ "$email_id, separator index $separator_label" ] = array( $email_class, $separator );
+			}
+		}
+
+		return $cases;
+	}
+
+	/**
+	 * @testdox A line break in the billing name does not add an extra header line.
+	 * @dataProvider admin_order_email_and_separator_provider
+	 *
+	 * @param string $email_class Admin order email class to instantiate.
+	 * @param string $separator   Line-break variant used inside the billing name.
+	 */
+	public function test_admin_order_email_billing_name_line_break_does_not_inject_header( string $email_class, string $separator ): void {
+		$order = WC_Helper_Order::create_order();
+		$order->set_billing_first_name( "Foo{$separator}Bcc: attacker@example.test{$separator}X-Injected:" );
+		$order->set_billing_last_name( 'Bar' );
+		$order->set_billing_email( 'guest@example.com' );
+		$order->save();
+
+		$email         = new $email_class();
+		$email->object = $order;
+
+		$headers = $email->get_headers();
+
+		$this->assertStringContainsString(
+			"Reply-to: Foo Bcc: attacker@example.test X-Injected: Bar <guest@example.com>\r\n",
+			$headers
+		);
+
+		$lines              = $this->split_headers( $headers );
+		$reply_to_lines     = array_filter( $lines, static fn( $line ) => 0 === strpos( $line, 'Reply-to:' ) );
+		$non_reply_to_lines = array_filter( $lines, static fn( $line ) => 0 !== strpos( $line, 'Reply-to:' ) );
+
+		$this->assertCount( 1, $reply_to_lines, 'Exactly one Reply-to line should be present' );
+		foreach ( $non_reply_to_lines as $line ) {
+			$this->assertStringNotContainsString( 'attacker@example.test', $line, 'No header other than Reply-to should reference the injected address' );
+		}
+	}
+
+	/**
+	 * @testdox A comma in the billing name is removed since wp_mail splits Reply-to on commas.
+	 */
+	public function test_admin_order_email_billing_name_comma_is_removed(): void {
+		$order = WC_Helper_Order::create_order();
+		$order->set_billing_first_name( 'Smith,' );
+		$order->set_billing_last_name( 'Jr.' );
+		$order->set_billing_email( 'guest@example.com' );
+		$order->save();
+
+		$email         = new WC_Email_New_Order();
+		$email->object = $order;
+
+		$this->assertStringContainsString( "Reply-to: Smith Jr. <guest@example.com>\r\n", $email->get_headers() );
+	}
+
+	/**
+	 * @testdox Legitimate names with accents and apostrophes are unchanged.
+	 */
+	public function test_admin_order_email_billing_name_with_accents_and_apostrophe_is_unchanged(): void {
+		$order = WC_Helper_Order::create_order();
+		$order->set_billing_first_name( 'María' );
+		$order->set_billing_last_name( "O'Brien" );
+		$order->set_billing_email( 'guest@example.com' );
+		$order->save();
+
+		$email         = new WC_Email_New_Order();
+		$email->object = $order;
+
+		$this->assertStringContainsString( "Reply-to: María O'Brien <guest@example.com>\r\n", $email->get_headers() );
+	}
+
+	/**
+	 * @testdox No Reply-to line is added when the billing email is invalid.
+	 */
+	public function test_admin_order_email_invalid_billing_email_produces_no_reply_to(): void {
+		// WC_Order::set_billing_email() throws on an invalid address, so the
+		// order getters are stubbed on a mock instead of going through a real order.
+		$order = $this->createMock( WC_Order::class );
+		$order->method( 'get_billing_first_name' )->willReturn( 'Foo' );
+		$order->method( 'get_billing_last_name' )->willReturn( 'Bar' );
+		$order->method( 'get_billing_email' )->willReturn( 'not-an-email' );
+
+		$email         = new WC_Email_New_Order();
+		$email->object = $order;
+
+		$this->assertStringNotContainsString( 'Reply-to:', $email->get_headers() );
+	}
+
+	/**
+	 * @testdox No Reply-to line is added when a "sanitize_email" filter hands back a value that fails is_email().
+	 */
+	public function test_admin_order_email_sanitize_email_filter_returning_invalid_address_produces_no_reply_to(): void {
+		// sanitize_email() ends with an apply_filters( 'sanitize_email', ... ) call, so a callback can
+		// hand back a non-empty value that does not pass is_email().
+		$filter = static fn() => 'not-an-email';
+		add_filter( 'sanitize_email', $filter );
+
+		$order = $this->createMock( WC_Order::class );
+		$order->method( 'get_billing_first_name' )->willReturn( 'Foo' );
+		$order->method( 'get_billing_last_name' )->willReturn( 'Bar' );
+		$order->method( 'get_billing_email' )->willReturn( 'guest@example.com' );
+
+		$email         = new WC_Email_New_Order();
+		$email->object = $order;
+
+		try {
+			$headers = $email->get_headers();
+		} finally {
+			remove_filter( 'sanitize_email', $filter );
+		}
+
+		$this->assertStringNotContainsString( 'Reply-to:', $headers );
+	}
+
+	/**
+	 * @testdox No Reply-to line is added when the billing name has no visible characters.
+	 * @testWith ["\r\n"]
+	 *           [" "]
+	 *
+	 * @param string $name Billing first name made up only of whitespace/line breaks.
+	 */
+	public function test_admin_order_email_blank_billing_name_produces_no_reply_to( string $name ): void {
+		$order = $this->createMock( WC_Order::class );
+		$order->method( 'get_billing_first_name' )->willReturn( $name );
+		$order->method( 'get_billing_last_name' )->willReturn( '' );
+		$order->method( 'get_billing_email' )->willReturn( 'guest@example.com' );
+
+		$email         = new WC_Email_New_Order();
+		$email->object = $order;
+
+		$this->assertStringNotContainsString( 'Reply-to:', $email->get_headers() );
+	}
+
+	/**
+	 * @testdox A billing name of "0" is not treated as empty.
+	 */
+	public function test_admin_order_email_billing_name_of_zero_is_kept(): void {
+		$order = $this->createMock( WC_Order::class );
+		$order->method( 'get_billing_first_name' )->willReturn( '0' );
+		$order->method( 'get_billing_last_name' )->willReturn( '' );
+		$order->method( 'get_billing_email' )->willReturn( 'guest@example.com' );
+
+		$email         = new WC_Email_New_Order();
+		$email->object = $order;
+
+		$this->assertStringContainsString( "Reply-to: 0 <guest@example.com>\r\n", $email->get_headers() );
+	}
+
+	/**
+	 * @testdox No Reply-to line is added when the object is not a WC_Order.
+	 */
+	public function test_admin_order_email_non_order_object_produces_no_reply_to(): void {
+		$order = $this->getMockBuilder( 'stdClass' )
+			->addMethods( array( 'get_billing_first_name', 'get_billing_last_name', 'get_billing_email' ) )
+			->getMock();
+		$order->method( 'get_billing_first_name' )->willReturn( 'Foo' );
+		$order->method( 'get_billing_last_name' )->willReturn( 'Bar' );
+		$order->method( 'get_billing_email' )->willReturn( 'guest@example.com' );
+
+		$email         = new WC_Email_New_Order();
+		$email->object = $order;
+
+		$this->assertStringNotContainsString( 'Reply-to:', $email->get_headers() );
+	}
+
+	/**
+	 * @testdox Custom reply-to name falling back to a filtered from-name stays on a single line.
+	 */
+	public function test_custom_reply_to_falls_back_to_from_name_without_injecting_header(): void {
+		update_option( 'woocommerce_email_reply_to_enabled', 'yes' );
+		update_option( 'woocommerce_email_reply_to_address', 'reply@example.com' );
+		update_option( 'woocommerce_email_reply_to_name', '' );
+
+		$filter = static fn() => "Shop\r\nBcc: x@evil.test";
+		add_filter( 'woocommerce_email_from_name', $filter );
+
+		$email = new WC_Email_Customer_Processing_Order();
+
+		$headers = $email->get_headers();
+
+		remove_filter( 'woocommerce_email_from_name', $filter );
+
+		$reply_to_line = "Reply-to: Shop Bcc: x@evil.test <reply@example.com>\r\n";
+		$this->assertStringContainsString( $reply_to_line, $headers );
+		$this->assertStringNotContainsString( 'x@evil.test', str_replace( $reply_to_line, '', $headers ) );
+	}
+
+	/**
+	 * @testdox A configured reply-to name left empty by the cleanup falls back to the from-name.
+	 */
+	public function test_custom_reply_to_name_that_cleans_to_nothing_falls_back_to_from_name(): void {
+		update_option( 'woocommerce_email_reply_to_enabled', 'yes' );
+		update_option( 'woocommerce_email_reply_to_address', 'reply@example.com' );
+		update_option( 'woocommerce_email_reply_to_name', ',' );
+
+		$filter = static fn() => 'Shop';
+		add_filter( 'woocommerce_email_from_name', $filter );
+
+		$email   = new WC_Email_Customer_Processing_Order();
+		$headers = $email->get_headers();
+
+		remove_filter( 'woocommerce_email_from_name', $filter );
+
+		$this->assertStringContainsString( "Reply-to: Shop <reply@example.com>\r\n", $headers );
+	}
+
+	/**
+	 * @testdox From-name fallback for the reply-to header stays on a single line.
+	 */
+	public function test_from_name_fallback_reply_to_does_not_inject_header(): void {
+		update_option( 'woocommerce_email_reply_to_enabled', 'no' );
+		update_option( 'woocommerce_email_from_address', 'from@address.com' );
+
+		$filter = static fn() => "Shop\r\nBcc: x@evil.test";
+		add_filter( 'woocommerce_email_from_name', $filter );
+
+		$email = new WC_Email_Customer_Processing_Order();
+
+		$headers = $email->get_headers();
+
+		remove_filter( 'woocommerce_email_from_name', $filter );
+
+		$reply_to_line = "Reply-to: Shop Bcc: x@evil.test <from@address.com>\r\n";
+		$this->assertStringContainsString( $reply_to_line, $headers );
+		$this->assertStringNotContainsString( 'x@evil.test', str_replace( $reply_to_line, '', $headers ) );
+	}
+
+	/**
+	 * @testdox A comma in a filtered from-name is removed for the reply-to header.
+	 */
+	public function test_from_name_fallback_reply_to_comma_is_removed(): void {
+		update_option( 'woocommerce_email_reply_to_enabled', 'no' );
+		update_option( 'woocommerce_email_from_address', 'from@address.com' );
+
+		$filter = static fn() => 'Shop, Inc.';
+		add_filter( 'woocommerce_email_from_name', $filter );
+
+		$email   = new WC_Email_Customer_Processing_Order();
+		$headers = $email->get_headers();
+
+		remove_filter( 'woocommerce_email_from_name', $filter );
+
+		$this->assertStringContainsString( "Reply-to: Shop Inc. <from@address.com>\r\n", $headers );
+	}
+
+	/**
+	 * @testdox An address in angle brackets inside a filtered from-name does not reach the Reply-to header.
+	 */
+	public function test_from_name_fallback_reply_to_strips_embedded_address(): void {
+		update_option( 'woocommerce_email_reply_to_enabled', 'no' );
+		update_option( 'woocommerce_email_from_address', 'from@address.com' );
+
+		$filter = static fn() => 'Support <help@attacker.test>';
+		add_filter( 'woocommerce_email_from_name', $filter );
+
+		$email   = new WC_Email_Customer_Processing_Order();
+		$headers = $email->get_headers();
+
+		remove_filter( 'woocommerce_email_from_name', $filter );
+
+		$this->assertStringContainsString( 'Reply-to: Support <from@address.com>' . "\r\n", $headers );
+		$this->assertStringNotContainsString( 'help@attacker.test', $headers );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
index 84ba8e8fe9b..896b7ce014d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/V1/AbstractAddressSchemaTest.php
@@ -33,12 +33,31 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
 	 */
 	private $sut;

+	/**
+	 * The id of the additional address field registered for these tests.
+	 *
+	 * @var string
+	 */
+	private $field_id = 'plugin-namespace/delivery-notes';
+
 	/**
 	 * Set up before test.
 	 */
 	public function setUp(): void {
 		parent::setUp();

+		add_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+
+		woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => $this->field_id,
+				'label'    => 'Delivery notes',
+				'location' => 'address',
+				'type'     => 'text',
+				'required' => false,
+			)
+		);
+
 		$formatters = new Formatters();
 		$formatters->register( 'money', MoneyFormatter::class );
 		$formatters->register( 'html', HtmlFormatter::class );
@@ -49,6 +68,16 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
 		$this->sut         = $schema_controller->get( BillingAddressSchema::IDENTIFIER );
 	}

+	/**
+	 * Tear down after test.
+	 */
+	public function tearDown(): void {
+		__internal_woocommerce_blocks_deregister_checkout_field( $this->field_id );
+		remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
+
+		parent::tearDown();
+	}
+
 	/**
 	 * Build a minimal valid address with the given overrides.
 	 *
@@ -173,4 +202,103 @@ class AbstractAddressSchemaTest extends WC_Unit_Test_Case {
 			'Billing email addresses should be returned as raw data, not typographic display text.'
 		);
 	}
+
+	/**
+	 * @testdox Should collapse embedded line breaks in a free-text field like classic checkout does.
+	 */
+	public function test_collapses_line_breaks_in_first_name(): void {
+		$address = $this->make_address( array( 'first_name' => "Foo\r\nBcc: attacker@example.test\r\nX-Injected:" ) );
+
+		$result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+		$this->assertSame( 'Foo Bcc: attacker@example.test X-Injected:', $result['first_name'] );
+	}
+
+	/**
+	 * @testdox Should collapse each kind of line break to a single space.
+	 * @testWith ["\n"]
+	 *           ["\r\n"]
+	 *           ["\r"]
+	 *
+	 * @param string $line_break The line break variant being tested.
+	 */
+	public function test_collapses_line_breaks_in_free_text_fields( string $line_break ): void {
+		$address = $this->make_address(
+			array(
+				'last_name' => 'Doe' . $line_break . 'Smith',
+				'company'   => 'Acme' . $line_break . 'Inc',
+				'address_1' => '123 Main' . $line_break . 'Street',
+				'address_2' => 'Suite' . $line_break . '100',
+				'city'      => 'New' . $line_break . 'York',
+			)
+		);
+
+		$result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+		$this->assertSame( 'Doe Smith', $result['last_name'] );
+		$this->assertSame( 'Acme Inc', $result['company'] );
+		$this->assertSame( '123 Main Street', $result['address_1'] );
+		$this->assertSame( 'Suite 100', $result['address_2'] );
+		$this->assertSame( 'New York', $result['city'] );
+	}
+
+	/**
+	 * @testdox Should keep accented and apostrophe characters verbatim.
+	 */
+	public function test_keeps_accented_and_apostrophe_names_verbatim(): void {
+		$address = $this->make_address(
+			array(
+				'first_name' => 'María',
+				'last_name'  => "O'Brien",
+			)
+		);
+
+		$result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+		$this->assertSame( 'María', $result['first_name'] );
+		$this->assertSame( "O'Brien", $result['last_name'] );
+	}
+
+	/**
+	 * @testdox Should sanitize a free-text field the same way classic checkout's wc_clean() would.
+	 */
+	public function test_sanitizes_address_2_like_classic_checkout(): void {
+		$address = $this->make_address( array( 'address_2' => 'Suite%20100' ) );
+
+		$result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+		$this->assertSame( sanitize_text_field( 'Suite%20100' ), $result['address_2'] );
+	}
+
+	/**
+	 * @testdox Should not run an additional address field through sanitize_text_field.
+	 *
+	 * Additional fields are sanitized by their own field type (sanitize_field()), not by the
+	 * core-field sanitization added to the default case of the switch above. A text field's
+	 * default sanitize() is a no-op, so a percent-encoded run untouched by wp_kses() is
+	 * evidence the new sanitize_text_field() call was skipped for this key.
+	 */
+	public function test_does_not_sanitize_additional_address_field_like_a_core_field(): void {
+		$address = $this->make_address( array( $this->field_id => 'Suite%20100' ) );
+
+		$result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+		$this->assertSame( 'Suite%20100', $result[ $this->field_id ] );
+	}
+
+	/**
+	 * @testdox Should not run email through sanitize_text_field before sanitize_email.
+	 *
+	 * sanitize_text_field() strips percent-encoded octets like "%41" out of a string. A local
+	 * part such as "user%41b" is valid per is_email() and untouched by sanitize_email(), so
+	 * running it through sanitize_text_field() first would silently change the address.
+	 */
+	public function test_does_not_mangle_email_with_percent_encoded_characters(): void {
+		$email   = 'user%41b@example.com';
+		$address = $this->make_address( array( 'email' => $email ) );
+
+		$result = $this->sut->sanitize_callback( $address, null, 'billing_address' );
+
+		$this->assertSame( sanitize_email( $email ), $result['email'] );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/EmailHeadersTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/EmailHeadersTest.php
new file mode 100644
index 00000000000..4d5f7e609d0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/EmailHeadersTest.php
@@ -0,0 +1,45 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Email;
+
+use Automattic\WooCommerce\Internal\Email\EmailHeaders;
+use WC_Unit_Test_Case;
+
+/**
+ * EmailHeaders test.
+ *
+ * @covers \Automattic\WooCommerce\Internal\Email\EmailHeaders
+ */
+class EmailHeadersTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testWith ["Foo\r\nBcc: a@b.test", "Foo Bcc: a@b.test"]
+	 *           ["Smith, Jr.", "Smith Jr."]
+	 *           ["María O'Brien", "María O'Brien"]
+	 *           ["   ", ""]
+	 *
+	 * @param string $name     Name to clean.
+	 * @param string $expected Expected cleaned name.
+	 */
+	public function test_sanitize_reply_to_name( string $name, string $expected ) {
+		$this->assertSame( $expected, EmailHeaders::sanitize_reply_to_name( $name ) );
+	}
+
+	/**
+	 * @testdox A "sanitize_text_field" filter callback reintroducing line breaks does not leak them into the result.
+	 */
+	public function test_sanitize_reply_to_name_removes_line_breaks_reintroduced_by_filter(): void {
+		$filter = static fn() => "Shop\r\nX-Test: 1";
+		add_filter( 'sanitize_text_field', $filter );
+
+		try {
+			$result = EmailHeaders::sanitize_reply_to_name( 'Shop' );
+		} finally {
+			remove_filter( 'sanitize_text_field', $filter );
+		}
+
+		$this->assertSame( 'Shop  X-Test: 1', $result );
+	}
+}