Help us understand the problem. What is going on with this article?

symfony/mailerでbase64やiso-2022-jpを試してみる

Symfony Advent Calendar 2019 8日目の記事です。
昨日は@brtriverさんSymfonyの歴史を振り返ってみるでした!

はじめに

こちらの記事は、symfony/mailerで試行錯誤したことの続きになります。

前回は、配信処理までの設定で四苦八苦しましたが、今回は送信するメールの文字コードやエンコードの仕方について試行錯誤していきたいと思います。

メールは、インターネット時代のコミュニケーション手段として、

  • utf-8、iso-2022-jpなどの文字コード
  • 7bit, 8bit, base64, quoted-printableなどのエンコーディング
  • 単純にテキストのみ、テキストとHTMLの併用など

様々な設定ができるようになっています。

そしてSPAM配信の増加による、迷惑メール対策技術の向上によって、
メールを迷惑メールとして判定させないために、いろいろと気をつける部分となります。

新規のシステムならよいのですが、
システムのリニューアルによって、メールが届かなくなるということはよくあります。

これは送信されるメールの文面が、以前のシステムと異なっていることも関係します。

例えば、夏休みに明けに、気になるあの子が茶髪ギャルになっていたら、違和感覚えますよね。
迷惑メール判定も、そんな感じです。

夏休み前 夏休み明け

ここまでの変化ではないにしても、ちょっと髪型変えたかな?ぐらいの変化、
メールの世界だと、ヘッダーにX-Maillerが追加されただけでも、迷惑メールとして判定されたりします。

感覚的には、文字コード、エンコーディングが違うと大きな変化だったりします。

なので、前回のシステムと同様のメールを送信ができるように、Symfony/mailerも使いこなせる様になる必要があるのです。

テスト環境の構築

Dockerで開発環境を作って、SymfonyのWebスケルトンプロジェクトを作ります。
そこまで作ったのが以下になります。

https://github.com/idani/symfony_mailer_tutorial2/tree/de7e20b8af6886a3f1c07927ed5e7a6f3be0801b

一番かんたんな送信方法

Creating & Sending Messages」のサンプルコードを実行します。

MaillerController.php(その1)
class MailerController extends AbstractController
{
    /**
     * @Route("/mailer", name="mailer")
     */
    public function index(MailerInterface $mailer)
    {
        $email = (new Email())
            ->from('hello@example.com')
            ->to('you@example.com')
            //->cc('cc@example.com')
            //->bcc('bcc@example.com')
            //->replyTo('fabien@example.com')
            //->priority(Email::PRIORITY_HIGH)
            ->subject('Time for Symfony Mailer!')
            ->text('Sending emails is fun again!')
            ->html('<p>See Twig integration for better HTML integration!</p>');

        $mailer->send($email);

        return $this->render('mailer/index.html.twig', [
            'controller_name' => 'MailerController',
        ]);
    }
}

https://github.com/idani/symfony_mailer_tutorial2/commit/7e17f841fc116d0cacab976ec767028c0ce99d49

http://localhost:8000/mailer を開くと以下の画面が表示されてメールが送信されました。

image.png

MailDevで確認すると、受信されています。
1つ目の画像はHTMLメールを表示した場合。2つ目の画像は、テキストの画面を表示した場合です。

image.png

image.png

メールのソースは、以下になります。

メールのソース
From: hello@example.com
To: you@example.com
Subject: Time for Symfony Mailer!
Message-ID: <f9549d7eac6696b8cd513cc398e5bcc9@example.com>
MIME-Version: 1.0
Date: Wed, 04 Dec 2019 14:51:44 +0900
Content-Type: multipart/alternative;
 boundary="_=_symfony_1575438704_55dde3f773e700c11643dd0965c1db31_=_"

--_=_symfony_1575438704_55dde3f773e700c11643dd0965c1db31_=_
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable

Sending emails is fun again!
--_=_symfony_1575438704_55dde3f773e700c11643dd0965c1db31_=_
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable

<p>See Twig integration for better HTML integration!</p>
--_=_symfony_1575438704_55dde3f773e700c11643dd0965c1db31_=_--

日本語を試してみる

送信者名、受信者名、サブジェクト、本文に日本語を試してみましょう。

メールアドレスでの名前の指定は、Email Addressesに記載がありますように、Address.phpクラスを利用すると良さそうです。

サブジェクト、本文は、何も考えずに日本語を入れてみます。
また、HTMLは面倒なので、ここでは一旦、オミットしておきます。

以下のように修正しました。

