Учим WordPress правильно кодировать письма в UTF-8
Решение проблемы битых заголовков в email
Те, кто используют русскоязычную версию WordPress, наверняка не раз сталкивались с проблемой битого заголовка Subject в уведомлениях WordPress. Навреное, проще проиллюстрировать:
Очевидно, что это не хорошо
Более того, битая кодировка может служить критерием для определения письма спамом.
Для того, чтобы убедиться, что такое отображение письма — это не ошибка почтового клиента, я написал маленький тестовый скрипт, который отправляет письма на GMail:
require_once('wp-config.php');
wp_mail('blablabla@gmail.com', '[1234567890] New Comment On: Пятерка порадовавших меня запросов', 'Test Message');
?>
Когда Google отобразил битый Subject, стало понятно, что виноват всё-таки WordPress.
Если посмотреть на исходный текст самого письма, то увидим такие строки:
=?UTF-8?B?v9C+0YDQsNC00L7QstCw0LLRiNC40YUg0LzQtdC90Y8g0LfQsNC/0YDQvtGB?=
=?UTF-8?B?0L7Qsg==?=
Она заслуживает пристального внимания. В соответствии с RFC 2822 WordPress (а точнее — PHP Mailer) разбил длинный заголовок на три фрагмента, каждый из которых не превышает 78 байт. Очевидно, что проблема заключается в том, что скрипт разбивал строку, закодированную BASE64, что привело к тому, что многобайтовые символы UTF-8 были разорваны.
Код это подтверждает:
$maxlen -= $maxlen % 4;
$encoded = trim(chunk_split($encoded, $maxlen, "\n"));
Есть два варианта исправления. Но оба сводятся к редактирования исходного текста WordPress. Иначе никак.
Простой вариант заключается в изменении верхней границы допустимой длины заголовка сообщения. В принципе, это не сильно противоречит стандарту:
There are two limits that this standard places on the number of characters in a line. Each line of characters MUST be no more than 998 characters, and SHOULD be no more than 78 characters, excluding the CRLF.
То есть, исправив длину, мы проигнорируем SHOULD, но будем принмать во внимание MUST. Лучше, чем ничего.
Итак, патч в формате unified diff (должен применяться к файлу wp-includes/class-phpmailer.php):
+++ class-phpmailer.php 2008-09-27 02:39:26.000000000 +0300
@@ -1160,7 +1160,7 @@
if ($x == 0)
return ($str);
- $maxlen = 75 - 7 - strlen($this->CharSet);
+ $maxlen = 995 - 7 - strlen($this->CharSet);
// Try to select the encoding which should produce the shortest output
if (strlen($str)/3 < $x) {
$encoding = 'B';
Второе решение (тоже патч) более серьёзное и более надёжное:
+++ class-phpmailer.php 2008-09-27 07:54:25.000000000 +0300
@@ -655,6 +655,7 @@
*/
function WrapText($message, $length, $qp_mode = false) {
$soft_break = ($qp_mode) ? sprintf(" =%s", $this->LE) : $this->LE;
+ $is_utf8 = ("utf-8" == strtolower($this->CharSet));
$message = $this->FixEOL($message);
if (substr($message, -1) == $this->LE)
@@ -677,7 +678,9 @@
if ($space_left > 20)
{
$len = $space_left;
- if (substr($word, $len - 1, 1) == "=")
+ if ($is_utf8)
+ $len = $this->getUtf8CharBoundary($word, $len);
+ elseif (substr($word, $len - 1, 1) == "=")
$len--;
elseif (substr($word, $len - 2, 1) == "=")
$len -= 2;
@@ -695,7 +698,9 @@
while (strlen($word) > 0)
{
$len = $length;
- if (substr($word, $len - 1, 1) == "=")
+ if ($is_utf8)
+ $len = $this->getUtf8CharBoundary($word, $len);
+ elseif (substr($word, $len - 1, 1) == "=")
$len--;
elseif (substr($word, $len - 2, 1) == "=")
$len -= 2;
@@ -1164,9 +1169,14 @@
// Try to select the encoding which should produce the shortest output
if (strlen($str)/3 < $x) {
$encoding = 'B';
+ if (true == function_exists('mb_strlen') && strlen($str) > mb_strlen($str, $this->CharSet)) {
+ $encoded = $this->b64Multibyte($str);
+ }
+ else {
$encoded = base64_encode($str);
$maxlen -= $maxlen % 4;
$encoded = trim(chunk_split($encoded, $maxlen, "\n"));
+ }
} else {
$encoding = 'Q';
$encoded = $this->EncodeQ($str, $position);
@@ -1492,6 +1502,66 @@
function AddCustomHeader($custom_header) {
$this->CustomHeader[] = explode(":", $custom_header, 2);
}
+
+ function getUtf8CharBoundary($s, $max_len)
+ {
+ $lb = 3;
+ while (true) {
+ $x = substr($s, $max_len - $lb, $lb);
+ $pos = strpos($x, "=");
+ if (false !== $pos) {
+ $hex = substr($s, $max_len - $lb + $pos + 1, 2);
+ $dec = hexdec($hex);
+ if ($dec < 128) {
+ if ($pos > 0) {
+ $max_len = $max_len - $lb + $pos;
+ }
+
+ break;
+ }
+
+ if ($dec >= 192) {
+ $max_len = $max_len - $lb + $pos;
+ break;
+ }
+
+ $lb += 3;
+ }
+ else {
+ break;
+ }
+ }
+
+ return $max_len;
+ }
+
+ function b64MultiByte($s)
+ {
+ $start = "=?{$this->CharSet}?B?";
+ $end = "?=";
+ $encoded = "";
+
+ $mb_length = mb_strlen($s, $this->CharSet);
+ $str_len = strlen($s);
+ $length = 75 - strlen($start) - 2; //2 - strlen($end)
+ $step = floor(0.75 * $length * $mb_length/$str_len);
+ $average = $step;
+
+ for ($i=0; $i<$mb_length; $i+=$step) {
+ $lb = 0;
+
+ do {
+ $step = $average - $lb;
+ $tmp = base64_encode(mb_substr($s, $i, $step, $this->CharSet));
+ ++$lb;
+ }
+ while (strlen($tmp) > $length);
+
+ $encoded .= $tmp . $this->LE;
+ }
+
+ return substr($encoded, 0, -strlen($this->LE));
+ }
}
?>
Очень надеюсь, что решение кому-нибудь поможет
Связанные записи
Автор: Vladimir; опубликовано в: Патчи; метки: PHP Mailer, utf8, WordPress, ошибка, патчСен
2008



Меня зовут Владимир, я программист-фрилансер, специализирующийся на Web-программировании и програмировании под Linux.
По совместительству занимаюсь администрированием LAMP/LNMP-серверов и техническим переводом.






Блин, извращенцы, руки оторвать
Иван, а потом как разбивать этот UTF-8 на блоки по 78 байт? По сути старая версия PHP Mailer применяла подход, похожий на Ваш, после чего в заголовках и стали попадаться кракозябры (когда символ UTF-8 бился посередине).
Собственно, приведённое извращение и борется с проблемой разбивки по блокам в соответствии с RFC.
По сути функция сама бьет строку на кодированные блоки по 78 байт.
Только в отличие от старого вордпресса корректно.
Ага, дошло, невнимательно посмотрел.
Только она требует
mbstring, а разработчики WordPress не ставятmbstringв минимальные требования для WordPress. Поэтому на серверах без расширения mbstring работать не будет.Да, и еще один момент:
Конечно не ставят, для леммингов ведь в частности делается, а в таких поделках как денвер мбстринг по умолчанию не установлен, а поставить – это ведь думать нужно, лемминги не думают. Им проще реализовать дополнительную кривую функцию, которая будет работать везде, чем отбиваться от пачки запросов почему не работает.
Вообще я не понимаю ситуации – на хостинге не установлено что-то, раз не установлено, так надо поставить, да и дубаю что mbstring будет установлен на всех по крайне мере русских хостингах, иначе это уже не хостинг, а так, детский сад.
По поводу “This function…
Я так понял могут появляться лишние пробелы при кодировании переводов строк или что-то еще
Сейчас проверил – многостроковая тема корректно кодируется и потом корректно раскодируется в кмайле, и в случае попадания пробела между котированными частями на перевод строки тоже.
Ну WordPress-то не в России разрабатывают.
Они всё не могут оставить в покое хладный труп PHP4, а тут mbstring
Вообще насколько я знаю ситуацию на америкосовских хостингах, заставить хостера поставить что-нибудь на работающий (во всех смыслах) сервер — это очень тяжело.
Я помню, как клиент неделю(!) уговаривал хостеров подключить mod_rewrite(!!!).
По-видимому, это одна из причин, по которой разработчики не хотят создавать дополнительные зависимости.
А, ну если PHP4 не закопали, то тогда все понятно
(поубивал бы
Я тоже с поддержкой PHP 5.2 в своем движке несколько вещей добавил, которые бы писались проще в 5.3, а в 5.2 не работают, но чтобы PHP4
echo $s."\n";
echo mb_encode_mimeheader($s, 'utf-8')."\n";
echo "\n\n\n";
$s = 'Ну_очень_очень_длинная_тема_без_пробелов_Ну_очень_очень_длинная_тема_без_пробелов_Ну_очень_очень_длинная_тема_без_пробелов';
echo $s."\n";
echo mb_encode_mimeheader($s, 'utf-8')."\n";
=?UTF-8?B?0J3RgyDQvtGH0LXQvdGMINC+0YfQtdC90Ywg0LTQu9C40L3QvdCw0Y8g0YI=?=
=?UTF-8?B?0LXQvNCwINCd0YMg0L7Rh9C10L3RjCDQvtGH0LXQvdGMINC00LvQuNC90L0=?=
=?UTF-8?B?0LDRjyDRgtC10LzQsCDQndGDINC+0YfQtdC90Ywg0L7Rh9C10L3RjCDQtNC7?=
=?UTF-8?B?0LjQvdC90LDRjyDRgtC10LzQsCDQndGDINC+0YfQtdC90Ywg0L7Rh9C10L0=?=
=?UTF-8?B?0Ywg0LTQu9C40L3QvdCw0Y8g0YLQtdC80LA=?=
Ну_очень_очень_длинная_тема_без_пробелов_Ну_очень_очень_длинная_тема_без_пробелов_Ну_очень_очень_длинная_тема_без_пробелов
=?UTF-8?B?0J3Rg1/QvtGH0LXQvdGMX9C+0YfQtdC90Yxf0LTQu9C40L3QvdCw0Y9f0YI=?=
=?UTF-8?B?0LXQvNCwX9Cx0LXQt1/Qv9GA0L7QsdC10LvQvtCyX9Cd0YNf0L7Rh9C10L0=?=
=?UTF-8?B?0Yxf0L7Rh9C10L3RjF/QtNC70LjQvdC90LDRj1/RgtC10LzQsF/QsdC10Ldf?=
=?UTF-8?B?0L/RgNC+0LHQtdC70L7Qsl/QndGDX9C+0YfQtdC90Yxf0L7Rh9C10L3RjF8=?=
=?UTF-8?B?0LTQu9C40L3QvdCw0Y9f0YLQtdC80LBf0LHQtdC3X9C/0YDQvtCx0LXQu9C+?=
=?UTF-8?B?0LI=?=
А у меня знаки вопроса вместо букв появляются в анонсах комментариев, которые отображаются на главной странице?
Вероятно, из-за того, что используется функция
substr()вместоmb_substr().