背景
メールの生ヘッダを眺めていると、件名が =?UTF-8?B?44OG44K544OI...?= のような謎の文字列になっていることがあります。
私はこれを毎回ググったツールに貼って読んでいたのですが、さすがに自分で作ろうと思い、ハシトシステムに MIME ヘッダのデコード/エンコードツールを置きました。
正規表現1本で済むと思っていたら、継ぎ目のところで2回ほど転びました…
その2点の備忘録です。
encoded-word とは
メールのヘッダは、もともと ASCII(英数字と記号だけの7ビット文字)しか置けない決まりだ。
そこで日本語の件名は、RFC 2047 という仕様に沿って下記の形に包んで運ばれる。
=?{文字コード}?{変換方式}?{本文}?=
これを encoded-word と呼ぶ。
変換方式は2種類で、B は Base64(バイト列を英数字64種で表す方式)、Q は Quoted-Printable を少し変えたもの(=E3 のように1バイトを16進で書く方式)だ。
Q では _ 1文字が空白の代わりになる、という点だけ覚えておけばだいたい読めます。
仕様の原文はこちらです。
https://www.rfc-editor.org/rfc/rfc2047
デコード側の落とし穴:継ぎ目の空白
encoded-word 1つは75文字までと決まっています。
なので長い件名は、複数の encoded-word に分割され、空白や改行を挟んで並びます。
ツールの初期表示にも入れている下記の件名で試します。
Subject: =?UTF-8?B?44OG44K544OI44Oh44O844Or?= =?UTF-8?B?77yI57eK5oCl77yJ?=
素直に「encoded-word を見つけたら中身に置き換える」だけだと、こうなります。
Subject: テストメール (緊急)
「メール」と「(緊急)」の間に、元の件名には無い空白が入ってしまいました。
RFC 2047 の 6.2 節に、encoded-word どうしが空白だけを挟んで隣り合っているとき、その空白は表示しない、という決まりがあるためです。
逆に、Subject: のような地の文と encoded-word の間の空白は残します。
こちらまで消すと「Subject:テストメール」になって、それはそれで変です。
下記が、ツールでやっていることを Node.js 用に切り出したものです。
decodeHeader にヘッダの値(1行の文字列)を渡すと、読める文字列が返ります。
Node.js 18 以降なら外部パッケージなしでそのまま動きます。
const EW = /=\?([A-Za-z0-9_.:+-]+)\?([BbQq])\?([^?]*)\?=/g;
function toBytes(enc, text) {
if (enc === 'B') return Buffer.from(text, 'base64');
// Q 方式: "_" は空白、"=XX" は16進1バイト、それ以外はそのまま
const out = [];
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c === '_') out.push(0x20);
else if (c === '=' && /^[0-9A-Fa-f]{2}$/.test(text.substr(i + 1, 2))) {
out.push(parseInt(text.substr(i + 1, 2), 16));
i += 2;
} else out.push(c.charCodeAt(0) & 0xff);
}
return Uint8Array.from(out);
}
function decodeHeader(input) {
// 1. 地の文(raw)と encoded-word(word)に切り分ける
const parts = [];
let last = 0, m;
EW.lastIndex = 0;
while ((m = EW.exec(input)) !== null) {
if (m.index > last) parts.push({ kind: 'raw', text: input.slice(last, m.index) });
const bytes = toBytes(m[2].toUpperCase(), m[3]);
parts.push({ kind: 'word', text: new TextDecoder(m[1].toLowerCase()).decode(bytes) });
last = m.index + m[0].length;
}
if (last < input.length) parts.push({ kind: 'raw', text: input.slice(last) });
// 2. word と word に挟まれた「空白だけの raw」は捨てる(RFC 2047 6.2)
let out = '';
parts.forEach((p, i) => {
const between = i > 0 && parts[i - 1].kind === 'word' && parts[i + 1] && parts[i + 1].kind === 'word';
if (p.kind === 'raw' && between && /^[ \t\r\n]*$/.test(p.text)) return;
out += p.text;
});
return out;
}
一度に置換せず、いったん raw と word の配列にしているのがポイントです。
置換してしまうと「この空白は word と word の間だったのか」が分からなくなるので、前後の種類を見てから捨てるかどうかを決めています。
実行するとこうなりました。
> decodeHeader('Subject: =?UTF-8?B?44OG44K544OI44Oh44O844Or?= =?UTF-8?B?77yI57eK5oCl77yJ?=')
'Subject: テストメール(緊急)'
> decodeHeader('=?UTF-8?Q?Hello_World?=')
'Hello World'
> decodeHeader('=?ISO-2022-JP?B?GyRCJUYlOSVIGyhC?=')
'テスト'
継ぎ目の空白が消え、Q 方式の _ も空白に戻っているのがわかると思います。
古いメールに多い ISO-2022-JP も、TextDecoder が対応しているので文字コード名を渡すだけで読めました。
エンコード側の落とし穴:文字の途中で切る
逆方向、つまり日本語の件名から encoded-word を作るときも、75文字の上限で分割が要ります。
=?UTF-8?B? と ?= で12文字使うので、本文に使えるのは63文字。
Base64 は3バイトを4文字にするので、1個に入るのは floor(63 / 4) * 3 = 45 バイトです。
最初は、UTF-8 のバイト列を素直に45バイトずつ切っていました。
UTF-8 は日本語1文字を3バイトで表すので、45バイトならちょうど15文字で割り切れる…と思っていたのですが、件名の頭に Re: が付いただけで4バイトずれます。
下記は Re: 【重要】来週月曜日のサーバーメンテナンスに伴うサービス一時停止のお知らせ を45バイトずつ切って、それぞれを単独でデコードした結果です。
=?UTF-8?B?UmU6IOOAkOmHjeimgeOAkeadpemAseaciOabnOaXpeOBruOCteODvOODkOOD?= => Re: 【重要】来週月曜日のサーバ�
=?UTF-8?B?vOODoeODs+ODhuODiuODs+OCueOBq+S8tOOBhuOCteODvOODk+OCueS4gOaZ?= => �メンテナンスに伴うサービス一�
=?UTF-8?B?guWBnOatouOBruOBiuefpeOCieOBmw==?= => �停止のお知らせ
「ー」と「時」が、encoded-word の境目で真っ二つになって化けています。
RFC 2047 の5節には、encoded-word 1つ1つが文字として完結していなければならない、とあります。
受け取る側は encoded-word ごとにデコードするので、上の decodeHeader に通しても「サーバ��メンテナンス」になりました。
直し方は、切る位置の次のバイトが UTF-8 の「続きバイト」なら、1バイトずつ手前に戻すだけです。
続きバイトは2進で 10xxxxxx の形なので、(b & 0xc0) === 0x80 で判定できる。
下記が分割部分です。
text は件名の文字列で、戻り値は encoded-word の配列になります。
function encodeB(text, limit = 75) {
const bytes = new TextEncoder().encode(text); // UTF-8 のバイト列
const head = '=?UTF-8?B?', tail = '?=';
const perChunk = Math.floor((limit - head.length - tail.length) / 4) * 3; // 45
const words = [];
let i = 0;
while (i < bytes.length) {
let take = Math.min(perChunk, bytes.length - i);
// 次のバイトが続きバイト(10xxxxxx)なら、1バイトずつ手前に戻す
while (take > 0 && i + take < bytes.length && (bytes[i + take] & 0xc0) === 0x80) take--;
words.push(head + Buffer.from(bytes.subarray(i, i + take)).toString('base64') + tail);
i += take;
}
return words;
}
同じ件名で実行した結果です。
=?UTF-8?B?UmU6IOOAkOmHjeimgeOAkeadpemAseaciOabnOaXpeOBruOCteODvOODkA==?= => Re: 【重要】来週月曜日のサーバ
=?UTF-8?B?44O844Oh44Oz44OG44OK44Oz44K544Gr5Ly044GG44K144O844OT44K55LiA?= => ーメンテナンスに伴うサービス一
=?UTF-8?B?5pmC5YGc5q2i44Gu44GK55+l44KJ44Gb?= => 時停止のお知らせ
1個目が43バイト(「ー」の3バイトが入りきらない位置)で止まり、末尾が == のパディングになっています。
2バイト損して見えますが、その代わりにどの encoded-word も単独で読めるようになりました。
ツールでは、これを改行+空白(folding)でつないでそのままヘッダに書ける形で出しています。
なぜ自前で書いたのか
サーバ側の Node.js なら、メール送信ライブラリに任せるのが普通だと思います。
ただ、ツールはブラウザだけで完結させたかったので、組み込みの TextDecoder / TextEncoder と atob / btoa だけで書きました。
その代わり、生成側は UTF-8 のみです(TextEncoder が UTF-8 しか出せないため)。
ISO-2022-JP で作りたい場合は、変換表を持つライブラリに頼るほうが確実だと思います。
encoded-word を自前で扱うなら、デコードは「word と word の間の空白を捨てる」、エンコードは「続きバイトの手前で切る」の2点だけは最初から入れておくと、件名の空白や文字化けで悩まずに済みます。
本記事はAI補助で執筆した、個人開発の紹介記事です。