Учим WordPress правильно кодировать письма в UTF-8

Решение проблемы битых заголовков в email

Те, кто используют русскоязычную версию , наверняка не раз сталкивались с проблемой битого заголовка Subject в уведомлениях . Навреное, проще проиллюстрировать:

Битый заголовок Subject

Очевидно, что это не хорошо :-) Более того, битая кодировка может служить критерием для определения письма спамом.

Для того, чтобы убедиться, что такое отображение письма — это не почтового клиента, я написал маленький тестовый скрипт, который отправляет письма на GMail:

[-]
View Code PHP
<?php
    require_once('wp-config.php');
    wp_mail('blablabla@gmail.com', '[1234567890] New Comment On: Пятерка порадовавших меня запросов', 'Test Message');
?>

Когда Google отобразил битый Subject, стало понятно, что виноват всё-таки .

Если посмотреть на исходный текст самого письма, то увидим такие строки:

[-]
View Code eMail (mbox)

Она заслуживает пристального внимания. В соответствии с RFC 2822 (а точнее — ) разбил длинный заголовок на три фрагмента, каждый из которых не превышает 78 байт. Очевидно, что проблема заключается в том, что скрипт разбивал строку, закодированную BASE64, что привело к тому, что многобайтовые символы UTF-8 были разорваны.

Код это подтверждает:

[-]
View Code PHP
          $encoded = base64_encode($str);
          $maxlen -= $maxlen % 4;
          $encoded = trim(chunk_split($encoded, $maxlen, "\n"));

Есть два варианта исправления. Но оба сводятся к редактирования исходного текста . Иначе никак.

Простой вариант заключается в изменении верхней границы допустимой длины заголовка сообщения. В принципе, это не сильно противоречит стандарту:

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.orig    2008-06-14 19:36:13.000000000 +0300
+++ 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';

Второе решение (тоже ) более серьёзное и более надёжное:

[-]
View Code Diff
--- class-phpmailer.php.orig    2008-06-14 19:36:13.000000000 +0300
+++ 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, ошибка, патч
27
Сен
2008

RSS Комментарии к статье «Учим WordPress правильно кодировать письма в UTF-8» (30)  »

  1. Ivan1986

    Блин, извращенцы, руки оторвать

    [-]
    View Code PHP
    $encoded = mb_encode_mimeheader($encoded, 'utf-8');
    • Иван, а потом как разбивать этот UTF-8 на блоки по 78 байт? По сути старая версия PHP Mailer применяла подход, похожий на Ваш, после чего в заголовках и стали попадаться кракозябры (когда символ UTF-8 бился посередине).

      Собственно, приведённое извращение и борется с проблемой разбивки по блокам в соответствии с RFC.

      • Ivan1986

        По сути функция сама бьет строку на кодированные блоки по 78 байт.
        Только в отличие от старого вордпресса корректно.

        • Ага, дошло, невнимательно посмотрел.

          Только она требует mbstring, а разработчики WordPress не ставят mbstring в минимальные требования для WordPress. Поэтому на серверах без расширения mbstring работать не будет.

          Да, и еще один момент: This function isn’t designed to break lines at higher-level contextual break points (word boundaries, etc.). This behaviour may clutter up the original string with unexpected spaces.

          • Ivan1986

            Конечно не ставят, для леммингов ведь в частности делается, а в таких поделках как денвер мбстринг по умолчанию не установлен, а поставить – это ведь думать нужно, лемминги не думают. Им проще реализовать дополнительную кривую функцию, которая будет работать везде, чем отбиваться от пачки запросов почему не работает.

            Вообще я не понимаю ситуации – на хостинге не установлено что-то, раз не установлено, так надо поставить, да и дубаю что mbstring будет установлен на всех по крайне мере русских хостингах, иначе это уже не хостинг, а так, детский сад.

            По поводу “This function…
            Я так понял могут появляться лишние пробелы при кодировании переводов строк или что-то еще
            Сейчас проверил – многостроковая тема корректно кодируется и потом корректно раскодируется в кмайле, и в случае попадания пробела между котированными частями на перевод строки тоже.

        • Ну WordPress-то не в России разрабатывают.

          Они всё не могут оставить в покое хладный труп PHP4, а тут mbstring :-)

          Вообще насколько я знаю ситуацию на америкосовских хостингах, заставить хостера поставить что-нибудь на работающий (во всех смыслах) сервер — это очень тяжело.

          Я помню, как клиент неделю(!) уговаривал хостеров подключить mod_rewrite(!!!).

          По-видимому, это одна из причин, по которой разработчики не хотят создавать дополнительные зависимости.

          • Ivan1986

            А, ну если PHP4 не закопали, то тогда все понятно :)
            (поубивал бы :)

            Я тоже с поддержкой PHP 5.2 в своем движке несколько вещей добавил, которые бы писались проще в 5.3, а в 5.2 не работают, но чтобы PHP4

      • Ivan1986
        [-]
        View Code PHP
        $s = 'Ну очень очень длинная тема Ну очень очень длинная тема Ну очень очень длинная тема Ну очень очень длинная тема';
        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";
        [-]
        View Code Text
        Ну очень очень длинная тема Ну очень очень длинная тема Ну очень очень длинная тема Ну очень очень длинная тема
        =?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=?=
  2. Артур

    А у меня знаки вопроса вместо букв появляются в анонсах комментариев, которые отображаются на главной странице?

Оставить комментарий к записи «Учим WordPress правильно кодировать письма в UTF-8»

Вы можете использовать данные тэги: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

Изображения должны быть включены!

Оставляя комментарий, вы выражаете своё согласие с Правилами комментирования.

Подписаться, не комментируя