From fbe204e9aeaf8322589d6eae95c30c7ae178bb45 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 17 Aug 2025 15:55:03 +0200 Subject: [PATCH 01/10] Mail: New Version for Multipart solution --- src/wp-includes/pluggable.php | 7 +- tests/phpunit/tests/pluggable/wpMail.php | 86 ++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 37fda9e412217..cc30e3e742375 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -351,6 +351,9 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() } elseif ( false !== stripos( $charset_content, 'boundary=' ) ) { $boundary = trim( str_replace( array( 'BOUNDARY=', 'boundary=', '"' ), '', $charset_content ) ); $charset = ''; + if ( preg_match( '~multipart/([a-z!#$&^_.+-]+)~i', $content_type, $matches ) ) { + $content_type = 'multipart/' . strtolower( $matches[1] ) . '; boundary="' . $boundary . '"'; + } } // Avoid setting an empty $content_type. @@ -547,10 +550,6 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() } } } - - if ( false !== stripos( $content_type, 'multipart' ) && ! empty( $boundary ) ) { - $phpmailer->addCustomHeader( sprintf( 'Content-Type: %s; boundary="%s"', $content_type, $boundary ) ); - } } if ( ! empty( $attachments ) ) { diff --git a/tests/phpunit/tests/pluggable/wpMail.php b/tests/phpunit/tests/pluggable/wpMail.php index 7043e0970073a..f98d3416f7424 100644 --- a/tests/phpunit/tests/pluggable/wpMail.php +++ b/tests/phpunit/tests/pluggable/wpMail.php @@ -669,4 +669,90 @@ public function test_wp_mail_encoding_does_not_bleed() { $mailer = tests_retrieve_phpmailer_instance(); $this->assertEquals( '7bit', $mailer->Encoding ); } + + /** + * Test that wp_mail() can send a multipart/alternative email with plain text and html versions. + * + * @ticket 15448 + */ + public function test_wp_mail_plain_and_html() { + $headers = 'Content-Type: multipart/alternative; boundary="TestBoundary"'; + $to = 'user@example.com'; + $subject = 'Test email with plain text and html versions'; + $message = <<Here is the HTML with UTF-8 γειά σου Κόσμε;-) +--TestBoundary-- +EOT; + + wp_mail( $to, $subject, $message, $headers ); + $mailer = tests_retrieve_phpmailer_instance(); + + preg_match( '/boundary="(.*)"/', $mailer->get_sent()->header, $matches ); + $boundary = $matches[1]; + $body = '--' . $boundary . "\n"; + $body .= 'Content-Type: text/plain; charset=us-ascii' . "\n"; + $body .= "\n"; + $body .= 'Here is some plain text.' . "\n"; + $body .= '--' . $boundary . "\n"; + $body .= 'Content-Type: text/html; charset=UTF-8' . "\n"; + $body .= 'Content-Transfer-Encoding: 8bit' . "\n"; + $body .= "\n"; + $body .= 'Here is the HTML with UTF-8 γειά σου Κόσμε;-)' . "\n"; + $body .= '--' . $boundary . '--' . "\n"; + + $this->assertSameIgnoreEOL( $body, $mailer->get_sent()->body, 'The body is not as expected.' ); + $this->assertStringContainsString( + 'Content-Type: multipart/alternative;', + $mailer->get_sent()->header, + 'The multipart/alternative header is not present.' + ); + } + + /* + * 'phpmailer_init' action for test_wp_mail_plain_and_html_workaround(). + */ + public function wp_mail_set_alt_body( $mailer ) { + $mailer->AltBody = strip_tags( $mailer->Body ); + } + + /** + * Check workarounds using phpmailer_init still work around. + * + * @ticket 15448 + */ + public function test_wp_mail_plain_and_html_workaround() { + $to = 'user@example.com'; + $subject = 'Test email with plain text derived from html version'; + $message = '

Hello World! γειά σου Κόσμε

