nyanpyou Note

主な目的は調べたり作ったりしたプログラミング備忘録(予定)

Pythonで送信したメールの添付ファイルが正しく見えない件について

以前書いたPythonを用いたメール送信方法を一斉送信を行う際に利用していたが、問題が発覚したのでその内容と解決方法についてメモ。

症状

Pythonで添付ファイル付きのメールを送り、そのメールをWindows10メールで受け取ると添付ファイルが見えない。Outlookで受け取ると無題の添付ファイル.datとなる。
(添付ファイルの中身がおかしくなるのではなく、添付ファイル自体がそもそも存在しないメールとして受信される。メールソースを見てもやはり添付ファイルの部分が存在しない。)
添付ファイル付きメールとして送れていないわけではないようで、Thunderbirdで受信すると問題なく添付ファイルが添付されているし、ファイルの中身も確認できる。
全く同じ文面のメールをThunderbirdで作成して送信した場合は、問題なく添付ファイルと共にメールが届く。

原因

本現象についてネットを彷徨い調べてみると、どうもメールソース中の添付ファイルの名前の書き方が問題らしいということが分かった。

qiita.com

tearoller.tea-nifty.com

moji-memo.hatenablog.jp

sendgrid.kke.co.jp

これらの情報によると、Windows10メールやOutlookといったMicrosoft製のメーラーで日本語で名前を付けた添付ファイルを確認するためには、Content-Typeのname部分にBase64エンコードされたファイル名が書かれている必要があるそうだ。
その際の書き方は以下のようにする必要がある

=?文字コード?エンコード方式?エンコード後の文字列?=

文字コードの部分にはUTF-8の場合はutf-8エンコード方式の部分にはBase64を用いる場合はbが入る。試した限りでは大文字小文字は区別しないようだった。
但しこれは技術仕様を定めたRFC的には正しくない書き方で、Content-Typeに添付ファイルの名前を書いておく必要は本来はないらしい。
20年ほど前に書かれたものであるが、電子メールに関するRFCをまとめているページがあった。量が多くて原文を読めていないものの、以下に書き留めておく。

www.emaillab.org

今回RFCというものを初めて知ったが、IETFという技術の標準化を推進する団体がまとめている文書だそうだ。

e-words.jp

つまりまとめると、RFCに則るのであれば、メールソース内のContent-DispositionにUTF-8でファイル名が書かれていればそれでよい。が、それでは添付ファイルを認識してくれないメーラーも存在するため、別途対処が必要ということだ。
(そんなんわかるか!w必ずしも従う必要のないルールとはいえ、規格は統一しといて欲しいと本当に思う。)
以上のことが分かったので、Pythonで送ったメールと、Thunderbirdで送ったメールの2つについて、メールソースを確認し、添付ファイルの情報が書かれている部分を実際に見比べてみる。どちらのメールも、添付ファイルには添付ファイル.pdfという名前のファイルを付けている。

#使用したPythonプログラム(再掲)
import smtplib, ssl
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from os.path import basename
from getpass import getpass
 
# SMTP認証情報
account = "メールを送信させたいアドレス"
password = getpass("email password?:")
 
# 送受信先
to_email = "送り先"
from_email = "送り主"
 
# MIMEの作成
subject = "送信テスト"
message = "Pythonプログラムから送信しています。"
msg = MIMEMultipart()
msg.attach(MIMEText(message))
msg["Subject"] = subject
msg["To"] = to_email
msg["From"] = from_email

file_path = "添付したいファイルのパス"
with open(file_path, "rb") as f:
     part = MIMEApplication(f.read(), Name=basename(file_path))
 
part['Content-Disposition'] = 'attachment; filename="%s"' % basename(file_path)
msg.attach(part)
 
server = smtplib.SMTP_SSL("smtpサーバーのアドレス", ポート番号, context=ssl.create_default_context())
 
server.login(account, password)
server.send_message(msg)
server.quit()
#Pythonで送信したメールをThunderbirdで受け取った場合
Content-Type: application/octet-stream;
 Name*=utf-8''%E6%B7%BB%E4%BB%98%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.pdf
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: =?utf-8?b?YXR0YWNobWVudDsgZmlsZW5hbWU9Iua3u+S7mOODlQ==?=
 =?utf-8?b?44Kh44Kk44OrLnBkZiI=?=
