はじめに
メールを「送る」側は、何度かやったことがありました。SMTP で投げて、テンプレートを差し込んで、バウンスを眺めて……。だから「受信したメールを Python で読んで自動処理する」くらい、ちょっとした正規表現と email モジュールで終わるだろうと思っていたんです。
正直、ナメてました。
実際に書き始めたら、件名は呪文みたいな文字列のまま出てくるし、本文は文字化けするし、極めつけに件名が文字列ですらないオブジェクトに化けた。「日本語メールのパース」は、件名と本文でまったく別の地獄が口を開けて待っていました。
この記事は、その地獄を自分で再現して、一つずつ攻略していった記録です。机上の解説ではなく、手元で 8 通の「やらかしメール」を作って、素朴な実装でわざと全部踏み抜いてから直していきます。
(先に結論)
- 件名・添付ファイル名のデコードは
email.policy.defaultに任せれば 8 割片付く(compat32のままだと地獄) - でも本文の文字コードは別問題。
charset=Shift_JISを真に受けると機種依存文字が化ける(実体はcp932) - charset 宣言が嘘をついているメールは実在する。宣言を信じず、中身から推定するしかない
対象読者
- Python で受信メール(.eml / IMAP の生バイト)を扱う必要が出てきた人
- 「件名が
=?ISO-2022-JP?B?...?=のまま出てきた」で検索してここに来た人- 文字コードの話が嫌いだけど向き合わざるを得なくなった人
検証環境
Python : 3.14.4
ライブラリ : email(標準), charset-normalizer 3.4.7
OS : macOS (Apple Silicon)
文字コード判定に charset-normalizer だけ追加で入れています。
python3 -m pip install charset-normalizer
まず"地獄"を自分で作る
文字化けメールというのは、欲しいときに限って手元に都合よく転がっていません。なので、送信側のやらかしを再現する生成スクリプトを書くところから始めました。狙った地獄はこの 8 パターンです。
| # | 地獄パターン | 何がつらいか |
|---|---|---|
| 01 | ISO-2022-JP 本文 + RFC2047 の件名 | ど真ん中の日本のメール。これが基準 |
| 02 | Shift_JIS 宣言 + 機種依存文字(№①㈱) | 「Shift_JIS」が罠 |
| 03 | charset が嘘(実体 UTF-8 / 宣言 latin-1) | 宣言を信じると死ぬ |
| 04 | 添付ファイル名が RFC2047 | 仕様違反だが Gmail 等が出す |
| 05 | 添付ファイル名が RFC2231 | こっちが正式 |
| 06 | multipart/alternative | 本文がどこにあるのか |
| 07 | 長い件名の folding + 複数 encoded-word | 分割された呪文 |
| 08 | ヘッダに生の 8bit バイト混入 | RFC 違反の現実 |
生成スクリプトの肝は、件名を RFC2047 でエンコードする部分です。
import base64
def rfc2047_b(text, charset="iso-2022-jp"):
"""件名やファイル名に使う RFC2047 B-encode。"""
enc = text.encode(charset)
return f"=?{charset}?B?{base64.b64encode(enc).decode('ascii')}?="
subj = rfc2047_b("【重要】請求書送付のご案内(5月分)")
body = "山田様\n\nいつもお世話になっております。\n...".encode("iso-2022-jp")
……で、いきなりつまずきました。機種依存文字を盛り込もうとして ㊙(U+3299)を本文に入れたら、テストデータを作る段階で例外です。
UnicodeEncodeError: 'cp932' codec can't encode character '㊙'
in position 21: illegal multibyte sequence
まだパースの「パ」の字も始まっていないのに、もう転んでいる。「機種依存文字」とひとくくりに呼んでいた中にも、Windows のコードページ(cp932)にすら載っていない字がある、というオチでした。㊙ は外して先に進みます。……これ、序の口でした。
第1の罠:とりあえず message_from_bytes()
受信メールを読むとき、message_from_bytes() をそのまま使っていませんか? 新人だった頃の自分なら、まず間違いなくこう書きました。読み込んで、['Subject'] を取り出して、get_payload() で本文を取る。それだけ。
import email, glob, os
for path in sorted(glob.glob("eml/*.eml")):
with open(path, "rb") as f:
msg = email.message_from_bytes(f.read()) # ← policy 未指定
print("Subject :", repr(msg["Subject"]))
print("Body[:50] :", repr(msg.get_payload()[:50]))
走らせます。01(普通の日本語メール)の結果がこれです。
Subject : '=?iso-2022-jp?B?GyRCIVo9RU1XIVtAQTVhPXFBd0lVJE4kNDBGRmIhShsoQjUbJEI3bkosIUsbKEI=?='
Body[:50] : '\x1b$B;3EDMM\x1b(B\n\n\x1b$B$$$D$b$*@$OC$K$J$C$F$*$j$^$9!#\x1b(B'
件名が呪文です。=?iso-2022-jp?B?...?= という RFC2047 のエンコード文字列が、デコードされずにそのまま返ってきました。本文も \x1b$B という ISO-2022-JP のエスケープシーケンスが生で見えています。「あ、これ自分でデコードしないといけないやつだ」と察した瞬間でした。
機種依存文字入りの 02 はもっとひどい。
Subject : '=?cp932?B?gqiMqZDPh4KHQILMjI8=?='
Body[:50] : 'お見積�g@〜�Bについて\n担当:�梶宦寶、事\n金額:1,000円\r\n'
№①㈱ のあたりが �g@ �梶宦寶、 と完全に崩壊しています。get_payload() が中身のバイトをそのまま str 化してしまうので、こうなる。
そして、いちばん面食らったのが 08(ヘッダに生バイトが混入したメール)でした。
Subject : <email.header.Header object at 0x1096206e0>
件名が、文字列ですらない。Header オブジェクトがそのまま返ってきました。print した画面を見て、数秒フリーズしました。「いや、件名くれよ……」と。
なぜこうなるのか。message_from_bytes() は policy を指定しないと、compat32 というレガシーなポリシーで動きます。これは Python 3.3 より前の email パッケージとの後方互換のための挙動で、ヘッダの自動デコードをしてくれません。
message_from_bytes() などパーサ系のデフォルトは、今でも compat32 です。公式ドキュメントにも「将来のバージョンで email.policy.default に変わる予定なので、policy キーワードは常に明示すべき」と書かれています。つまり「無指定」がいちばん危ない。
the policy keyword should always be specified as the default will change to
email.policy.defaultin a future version of Python.
(email.parser — Python 3.14 documentation より)
救世主:email.policy.default
直し方は、拍子抜けするほど簡単でした。policy を渡すだけ。
- msg = email.message_from_bytes(f.read())
+ msg = email.message_from_bytes(f.read(), policy=email.policy.default)
これだけで、返ってくるオブジェクトが昔ながらの Message から新しい EmailMessage に変わり、世界が一変します。同じ 8 通を流し直した結果がこれです。
FILE : 01_iso2022jp.eml
Subject: 【重要】請求書送付のご案内(5月分)
Body : '山田様\n\nいつもお世話になっております。\n5月分の請求書をお送りいたします。\n...'
FILE : 07_folded_subject.eml
Subject: 【ご案内】夏季休業期間中のお問い合わせ対応について(必ずお読みください)
FILE : 08_raw_8bit_header.eml
Subject: 生バイトの件名です
件名が日本語で出る。複数の encoded-word に分割されて折り返されていた 07 もちゃんと連結される。Header オブジェクトに化けていた 08 まで普通の文字列で返ってきました。これは効きました。
添付ファイル名も同様です。iter_attachments() と get_filename() を使えば、04(RFC2047 形式、本来は仕様違反)も 05(RFC2231 形式、正式)も、どちらも同じように読めました。
FILE : 04_attach_rfc2047.eml → Attach : '請求書_2026年5月.pdf'
FILE : 05_attach_rfc2231.eml → Attach : '請求書_2026年5月.pdf'
そもそも RFC 2047 はヘッダ本体や phrase のためのもので、Content-Disposition のパラメータ(filename)に使うのは規格違反(MUST NOT)です。パラメータ値のエンコードは RFC 2231 が正式。
ところが Gmail の Web UI などは添付ファイル名に RFC 2047 を平気で使ってきます。email.policy.default は、この「現実の非標準」も黙って吸収してくれる。賢い。
ここまでをまとめると、compat32 と policy.default は事実上「2 つの別の世界」です。
| 項目 | compat32(レガシー・無指定時) | policy.default(モダン) |
|---|---|---|
| 返るクラス | Message |
EmailMessage |
| 件名の RFC2047 | デコードしない(呪文のまま) | 自動デコード |
| 折り返された件名 | 分割されたまま | 連結してデコード |
| 添付名 RFC2047/2231 | 自力 | 両方自動対応 |
| 生バイトヘッダ |
Header オブジェクトに化ける |
文字列で返る |
| 本文取得 | get_payload() |
get_body() / get_content()
|
新 API は Python 3.3 で導入され、3.6 で安定版になりました。今から書くなら policy.default 一択です。
……で、ここで終われば平和だったんですが。本文の文字化けは、まだ半分しか直っていませんでした。
それでも本文が化ける:「Shift_JIS」を信じてはいけない
policy.default にしても、02 の本文だけは化けたままだったんです。
FILE : 02_shiftjis_kishu.eml
Subject: お見積№①の件 ← 件名は正しい!
Body : 'お見積�g@〜�Bについて\n担当:�梶宦寶、事\n...' ← 本文は化けたまま
件名は №① まできれいに出ているのに、本文の同じ文字が崩れている。同じメールの中で勝者と敗者が分かれていて、しばらく意味が分かりませんでした。
切り分けるために、生バイトを直接デコードして比べてみます。
b = "№①㈱".encode("cp932")
print("cp932 bytes :", b)
print("decode('shift_jis'):", repr(b.decode("shift_jis", errors="replace")))
print("decode('cp932') :", repr(b.decode("cp932")))
cp932 bytes : b'\x87\x82\x87@\x87\x8a'
decode('shift_jis'): '�g@��'
decode('cp932') : '№①㈱'
犯人が分かりました。№①㈱ のような文字(NEC 特殊文字と呼ばれる領域)は、Python の shift_jis コーデックには載っていません。cp932(Windows-31J)には載っている。
このメールの本文は Content-Type: charset=Shift_JIS と宣言していました。policy.default はその宣言を真面目に信じて shift_jis でデコードした結果、機種依存文字が全滅したわけです。件名のほうは送信側が RFC2047 に =?cp932?B?...?= と明示していたので助かっていました。
日本で「Shift_JIS」と名乗っているメールやファイルの実体は、ほぼ Windows-31J、つまり cp932 です。宣言を額面どおり受け取ってはいけない。
charset=Shift_JIS と書いてあっても、shift_jis ではなく cp932 でデコードする。これだけで日本のメールの文字化けはかなり減ります。
おまけ:波ダッシュ 〜 が勝手に ~ に化ける
ついでに、機種依存文字つながりで有名な「波ダッシュ問題」も手元で確かめました。私はてっきり「〜(U+301C)は cp932 でエンコードできずに例外になる」と思い込んでいたんですが、実際は違いました。
for cp, name in [("〜", "U+301C WAVE DASH"), ("~", "U+FF5E FULLWIDTH TILDE")]:
print(f"{name}: cp932 -> {cp.encode('cp932')}")
U+301C WAVE DASH : cp932 -> b'\x81`'
U+FF5E FULLWIDTH TILDE: cp932 -> b'\x81`'
別々の Unicode 文字なのに、cp932 では両方とも同じ \x81\x60 に潰れます。そして decode すると U+FF5E のほうに戻る。つまり 〜 を送ったのに、受信側で勝手に ~ に化けるわけです。
これは Microsoft が Shift_JIS↔Unicode の変換テーブルを作ったとき、JIS の波ダッシュを Unicode の全角チルダに割り当ててしまった歴史的経緯によるものです1。「記憶を信じず手元で確かめる」の良い実例でした(私の予想は外れていたので)。
charset が嘘をつくメール
最後の地獄が 03 です。本文は UTF-8 で書かれているのに、ヘッダでは charset=iso-8859-1 と宣言している。こういうメール、実在するんです。
policy.default でも、宣言を信じて latin-1 でデコードするので、見事な mojibake になります。
FILE : 03_lying_charset.eml
Body : 'æ\x9c¬æ\x96\x87ã\x81¯UTF-8ã\x81ªã\x81®ã\x81«...'
厄介なのは、iso-8859-1 はどんなバイト列でもエラーなくデコードできてしまう点です。1 バイト = 1 文字で必ず通るので、「デコードに失敗したから怪しい」という検知すらできない。静かに化けます。
宣言が当てにならないなら、中身そのものから推定するしかありません。ここで charset-normalizer の出番です。
from charset_normalizer import from_bytes
raw = "これはUTF-8の本文です".encode("utf-8")
guess = from_bytes(raw).best()
print(guess.encoding) # -> 'utf_8'
攻略:ヘッダは policy に任せ、本文は宣言を疑う
ここまでの教訓を 1 つのデコード方針にまとめます。考え方はシンプルです。
実装した堅牢デコード関数の一例がこれです。
from charset_normalizer import from_bytes
# 「Shift_JIS」と名乗るメールの実体はほぼ Windows-31J(cp932)
SJIS_ALIASES = {"shift_jis", "shift-jis", "sjis", "x-sjis",
"windows-31j", "ms_kanji", "ms932", "csshiftjis"}
# バイト列を素通しでデコードできてしまう = 宣言として当てにならない
UNRELIABLE = {"", "us-ascii", "ascii", "iso-8859-1", "latin-1",
"latin1", "iso8859-1", "windows-1252", "cp1252"}
def normalize_charset(cs):
cs = (cs or "").strip().lower()
return "cp932" if cs in SJIS_ALIASES else cs
def robust_decode(raw, declared):
raw = raw or b""
low = (declared or "").strip().lower()
norm = normalize_charset(declared)
# 1) 宣言が当てにならない/無い → 中身から推定
if low in UNRELIABLE or not norm:
g = from_bytes(raw).best()
if g is not None:
return str(g), f"auto:{g.encoding}"
# 2) 宣言(正規化後)でデコード
if norm:
try:
return raw.decode(norm), f"declared:{norm}"
except (UnicodeDecodeError, LookupError):
pass
# 3) 推定にフォールバック
g = from_bytes(raw).best()
if g is not None:
return str(g), f"auto:{g.encoding}"
# 4) 最終手段(壊さず読む)
return raw.decode("utf-8", errors="replace"), "replace"
ポイントは、ヘッダ(件名・差出人・添付名)のデコードは email.policy.default に丸投げし、本文の文字コードだけ自分で面倒を見るというハイブリッドにしたことです。ヘッダの RFC2047/2231 を自前で実装するのは車輪の再発明だし、まず勝てません。
この方針で 8 通を流し直した最終結果がこれです。
02_shiftjis_kishu.eml declared=shift_jis used=declared:cp932 ✅ 'お見積№①~③について...'
03_lying_charset.eml declared=iso-8859-1 used=auto:utf_8 ✅ '本文はUTF-8なのに...'
charset=shift_jis を cp932 に読み替え、iso-8859-1 の嘘を中身から utf_8 と見抜く。残っていた 2 つの地獄が、両方とも攻略できました。
最後にまとめ。
メールを「送る」のと「受けて読む」のは、まったく別の難易度でした。受信側は、世界中のメールクライアントがやらかした非標準を全部受け止める仕事だからです。最後に、自分が踏み抜いた地獄を踏まないためのチェックリストを置いておきます。
-
message_from_bytes()には必ずpolicy=email.policy.defaultを渡す(無指定のcompat32に甘えない) - 件名・差出人・添付ファイル名のデコードは
policy.defaultに任せる(自前実装しない) - 本文で
charset=Shift_JISと書いてあったらcp932で読む -
iso-8859-1/us-ascii/ 宣言なしは信用せず、中身から推定する(charset-normalizer) - 波ダッシュ・機種依存文字は文字コードとは別レイヤーの問題。必要なら正規化を検討する
メールの文字コードは「枯れた技術」と言われがちですが、枯れているのは仕様であって、現実に飛んでくるメールはぜんぜん枯れていません。同じように =?ISO-2022-JP?B? で検索して迷い込んできた人の、何かのヒントになれば。
もし「うちにはもっとひどいメールが来るぞ」という猛者がいたら、ぜひコメントで地獄を見せてください。
-
JIS 準拠の Shift_JIS では 0x8160 を U+301C(波ダッシュ)とみなすが、Windows の CP932 では同じ 0x8160 を U+FF5E(全角チルダ)に割り当てている。詳しくは 波ダッシュ・全角チルダ問題 - とほほのWWW入門 が分かりやすい。 ↩
