Python 解析 MIME 邮件:三个 text 部分,为什么只有一个该当正文
按 text 类型全部拼起来,会读到什么
解析一封带附件的邮件时,把 walk 遍历到的所有 text/plain 拼成正文,可能把附带的说明文件也读进去;再把 text/html 加上,又会重复一次正文。MIME 邮件描述的是层次结构,内容类型只回答如何解释这个部分,还需要结合容器和内容处置方式,判断它在整封邮件中的作用。
下面的代码在 Python 3.12.14 上验证,保存为 demo.py 后运行即可。它在内存里创建一封虚构测试邮件,再序列化成字节重新解析,没有登录邮箱,也不会发送邮件。邮件包含纯文本正文、表达同一消息的 HTML 版本,以及 notes.txt 文本附件,便于把三者放在同一个样本中核对。
先选择正文,再列附件
先调用 set_content,再调用 add_alternative,形成可替代的正文版本;添加附件后,外层成为 multipart/mixed,第一项仍包含 multipart/alternative。读取时显式使用 policy.default,使 BytesParser 产生带这些便捷方法的 EmailMessage。get_body 的偏好列表只写 plain,表示本实验只接受纯文本正文候选。
AI概念示意图:邮件树中,一支保存两种可替代正文,另一支保存独立附件。图片用于解释结构,不是邮件客户端截图。
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
msg = EmailMessage()
msg["Subject"] = "Local MIME demo"
msg.set_content("Hello from the body.")
msg.add_alternative("<p>Hello from the body.</p>", subtype="html")
msg.add_attachment("This is an attached note.", filename="notes.txt")
parsed = BytesParser(policy=policy.default).parsebytes(msg.as_bytes())
assert parsed.get_content_type() == "multipart/mixed"
body = parsed.get_body(preferencelist=("plain",))
assert body is not None
assert body.get_content().strip() == "Hello from the body."
attachments = list(parsed.iter_attachments())
assert len(attachments) == 1
assert attachments[0].get_filename() == "notes.txt"
assert attachments[0].get_content().strip() == "This is an attached note."
leaves = [(part.get_content_type(), part.get_content_disposition())
for part in parsed.walk() if not part.is_multipart()]
assert leaves == [("text/plain", None), ("text/html", None),
("text/plain", "attachment")]
print("root:", parsed.get_content_type())
print("leaves:", leaves)
print("body:", body.get_content().strip())
print("attachments:", [p.get_filename() for p in attachments])遍历结构,不等于选择内容
输出第一行是 root: multipart/mixed,随后列出三个叶子部分:正文的 text/plain、正文的 text/html,以及处置方式为 attachment 的 text/plain。body 输出 Hello from the body.,attachments 输出仅含 notes.txt 的列表。叶子计数为三,但正文不是三段相加;同一封邮件的两种正文表示也不应被当成两封邮件。
get_content 按当前内容管理器返回已解码内容。对于这个文本附件,结果是字符串,可以校验文字;其他类型可能返回字节或对象,应按具体类型处理。示例使用 strip 只是为了核对测试文本,正式归档不应随意抹掉原文首尾空白。需要可追溯记录时,还应保留原始邮件字节。
真实邮件需要哪些额外判断
如果只有 HTML 版本,而偏好列表只允许 plain,get_body 可能返回 None,调用方必须安排这一分支。改成同时允许 html 后,拿到 HTML 也不代表可以直接作为可信网页显示;显示策略与正文选择是两项独立工作。附件的文件名同样来自消息内容,保存时应由应用决定安全文件名和目标位置。
iter_attachments 根据邮件结构跳过正文候选,并非仅做 filename 是否存在的筛选。更复杂的邮件还可能包含 related 图片、嵌套转发或不规范的头字段,因此应先用样本打印树形结构,再核验业务需要的选择结果。本文的断言证明这封标准构造样本的解析行为,不能把它扩展为所有历史邮件都恰好有一个正文和一个附件。
如果需要对接归档系统,可以先为这份样本记录三个验收结果:选择出的正文类型、附件文件名列表、每个附件的解码后内容。然后逐一增加只有纯文本、只有网页正文、没有附件的样本,每次只改变一个结构条件。这样某次改动导致正文重复或附件消失时,可以定位到具体消息形态。直接拿一封很复杂的邮件调试,往往难以分清问题来自结构选择、字符解码还是后续展示。


