中年はなぜかたちむかうものがおおい
1. サンプル実装
ウダウダ書く前に実装を出すスタイル。完璧な実装じゃないのであくまで素材として。
2. メールは(悪い意味で)フリーダム
以下仕様の話がしばらく続くけど、すべてのメールが仕様どおりに作成されているわけではなかったり、歴史的な理由で現在の仕様に沿った値が入っているわけではない。例えば Content-Type: text/plain なのに中身はどう見てもHTMLとか。現在のPythonでは解釈できない(古い)Charactor Setがあったりと、割とフリーダム。パーサを作るときには想定した仕様からはみ出すものをどのように扱うかを考慮に入れる必要がある。
3. 仕様の話
3.1. 公式の定義
着手した時はRFC読めばいけんじゃね?と思ったんだけど多すぎるんだよね・・・
ざっくり以下のものが対象
- RFC 822 : STANDARD FOR THE FORMAT OF ARPA INTERNET TEXT MESSAGES(link)
- RFC 1345 : Character Mnemonics & Character Sets(link)
- RFC 2046 : Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types(link)
- RFC 2047 : MIME (Multipurpose Internet Mail Extensions) Part Three: Message Header Extensions for Non-ASCII Text(link)
- RFC 2231 : MIME Parameter Value and Encoded Word Extensions: Character Sets, Languages, and Continuations(link)
- RFC 2387 : The MIME Multipart/Related Content-type(link)
- RFC 5322 : Internet Message Format(link)
- RFC 6047 : iCalendar Message-Based Interoperability Protocol (iMIP)(link)
- RFC 8551 : Secure/Multipurpose Internet Mail Extensions (S/MIME) Version 4.0 Message Specification(link)
関連するものを入れると無限に出てくる。しかも前のRFCからupdateという形で別のRFC番号が振られてたりする。
IETF(的なアレ)「RFCは living-standard だから!」
私「せやな・・・(白目)」
ということで早々にRFCをきっちり読むことはあきらめた。これ無理ゲーだろ
3.2. 個人的なマルチパートの解釈
で、最初にRFCを読み込むより、実データを見ながら後追いでRFCを参照して作り込んでいく方法にした。
で、最近のメールは多くがマルチパートメールになってるのでそこの解説から。
3.2.1. 基本的な構造
以下は私なりのマルチパートメールの理解。基本的にはこう(下図)。
ボディの直下にmultipart/mixedコンテナが入っている場合。ほかにもmultipart/digest, multipart/parallel, multipart/signed とかある(RFC2046)。
-
multipart/mixed:パートのコンテナそのもの。パートに入るデータについては特に規定はない。(RFC2046)
-
multipart/alternative:(ユーザの好みの方法で出し分けるために)メールの本文が複数の形式で入っているコンテナ。
text/plain,text/htmlが代表的だけど、たまに他の形式も入っている。multipart/mixedではすべてのパートをユーザに見せなければいけないけど、multipart/alternativeの場合は省略してもいいですよってのが違い。(RFC2046) -
multipart/related:メールの本文と関連ファイル(本文内の画像など)をひとまとめにしたコンテナ。(RFC2387)
-
multipart/signed:S/MIME。メールの本文とそのPGP署名をセットにしたコンテナ(RFC8551)
パートの要素には「本文」とか「添付ファイル(画像とかPDFファイルとか)など」が入る。
3.2.2. マルチパートは入れ子になることがある
マルチパートメールは以下のように入れ子になることが割とある。
子パートは私が手元のデータを見る限りではmultipart/alternative, multipart/mixedの2タイプが大多数だが、他の multipart/* も想定できる。
3.2.3. 入れ子の具体例(1)
私の環境ではとてもよく出てくる形式。本文が multipart/alternative の中に入っていて、親パートには添付ファイルがある形式。後述するけどこの添付ファイルはHTML本文の埋め込み画像として使用されている場合もよく見かける。
3.2.4. 入れ子の具体例(2)
私の環境ではめったに出てこないけどメールに他のメールが埋め込まれているケース。実際には埋め込まれたメールはユーザに表示すると同時に添付ファイルとしても扱う必要がある。(根拠は見つけられなかったけど、Thunderbirdの場合はそうなっている)
画像を見ただけでもげんなりするが、仕様上このmultipart/*のネストの段数に制限はない。当然パースできるようにしなければいけないし、ネストしたデータをどのようにユーザに見せるかを考えなければいけない。アプリケーションによってはこのネストの段数をどの程度まで辿るか制限を設けているところもあるようだ(参考)。
3.3. パートのメタデータ
まだまだ続くよ仕様の話。次に各パートのメタデータを見て、そのパートのデータがどのように扱われるべきかを理解する必要がある。主なメタデータは以下の通り。
-
Content-Transfer-Encoding:このパートのデータがどのようなエンコードになっているかが指定されている(RFC2045)。これを見て、適切な方式でデコードする。後述するが、実際は Python の email ライブラリがいい感じによろしくやってくれるのであまり気にする必要はない。
-
8bit: 見ての通り。8-bit で表現されたバイナリ(オクテット列)そのもの。転送経路のサーバによっては壊れる可能性もあるので最近はあまり使用されない、と思う -
quoted-printable: これもよく出てくる。=XX(XXは16進数)で表記したもの -
base64: 最近の本文以外はこれが多い -
それ以外:7bit,binary,ietf-token(?),x-token(?)という形式もあるらしい(RFC2045)
-
-
Content-Type:言わずもがなコンテンツの形式(RFC2045)。
text/plain,text/html,image/jpeg,application/pdfなど。ここは本当に多種多様な形式があるので、受信側としては何が表示可能か、そうでないのかを仕分けるために使う。-
charset: デコードしたデータをどのCharater Setで解釈すべきかが指定されている(RFC2046)。私が観測した中ではここが一番フリーダムな定義が多かった(後述)
-
-
Content-Disposition:Mail User Agent(MUA)がこのパートのデータをどのように扱うべきかの指定。(RFC2183)
4. 実装に落とし込むときの諸事項
上記を踏まえたうえで、実装にあたって考えないといけないことも出てくる
4.1. charsetに解釈できない形式が指定されている
現実には上記の仕様に沿っていないメールがあるのも事実。私が観測した中では以下のようなケースがあった。
-
例(1) -
charset=ansi_x3.110-1983ansi_x3.110-1983はRFC1345で定義されているcharacter set。代替セットとしてiso-ir-99,CSA_T500-1983,NAPLPS,csISO99NAPLPSが定義されているが、どれも Python email ライブラリでは解釈できない。 -
例(2) -
charset=windows-1256cp1256の古い表記。昔のメールを対象にするとこういう仕様の変化にも対応しないといけない -
例(3) -
charset=utf-10そんな仕様は存在しない。こんなメールもあるんやなあ・・・(ため息)
ということで、メタデータのcharsetは信用できないこともある。わかる場合は使用可能な character set に置き換えればいいし、最後の手段で本文から推測するという手もある。
4.2. 埋め込み画像は実際には Content-Disposition: attachment である場合もある
以下はとあるメールの埋め込み画像。見てもらうとわかる通り、Content-Disposition: inline ではないのに、Content-ID が指定されている。
Content-Type: image/jpeg
Content-Transfer-Encoding: base64
Content-ID: <image003.jpg@01D6C440.E58EA450>
Content-Disposition: attachment;
filename*=utf-8''image003.jpg;
filename="image003.jpg"
本文のHTMLには以下のように埋め込み位置の指定がある。
<img id="embeded_image1" src="cid:image003.jpg@01D6C440.E58EA450">
ということで、Content-IDの有無を見て埋め込むかどうかを判断するようにした。
4.3. Content-Transfer-Encoding は Python側でよろしくやってくれる
上でいろいろ書いたけど、実際にはPythonで以下のように書けば、ペイロードのContent-Transfer-Encodingに応じて適切にデコードしてくれる
# payload取得
payload = part.get_payload(decode=True)
4.4. Python3 email.policyライブラリの仕様
Python の email ライブラリは、バージョン 3.3 以降仕組みが変わったようで、parser呼び出し時にpolicyを指定する必要がある。で、そこで指定するポリシーによってはパースが失敗するケースがある。
私が経験したのは、添付ファイルのファイル名にnon-asciiのファイル名が使われ、ファイル名の指定が filename*0*="XXXXXXX" とかになる場合。この場合、workaroundとして policy=email.policy.compat32 を指定すれば回避できる。
以下のようにフォールバックを実装した。
try:
# まずemail.policy.defaultでの読み込みを試す
with open(filepath, 'rb') as f:
msg = BytesParser(policy=policy.default).parse(f)
return parse_body(msg)
except:
# email.policy.defaultで読み込めない時は
# email.policy.compat32にフォールバックして読み込む
try:
with open(filepath, 'rb') as f:
msg = BytesParser(policy=policy.compat32).parse(f)
return parse_body(msg)
except:
# email.policy.compat32でも読み込めなかったらエラー
raise
5. で、実装する
上記の通り・・・なんだけど懸念点がひとつ。
Pythonの実装は遅い。具体的には、私の環境にはメールが約800,000ファイルほどあるんだけど、この記事のサンプルで全ファイルをパースするのに2時間ほどかかった。つまり 800000÷(2x60x60)=111 files/secくらいの性能。ローカルのSATA SSDで計測。主なボトルネックは正直切り分けきれていないです。IOのような、CPUのような。
個人用途ではこれでもいいかもしれないけど、マルチユーザとか、マルチテナントを考えると正直これは厳しい。ということで、そういう用途をお考えの方にはインフラなんとかするとか、Pythonじゃなくてもっと高速に処理できる言語で実装することをおすすめしたいです。
6. さいごに
とくに言うことはありません。みなさんの健闘を祈ります。
最近作った冷やし中華の写真置いときます




