メール件名の=?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か所である。

  1. 長さ(section 2): encoded-word は、charset・encoding・encoded-text と区切り記号を含めて 75文字を超えてはならない
  2. 文字の単位(section 5): 各 encoded-word は 整数個の文字 を表さなければならない(MUST)。複数バイトの文字を、隣り合う encoded-word にまたがって分割してはならない
  3. 空白(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
2
3
var head = '=?UTF-8?' + enc + '?', tail = '?=';
var room = limit - head.length - tail.length; // 75 - 12 = 63
var perChunk = (enc === 'B') ? Math.max(3, Math.floor(room / 4) * 3) : Math.max(1, room);

日本語の文字(ひらがな・漢字・全角記号)は UTF-8 で3バイトなので、45バイトはちょうど15文字分になる。件名が日本語だけなら、45バイトごとに切っても偶然文字の境界に当たる。問題は、Re: や数字のような1バイトの文字が混ざったときだ。

機械的に45バイトで切ると何が起きるか

件名を Re: 2026年10月の定例会議は中止します(会議室の工事のため) として、UTF-8 のバイト列を45バイトごとに切ってみる。この件名は34文字・82バイトである。

1
2
3
4
5
6
7
8
9
const subject = "Re: 2026年10月の定例会議は中止します(会議室の工事のため)";
const bytes = Buffer.from(subject, "utf8");
const naive = [];
for (let i = 0; i < bytes.length; i += 45) naive.push(bytes.subarray(i, i + 45));
naive.forEach((b, i) => {
const t = new TextDecoder("utf-8").decode(b);
console.log("naive#" + (i + 1), b.length + "B", JSON.stringify(t.slice(-3)),
"U+FFFD:", (t.match(/�/g) || []).length);
});

実行結果:

1
2
naive#1 45B "止し�" U+FFFD: 1
naive#2 37B "ため)" U+FFFD: 1

先頭の Re: 2026(8バイト)と 年(3バイト)、10(2バイト)がずれを作り、45バイト目が「ま」の3バイトの途中に来ている。1個目の末尾に「ま」の先頭2バイト、2個目の先頭に残りの1バイトが入る。これは section 5 の MUST に反する encoded-word である。

encoded-word ごとに文字列へ戻す受信側では、こう見える。

1
Re: 2026年10月の定例会議は中止し��す(会議室の工事のため)

「ます」の「ま」が2個の U+FFFD に化けた。

文字の途中で切らない: 後続バイトの手前まで戻す

UTF-8 では、1文字の2バイト目以降(後続バイト)は必ず 10xxxxxx の形をしている。つまり「次のバイトが 10xxxxxx なら、ここは文字の途中」と判定できる。ツールの実装は、切る位置の直後が後続バイトである限り、切る位置を1バイトずつ手前に戻している。

1
2
3
var take = Math.min(perChunk, bytes.length - i);
// UTF-8 の途中で切らない(後続バイト 10xxxxxx の手前まで戻す)
while (take > 0 && i + take < bytes.length && (bytes[i + take] & 0xc0) === 0x80) take--;

& 0xc0 で上位2ビットだけを取り出し、0x80(10)と比べている。同じ処理を Node.js で書いて、さきほどの件名を分割する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function encodeWords(text, limit) {
const by = Buffer.from(text, "utf8");
const head = "=?UTF-8?B?", tail = "?=";
const per = Math.floor((limit - head.length - tail.length) / 4) * 3; // 45
const out = [];
let i = 0;
while (i < by.length) {
let take = Math.min(per, by.length - i);
while (take > 0 && i + take < by.length && (by[i + take] & 0xc0) === 0x80) take--;
out.push(head + by.subarray(i, i + take).toString("base64") + tail);
i += take;
}
return out;
}
const words = encodeWords("Re: 2026年10月の定例会議は中止します(会議室の工事のため)", 75);
words.forEach((w, i) => console.log("word#" + (i + 1), w.length + "chars", w));

実行結果:

1
2
word#1 72chars =?UTF-8?B?UmU6IDIwMjblubQxMOaciOOBruWumuS+i+S8muitsOOBr+S4reatouOBlw==?=
word#2 64chars =?UTF-8?B?44G+44GZ77yI5Lya6K2w5a6k44Gu5bel5LqL44Gu44Gf44KB77yJ?=

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
2
3
4
5
6
7
8
9
10
11
12
13
14
function decode(header) {
const re = /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g;
return header
.replace(/\?=\s+=\?/g, "?==?") // 隣り合う encoded-word の間の空白を落とす
.replace(re, (_, cs, enc, txt) => {
const b = enc.toUpperCase() === "B"
? Buffer.from(txt, "base64")
: Buffer.from(txt.replace(/_/g, " ")
.replace(/=([0-9A-Fa-f]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16))), "latin1");
return new TextDecoder(cs).decode(b);
});
}
const folded = "Subject: " + words.join("\r\n ");
console.log(decode(folded));

実行結果:

1
Subject: Re: 2026年10月の定例会議は中止します(会議室の工事のため)

ツールの joinParts も同じ考え方で、encoded-word にはさまれた部分が空白とタブだけなら出力に含めない。encoded-word でない普通の文字列との間の空白は残すので、Subject: の後ろの空白は消えない。

Q 方式の _ は空白(0x20)を表す(section 4.2)。上の関数で =?UTF-8?Q?a_b=3Dc?= をデコードすると a b=c になる。

Python で試すと化けない理由

ここで、壊れた2個(文字の途中で切ったもの)を Python に読ませてみる。

1
2
3
4
from email.header import decode_header, make_header
h = ('=?UTF-8?B?UmU6IDIwMjblubQxMOaciOOBruWumuS+i+S8muitsOOBr+S4reatouOBl+OB?= '
'=?UTF-8?B?vuOBme+8iOS8muitsOWupOOBruW3peS6i+OBruOBn+OCge+8iQ==?=')
print(str(make_header(decode_header(h))))

実行結果:

1
Re: 2026年10月の定例会議は中止します(会議室の工事のため)

化けない。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
2
node -v                      # v24.11.1 で確認
node mime-test.js # 下のコードを保存したファイル
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// mime-test.js
const subject = "Re: 2026年10月の定例会議は中止します(会議室の工事のため)";
const per = Math.floor((75 - 12) / 4) * 3; // 45バイト
function split(by, safe) {
const out = [];
for (let i = 0; i < by.length;) {
let take = Math.min(per, by.length - i);
if (safe) while (take > 0 && i + take < by.length && (by[i + take] & 0xc0) === 0x80) take--;
out.push(by.subarray(i, i + take));
i += take;
}
return out;
}
const by = Buffer.from(subject, "utf8");
for (const safe of [false, true]) {
const words = split(by, safe).map(b => "=?UTF-8?B?" + b.toString("base64") + "?=");
const perWord = words.map(w => new TextDecoder().decode(Buffer.from(w.slice(10, -2), "base64"))).join("");
console.log(safe ? "safe :" : "naive:", words.map(w => w.length).join("/"), "chars", "U+FFFD=" + (perWord.match(/�/g) || []).length);
}

期待される出力:

1
2
naive: 72/64 chars U+FFFD=2
safe : 72/64 chars U+FFFD=0

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個ずつデコードして行う

参考


メール件名の=?UTF-8?B?を75文字で分割するとき文字を切らない方法
https://blog.hashito.biz/2026/10/04/mime-encoded-word-75-chars-utf8-split-rfc2047/
著者
hashito
作成日
2026年10月4日
著作権