Summary
Because of a bug in WP Core, sending multipart emails (html/text) with wp_mail() (to reduce chance of emails ending up in spam folders) will ironically result with your domain being blocked by Hotmail (and other Microsoft emails).
This is a complex problem that I'll aim to break down in great detail in an attempt to help someone find a workable solution which may eventually be implemented in core.
It's going to be a rewarding read. Let's begin...
The bug
The most common advice to avoid having your newsletter emails ending up in spam folders is to send multipart messages.
Multi-part (mime) refers to sending both an HTML and TEXT part of an email message in a single email. When a client receives a multipart message, it accepts the HTML version if it can render HTML, otherwise it presents the plain text version.
This is proven to work. When sending to gmail, all our emails landed in spam folders until we changed the messages to multipart when they came through to main inbox. Great stuff.
Now, when sending multipart messages via wp_mail(), it outputs the Content Type (multipart/*) twice, once with boundary (if customly set) and once without. This behaviour results with the email being displayed as a raw message and not multipart on some emails, including all Microsoft (Hotmail, Outlook, etc...)
Microsoft will flag this message as junk, and the few messages that comes through will be flagged manually by the recipient. Unfortunately, Microsoft emails addresses are widely used. 40% of our subscribers use it.
This is confirmed by Microsoft via an email exchange we had recently.
The flagging of the messages will result with the domain being completely blocked. This means that message will not be sent to spam folder, they will not even be delivered to the recipient at all.
We have had our main domain blocked 3 times so far.
Because this is a bug in the WP core, every domain that sends multipart messages are being blocked. The problem is that most webmasters do not know why. I have confirmed this when doing my research and seeing other users discussing this on forums etc. It requires delving into the raw code and having a good knowledge of how these type of email messages work, which we are going on to next...
Let's break it down into code
Create a hotmail/outlook account. Then, run the following code:
// Set $to to an hotmail.com or outlook.com email
$to = "[email protected]";
$subject = 'wp_mail testing multipart';
$message = '------=_Part_18243133_1346573420.1408991447668
Content-Type: text/plain; charset=UTF-8
Hello world! This is plain text...
------=_Part_18243133_1346573420.1408991447668
Content-Type: text/html; charset=UTF-8
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body>
<p>Hello World! This is HTML...</p>
</body>
</html>
------=_Part_18243133_1346573420.1408991447668--';
$headers = "MIME-Version: 1.0\r\n";
$headers .= "From: Foo <[email protected]>\r\n";
$headers .= 'Content-Type: multipart/alternative;boundary="----=_Part_18243133_1346573420.1408991447668"';
// send email
wp_mail( $to, $subject, $message, $headers );
And if you want to change the default content type, use:
add_filter( 'wp_mail_content_type', 'set_content_type' );
function set_content_type( $content_type ) {
return 'multipart/alternative';
}
This will send a multipart message.
So if you check the full raw source of the message, you'll notice that the content type is added twice, once without boundary:
MIME-Version: 1.0
Content-Type: multipart/alternative;
boundary="====f230673f9d7c359a81ffebccb88e5d61=="
MIME-Version: 1.0
Content-Type: multipart/alternative; charset=
That's the issue.
The source of the problem lies in pluggable.php
- if we look somewhere here:
// Set Content-Type and charset
// If we don't have a content-type from the input headers
if ( !isset( $content_type ) )
$content_type = 'text/plain';
/**
* Filter the wp_mail() content type.
*
* @since 2.3.0
*
* @param string $content_type Default wp_mail() content type.
*/
$content_type = apply_filters( 'wp_mail_content_type', $content_type );
$phpmailer->ContentType = $content_type;
// Set whether it's plaintext, depending on $content_type
if ( 'text/html' == $content_type )
$phpmailer->IsHTML( true );
// If we don't have a charset from the input headers
if ( !isset( $charset ) )
$charset = get_bloginfo( 'charset' );
// Set the content-type and charset
/**
* Filter the default wp_mail() charset.
*
* @since 2.3.0
*
* @param string $charset Default email charset.
*/
$phpmailer->CharSet = apply_filters( 'wp_mail_charset', $charset );
// Set custom headers
if ( !empty( $headers ) ) {
foreach( (array) $headers as $name => $content ) {
$phpmailer->AddCustomHeader( sprintf( '%1$s: %2$s', $name, $content ) );
}
if ( false !== stripos( $content_type, 'multipart' ) && ! empty($boundary) )
$phpmailer->AddCustomHeader( sprintf( "Content-Type: %s;\n\t boundary=\"%s\"", $content_type, $boundary ) );
}
if ( !empty( $attachments ) ) {
foreach ( $attachments as $attachment ) {
try {
$phpmailer->AddAttachment($attachment);
} catch ( phpmailerException $e ) {
continue;
}
}
}
Potential solutions
So you are wondering, why have you not reported this at trac? I already have. To my great surprise, a different ticket was created 5 years ago outlining the same problem.
Let's face it, it's been a half decade. In internet years, that is more like 30. The issue has clearly been abandoned and basically will never be fixed (...unless if we resolve it here).
I found a great thread here offering a solution, but while his solution works, it breaks emails that do not have custom $headers
set.
That's where we crash every time. Either the multipart version work fine, and normal unset $headers
messages don't, or vise verse.
The solution we came up with was:
if ( false !== stripos( $content_type, 'multipart' ) && ! empty($boundary) ) {
$phpmailer->ContentType = $content_type . "; boundary=" . $boundary;
}
else {
$content_type = apply_filters( 'wp_mail_content_type', $content_type );
$phpmailer->ContentType = $content_type;
// Set whether it's plaintext, depending on $content_type
if ( 'text/html' == $content_type )
$phpmailer->IsHTML( true );
// If we don't have a charset from the input headers
if ( !isset( $charset ) )
$charset = get_bloginfo( 'charset' );
}
// Set the content-type and charset
/**
* Filter the default wp_mail() charset.
*
* @since 2.3.0
*
* @param string $charset Default email charset.
*/
$phpmailer->CharSet = apply_filters( 'wp_mail_charset', $charset );
// Set custom headers
if ( !empty( $headers ) ) {
foreach( (array) $headers as $name => $content ) {
$phpmailer->AddCustomHeader( sprintf( '%1$s: %2$s', $name, $content ) );
}
}
Yes, I know, editing core files are taboo, sit back down... this was a desperate fix and a poor attempt to provide a fix for core.
The problem with our fix is that default emails like new registrations, comment, password reset etc will be delivered as blank messages. So we have a working wp_mail() script that will send multipart messages but nothing else.
What to do
The aim here is to find a way to send both normal (plain text) and multipart messages using the core wp_mail() function (not a custom sendmail function).
When attempting to solve this, the main problem you will encounter is the amount of time that you'll spend on sending dummy messages, checking if they're received and basically opening a box of aspirin and cursing at Microsoft because you are used to their IE issues while the gremlin here is unfortunately WordPress.
Update
The solution posted by @bonger allows $message
to be an array containing content-type keyed alternates. I have confirmed that it works in all scenarios.
We will allow this question to remain open until bounty runs out to raise awareness about the problem, maybe to a level where it will be fixed in core. Feel free to post an alternative solution where $message
can be a string.
wp_mail()
function is pluggable is not defining your replacement as a must-use plugin (in wp-content/mu-plugins) not a good solution for you (and everyone else, failing core fix)? In which case would not moving the multipart/boundary check to after setting$phpmailer->ContentType = $content_type;
(rather than elsing) not work?wp_mail
is pluggable. Copy original function in a plugin, edit it like you need and activate the plugin. WordPress will use your edited function instead of the original one, with no need to edit core.