Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/wp-includes/formatting.php
Original file line number Diff line number Diff line change
Expand Up @@ -6171,13 +6171,21 @@ function wp_staticize_emoji_for_email( $mail ) {
}
}

foreach ( $headers as $header ) {
if ( ! str_contains( $header, ':' ) ) {
foreach ( $headers as $key => $header ) {
if ( is_string( $header ) &&
strpos( $header, ':' ) === false &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why switch to strpos() from str_contains()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having this option, why using a PHP 8.0 function?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WordPress includes a polyfill for str_contains()

if ( ! function_exists( 'str_contains' ) ) {
/**
* Polyfill for `str_contains()` function added in PHP 8.0.
*
* Performs a case-sensitive check indicating if needle is
* contained in haystack.
*
* @since 5.9.0
*
* @param string $haystack The string to search in.
* @param string $needle The substring to search for in the `$haystack`.
* @return bool True if `$needle` is in `$haystack`, otherwise false.
*/
function str_contains( $haystack, $needle ) {
if ( '' === $needle ) {
return true;
}
return false !== strpos( $haystack, $needle );
}
}

Copy link
Member Author

@SirLouen SirLouen Oct 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I knew that it used a polyfill that triggers that exact function. This is why it makes no sense to me to use str_contains in this context.

Also, now I remember that the original patch using strpos was made like one decade ago, and I felt that most of the logic was very appropriate, so I preferred to preserve it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I knew that it used a polyfill that triggers that exact function. This is why it makes no sense to me to use str_contains in this context.

Why are you reluctant to use it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I knew that it used a polyfill that triggers that exact function. This is why it makes no sense to me to use str_contains in this context.

Why are you reluctant to use it?

Not reluctant. Simply in the original logic in pluggable.php, it was using strpos, so I respected, and wanted to match both scenarios. Since its basically identical, I don't really care one or another.

is_numeric( $key )
) {
continue;
}

// Explode them out.
list( $name, $content ) = explode( ':', trim( $header ), 2 );
list( $name, $content ) = ( is_numeric( $key ) ) ? explode( ':', trim( $header ), 2 ) : array( $key, $header );

// Skip because Content-Type must be an string.
if ( ! is_string( $name ) || ! is_string( $content ) ) {
Comment on lines +6185 to +6186
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if someone $content here is array( 'text/html' )?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you show me an example of how you can make this array possible?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the Trac ticket description: https://core.trac.wordpress.org/ticket/30128

$headers = [
	'From' => 'Me Myself <me@example.net>',
	'Cc'   => [
		'John Q Codex <jqc@wordpress.org>',
		'iluvwp@wordpress.org',
	],
];

What if someone provided:

$headers = [
	'Content-Type' => [
		'text/html',
	],
	'Cc'   => [
		'John Q Codex <jqc@wordpress.org>',
		'iluvwp@wordpress.org',
	],
];

Where arrays are used for every value for consistency.

Relatedly, there's some interesting behaviors when you try to send multiple Content-Type headers, as I found recently: #8412 (comment)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, I did not consider adding this to the mix, but technically it is plausible.

I will give this a second thought.

continue;
}

// Cleanup crew.
$name = trim( $name );
Expand Down
47 changes: 37 additions & 10 deletions src/wp-includes/pluggable.php
Original file line number Diff line number Diff line change
Expand Up @@ -306,16 +306,20 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array()
// If it's actually got contents.
if ( ! empty( $tempheaders ) ) {
// Iterate through the raw headers.
foreach ( (array) $tempheaders as $header ) {
if ( ! str_contains( $header, ':' ) ) {
foreach ( (array) $tempheaders as $key => $header ) {
if ( is_array( $header ) ) {
$header = implode( ',', $header );
} elseif ( strpos( $header, ':' ) === false ) {
if ( false !== stripos( $header, 'boundary=' ) ) {
$parts = preg_split( '/boundary=/i', trim( $header ) );
$boundary = trim( str_replace( array( "'", '"' ), '', $parts[1] ) );
}
continue;
if ( is_numeric( $key ) ) {
continue;
}
}
// Explode them out.
list( $name, $content ) = explode( ':', trim( $header ), 2 );
list( $name, $content ) = ( is_numeric( $key ) ) ? explode( ':', trim( $header ), 2 ) : array( $key, $header );

// Cleanup crew.
$name = trim( $name );
Expand Down Expand Up @@ -359,17 +363,34 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array()
}
break;
case 'cc':
$cc = array_merge( (array) $cc, explode( ',', $content ) );
$cc = array_merge(
(array) $cc,
( is_array( $content ) ) ? $content : explode( ',', $content )
);
break;
case 'bcc':
$bcc = array_merge( (array) $bcc, explode( ',', $content ) );
$bcc = array_merge(
(array) $bcc,
( is_array( $content ) ) ? $content : explode( ',', $content )
);
break;
case 'reply-to':
$reply_to = array_merge( (array) $reply_to, explode( ',', $content ) );
$reply_to = array_merge(
(array) $reply_to,
( is_array( $content ) ) ? $content : explode( ',', $content )
);
break;
default:
// Add it to our grand headers array.
$headers[ trim( $name ) ] = trim( $content );
$name = trim( $name );
$content = trim( $content );
if ( isset( $headers[ $name ] ) ) {
if ( ! is_array( $headers[ $name ] ) ) {
$headers[ $name ] = array( $headers[ $name ] );
}
$headers[ $name ][] = $content;
} else {
$headers[ $name ] = $content;
}
break;
}
}
Expand Down Expand Up @@ -532,7 +553,13 @@ function wp_mail( $to, $subject, $message, $headers = '', $attachments = array()
// Only add custom headers not added automatically by PHPMailer.
if ( ! in_array( $name, array( 'MIME-Version', 'X-Mailer' ), true ) ) {
try {
$phpmailer->addCustomHeader( sprintf( '%1$s: %2$s', $name, $content ) );
if ( is_array( $content ) ) {
foreach ( $content as $value ) {
$phpmailer->addCustomHeader( sprintf( '%1$s: %2$s', $name, $value ) );
}
} else {
$phpmailer->addCustomHeader( sprintf( '%1$s: %2$s', $name, $content ) );
}
} catch ( PHPMailer\PHPMailer\Exception $e ) {
continue;
}
Expand Down
83 changes: 83 additions & 0 deletions tests/phpunit/tests/pluggable/wpMail.php
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,89 @@ public function test_wp_mail_resets_properties() {
$this->assertNotSame( 'user1', $phpmailer->AltBody );
}

/**
* Test that multiple headers with the same name are sent correctly.
*
* @ticket 30128
* @ticket 56779
*/
public function test_wp_mail_with_multiple_same_name_headers() {
$to = 'test@example.com';
$subject = 'Testing Multiple Headers';
$message = 'This is a test message.';
$headers = array(
'x-my-things: thing1',
'x-my-things: thing2',
);

wp_mail( $to, $subject, $message, $headers );

$mailer = tests_retrieve_phpmailer_instance();
$sent_header = $mailer->get_sent()->header;

$this->assertStringContainsString( 'x-my-things: thing1', $sent_header, 'The first header was not sent.' );
$this->assertStringContainsString( 'x-my-things: thing2', $sent_header, 'The second header was not sent.' );
}

/**
* Tests that headers are handled correctly in various formats.
*
* @dataProvider data_wp_mail_header_formats
*
* @ticket 30128
*/
public function test_wp_mail_header_formats( $headers, $expected_strings ) {
wp_mail( 'test@example.com', 'Test', 'Message', $headers );

$mailer = tests_retrieve_phpmailer_instance();
$sent_header = $mailer->get_sent()->header;

foreach ( $expected_strings as $expected_string ) {
$this->assertStringContainsString( $expected_string, $sent_header );
}
}

/**
* Data provider for test_wp_mail_header_formats.
*/
public function data_wp_mail_header_formats() {
return array(
'associative array' => array(
'headers' => array(
'From' => 'Me Myself <me@example.org>',
'Cc' => 'Isaaco Harrelson <iharrel@example.org>',
),
'expected_strings' => array(
'From: Me Myself <me@example.org>',
'Cc: Isaaco Harrelson <iharrel@example.org>',
),
),
'indexed array' => array(
'headers' => array(
'From: Me Myself <me@example.org>',
'Cc: Isaaco Harrelson <iharrel@example.org>',
),
'expected_strings' => array(
'From: Me Myself <me@example.org>',
'Cc: Isaaco Harrelson <iharrel@example.org>',
),
),
'associative array with multiple CCs' => array(
'headers' => array(
'From' => 'Me Myself <me@example.org>',
'Cc' => array(
'Isaaco Harrelson <iharrel@example.org>',
'frederick@wordpress.org',
),
),
'expected_strings' => array(
'From: Me Myself <me@example.org>',
'Cc: Isaaco Harrelson <iharrel@example.org>, frederick@wordpress.org',
),
),
);
}

/**
* Tests that wp_mail() can send embedded images.
*
Expand Down
Loading