#Thunderbirdで送信したメールをThunderbirdで受け取った場合
Content-Type: application/pdf;
 name="=?UTF-8?B?5re75LuY44OV44Kh44Kk44OrLnBkZg==?="
Content-Transfer-Encoding: base64
Content-Disposition: attachment;
 filename*0*=UTF-8''%E6%B7%BB%E4%BB%98%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB;
 filename*1*=%2E%70%64%66

Thunderbirdで送信したメールは確かにContent-TypeのnameにはBase64エンコードされたファイル名が、Content-DispositionにはUTF-8のファイル名が書かれており、このメールはThunderbirdでもWindows10メールでも添付ファイルを見ることが出来る。それに比べ、Pythonで送ったメールのソースコードは随分内容が異なっていることが初めて分かった。(やっぱりブラックボックスをコードに残すのは良くないと再認識・・・)
よってコードの見直しを行い、Thunderbirdで作成したメールと同じソースになるようにプログラムを修正することにした。

修正

修正したコードは以下のようになる。

import smtplib, ssl
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from os.path import basename
from getpass import getpass
 
# SMTP認証情報
account = "メールを送信させたいアドレス"
password = getpass("email password?:")
 
# 送受信先
to_email = "送り先"
from_email = "送り主"
 
# MIMEの作成
subject = "送信テスト"
message = "Pythonプログラムから送信しています。"
msg = MIMEMultipart()
msg.attach(MIMEText(message))
msg["Subject"] = subject
msg["To"] = to_email
msg["From"] = from_email

file_path = "添付したいファイルのパス"

#ファイル名をBase64エンコードしてその結果を文字列に変換する
base64encoded_filename = base64.b64encode(basename(file_path).encode("utf-8")).decode()
        
with open(file_path, "rb") as f:
    part = MIMEApplication(f.read(), name="=?utf-8?b?{}?=".format(base64encoded_filename))

#Content-Dispositionに情報を記載する。ちゃんと改めて公式ドキュメントを調べたら専用のメソッドがあった。
part.add_header("Content-Disposition", "attachment", filename="{}".format(basename(file_path)))
msg.attach(part)
server = smtplib.SMTP_SSL("smtpサーバーのアドレス", ポート番号, context=ssl.create_default_context())

Base64エンコードする部分を細かく見ると以下の手順でエンコードが進む。

x="添付ファイル.pdf".encode("utf-8")
>>x
>>b'\xe6\xb7\xbb\xe4\xbb\x98\xe3\x83\x95\xe3\x82\xa1\xe3\x82\xa4\xe3\x83\xab.pdf'
y=base64.b64encode(a)
>>y
>>b'5re75LuY44OV44Kh44Kk44OrLnBkZg=='
z.decode()
>>'5re75LuY44OV44Kh44Kk44OrLnBkZg=='

Base64はデータをASCII文字(半角英数字などで構成される128文字)に変換するためのエンコード方式である。

qiita.com

decode()は引数に何も指定しない場合utf-8でデコードを行うが、ASCIIを拡張した文字コード(頻繁に用いるUTF-8やShift-JISは全てASCIIを拡張した文字コード。今回初めて知った。)は、ASCIIに含まれる文字については全てASCIIと同じコードのため、ASCIIを引数に指定しなくてもstr型となった半角英数字を得ることが出来る。UTF-8を指定してもShift-JISを指定しても結果は同じ。
以上の手順を踏むことでstr型のエンコード済みファイル名が手に入る。

教訓

コードの不明点は残さないようにしましょう。

余談

これを書くために追加で、手持ちのメールアドレス何個かを用いてPythonから送ったメールの受信を試してみたが、なんとhotmailのアドレスに送った場合は元々のプログラムのままでもWindows10メールで添付ファイルが表示されることを確認した。メーラーだけでなく使うアドレスにもどうやら依存するらしい・・・まだまだ謎は多い・・・