MailerController.php(その2)
class MailerController extends AbstractController
{
    /**
     * @Route("/mailer", name="mailer")
     */
    public function index(MailerInterface $mailer)
    {
        $body = <<<EOL
https://symfony.com/why-use-a-framework

フレームワークを使用する必要があるのはなぜですか?
フレームワークは絶対に必要というわけではありません。それは、あなたがより良く、より速く開発するのを

~~~中略~~~

この点で、フレームワークはブラックボックスではありません!Symfonyの場合、それはまだPHPです...開発されるアプリケーションはSymfonyユニバースに限定されず、たとえば他のPHPライブラリとネイティブに相互運用できます。
EOL;


        $email = (new Email())
            ->from(new Address('hello@example.com', '送信者名'))
            ->to(new Address('you@example.com', '受信者名'))
            //->cc('cc@example.com')
            //->bcc('bcc@example.com')
            //->replyTo('fabien@example.com')
            //->priority(Email::PRIORITY_HIGH)
            ->subject('日本語のサブジェクトになります。長くなると文字化けするという話もありますので、長く書いてみます。これぐらい長いとどうかな?')
            ->text($body)
//            ->html('<p>See Twig integration for better HTML integration!</p>')
        ;

        $mailer->send($email);

        return $this->render('mailer/index.html.twig', [
            'controller_name' => 'MailerController',
        ]);
    }
}

https://github.com/idani/symfony_mailer_tutorial2/commit/91811b3273a7010af25cb132831c4c1a107d64a6

MailDevではこのように受信ができました。
パッと見はよさそうです。

image.png

メールソースを見ると、quoted-printable でエンコーディングをされていました。

メールソース(日本語)
From: =?utf-8?Q?=E9=80=81=E4=BF=A1=E8=80=85=E5=90=8D?= <hello@example.com>
To: =?utf-8?Q?=E5=8F=97=E4=BF=A1=E8=80=85=E5=90=8D?= <you@example.com>
Subject: =?utf-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE?=
 =?utf-8?Q?=E3=82=B5=E3=83=96=E3=82=B8=E3=82=A7?=
 =?utf-8?Q?=E3=82=AF=E3=83=88=E3=81=AB=E3=81=AA?=
 =?utf-8?Q?=E3=82=8A=E3=81=BE=E3=81=99=E3=80=82?=
 =?utf-8?Q?=E9=95=B7=E3=81=8F=E3=81=AA=E3=82=8B?=
 =?utf-8?Q?=E3=81=A8=E6=96=87=E5=AD=97=E5=8C=96?=
 =?utf-8?Q?=E3=81=91=E3=81=99=E3=82=8B=E3=81=A8?=
 =?utf-8?Q?=E3=81=84=E3=81=86=E8=A9=B1=E3=82=82?=
 =?utf-8?Q?=E3=81=82=E3=82=8A=E3=81=BE=E3=81=99?=
 =?utf-8?Q?=E3=81=AE=E3=81=A7=E3=80=81=E9=95=B7?=
 =?utf-8?Q?=E3=81=8F=E6=9B=B8=E3=81=84=E3=81=A6?=
 =?utf-8?Q?=E3=81=BF=E3=81=BE=E3=81=99=E3=80=82?=
 =?utf-8?Q?=E3=81=93=E3=82=8C=E3=81=90=E3=82=89?=
 =?utf-8?Q?=E3=81=84=E9=95=B7=E3=81=84=E3=81=A8?=
 =?utf-8?Q?=E3=81=A9=E3=81=86=E3=81=8B=E3=81=AA=EF=BC=9F?=
Message-ID: <964a02541e6dc9af98f5af3f31b3bf77@example.com>
MIME-Version: 1.0
Date: Wed, 04 Dec 2019 15:22:13 +0900
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable

https://symfony.com/why-use-a-framework

=E3=83=95=E3=83=AC=E3=83=BC=
=E3=83=A0=E3=83=AF=E3=83=BC=E3=82=AF=E3=82=92=E4=BD=BF=E7=94=A8=E3=81=99=
=E3=82=8B=E5=BF=85=E8=A6=81=E3=81=8C=E3=81=82=E3=82=8B=E3=81=AE=E3=81=AF=
=E3=81=AA=E3=81=9C=E3=81=A7=E3=81=99=E3=81=8B=EF=BC=9F
=E3=83=95=E3=83=
=AC=E3=83=BC=E3=83=A0=E3=83=AF=E3=83=BC=E3=82=AF=E3=81=AF=E7=B5=B6=E5=AF=
=BE=E3=81=AB=E5=BF=85=E8=A6=81=E3=81=A8=E3=81=84=E3=81=86=E3=82=8F=E3=81=
=91=E3=81=A7=E3=81=AF=E3=81=82=E3=82=8A=E3=81=BE=E3=81=9B=E3=82=93=E3=80=
以下省略

Base64で送信する

日本語は送信ができるようですが、エンコーディングがquoted-printableなので、いろいろとデータ量が増えています。日本だとbase64が主流なのでbase64に変更できるかトライしてみます。

Emailクラスでメールデータを作成しますので、Email.phpを見ていきます。

そうすると、EmailクラスがMimeコンポーネントに所属していることがわかりました!

メールデータをカスタマイズする

The Mime Componentドキュメントに目を通すと、Emailクラスは高レベルAPI、Messageクラスは低レベルAPIと記載があります。

そして、Creating Raw Email Messagesとして、直接メッセージを作成する方法が記載されていました。

MIMEコンポーネントドキュメント内のサンプル
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\Message;
use Symfony\Component\Mime\Part\Multipart\AlternativePart;
use Symfony\Component\Mime\Part\TextPart;

$headers = (new Headers())
    ->addMailboxListHeader('From', ['fabien@symfony.com'])
    ->addMailboxListHeader('To', ['foo@example.com'])
    ->addTextHeader('Subject', 'Important Notification')
