メールの件名が一部文字化けする話をRFC2047からひも解く

「こういう長い件名を書くと途中で文字が2=$1$k」みたいに件名の途中から文字化けする問題に引っかかったので、じゃあどうエンコードするのが正解なの?というのを調べた。

なおencoded-wordとencoded-textはRFCに書かれてるやつです。

 encoded-word = "=?" charset "?" encoding "?" encoded-text "?="

BASE64における4文字のかたまり

3バイトを4文字に置き換えるBASE64みたいなやつで、その4文字のかたまりを分割してもいいかどうか。

A.それぞれのencoded-textは独立していなければならず、他のencoded-textから継続してはならない。

The 'encoded-text' in an 'encoded-word' must be self-contained; 'encoded-text' MUST NOT be continued from one 'encoded-word' to another. This implies that the 'encoded-text' portion of a "B" 'encoded-word' will be a multiple of 4 characters long; for a "Q" 'encoded-word', any "=" character that appears in the 'encoded-text' portion will be followed by two hexadecimal characters.

http://tools.ietf.org/html/rfc2047#section-5

PHPでmb系使うならBASE64エンコードと行分割は同時に行われるので普通ありえない。

ISO-2022-JPにおけるESC$Bみたいなモード切替

ISO-2022-JPなんかだとESC$Bとかで文字セットを切り替えるわけですが、切り替えたまま次のencoded-wordに行けるか、その状態が次のencoded-wordでも引き継がれるか。

A.encoded-word内で非ASCIIモードに切り替える場合、encoded-wordの末尾でASCIIモードに戻っているようにコントロールコードを含まなければならない。

Some character sets use code-switching techniques to switch between "ASCII mode" and other modes. If unencoded text in an 'encoded-word' contains a sequence which causes the charset interpreter to switch out of ASCII mode, it MUST contain additional control codes such that ASCII mode is again selected at the end of the 'encoded-word'. (This rule applies separately to each 'encoded-word', including adjacent 'encoded-word's within a single header field.)

http://tools.ietf.org/html/rfc2047#section-3

エンコード済みテキストを表示する際はASCIIモードであることが黙示され、encoded-wordを表示した後は確実にASCIIモードに戻っているようにしなければならない。

If the character set being used employs code-switching techniques, display of the encoded text implicitly begins in "ASCII mode". In addition, the mail reader must ensure that the output device is once again in "ASCII mode" after the 'encoded-word' is displayed.

http://tools.ietf.org/html/rfc2047#section-6.2

PHPでいうと、MIMEエンコードとは別に先にISO-2022-JPに変換しちゃって、mb_internal_encodingもISO-2022-JPでない、という時に起きうる。

<?php
mb_internal_encoding('utf-8');

// 先にエンコードしよう!
$input = 'なんか長い文字列(40バイトぐらい)';
$encoded = mb_convert_encoding($input, 'iso-2022-jp');
$mime_encoded = mb_encode_mimeheader($encoded, 'iso-2022-jp');

// 先にエンコードしよう!
$input = 'なんか長い件名(40バイトぐらい)';
$encoded = mb_convert_encoding($input, 'iso-2022-jp');
mb_send_mail('aaa@example.com', $encoded, 'msg');

ここで$encodedの文字コードとmb_internal_encodingが食い違っているので全体が化けそうなものだけど、ISO-2022-JPは大半がASCII文字と被ってるのでそのまま通ってしまう*1・・・。

MBCS

A.複数オクテットで構成される文字が複数のencoded-wordに分割されてはならない。

Each 'encoded-word' MUST represent an integral number of characters. A multi-octet character may not be split across adjacent 'encoded-word's.

http://tools.ietf.org/html/rfc2047#section-5

同上。

おまけ:一部のMUAがTo/Fromとかの引用符の中をエンコードしちゃう件

RFC822(古い?)から関連する構文を抜き出すとこんな感じ。

  ; from §4.1
authentic   =   "From"       ":"   mailbox  ; Single author
            / ( "Sender"     ":"   mailbox  ; Actual submittor
                "From"       ":" 1#mailbox) ; Multiple authors or not sender
destination =  "To"          ":" 1#address  ; Primary
            /  "Resent-To"   ":" 1#address
            /  "cc"          ":" 1#address  ; Secondary
            /  "Resent-cc"   ":" 1#address
            /  "bcc"         ":"  #address  ; Blind carbon
            /  "Resent-bcc"  ":"  #address

  ; from §6.1
address     =  mailbox                      ; one addressee
            /  group                        ; named list
group       =  phrase ":" [#mailbox] ";"
mailbox     =  addr-spec                    ; simple address
            /  phrase route-addr            ; name & addr-spec

  ; from §3.3
atom        =  1*<any CHAR except specials, SPACE and CTLs>
quoted-string = <"> *(qtext/quoted-pair) <">; Regular qtext or quoted chars.
phrase      =  1*word                       ; Sequence of words
word        =  atom / quoted-string

要するに、あの名前の部分はphrase=atom/quoted-stringの羅列。で、RFC2047に戻ると、確かにquoted-stringの中には使えないと書かれている。

These are the ONLY locations where an 'encoded-word' may appear. In particular:
(略)
+ An 'encoded-word' MUST NOT appear within a 'quoted-string'.

http://tools.ietf.org/html/rfc2047#section-5

じゃあencoded-wordは名前として使えないのかと言うと、encoded-wordはphraseに含まれるwordの代わりとして使える、とある。

(3) As a replacement for a 'word' entity within a 'phrase', for example,
one that precedes an address in a From, To, or Cc header. The ABNF
definition for 'phrase' from RFC 822 thus becomes:

phrase = 1*( encoded-word / word )

http://tools.ietf.org/html/rfc2047#section-5

つまり、引用符で囲まなければいいんだろうか。あるいは引用符ごとエンコードすればいいんだろうか。

RFC2822でも同じような定義だった(§3.4)ので、そのうち書き直そう。

おまけ:一行の文字数

An 'encoded-word' may not be more than 75 characters long, including 'charset', 'encoding', 'encoded-text', and delimiters. If it is desirable to encode more text than will fit in an 'encoded-word' of 75 characters, multiple 'encoded-word's (separated by CRLF SPACE) may be used.
While there is no limit to the length of a multiple-line header field, each line of a header field that contains one or more 'encoded-word's is limited to 76 characters.

http://tools.ietf.org/html/rfc2047#section-2

encoded-word自体の制限としては75文字?だとしてもメール側の標準で文字数制限あるんでしょ?と思ったら、RFC822では65〜72文字だと困らないよねぐらいのふわっとした書き方で、RFC2822になって明言されていた。

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.

http://tools.ietf.org/html/rfc2822#section-2.1.1

というわけで、SHOULDに従うなら「Subject: 」みたいな部分の長さも考慮する必要がありますね。mb_encode_mimeheaderの$indentとか。

PHP向けのまとめ

mb_encode_mimeheaderが第二引数に指定した文字コードに変換してくれる、とだけ考えればよほど失敗しないはず。
ちなみに$charsetを省略するとmb_languageの設定に基づいて$charsetと$transfer_encodingが決まる。
http://ideone.com/KtgtZD
https://github.com/php/php-src/blob/0e30c543ec8e6c371e0aef6e125e7b90f4b1b790/ext/mbstring/mbstring.c#L3382

あたりがハマりポイントなのかなあと。