2008/02/08(金)2行に渡るメールヘッダの正しい処理
問題発生
ある方より、メール投稿利用時に長い日本語タイトルを付けると、途中に半角スペースが入ってしまうというバグ報告を受けました。
Subject: =?ISO-2022-JP?B?GyRCPmFHKz5hRys+YUcrPmFHKz5hRys+YUcrPmFHKz5hGyhC?= =?ISO-2022-JP?B?GyRCRys+YUcrPmFHKz5hRys+YUcrPmFHKz5hRys+YUcrPmFHKz5hGyhC?=
のようにMIMEエンコードが長くなり空白が入ったときに、この空白がデコードされてもそのまま残ってしまうことが原因です。やっつけならこの空白を除去するだけで良いのですが、やっつけプログラムは最低なのできちんと調査してみました。
長いメールヘッダの規定
RFC 2822「Internet Message Format」には次のように規定されています。
2.1.1. 行の長さの制限
それぞれの行の文字はCRLFを除いて、決して998文字以下でなければならず(MUST)、78文字以下であるべきである(SHOULD)。
2.2.3. 長いヘッダフィールド
それぞれのヘッダフィールドは(略)単一論理行である。しかしながら便宜のため、文字数制限のために、ヘッダフィールドの一部であるフィールドボディは複数行に分割して表現でき、これをfoldingと呼ぶ。どこであれfoldingのホワイトスペース(WSPではない)があるとき、WSP(SPACE/0x20またはTAB/0x09)の前にCRLFを挿入してよい。例えば、ヘッダフィールド:
Subject: This is a testは以下のように表現できる。
Subject: This is a test(中略)
このfoldingされたヘッダフィールドの複数行表現を1行表現にする過程をunfolding と呼ぶ。unfolding はWSPがすぐ後に続くあらゆるCRLFを単に削除することでなされる。
つまりRFCの規定に従い、最初に例示したメールヘッダは次のように復元されます。
Subject: =?ISO-2022-JP?B?(略)?= =?ISO-2022-JP?B?(略)?=
ここまでは正しい復元過程であり、やっつけ実装として考えた「単純に複数行ヘッダの2行目以降の先頭空白の除去」は誤りであることがわかります。実際、長い英字タイトルに1文字だけ空白を入れた「aa(略)aaa bbb(略)」というメールを送信したところ
Subject: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaa ←※実際にはここまで1行
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
というように78文字の制限を超えてメールヘッダが作られました。単純な先頭空白除去では、このような改行位置が半角の文になっているメールを受信した際に問題が発生します*1。そもそもRFCの規定で「CRLFを単に削除する」となっているのですから、そこは正しく従わねばなりません。
MIMEの規定
では「長い日本語タイトルの途中に空白が入る問題」はどこに実装の誤りがあるのでしょうか。これについて、RFC 2047「非ASCIIテクストのためのメッセージヘッダ拡張」を参照します。
6.2. 'encoded-word' のディスプレイ
複数の 'encoded-word' を含むある特定のヘッダフィールドを表示する時は、隣接する 'encoded-word' を分離するあらゆる 'linear-white-space' は無視される。
'encoded-word'は、いわゆるMIMEエンコードのことです。linear-white-space はRFC 822により次にように定義されています。
HTAB = <ASCII HT, horizontal-tab> ; (文字コード:9) LWSP-char = SPACE / HTAB ; semantics = SPACE linear-white-space = 1*([CRLF] LWSP-char) ; semantics = SPACE [CRLF] は CRLF があってもなくても良い。(0回以上1回未満の繰り返し) 1*(element) はelementの少なくとも1つ以上の繰り返し。
まとめると「=?ISO-2022-JP?B?(略)?=」ブロックと「=?ISO-2022-JP?B?(略)?=」ブロックの間にある、"CR LF SPACE(又はTAB)"の1つ以上の繰り返しは削除すべしということになります。
正しいメールヘッダ復元の実装
Perl正規表現で次にように行います。
$hl = (1つのヘッダフィールド(1行または複数行)) while ($hl =~ /(.*?=\?ISO-2022-JP\?B\?[A-Za-z0-9\+\/=]*\?=)(?:(?:\r\n)?[\t ])+(=\?ISO-2022-JP\?B\?[A-Za-z0-9\+\/=]*\?=.*)/i) { $hl="$1$2"; } $hl =~ s/=\?ISO-2022-JP\?B\?([A-Za-z0-9\+\/=]*)\?=/ &ベース64デコード($1) /ieg;
ベース64デコード関数は別に用意してください。(たとえばこれ)
(?:(?:\r\n)?\r\n[\t ])+
上記が、linear-white-spaceに相当します。ただ、メールデータを読み込む状況によっては \n \r \r\n のいずれになってプログラムに渡されるかわかりませんので、若干厳密な実装ではなくなりますが、
$hl =~ s/\r\n|\r/\n/g;
しておいてから、
(?:\n?[\t ])+
の方が良いと思います。さらに言えば "RFC 2822" の規定を先に適用して、先に改行コードを除去してから、MIMEエンコード間の空白除去を行っても動作としては変わりません。それをベースにした adiary の実装を最後に示します。
$hl =~ s/\x00//g; my @buf; # MIMEデコードをしてエスケープ表記"\0 num \0"に置き換え $hl =~ s/=\?[\w\-]+\?B\?([A-Za-z0-9\+\/=]*)\?=/ push(@buf, $self->base64decode($1)); "\x00$#buf\x00"; /ieg; # RFC 2047 $hl =~ s/\x00[\t ]+\x00/\x00\x00/g; # エスケープ復元 $hl =~ s/\x00(\d+)\x00/$buf[$1]/g;