'; + + add_action( 'phpmailer_init', array( $this, 'wp_mail_set_alt_body' ) ); + wp_mail( $to, $subject, $message ); + remove_action( 'phpmailer_init', array( $this, 'wp_mail_set_alt_body' ) ); + + $mailer = tests_retrieve_phpmailer_instance(); + + $this->assertStringContainsString( + 'Content-Type: multipart/alternative;', + $mailer->get_sent()->header, + 'The multipart/alternative header is not present.' + ); + $this->assertStringContainsString( + 'Content-Type: text/plain; charset=UTF-8', + $mailer->get_sent()->body, + 'The text/plain Content-Type header is not present.' + ); + $this->assertStringContainsString( + 'Content-Type: text/html; charset=UTF-8', + $mailer->get_sent()->body, + 'The text/html Content-Type header is not present.' + ); + } } From 22d66861d5daf98fd7e3d6917e956e189b370644 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 17 Aug 2025 16:01:22 +0200 Subject: [PATCH 02/10] Much simpler regex --- src/wp-includes/pluggable.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index cc30e3e742375..c9040f47437e3 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -351,7 +351,7 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() } elseif ( false !== stripos( $charset_content, 'boundary=' ) ) { $boundary = trim( str_replace( array( 'BOUNDARY=', 'boundary=', '"' ), '', $charset_content ) ); $charset = ''; - if ( preg_match( '~multipart/([a-z!#$&^_.+-]+)~i', $content_type, $matches ) ) { + if ( preg_match( '~multipart/([a-z]+);~i', $content_type, $matches ) ) { $content_type = 'multipart/' . strtolower( $matches[1] ) . '; boundary="' . $boundary . '"'; } } From 9787007977ae925c6a0d27da368840d08d011e3b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 17 Aug 2025 16:04:25 +0200 Subject: [PATCH 03/10] Fixing Little Typo --- src/wp-includes/pluggable.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index c9040f47437e3..11cb94d9a19e5 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -351,7 +351,7 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() } elseif ( false !== stripos( $charset_content, 'boundary=' ) ) { $boundary = trim( str_replace( array( 'BOUNDARY=', 'boundary=', '"' ), '', $charset_content ) ); $charset = ''; - if ( preg_match( '~multipart/([a-z]+);~i', $content_type, $matches ) ) { + if ( preg_match( '~multipart/([a-z]+)~i', $content_type, $matches ) ) { $content_type = 'multipart/' . strtolower( $matches[1] ) . '; boundary="' . $boundary . '"'; } } From 883983ceab1af916983cdd1a97060ab1c6d4c6f1 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 17 Aug 2025 16:55:05 +0200 Subject: [PATCH 04/10] Fixing this test, why using iconv_mime_decode_headers? --- tests/phpunit/tests/pluggable/wpMail.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/pluggable/wpMail.php b/tests/phpunit/tests/pluggable/wpMail.php index f98d3416f7424..23b11ecc62a20 100644 --- a/tests/phpunit/tests/pluggable/wpMail.php +++ b/tests/phpunit/tests/pluggable/wpMail.php @@ -83,7 +83,7 @@ public function test_wp_mail_custom_boundaries() { // We need some better assertions here but these catch the failure for now. $this->assertSameIgnoreEOL( $body, $mailer->get_sent()->body ); - $this->assertStringContainsString( 'boundary="----=_Part_4892_25692638.1192452070893"', iconv_mime_decode_headers( ( $mailer->get_sent()->header ) )['Content-Type'][0] ); + $this->assertStringContainsString( 'boundary="----=_Part_4892_25692638.1192452070893"', $mailer->get_sent()->header ); $this->assertStringContainsString( 'charset=', $mailer->get_sent()->header ); } From 1fd5ac9260411f9c8fe9fcb26429b1888c54be9f Mon Sep 17 00:00:00 2001 From: SirLouen Date: Wed, 5 Nov 2025 14:51:10 +0100 Subject: [PATCH 05/10] The Weston's Regex --- src/wp-includes/pluggable.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 11cb94d9a19e5..6f4586300898b 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -351,7 +351,7 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() } elseif ( false !== stripos( $charset_content, 'boundary=' ) ) { $boundary = trim( str_replace( array( 'BOUNDARY=', 'boundary=', '"' ), '', $charset_content ) ); $charset = ''; - if ( preg_match( '~multipart/([a-z]+)~i', $content_type, $matches ) ) { + if ( preg_match( '~^multipart/(\S+)~', $content_type, $matches ) ) { $content_type = 'multipart/' . strtolower( $matches[1] ) . '; boundary="' . $boundary . '"'; } } From 8c5ea89718a8d0280de71d0cf81ad131ce01fd07 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 10 Nov 2025 13:16:55 -0800 Subject: [PATCH 06/10] Add assertion to ensure boundary directive is matched in header --- tests/phpunit/tests/pluggable/wpMail.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/pluggable/wpMail.php b/tests/phpunit/tests/pluggable/wpMail.php index 23b11ecc62a20..116fccf1f9acf 100644 --- a/tests/phpunit/tests/pluggable/wpMail.php +++ b/tests/phpunit/tests/pluggable/wpMail.php @@ -695,7 +695,7 @@ public function test_wp_mail_plain_and_html() { wp_mail( $to, $subject, $message, $headers ); $mailer = tests_retrieve_phpmailer_instance(); - preg_match( '/boundary="(.*)"/', $mailer->get_sent()->header, $matches ); + $this->assertTrue( preg_match( '/boundary="(.*)"/', $mailer->get_sent()->header, $matches ), 'Expected to match boundary directive in header.' ); $boundary = $matches[1]; $body = '--' . $boundary . "\n"; $body .= 'Content-Type: text/plain; charset=us-ascii' . "\n"; From 8acd7e4706baec5c80f7c94aa563929eb061ee84 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 10 Nov 2025 13:20:26 -0800 Subject: [PATCH 07/10] Add since tag for improved multipart message handling --- src/wp-includes/pluggable.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index abcb916a1408a..ca85a85e4e109 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -174,6 +174,7 @@ function cache_users( $user_ids ) { * @since 5.5.0 is_email() is used for email validation, * instead of PHPMailer's default validator. * @since 6.9.0 Added $embeds parameter. + * @since 6.9.0 Improved Content-Type header handling for multipart messages. * * @global PHPMailer\PHPMailer\PHPMailer $phpmailer * From ee57f4fb4cc91254290205b3e2fc9de95a2bb283 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 11 Nov 2025 00:35:14 +0100 Subject: [PATCH 08/10] Fix assert --- tests/phpunit/tests/pluggable/wpMail.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/pluggable/wpMail.php b/tests/phpunit/tests/pluggable/wpMail.php index 116fccf1f9acf..f958bfb2a2e42 100644 --- a/tests/phpunit/tests/pluggable/wpMail.php +++ b/tests/phpunit/tests/pluggable/wpMail.php @@ -695,7 +695,7 @@ public function test_wp_mail_plain_and_html() { wp_mail( $to, $subject, $message, $headers ); $mailer = tests_retrieve_phpmailer_instance(); - $this->assertTrue( preg_match( '/boundary="(.*)"/', $mailer->get_sent()->header, $matches ), 'Expected to match boundary directive in header.' ); + $this->assertSame( 1, preg_match( '/boundary="(.*)"/', $mailer->get_sent()->header, $matches ), 'Expected to match boundary directive in header.' ); $boundary = $matches[1]; $body = '--' . $boundary . "\n"; $body .= 'Content-Type: text/plain; charset=us-ascii' . "\n"; From 3b9179efd368633d849978257b49d8c675c2f735 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 10 Nov 2025 17:48:44 -0800 Subject: [PATCH 09/10] Update assertions to account for deleted addCustomHeader code --- tests/phpunit/tests/pluggable/wpMail.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/pluggable/wpMail.php b/tests/phpunit/tests/pluggable/wpMail.php index f958bfb2a2e42..24441d76f0285 100644 --- a/tests/phpunit/tests/pluggable/wpMail.php +++ b/tests/phpunit/tests/pluggable/wpMail.php @@ -83,8 +83,11 @@ public function test_wp_mail_custom_boundaries() { // We need some better assertions here but these catch the failure for now. $this->assertSameIgnoreEOL( $body, $mailer->get_sent()->body ); - $this->assertStringContainsString( 'boundary="----=_Part_4892_25692638.1192452070893"', $mailer->get_sent()->header ); - $this->assertStringContainsString( 'charset=', $mailer->get_sent()->header ); + $headers = iconv_mime_decode_headers( $mailer->get_sent()->header ); + $this->assertArrayHasKey( 'Content-Type', $headers, 'Expected Content-Type header to be sent.' ); + $content_type_headers = (array) $headers['Content-Type']; + $this->assertCount( 1, $content_type_headers, "Expected only one Content-Type header to be sent. Saw:\n" . implode( "\n", $content_type_headers ) ); + $this->assertSame( 'multipart/mixed; boundary="----=_Part_4892_25692638.1192452070893"; charset=', $content_type_headers[0], 'Expected Content-Type to match.' ); } /** From 1c55eae85537ad03ed15f40609f1849220c49562 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 10 Nov 2025 18:06:30 -0800 Subject: [PATCH 10/10] Eliminate method in favor of closure --- tests/phpunit/tests/pluggable/wpMail.php | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/pluggable/wpMail.php b/tests/phpunit/tests/pluggable/wpMail.php index 24441d76f0285..b7ed263b10ded 100644 --- a/tests/phpunit/tests/pluggable/wpMail.php +++ b/tests/phpunit/tests/pluggable/wpMail.php @@ -719,13 +719,6 @@ public function test_wp_mail_plain_and_html() { ); } - /* - * 'phpmailer_init' action for test_wp_mail_plain_and_html_workaround(). - */ - public function wp_mail_set_alt_body( $mailer ) { - $mailer->AltBody = strip_tags( $mailer->Body ); - } - /** * Check workarounds using phpmailer_init still work around. * @@ -736,9 +729,12 @@ public function test_wp_mail_plain_and_html_workaround() { $subject = 'Test email with plain text derived from html version'; $message = '

Hello World! γειά σου Κόσμε

'; - add_action( 'phpmailer_init', array( $this, 'wp_mail_set_alt_body' ) ); + $set_alt_body = static function ( WP_PHPMailer $mailer ) { + $mailer->AltBody = strip_tags( $mailer->Body ); + }; + add_action( 'phpmailer_init', $set_alt_body ); wp_mail( $to, $subject, $message ); - remove_action( 'phpmailer_init', array( $this, 'wp_mail_set_alt_body' ) ); + remove_action( 'phpmailer_init', $set_alt_body ); $mailer = tests_retrieve_phpmailer_instance();