メール件名の=?UTF-8?B?を75文字で分割するとき文字を切らない方法
日本語のメール件名は、ヘッダーに =?UTF-8?B?44OG44K544OI...?= のような形で入っている。これは RFC 2047 の encoded-word で、文字コード名・変換方式(B か Q)・変換後の文字列を =? と ?= で囲んだものだ。長い件名は1個に収まらないので、複数の encoded-word に分けて並べる。
この「分ける」ところに落とし穴がある。UTF-8 のバイト列を決まったバイト数で機械的に切ると、1文字の途中で切れることがあり、受信側によっては件名に �(U+FFFD)が混じる。しかも、手元の Python で確かめると化けないので気づきにくい。
この記事では、ハシトシステムのMIMEヘッダ デコード/エンコードツールが実装している分割処理を読みながら、
- encoded-word の長さの上限(75文字)から、1個に詰められるバイト数を計算する
- 文字の途中で切ると何が起きるか、切らないためにどこで止めるか
- 隣り合う encoded-word の間の空白をデコード時にどう扱うか
を、手元で動かしたコードで確かめる。確認した環境は Windows 11 / Node.js v24.11.1 / Python 3.11.9 である。
RFC 2047 が決めている3つの規則
この記事で使う規則は、RFC 2047(MIME Part Three: Message Header Extensions for Non-ASCII Text)の次の3か所である。
- 長さ(section 2): encoded-word は、charset・encoding・encoded-text と区切り記号を含めて 75文字を超えてはならない
- 文字の単位(section 5): 各 encoded-word は 整数個の文字 を表さなければならない(MUST)。複数バイトの文字を、隣り合う encoded-word にまたがって分割してはならない
- 空白(section 6.2): 複数の encoded-word を含むヘッダーを表示するとき、隣り合う encoded-word の間の空白は無視する
2 があるので、「75文字に収まるように切る」だけでは足りない。切る位置は文字の境界でなければならない。
1個に何バイト詰められるか
UTF-8 の B 方式(Base64)なら、前後の固定部分は =?UTF-8?B?(10文字)と ?=(2文字)で、合わせて12文字。残りは 75 − 12 = 63文字 になる。
Base64 は3バイトを4文字にするので、63文字に収まる最大の4の倍数は60文字、つまり 45バイト が1個の上限になる。ツールの実装もこの計算をそのまま書いている。
1 | |
日本語の文字(ひらがな・漢字・全角記号)は UTF-8 で3バイトなので、45バイトはちょうど15文字分になる。件名が日本語だけなら、45バイトごとに切っても偶然文字の境界に当たる。問題は、Re: や数字のような1バイトの文字が混ざったときだ。
機械的に45バイトで切ると何が起きるか
件名を Re: 2026年10月の定例会議は中止します(会議室の工事のため) として、UTF-8 のバイト列を45バイトごとに切ってみる。この件名は34文字・82バイトである。
1 | |
実行結果:
1 | |
先頭の Re: 2026(8バイト)と 年(3バイト)、10(2バイト)がずれを作り、45バイト目が「ま」の3バイトの途中に来ている。1個目の末尾に「ま」の先頭2バイト、2個目の先頭に残りの1バイトが入る。これは section 5 の MUST に反する encoded-word である。
encoded-word ごとに文字列へ戻す受信側では、こう見える。
1 | |
「ます」の「ま」が2個の U+FFFD に化けた。
文字の途中で切らない: 後続バイトの手前まで戻す
UTF-8 では、1文字の2バイト目以降(後続バイト)は必ず 10xxxxxx の形をしている。つまり「次のバイトが 10xxxxxx なら、ここは文字の途中」と判定できる。ツールの実装は、切る位置の直後が後続バイトである限り、切る位置を1バイトずつ手前に戻している。
1 | |
& 0xc0 で上位2ビットだけを取り出し、0x80(10)と比べている。同じ処理を Node.js で書いて、さきほどの件名を分割する。
1 | |
実行結果:
1 | |
1個目は44バイト(「し」まで)で止まり、「ま」は丸ごと2個目に移った。44バイトは3の倍数ではないので Base64 の末尾に == が付き、長さは72文字になる。どちらも75文字以内である。
なお、Python の標準ライブラリ email.header.Header('...', 'utf-8').encode() で同じ件名をエンコードすると、こちらも文字の境界で区切った encoded-word を2個出力した。ただし Python が見ている上限は encoded-word の長さではなく 折り返した行の長さ(maxlinelen、既定76)なので、header_name を渡さないと1個目が76文字になった。header_name='Subject' を渡すと Subject: のぶんが差し引かれ、68文字と68文字に分かれた。75文字の上限を厳密に守りたいなら、生成した値の長さも確かめておくとよい。
デコード: 間の空白を落としてからつなぐ
長いヘッダーは複数行に折り返されるので、encoded-word の間には改行と空白が入る。section 6.2 の規則どおり、隣り合う encoded-word の間の空白は捨てて つなぐ。空白を残すと、日本語の件名の途中に半角スペースが入ってしまう。
1 | |
実行結果:
1 | |
ツールの joinParts も同じ考え方で、encoded-word にはさまれた部分が空白とタブだけなら出力に含めない。encoded-word でない普通の文字列との間の空白は残すので、Subject: の後ろの空白は消えない。
Q 方式の _ は空白(0x20)を表す(section 4.2)。上の関数で =?UTF-8?Q?a_b=3Dc?= をデコードすると a b=c になる。
Python で試すと化けない理由
ここで、壊れた2個(文字の途中で切ったもの)を Python に読ませてみる。
1 | |
実行結果:
1 | |
化けない。decode_header(h) の戻り値を見ると、2個の encoded-word が (b'Re: 2026\xe5\xb9\xb4...', 'utf-8') の1組にまとまっている。Python は同じ文字コードの encoded-word が隣り合っていると、バイト列をつないでから文字列に戻す ので、途中で割れた文字も元に戻る。
これは受信側の寛容さであって、送信側が section 5 を破ってよい理由にはならない。encoded-word を1個ずつ文字列に戻す実装(ハシトシステムのツールのデコードもこちら)では、さきほどの �� がそのまま表に出る。送る側のテストを Python だけで済ませると、この不具合を見逃す。生成した件名は、encoded-word を1個ずつデコードして U+FFFD が出ないことまで確かめておくのが確実である。
実際に試す
上のコードを1つのファイルにまとめると、次の手順で再現できる(Node.js 18 以降なら TextDecoder と Buffer が標準で使える)。
1 | |
1 | |
期待される出力:
1 | |
encoded-word の長さはどちらも75文字以内に収まっているので、長さの検査だけでは見つからない ことが分かる。
ブラウザ上で試すなら、MIMEヘッダ デコード/エンコードツールのエンコードに長い件名を入れると、文字の境界で区切った encoded-word が並ぶ。デコード側は encoded-word ごとに文字コード・B/Q・バイト数の内訳を出すので、受け取ったメールの件名が化けているとき、どの encoded-word で割れているかを確かめられる。Base64 の部分だけを確かめたいときは同じハシトシステムのBase64 変換、文字化けした文字列を戻したいときは文字化け復元が使える。
まとめ
- encoded-word は1個75文字まで。UTF-8 の B 方式なら1個に詰められるのは45バイト
- 1個の encoded-word は整数個の文字を表さなければならない(RFC 2047 section 5)。バイト数で機械的に切ると、
Re:や数字が混ざった件名で文字が割れる - 切る位置の直後が後続バイト(
10xxxxxx)なら1バイト手前に戻す。これで文字の境界で切れる - デコード時は、隣り合う encoded-word の間の空白を捨ててからつなぐ(section 6.2)
- Python の
decode_headerは隣り合う同じ文字コードのバイト列をつないでから戻すので、割れた件名でも化けない。送信側のテストは encoded-word を1個ずつデコードして行う