0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

メール件名の =?UTF-8?B?…?= を自前でデコードしたら、分割の継ぎ目に空白と文字化けが出た

0
Posted at

背景

メールの生ヘッダを眺めていると、件名が =?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補助で執筆した、個人開発の紹介記事です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?