;

$textContent = new TextPart('Lorem ipsum...');
$htmlContent = new TextPart('<h1>Lorem ipsum</h1> <p>...</p>', 'html');
$body = new AlternativePart($textContent, $htmlContent);

$email = new Message($headers, $body);

本文をBase64でエンコードする

サンプルを見ると、メール本文は、TextPartクラスが作成しています。

TextPartクラスのコンストラクタを見ると、ビンゴです!
エンコーディングを引数に取っていました。

/vendor/symfony/mime/Header/AbstractHeader.php(38)
public function __construct($body, ?string $charset = 'utf-8', $subtype = 'plain', string $encoding = null)
    {
        parent::__construct();

           ~~~中略~~~

        if (null === $encoding) {
            $this->encoding = $this->chooseEncoding();
        } else {
            if ('quoted-printable' !== $encoding && 'base64' !== $encoding && '8bit' !== $encoding) {
                throw new InvalidArgumentException(sprintf('The encoding must be one of "quoted-printable", "base64", or "8bit" ("%s" given).', $encoding));
            }
            $this->encoding = $encoding;
        }
    }

メールアドレスの名前部分をBase64でエンコードする

ヘッダーは、Headersクラスで定義していますが、エンコーディングについては、基底クラスのAbstractHeaderクラスで、quoted-printableに固定されていました。

/vendor/symfony/mime/Header/AbstractHeader.php
namespace Symfony\Component\Mime\Header;
use Symfony\Component\Mime\Encoder\QpMimeHeaderEncoder;
/**
 * An abstract base MIME Header.
 *
 * @author Chris Corbyn
 */
