Skip to content

Commit 36b2278

Browse files
authored
Merge pull request #12692 from google/enhancement/12632-default-html-content-type-header
Enhancement/12632 add default text/html Content-Type header to outgoing emails
2 parents b4ecff4 + 6a6d2bd commit 36b2278

2 files changed

Lines changed: 209 additions & 33 deletions

File tree

includes/Core/Email/Email.php

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,20 @@ public function build_headers( $headers = array() ) {
6464
/**
6565
* Sends an email using wp_mail with error tracking.
6666
*
67-
* Wraps wp_mail with a scoped listener for wp_mail_failed hook
68-
* to capture any errors during sending. When text_content is provided,
69-
* it will be set as the AltBody for multipart/alternative MIME emails.
67+
* Captures wp_mail_failed errors during sending. Adds a default
68+
* Content-Type: text/html; charset=UTF-8 header when no caller header is
69+
* supplied, so wp_mail replacements that bypass PHPMailer still deliver
70+
* HTML. When text_content is provided, it's set as the AltBody for
71+
* multipart/alternative MIME emails.
7072
*
7173
* @since 1.168.0
7274
* @since 1.170.0 Added $text_content parameter for plain text alternative.
75+
* @since n.e.x.t Inject default text/html Content-Type when none is supplied.
7376
*
7477
* @param string|array $to Array or comma-separated list of email addresses to send message.
7578
* @param string $subject Email subject.
7679
* @param string $content Message contents (HTML).
77-
* @param array $headers Optional. Additional headers. Default empty array.
80+
* @param array $headers Optional. Additional headers. A default text/html Content-Type is added when none is supplied. Default empty array.
7881
* @param string $text_content Optional. Plain text alternative content. Default empty string.
7982
* @return bool|WP_Error True if the email was sent successfully, WP_Error on failure.
8083
*/
@@ -98,12 +101,16 @@ public function send( $to, $subject, $content, $headers = array(), $text_content
98101
/**
99102
* Sends an email via wp_mail while capturing any errors.
100103
*
101-
* Attaches a temporary listener to the wp_mail_failed hook to capture
102-
* any errors that occur during sending. When text_content is provided,
103-
* uses phpmailer_init hook to set AltBody for multipart MIME emails.
104+
* Attaches a temporary listener to wp_mail_failed to capture errors during
105+
* sending. Injects a default Content-Type: text/html header when no caller
106+
* Content-Type is present, so wp_mail replacements that respect the headers
107+
* argument deliver HTML. When text_content is set, uses phpmailer_init to
108+
* attach it as AltBody. PHPMailer then upgrades the content type to
109+
* multipart/alternative.
104110
*
105111
* @since 1.168.0
106112
* @since 1.170.0 Added $text_content parameter for plain text alternative.
113+
* @since n.e.x.t Inject default text/html Content-Type when none is supplied.
107114
*
108115
* @param string|array $to Array or comma-separated list of email addresses to send message.
109116
* @param string $subject Email subject.
@@ -112,9 +119,19 @@ public function send( $to, $subject, $content, $headers = array(), $text_content
112119
* @param string $text_content Optional. Plain text alternative content. Default empty string.
113120
* @return bool Whether the email was sent successfully.
114121
*/
115-
protected function send_email_and_catch_errors( $to, $subject, $content, $headers, $text_content = '' ) {
122+
protected function send_email_and_catch_errors( $to, $subject, $content, $headers = array(), $text_content = '' ) {
116123
add_action( 'wp_mail_failed', array( $this, 'set_last_error' ) );
117124

125+
if ( ! is_array( $headers ) ) {
126+
$headers = array();
127+
}
128+
129+
// Default to text/html so wp_mail replacements that don't fire
130+
// phpmailer_init still deliver as HTML.
131+
if ( ! $this->headers_contain_content_type( $headers ) ) {
132+
$headers[] = 'Content-Type: text/html; charset=UTF-8';
133+
}
134+
118135
// Set up AltBody for multipart MIME email if text content is provided.
119136
$alt_body_callback = null;
120137
if ( ! empty( $text_content ) ) {
@@ -136,6 +153,30 @@ protected function send_email_and_catch_errors( $to, $subject, $content, $header
136153
return $result;
137154
}
138155

156+
/**
157+
* Checks whether the headers array already contains a Content-Type header.
158+
*
159+
* Detection is case-insensitive and skips non-string entries.
160+
*
161+
* @since n.e.x.t
162+
*
163+
* @param array $headers Headers array passed to wp_mail.
164+
* @return bool True if a Content-Type header is present, false otherwise.
165+
*/
166+
private function headers_contain_content_type( array $headers ): bool {
167+
foreach ( $headers as $header ) {
168+
if ( ! is_string( $header ) ) {
169+
continue;
170+
}
171+
172+
if ( 0 === stripos( ltrim( $header ), 'Content-Type:' ) ) {
173+
return true;
174+
}
175+
}
176+
177+
return false;
178+
}
179+
139180
/**
140181
* Sets the last error from a failed email attempt.
141182
*

tests/phpunit/integration/Core/Email/EmailTest.php

Lines changed: 160 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -56,22 +56,11 @@ function () use ( &$filter_val ) {
5656
}
5757

5858
public function test_send() {
59-
// The pre_wp_mail filter was introduced in WordPress 5.7.
60-
if ( version_compare( $GLOBALS['wp_version'], '5.7', '<' ) ) {
61-
$this->markTestSkipped( 'This test requires WordPress 5.7 or higher for the pre_wp_mail filter.' );
62-
}
59+
$this->skip_if_pre_wp_mail_unsupported();
6360

6461
// Use pre_wp_mail to simulate successful email sending and capture attributes.
6562
$captured_atts = null;
66-
add_filter(
67-
'pre_wp_mail',
68-
function ( $short_circuit, $atts ) use ( &$captured_atts ) {
69-
$captured_atts = $atts;
70-
return true;
71-
},
72-
10,
73-
2
74-
);
63+
$this->capture_wp_mail_atts( $captured_atts );
7564

7665
$to = 'test@example.com';
7766
$subject = 'Test Subject';
@@ -88,10 +77,7 @@ function ( $short_circuit, $atts ) use ( &$captured_atts ) {
8877
}
8978

9079
public function test_send_failure() {
91-
// The pre_wp_mail filter was introduced in WordPress 5.7.
92-
if ( version_compare( $GLOBALS['wp_version'], '5.7', '<' ) ) {
93-
$this->markTestSkipped( 'This test requires WordPress 5.7 or higher for the pre_wp_mail filter.' );
94-
}
80+
$this->skip_if_pre_wp_mail_unsupported();
9581

9682
// Force wp_mail failure using pre_wp_mail filter.
9783
$captured_atts = null;
@@ -126,6 +112,161 @@ function ( $short_circuit, $atts ) use ( &$captured_atts ) {
126112
$this->assertEquals( $content, $captured_atts['message'], 'Content should match.' );
127113
}
128114

115+
public function test_send_adds_default_content_type_header_when_none_supplied() {
116+
$this->skip_if_pre_wp_mail_unsupported();
117+
118+
$captured_atts = null;
119+
$this->capture_wp_mail_atts( $captured_atts );
120+
121+
$this->email->send( 'test@example.com', 'Test Subject', '<p>HTML Content</p>' );
122+
123+
$this->assertNotNull( $captured_atts, 'Attributes should be captured.' );
124+
$this->assertIsArray( $captured_atts['headers'], 'Headers should be passed as an array.' );
125+
$this->assertContains(
126+
'Content-Type: text/html; charset=UTF-8',
127+
$captured_atts['headers'],
128+
'Default text/html Content-Type header should be present when no caller Content-Type is supplied.'
129+
);
130+
$this->assertSingleContentTypeHeader( $captured_atts['headers'] );
131+
}
132+
133+
public function test_send_preserves_caller_supplied_content_type_header() {
134+
$this->skip_if_pre_wp_mail_unsupported();
135+
136+
$captured_atts = null;
137+
$this->capture_wp_mail_atts( $captured_atts );
138+
139+
$caller_content_type = 'Content-Type: text/plain; charset=ISO-8859-1';
140+
$this->email->send(
141+
'test@example.com',
142+
'Test Subject',
143+
'Plain content',
144+
array( $caller_content_type )
145+
);
146+
147+
$this->assertNotNull( $captured_atts, 'Attributes should be captured.' );
148+
$this->assertContains(
149+
$caller_content_type,
150+
$captured_atts['headers'],
151+
'Caller-supplied Content-Type should be preserved verbatim.'
152+
);
153+
$this->assertNotContains(
154+
'Content-Type: text/html; charset=UTF-8',
155+
$captured_atts['headers'],
156+
'Default Content-Type should not be added when a caller Content-Type is already present.'
157+
);
158+
$this->assertSingleContentTypeHeader( $captured_atts['headers'] );
159+
}
160+
161+
public function test_send_preserves_caller_supplied_content_type_header_case_insensitive() {
162+
$this->skip_if_pre_wp_mail_unsupported();
163+
164+
$captured_atts = null;
165+
$this->capture_wp_mail_atts( $captured_atts );
166+
167+
$caller_content_type = 'content-type: text/plain; charset=UTF-8';
168+
$this->email->send(
169+
'test@example.com',
170+
'Test Subject',
171+
'Plain content',
172+
array( $caller_content_type )
173+
);
174+
175+
$this->assertNotNull( $captured_atts, 'Attributes should be captured.' );
176+
$this->assertContains(
177+
$caller_content_type,
178+
$captured_atts['headers'],
179+
'Lowercase caller Content-Type should be preserved verbatim.'
180+
);
181+
$this->assertNotContains(
182+
'Content-Type: text/html; charset=UTF-8',
183+
$captured_atts['headers'],
184+
'Default Content-Type should not be added when a Content-Type header is present in any case.'
185+
);
186+
$this->assertSingleContentTypeHeader( $captured_atts['headers'] );
187+
}
188+
189+
public function test_send_preserves_multipart_alternative_when_text_content_provided() {
190+
$captured_initial_content_type = null;
191+
$captured_alt_body = null;
192+
$captured_final_content_type = null;
193+
194+
add_action(
195+
'phpmailer_init',
196+
function ( $phpmailer ) use ( &$captured_initial_content_type, &$captured_alt_body, &$captured_final_content_type ) {
197+
$captured_initial_content_type = $phpmailer->ContentType;
198+
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer property.
199+
$captured_alt_body = $phpmailer->AltBody;
200+
201+
// Trigger body construction so PHPMailer applies its multipart/alternative
202+
// upgrade for the attached AltBody.
203+
$phpmailer->preSend();
204+
$captured_final_content_type = $phpmailer->ContentType;
205+
206+
// Block delivery by clearing recipients.
207+
$phpmailer->clearAllRecipients();
208+
},
209+
999 // Run after Site Kit's hook (priority 10).
210+
);
211+
212+
// send() fails on the cleared recipients. Only the captured state matters.
213+
$this->email->send(
214+
'test@example.com',
215+
'Test Subject',
216+
'<html><body>HTML Content</body></html>',
217+
array(),
218+
'Plain text content'
219+
);
220+
221+
$this->assertEquals( 'text/html', $captured_initial_content_type, 'PHPMailer ContentType should be text/html from the default Site Kit header before AltBody upgrade.' );
222+
$this->assertEquals( 'Plain text content', $captured_alt_body, 'AltBody should be set so PHPMailer can produce a multipart/alternative message.' );
223+
$this->assertStringStartsWith( 'multipart/alternative', $captured_final_content_type, 'PHPMailer should upgrade ContentType to multipart/alternative when AltBody is set alongside the default text/html Content-Type.' );
224+
}
225+
226+
/**
227+
* Skips the test if the pre_wp_mail filter (WordPress 5.7+) isn't available.
228+
*/
229+
private function skip_if_pre_wp_mail_unsupported() {
230+
if ( version_compare( $GLOBALS['wp_version'], '5.7', '<' ) ) {
231+
$this->markTestSkipped( 'This test requires WordPress 5.7 or higher for the pre_wp_mail filter.' );
232+
}
233+
}
234+
235+
/**
236+
* Captures wp_mail arguments via pre_wp_mail and short-circuits delivery.
237+
*
238+
* @param array|null $captured_atts Reference filled with wp_mail args.
239+
*
240+
* @param-out array $captured_atts
241+
*/
242+
private function capture_wp_mail_atts( &$captured_atts ) {
243+
add_filter(
244+
'pre_wp_mail',
245+
function ( $short_circuit, $atts ) use ( &$captured_atts ) {
246+
$captured_atts = $atts;
247+
return true;
248+
},
249+
10,
250+
2
251+
);
252+
}
253+
254+
/**
255+
* Asserts that exactly one Content-Type header is present in the headers array.
256+
*
257+
* @param array $headers Headers array captured from wp_mail.
258+
*/
259+
private function assertSingleContentTypeHeader( array $headers ) {
260+
$content_type_headers = array_filter(
261+
$headers,
262+
static function ( $header ) {
263+
return is_string( $header ) && 0 === stripos( ltrim( $header ), 'Content-Type:' );
264+
}
265+
);
266+
267+
$this->assertCount( 1, $content_type_headers, 'Exactly one Content-Type header should be present.' );
268+
}
269+
129270
public function test_get_last_error() {
130271
$this->assertNull( $this->email->get_last_error(), 'Last error should be null initially.' );
131272
}
@@ -159,10 +300,7 @@ function ( $phpmailer ) use ( &$captured_alt_body ) {
159300
}
160301

161302
public function test_send_does_not_set_alt_body_when_text_content_empty() {
162-
// The pre_wp_mail filter was introduced in WordPress 5.7.
163-
if ( version_compare( $GLOBALS['wp_version'], '5.7', '<' ) ) {
164-
$this->markTestSkipped( 'This test requires WordPress 5.7 or higher for the pre_wp_mail filter.' );
165-
}
303+
$this->skip_if_pre_wp_mail_unsupported();
166304

167305
$alt_body_was_set = false;
168306

@@ -198,10 +336,7 @@ function () {
198336
}
199337

200338
public function test_send_removes_phpmailer_init_hook_after_send() {
201-
// The pre_wp_mail filter was introduced in WordPress 5.7.
202-
if ( version_compare( $GLOBALS['wp_version'], '5.7', '<' ) ) {
203-
$this->markTestSkipped( 'This test requires WordPress 5.7 or higher for the pre_wp_mail filter.' );
204-
}
339+
$this->skip_if_pre_wp_mail_unsupported();
205340

206341
// Use pre_wp_mail to simulate successful email sending.
207342
add_filter(

0 commit comments

Comments
 (0)