最近在利用phpmailer这个很火的第三方函数包开发php邮件发送功能时候,遇到一个很蹊跷的乱码问题,即发出去的邮件有时候邮件标题会变成一堆很特别的乱码,乱码的具体内容根据邮件标题是会发生变化的,不过共同的特点是都是有=?utf-8?B?这样的格式,在网上查阅了很多资料,包括stackoverflow,有不少人也都遇到过这个问题,但是回答的都比较模糊,初步的结论是邮件标题在进行base64编码时可能由于中文、特殊字符等原因产生了乱码,但单纯这么说毕竟不能令人信服,只能选择自己动手,查看phpmailer的源码来查找这个问题
经过反复var_dump和exit进行断点,最终目标确定在了这样一段代码上
public function EncodeHeader($str, $position = 'text') {
$x = 0;
switch (strtolower($position)) {
case 'phrase':
if (!preg_match('/[\200-\377]/', $str)) {
// Can't use addslashes as we don't know what value has magic_quotes_sybase
$encoded = addcslashes($str, "\0..\37\177\\\"");
if (($str == $encoded) && !preg_match('/[^A-Za-z0-9!#$%&\'*+\/=?^_`{|}~ -]/', $str)) {
return ($encoded);
} else {
return ("\"$encoded\"");
}
}
$x = preg_match_all('/[^\040\041\043-\133\135-\176]/', $str, $matches);
break;
case 'comment':
$x = preg_match_all('/[()"]/', $str, $matches);
// Fall-through
case 'text':
default:
$x += preg_match_all('/[\000-\010\013\014\016-\037\177-\377]/', $str, $matches);
break;
}
if ($x == 0) {
return ($str);
}
$maxlen = 75 - 7 - strlen($this->CharSet);
// Try to select the encoding which should produce the shortest output
if (strlen($str)/3 < $x) {
$encoding = 'B';
if (function_exists('mb_strlen') && $this->HasMultiBytes($str)) {
// Use a custom function which correctly encodes and wraps long
// multibyte strings without breaking lines within a character
$encoded = $this->Base64EncodeWrapMB($str);
} else {
$encoded = base64_encode($str);
$maxlen -= $maxlen % 4;
$encoded = trim(chunk_split($encoded, $maxlen, "\n"));
}
} else {
$encoding = 'Q';
$encoded = $this->EncodeQ($str, $position);
$encoded = $this->WrapText($encoded, $maxlen, true);
$encoded = str_replace('='.$this->LE, "\n", trim($encoded));
}
$encoded = preg_replace('/^(.*)$/m', " =?".$this->CharSet."?$encoding?\\1?=", $encoded);
$encoded = trim(str_replace("\n", $this->LE, $encoded));
return $encoded;
}
这一段是邮件标题在发送前进行的base64和smtp格式转化的部分(题外话:后台查阅资料发现开头说的=?utf-8?B?,这是smtp协议所规定的邮件标题格式,完整格式是=?charset?encoding?encoded-text?text ),在正常情况下,即将发送的邮件的标题格式应当是
=?utf-8?B?44CQ5byA5pyN5ZGo55+l44CR44CK5by55by55aCC44CLMjAxNi0xMC0yNSAxNzo0OToyNg==?=
但是,在出错的情况下这里的格式变成了
=?UTF-8?B?44CQ5byA5pyN5ZGo55+l44CR44CK5by55by55aCC44CLMjAxNi0xMC0yNSAx
?=
=?UTF-8?B?Nzo0OToyNg==?=
注意,这里中间有回车符间隔,且smtp协议格式的头部被重复了两次,经过仔细研究,发现乱码情况下出现的乱码就是后面一部分的=?UTF-8...这部分,此时可以得出结论,就是这里编码和格式的错误,导致邮件接收端无法正确地进行解析,从而导致了标题乱码
那么,又是什么原因导致了出现这种情况呢,再次研究,发现编码的错误出现在了上面这个位置:
$encoded = $this->Base64EncodeWrapMB($str);
切换到这个函数的细节,可以看到:
public function Base64EncodeWrapMB($str) {
$start = "=?".$this->CharSet."?B?";
$end = "?=";
$encoded = "";
$mb_length = mb_strlen($str, $this->CharSet);
// Each line must have length <= 75, including $start and $end
$length = 75 - strlen($start) - strlen($end);
// Average multi-byte ratio
$ratio = $mb_length / strlen($str);
// Base64 has a 4:3 ratio
$offset = $avgLength = floor($length * $ratio * .75);
for ($i = 0; $i < $mb_length; $i += $offset) {
$lookBack = 0;
do {
$offset = $avgLength - $lookBack;
$chunk = mb_substr($str, $i, $offset, $this->CharSet);
$chunk = base64_encode($chunk);
$lookBack++;
}
while (strlen($chunk) > $length);
$encoded .= $chunk . $this->LE;
}
// Chomp the last linefeed
$encoded = substr($encoded, 0, -strlen($this->LE));
return $encoded;
}
阅读源码后可以发现这里对于传过来的字符串是这样处理的,把字符串按照每76个字符的长度进行一次base64_encode,然后之间用回车符隔开后进行返回,然而这个时候一个很关键的问题出现了,在进行smtp格式转换的时候,源码是这样的encoded = preg_replace('/^(.*)$/m', " =?".$this->CharSet."?$encoding?\\1?=", $encoded);
对之前编码后的字符串,每个用回车符隔开的部分都会分别添加一次smtp格式的头,因而也就出现了之前提到的,乱码无法解析的情况,不过分析到这里依然还有奇怪的地方,就是作者为什么要画蛇添足对标题进行这种处理呢,后面我会讲,解决这个问题的办法就是直接进行base64_encode并添加smtp头,并不需要这个麻烦的Base64EncodeWrap函数,一种说法是base64编码对于编码长度实际上是有限制的,每隔76个字符(解释了作者写的76的问题)将自动进行一次换行,作者为了避免这个问题而如此处理,我确实查到了一个传说中的RFC2045-2049协议(多用途网际邮件扩充协议),里面也确实有这么一段话
The encoded output stream must be represented in lines of no more
than 76 characters each. All line breaks or other characters not
found in Table 1 must be ignored by decoding software. In base64
data, characters other than those in Table 1, line breaks, and other
white space probably indicate a transmission error, about which a
warning message or even a message rejection might be appropriate
under some circumstances.
大致意思是base64编码每行不能超过76个字符,超出的需要用回车符进行分隔,但是我实际测试中,长度超出76的编码串依然能正常发送,不过RFC似乎本身是1996年的协议,可能新的标准改变了也说不定,这里还需要进一步研究
最后说一下怎么解决邮件标题的乱码问题,如果能自己读懂源码的话,只要在我说的几个地方简单加以修改就可以了,这里我不再赘述,有一个简单的方法是在调用phpmailer配置邮件标题的时候进行处理,正常情况下代码是
$mail->Subject = $title;
这里修改成
$mail->Subject = "=?utf-8?B?".base64_encode("$title")."?=";
手动对其进行smtp格式转化和base64编码即可解决,有兴趣的话可以自行阅读源码看看为什么这样就可以解决问题,我自己看的时候发现是phpmailer中字符处理的判断,不含有ascII以外的字符时,将不再调用base64编码,直接使用传入的使用者传入的原始字符串