abstract class AbstractHeader implements HeaderInterface
{
    private static $encoder;

~~~中略~~~
    /**
     * Get a token as an encoded word for safe insertion into headers.
     */
    protected function getTokenAsEncodedWord(string $token, int $firstLineOffset = 0): string
    {
        if (null === self::$encoder) {
 ここで固定=>     self::$encoder = new QpMimeHeaderEncoder();
        }
~~~省略~~~

ただし、エンコーディングするのは条件があるようです。

MailboxListHeader.php(106)で、$this->createPhraseで名前をエンコードしています。

/vendor/symfony/mime/Header/MailboxListHeader.php(106)
    public function getAddressStrings(): array
    {
        $strings = [];
        foreach ($this->addresses as $address) {
            $str = $address->getEncodedAddress();
            if ($name = $address->getName()) {
                $str = $this->createPhrase($this, $name, $this->getCharset(), !$strings).' <'.$str.'>';
            }
            $strings[] = $str;
        }

        return $strings;
    }

これはAbstractHeader.php(88)で定義されていて、preg_match('/^'.self::PHRASE_PATTERN.'$/D', $phraseStr)にマッチしなければ$phraseStr = $this->encodeWords($header, $string, $usedLength);でエンコードするようです。

/vendor/symfony/mime/Header/AbstractHeader.php(88)
    protected function createPhrase(HeaderInterface $header, string $string, string $charset, bool $shorten = false): string
    {
        // Treat token as exactly what was given
        $phraseStr = $string;
        // If it's not valid
        if (!preg_match('/^'.self::PHRASE_PATTERN.'$/D', $phraseStr)) {
            // .. but it is just ascii text, try escaping some characters
            // and make it a quoted-string
            if (preg_match('/^[\x00-\x08\x0B\x0C\x0E-\x7F]*$/D', $phraseStr)) {
                foreach (['\\', '"'] as $char) {
                    $phraseStr = str_replace($char, '\\'.$char, $phraseStr);
                }
                $phraseStr = '"'.$phraseStr.'"';
            } else {
                // ... otherwise it needs encoding
                // Determine space remaining on line if first line
                if ($shorten) {
                    $usedLength = \strlen($header->getName().': ');
                } else {
                    $usedLength = 0;
                }
                $phraseStr = $this->encodeWords($header, $string, $usedLength);
            }
        }
        return $phraseStr;
    }

PHRASE_PATTERNAbstractHeader.php(23)で定義されていますが、長くて意味がわかりません。

たぶん、エンコード済みならスキップする処理じゃないかな?と予想します。
そしたら、名前は、mb_encode_mimeheaderして渡せば良さそうです。

/vendor/symfony/mime/Header/AbstractHeader.php(23)
const PHRASE_PATTERN = '(?:(?:(?:(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))*(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))|(?:(?:[ \t]*(?:\r\n))?[ \t])))?[a-zA-Z0-9!#\$%&\'\*\+\-\/=\?\^_`\{\}\|~]+(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))*(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))|(?:(?:[ \t]*(?:\r\n))?[ \t])))?)|(?:(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))*(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))|(?:(?:[ \t]*(?:\r\n))?[ \t])))?"((?:(?:[ \t]*(?:\r\n))?[ \t])?(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21\x23-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])))*(?:(?:[ \t]*(?:\r\n))?[ \t])?"(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))*(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))|(?:(?:[ \t]*(?:\r\n))?[ \t])))?))+?)';

サブジェクトをbase64でエンコードする

次にサブジェクトですが、Headers.php(112)からたどっていくと、AbstractHeaderクラスを継承したUnstructuredHeaderクラスに格納されていました。

/vendor/symfony/mime/Header/Headers.php(112)
   /**
     * @return $this
     */
    public function addTextHeader(string $name, string $value): self
    {
        return $this->add(new UnstructuredHeader($name, $value));
    }

最終的には、UnstructuredHeader.php(65)でエンコードして渡しています。

/vendor/symfony/mime/Header/UnstructuredHeader.php(65)
    /**
     * Get the value of this header prepared for rendering.
     */
    public function getBodyAsString(): string
    {
        return $this->encodeWords($this, $this->value);
    }

これはAbstractHeaderで定義されていて、$this->tokenNeedsEncodingがあります。

/vendor/symfony/mime/Header/AbstractHeader.php(120)
    /**
     * Encode needed word tokens within a string of input.
     */
    protected function encodeWords(HeaderInterface $header, string $input, int $usedLength = -1): string
    {
        $value = '';
        $tokens = $this->getEncodableWordTokens($input);
        foreach ($tokens as $token) {
            // See RFC 2822, Sect 2.2 (really 2.2 ??)
            if ($this->tokenNeedsEncoding($token)) {
                // Don't encode starting WSP
                $firstChar = substr($token, 0, 1);
                switch ($firstChar) {
                    case ' ':
                    case "\t":
                        $value .= $firstChar;
                        $token = substr($token, 1);
                }
                if (-1 == $usedLength) {
                    $usedLength = \strlen($header->getName().': ') + \strlen($value);
                }
                $value .= $this->getTokenAsEncodedWord($token, $usedLength);
            } else {
                $value .= $token;
            }
        }
        return $value;
    }

tokenNeedsEncoding()を確認すると、制御文字と改行が含まれてれば、エンコードが必要という判定をしています。

ですから、こちらもエンコード済みなら、この判定でFalseになって、そのまま使ってくれそうです。

/vendor/symfony/mime/Header/AbstractHeader.php(148)
    protected function tokenNeedsEncoding(string $token): bool
    {
        return (bool) preg_match('~[\x00-\x08\x10-\x19\x7F-\xFF\r\n]~', $token);
    }

サンプルを以下のように書き換えます。

MailerController.php(その3)
class MailerController extends AbstractController
{
    /**
     * @Route("/mailer", name="mailer")
     */
    public function index(MailerInterface $mailer)
    {
        $body = <<<EOL
https://symfony.com/why-use-a-framework

フレームワークを使用する必要があるのはなぜですか?
フレームワークは絶対に必要というわけではありません。それは、あなたがより良く、より速く開発するのを
~~~中略~~~
EOL;

        $headers = (new Headers())
            ->addMailboxListHeader('From', [new Address('hello@example.com', mb_encode_mimeheader('送信者名'))])
            ->addMailboxListHeader('To', [new Address('you@example.com', mb_encode_mimeheader('受信者名'))])
            ->addTextHeader('Subject', mb_encode_mimeheader('日本語のサブジェクトになります。長くなると文字化けするという話もありますので、長く書いてみます。これぐらい長いとどうかな?'))
        ;

        $textContent = new TextPart($body, 'utf-8', 'plain', 'base64');
        $email = new Message($headers, $textContent);

        $mailer->send($email);

        return $this->render('mailer/index.html.twig', [
            'controller_name' => 'MailerController',
        ]);
    }
}

https://github.com/idani/symfony_mailer_tutorial2/commit/a3085a095fb71068b5bb856f31c9d9d6ef4d2d9c

これでBase64のいい感じなメールがでるかと思いましたが、MailDevではサブジェクトが文字化けしてしまいました。。。

image.png

メールソースを確認すると、
本文は想定通りのUTF-8なのですが、
Subjectは、base64でエンコードしたデータを渡しているのですが、quoted-printableで更にエンコードがされていました。

また、From/Toはiso-2022-jpが使われています。

メールソース
From: =?ISO-2022-JP?B?GyRCQXc/LjxUTD4bKEI=?= <hello@example.com>
To: =?ISO-2022-JP?B?GyRCPHU/LjxUTD4bKEI=?= <you@example.com>
Subject: =?utf-8?Q?=3D=3FISO-2022-JP=3FB=3FGyRCRnxLXDhsJE4lNSVWJTglJyUv?=
 =?utf-8?Q?JUgkSyRKJGokXiQ5ISNEOSQvGyhC=3F=3D?=
 =?utf-8?Q?_=3D=3FISO-2022-JP=3FB=3FGyRCJEokayRISjg7ejI9JDEkOSRrJEgkJCQ?=
 =?utf-8?Q?mT0MkYiQiJGokXiQ5GyhC=3F=3D?=
 =?utf-8?Q?_=3D=3FISO-2022-JP=3FB=3FGy?=
 =?utf-8?Q?RCJE4kRyEiRDkkLz1xJCQkRiRfJF4kOSEjJDMkbCQwJGkkJEQ5GyhC=3F=3D?=
 =?utf-8?Q??= =?ISO-2022-JP?B?GyRCJCQkSCRJJCYkKyRKISkbKEI=?=
Message-ID: <d3e1f390c82ee075dbcf96b7565bccff@example.com>
MIME-Version: 1.0
Date: Wed, 04 Dec 2019 16:19:51 +0900
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64

aHR0cHM6Ly9zeW1mb255LmNvbS93aHktdXNlLWEtZnJhbWV3b3JrCgrjg5Xjg6zjg7zjg6Djg6/j
g7zjgq/jgpLkvb/nlKjjgZnjgovlv4XopoHjgYzjgYLjgovjga7jga/jgarjgZzjgafjgZnjgYvv
vJ8K44OV44Os44O844Og44Ov44O844Kv44Gv57W25a++44Gr5b+F6KaB44Go44GE44GG44KP44GR
44Gn44Gv44GC44KK44G+44Gb44KT44CC44Gd44KM44Gv44CB44GC44Gq44Gf44GM44KI44KK6Imv
44GP44CB44KI44KK6YCf44GP6ZaL55m644GZ44KL44Gu44KS5Yqp44GR44KL44Gf44KB44Gr5Yip
55So44Gn44GN44KL44OE44O844Or44Gu44CM44Gh44KH44GG44Gp44CNMeOBpOOBp+OBme+8gQoK
44OV44Os44O844Og44Ov44O844Kv44Gv44CB44OT44K444ON44K544Or44O844Or44Gr5a6M5YWo
44Gr5rqW5oug44GX44CB5qeL6YCg5YyW44GV44KM44CB44Oh44Oz44OG44OK44Oz44K544Go44Ki
44OD44OX44Kw44Os44O844OJ44Gu5Lih5pa544GM5Y+v6IO944Gq44Ki44OX44Oq44Kx44O844K3

iso-2022-jpについては、よくある話で、mb_language("uni")を宣言すれば良さそうです。

        mb_language("uni");
        mb_internal_encoding("UTF-8");

サブジェクトの方でquoted-printableになっていますので、
さきほどのAbstractHeader.php(120)のencodeWords()を更に見ていきましょう。

/vendor/symfony/mime/Header/AbstractHeader.php(120)
    /**
     * Encode needed word tokens within a string of input.
     */
    protected function encodeWords(HeaderInterface $header, string $input, int $usedLength = -1): string
    {
        $value = '';
        $tokens = $this->getEncodableWordTokens($input);
        foreach ($tokens as $token) {
            // See RFC 2822, Sect 2.2 (really 2.2 ??)
            if ($this->tokenNeedsEncoding($token)) {
                // Don't encode starting WSP
                $firstChar = substr($token, 0, 1);
                switch ($firstChar) {
                    case ' ':
                    case "\t":
                        $value .= $firstChar;
                        $token = substr($token, 1);
                }
                if (-1 == $usedLength) {
                    $usedLength = \strlen($header->getName().': ') + \strlen($value);
                }
                $value .= $this->getTokenAsEncodedWord($token, $usedLength);
            } else {
                $value .= $token;
            }
        }
        return $value;
    }

$this->getEncodableWordTokens($input)を見ていなかったので見てみます。

/vendor/symfony/mime/Header/AbstractHeader.php(158)
    protected function getEncodableWordTokens(string $string): array
    {
        $tokens = [];
        $encodedToken = '';
        // Split at all whitespace boundaries
        foreach (preg_split('~(?=[\t ])~', $string) as $token) {
            if ($this->tokenNeedsEncoding($token)) {
                $encodedToken .= $token;
            } else {
                if (\strlen($encodedToken) > 0) {
                    $tokens[] = $encodedToken;
                    $encodedToken = '';
                }
                $tokens[] = $token;
            }
        }
        if (\strlen($encodedToken)) {
            $tokens[] = $encodedToken;
        }
        return $tokens;
    }

preg_split('~(?=[\t ])~', $string)で、=?UTF-8?B?77yf?='の最後の?=<タブ or スペース>で分割しています。

それを$this->tokenNeedsEncodingでエンコード済みかチェックしているのですが。。。

サブジェクトをmb_encode_mimeheader()した結果は、以下になります。

=?UTF-8?B?5pel5pys6Kqe44Gu44K144OW44K444Kn44Kv44OI44Gr44Gq44KK44G+44GZ?=
 =?UTF-8?B?44CC6ZW344GP44Gq44KL44Go5paH5a2X5YyW44GR44GZ44KL44Go44GE44GG?=
 =?UTF-8?B?6Kmx44KC44GC44KK44G+44GZ44Gu44Gn44CB6ZW344GP5pu444GE44Gm44G/?=
 =?UTF-8?B?44G+44GZ44CC44GT44KM44GQ44KJ44GE6ZW344GE44Go44Gp44GG44GL44Gq?=
 =?UTF-8?B?77yf?='

さきほどの、preg_split('~(?=[\t ])~', $string)で分割をすると、分割したデータの最後に改行がついてしまうみたいで、その後の$this->tokenNeedsEncoding($token)でエンコード済みと判定されないようです。

それでは、mb_encode_mimeheader()の結果から改行コードを外してみます。

サンプルコードを以下のように修正しました。

MailerController.php(その4)
class MailerController extends AbstractController
{
    /**
     * @Route("/mailer", name="mailer")
     */
    public function index(MailerInterface $mailer)
    {
        $body = <<<EOL
https://symfony.com/why-use-a-framework

フレームワークを使用する必要があるのはなぜですか?
フレームワークは絶対に必要というわけではありません。それは、あなたがより良く、より速く開発するのを
~~~中略~~~
EOL;

        mb_language("uni");
        mb_internal_encoding("UTF-8");

        $subject = mb_encode_mimeheader('日本語のサブジェクトになります。長くなると文字化けするという話もありますので、長く書いてみます。これぐらい長いとどうかな?');
        $subject = str_replace("\r\n", '', $subject);

        $headers = (new Headers())
            ->addMailboxListHeader('From', [new Address('hello@example.com', mb_encode_mimeheader('送信者名'))])
            ->addMailboxListHeader('To', [new Address('you@example.com', mb_encode_mimeheader('受信者名'))])
            ->addTextHeader('Subject', $subject)
        ;

        $textContent = new TextPart($body, 'utf-8', 'plain', 'base64');
        $email = new Message($headers, $textContent);

        $mailer->send($email);

        return $this->render('mailer/index.html.twig', [
            'controller_name' => 'MailerController',
        ]);
    }
}

https://github.com/idani/symfony_mailer_tutorial2/commit/71283d6216dcc3f4e1b710a0d2dd452ca63a87e9

実行してみると、subjcetの文字化けが治りましたね。

image.png

メールソースを確認しましたが、想定どおりです。

メールソース
From: =?UTF-8?B?6YCB5L+h6ICF5ZCN?= <hello@example.com>
To: =?UTF-8?B?5Y+X5L+h6ICF5ZCN?= <you@example.com>
Subject: =?UTF-8?B?5pel5pys6Kqe44Gu44K144OW44K444Kn44Kv44OI44Gr44Gq44KK44G+44GZ?=
 =?UTF-8?B?44CC6ZW344GP44Gq44KL44Go5paH5a2X5YyW44GR44GZ44KL44Go44GE44GG?=
 =?UTF-8?B?6Kmx44KC44GC44KK44G+44GZ44Gu44Gn44CB6ZW344GP5pu444GE44Gm44G/?=
 =?UTF-8?B?44G+44GZ44CC44GT44KM44GQ44KJ44GE6ZW344GE44Go44Gp44GG44GL44Gq?=
 =?UTF-8?B?77yf?=
Message-ID: <1916f37d03fc486c2babe15a606eda8f@example.com>
MIME-Version: 1.0
Date: Thu, 05 Dec 2019 11:19:48 +0900
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64

aHR0cHM6Ly9zeW1mb255LmNvbS93aHktdXNlLWEtZnJhbWV3b3JrCgrjg5Xjg6zjg7zjg6Djg6/j
g7zjgq/jgpLkvb/nlKjjgZnjgovlv4XopoHjgYzjgYLjgovjga7jga/jgarjgZzjgafjgZnjgYvv
vJ8K44OV44Os44O844Og44Ov44O844Kv44Gv57W25a++44Gr5b+F6KaB44Go44GE44GG44KP44GR
44Gn44Gv44GC44KK44G+44Gb44KT44CC44Gd44KM44Gv44CB44GC44Gq44Gf44GM44KI44KK6Imv
以下省略

base64のまとめ

ということで、サブジェクトのワークアラウンドが微妙ですが、UTF-8+base64での送信が実現できました。

パッチを送る感じになるのかな?

iso-2022-jpで送信する

それでは、文字コードがiso-2022-jpで、エンコーディングが7bitにチャレンジしてみます。

ヘッダーは、mb_encode_mimeheader()で準備ができますし、本文が7bitが通れば行けそうです。

サンプルコードを以下のように修正します。

MailerController.php(その5)
class MailerController extends AbstractController
{
    /**
     * @Route("/mailer", name="mailer")
     */
    public function index(MailerInterface $mailer)
    {
        $body = <<<EOL
https://symfony.com/why-use-a-framework

フレームワークを使用する必要があるのはなぜですか?
フレームワークは絶対に必要というわけではありません。それは、あなたがより良く、より速く開発するのを
~~~中略~~~
EOL;

        $subject = mb_encode_mimeheader('日本語のサブジェクトになります。長くなると文字化けするという話もありますので、長く書いてみます。これぐらい長いとどうかな?');
        $subject = str_replace("\r\n", '', $subject);

        $headers = (new Headers())
            ->addMailboxListHeader('From', [new Address('hello@example.com', mb_encode_mimeheader('送信者名'))])
            ->addMailboxListHeader('To', [new Address('you@example.com', mb_encode_mimeheader('受信者名'))])
            ->addTextHeader('Subject', $subject)
        ;

        $textContent = new TextPart($body, 'iso-2022-jp', 'plain', '7bit');
        $email = new Message($headers, $textContent);

        $mailer->send($email);

        return $this->render('mailer/index.html.twig', [
            'controller_name' => 'MailerController',
        ]);
    }
}

https://github.com/idani/symfony_mailer_tutorial2/commit/5bc62be13f645e0f0554dabebb8ce7bd095fc6ad

以下のようなエラーがでました。
7bitのエンコーディングに対応していないようです。

image.png

しょうがないので、エンコーディングをbase64に変更してトライしてみます。

MailerController.php(その6)
class MailerController extends AbstractController
{
    /**
     * @Route("/mailer", name="mailer")
     */
    public function index(MailerInterface $mailer)
    {
        $body = <<<EOL
https://symfony.com/why-use-a-framework

フレームワークを使用する必要があるのはなぜですか?
フレームワークは絶対に必要というわけではありません。それは、あなたがより良く、より速く開発するのを
~~~中略~~~
EOL;

        $subject = mb_encode_mimeheader('日本語のサブジェクトになります。長くなると文字化けするという話もありますので、長く書いてみます。これぐらい長いとどうかな?');
        $subject = str_replace("\r\n", '', $subject);

        $headers = (new Headers())
            ->addMailboxListHeader('From', [new Address('hello@example.com', mb_encode_mimeheader('送信者名'))])
            ->addMailboxListHeader('To', [new Address('you@example.com', mb_encode_mimeheader('受信者名'))])
            ->addTextHeader('Subject', $subject)
        ;

        $textContent = new TextPart($body, 'iso-2022-jp', 'plain', 'base64');
        $email = new Message($headers, $textContent);

        $mailer->send($email);

        return $this->render('mailer/index.html.twig', [
            'controller_name' => 'MailerController',
        ]);
    }
}

https://github.com/idani/symfony_mailer_tutorial2/commit/71f73ca030d516c4e9ff95521798f7b65344dbd1

本文が痛い感じになってしまいました。

image.png

こちらがメールソースになります。From/To/Subjectのヘッダーは、想定通りですし、Content-Typeも想定どおりです。

メールソース
From: =?ISO-2022-JP?B?GyRCQXc/LjxUTD4bKEI=?= <hello@example.com>
To: =?ISO-2022-JP?B?GyRCPHU/LjxUTD4bKEI=?= <you@example.com>
Subject: =?ISO-2022-JP?B?GyRCRnxLXDhsJE4lNSVWJTglJyUvJUgkSyRKJGokXiQ5ISNEOSQvGyhC?=
 =?ISO-2022-JP?B?GyRCJEokayRISjg7ejI9JDEkOSRrJEgkJCQmT0MkYiQiJGokXiQ5GyhC?=
 =?ISO-2022-JP?B?GyRCJE4kRyEiRDkkLz1xJCQkRiRfJF4kOSEjJDMkbCQwJGkkJEQ5GyhC?=
 =?ISO-2022-JP?B?GyRCJCQkSCRJJCYkKyRKISkbKEI=?=
Message-ID: <e2e57a249f7daeaff165d25eddc2a876@example.com>
MIME-Version: 1.0
Date: Thu, 05 Dec 2019 12:20:15 +0900
Content-Type: text/plain; charset=iso-2022-jp
Content-Transfer-Encoding: base64

aHR0cHM6Ly9zeW1mb255LmNvbS93aHktdXNlLWEtZnJhbWV3b3JrCgrjg5Xjg6zjg7zjg6Djg6/j
g7zjgq/jgpLkvb/nlKjjgZnjgovlv4XopoHjgYzjgYLjgovjga7jga/jgarjgZzjgafjgZnjgYvv
vJ8K44OV44Os44O844Og44Ov44O844Kv44Gv57W25a++44Gr5b+F6KaB44Go44GE44GG44KP44GR
44Gn44Gv44GC44KK44G+44Gb44KT44CC44Gd44KM44Gv44CB44GC44Gq44Gf44GM44KI44KK6Imv
44GP44CB44KI44KK6YCf44GP6ZaL55m644GZ44KL44Gu44KS5Yqp44GR44KL44Gf44KB44Gr5Yip
55So44Gn44GN44KL44OE44O844Or44Gu44CM44Gh44KH44GG44Gp44CNMeOBpOOBp+OBme+8gQoK
以下省略

またまた、ソースコードを見てみると、TextPartクラスで渡しているiso-2022-jpというのは、Content-Typeの出力に使うだけで、文字コードの変換はしていませんでした。

一応、Base64Encoder.php(26)$charsetとして文字コードまでもらっているのですが、使っていません;;

/vendor/symfony/mime/Encoder/Base64Encoder.php(26)
class Base64Encoder implements EncoderInterface
{
    public function encodeString(string $string, ?string $charset = 'utf-8', int $firstLineOffset = 0, int $maxLineLength = 0): string
    {
        if (0 >= $maxLineLength || 76 < $maxLineLength) {
            $maxLineLength = 76;
        }

        $encodedString = base64_encode($string);
        $firstLine = '';
        if (0 !== $firstLineOffset) {
            $firstLine = substr($encodedString, 0, $maxLineLength - $firstLineOffset)."\r\n";
            $encodedString = substr($encodedString, $maxLineLength - $firstLineOffset);
        }

        return $firstLine.trim(chunk_split($encodedString, $maxLineLength, "\r\n"));
    }
}

そしたら本文も自前で変換する必要がわかったので、以下のようにします。

MailerController.php(その7)
class MailerController extends AbstractController
{
    /**
     * @Route("/mailer", name="mailer")
     */
    public function index(MailerInterface $mailer)
    {
        $body = <<<EOL
https://symfony.com/why-use-a-framework

フレームワークを使用する必要があるのはなぜですか?
フレームワークは絶対に必要というわけではありません。それは、あなたがより良く、より速く開発するのを
~~~中略~~~
EOL;

        $subject = mb_encode_mimeheader('日本語のサブジェクトになります。長くなると文字化けするという話もありますので、長く書いてみます。これぐらい長いとどうかな?');
        $subject = str_replace("\r\n", '', $subject);

        $headers = (new Headers())
            ->addMailboxListHeader('From', [new Address('hello@example.com', mb_encode_mimeheader('送信者名'))])
            ->addMailboxListHeader('To', [new Address('you@example.com', mb_encode_mimeheader('受信者名'))])
            ->addTextHeader('Subject', $subject)
        ;

        $body = mb_convert_encoding($body, 'ISO-2022-JP-MS', 'UTF-8');
        $textContent = new TextPart($body, 'iso-2022-jp', 'plain', 'base64');
        $email = new Message($headers, $textContent);

        $mailer->send($email);

        return $this->render('mailer/index.html.twig', [
            'controller_name' => 'MailerController',
        ]);
    }
}

https://github.com/idani/symfony_mailer_tutorial2/commit/dd161129fdcf0ba36bfb93e6461b67f03eac6b59

本文も問題なさそうです。

image.png

メールソースはこんな感じ。

メールソース
From: =?ISO-2022-JP?B?GyRCQXc/LjxUTD4bKEI=?= <hello@example.com>
To: =?ISO-2022-JP?B?GyRCPHU/LjxUTD4bKEI=?= <you@example.com>
Subject: =?ISO-2022-JP?B?GyRCRnxLXDhsJE4lNSVWJTglJyUvJUgkSyRKJGokXiQ5ISNEOSQvGyhC?=
 =?ISO-2022-JP?B?GyRCJEokayRISjg7ejI9JDEkOSRrJEgkJCQmT0MkYiQiJGokXiQ5GyhC?=
 =?ISO-2022-JP?B?GyRCJE4kRyEiRDkkLz1xJCQkRiRfJF4kOSEjJDMkbCQwJGkkJEQ5GyhC?=
 =?ISO-2022-JP?B?GyRCJCQkSCRJJCYkKyRKISkbKEI=?=
Message-ID: <e9e0eefd92fd68df3ec3a8bf6133bf9b@example.com>
MIME-Version: 1.0
Date: Thu, 05 Dec 2019 12:36:00 +0900
Content-Type: text/plain; charset=iso-2022-jp
Content-Transfer-Encoding: base64

aHR0cHM6Ly9zeW1mb255LmNvbS93aHktdXNlLWEtZnJhbWV3b3JrCgobJEIlVSVsITwlYCVvITwl
LyRyO0hNUSQ5JGtJLE1XJCwkIiRrJE4kTyRKJDwkRyQ5JCshKRsoQgobJEIlVSVsITwlYCVvITwl
LyRPQGRCUCRLSSxNVyRIJCQkJiRvJDEkRyRPJCIkaiReJDskcyEjJD0kbCRPISIkIiRKJD8kLCRo
JGpOSSQvISIkaCRqQi4kLzMrSC8kOSRrJE4kcj11JDEkayQ/JGEkS014TVEkRyQtJGslRCE8JWsk
TiFWJEEkZyQmJEkhVxsoQjEbJEIkRCRHJDkhKhsoQgoKGyRCJVUlbCE8JWAlbyE8JS8kTyEiJVMl
OCVNJTklayE8JWskSzQwQTQkSz1gNXIkNyEiOT1CJDI9JDUkbCEiJWElcyVGJUolcyU5JEglIiVD
JVclMCVsITwlSSROTj5KfSQsMkRHPSRKJSIlVyVqJTEhPCU3JWclcyRyMytILyQ3JEYkJCRrJEgk
JCQmM048QkAtJHJEczYhJDkkayQ/JGEkRyQ5ISMbKEIKChskQjMrSC88VCQsSEZNUSViJTglZSE8
JWskcjpGTXhNUSQ3JEZCPiROTk4waCRLPThDZiQ5JGskMyRIJEc7fjRWJHJAYUxzJEckLSRrJD8k
以下省略

iso-2022-jpのまとめ

メール本文に7bitエンコーディングが使えなかった。
base64でエンコーディングするなら、迷惑メール判定としては、大きく変更と感じる可能性があります。

つまり迷惑メールに初回は入る可能性があります。

それならISO-2022-jpを捨てて、utf-8にしても結局迷惑メールになりますので大差ありません。

UTF-8だと、顔文字など使える文字の種類が増えるメリットがありますから、UTF-8に乗り換えがよさそうです。

個人的なまとめ

symfony/mailerを使う場合は、システム刷新として使うなら、utf-8の文字コード、base64エンコーディングを使うのが良さそうです。

新規システムなら、エンコーディングがquoted-printableになりますが、簡単に使えるのも魅力的だと思います。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away