如何在 Python 中不重命名文件,发送多个文件名完全相同的邮件附件?
结论
Python 可以在一封邮件中添加多个 filename 完全相同的附件。EmailMessage.add_attachment() 不会因文件名相同而覆盖前一个附件。
发送端没有标准 MIME 头可以强制 Gmail 等客户端显示所有附件。如果界面上只看到一个附件,需要先检查收件端的原始邮件,才能判断原因,不能仅凭界面认定 Gmail 按文件名覆盖了文件。
加入零宽字符也不符合文件名完全相同的要求:filename 的实际值已经改变,只是看起来相似。
为什么同名附件可以共存
邮件中的每个附件都是独立的 MIME 部分。多次调用 add_attachment() 时,Python 会分别创建新的部分,放进 multipart/mixed 容器,不会把文件名当作字典键。Python 文档:add_attachment()
Content-Disposition 中的 filename 是建议保存时使用的文件名,不是附件的唯一标识。标准允许收件客户端根据本地文件系统规则调整保存名称。因此,发送端可以保留同名附件,但无法保证客户端如何展示它们,也无法保证最终下载名称。RFC 2183,第 2.3 节
源文件夹信息不会自动附在附件中。发送下面两个文件后,收件端通常只能得到文件名 report.pdf,无法知道每份文件来自哪个源目录:
folder1/report.pdf
folder2/report.pdf
先确认两个附件都写进了邮件
可以保留原来的循环,按正确的缩进添加附件,并设置正文、检查本地生成的邮件。示例中的文件是 PDF,使用 application/pdf。原先的 application/octet-stream 也能承载这些文件,修改类型并不能解决附件显示问题。
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from hashlib import sha256
from pathlib import Path
import aiosmtplib
files_to_attach = [
{"path": "folder1/report.pdf", "name": "report.pdf"},
{"path": "folder2/report.pdf", "name": "report.pdf"},
]
msg = EmailMessage()
msg["Subject"] = "Reports"
msg["From"] = "sender@gmail.com"
msg["To"] = "receiver@gmail.com"
msg.set_content("附件包含两份同名报告。")
for file in files_to_attach:
msg.add_attachment(
Path(file["path"]).read_bytes(),
maintype="application",
subtype="pdf",
filename=file["name"],
)
# 序列化后重新解析,检查生成的 MIME 数据。
raw = msg.as_bytes(policy=policy.SMTP)
parsed = BytesParser(policy=policy.default).parsebytes(raw)
attachments = list(parsed.iter_attachments())
assert len(attachments) == len(files_to_attach)
for index, part in enumerate(attachments, start=1):
data = part.get_payload(decode=True)
print(
index,
part.get_filename(),
len(data),
sha256(data).hexdigest(),
)
async def send_reports(**smtp_options):
return await aiosmtplib.send(msg, **smtp_options)
在现有异步流程中调用 await send_reports(**smtp_options),沿用已配置好的 SMTP 连接和认证参数即可。aiosmtplib.send() 可以直接接收 EmailMessage 对象。aiosmtplib 发送文档
这里的断言只检查本地生成的邮件。打印出的大小和 SHA-256 摘要可用于后续核对内容。即使两个摘要相同,也只能说明两份附件的内容相同,不能说明邮件中只有一个附件。
再检查 Gmail 收到的原始邮件
在 Gmail 网页版打开具体邮件,点击“回复”旁的“更多操作”,选择“显示原始邮件”。入口可参考 Gmail 帮助。
检查完整的 MIME 内容,确认是否有两个独立的附件部分,且各自包含附件头和数据。页面顶部的邮件标头摘要不足以判断附件数量。
按检查结果继续排查:
- 本地生成的邮件只有一个附件:检查实际执行的循环、文件列表,以及后续代码是否重建了
msg。 - 本地有两个附件,收件端原始邮件只有一个:检查发送代码和中间的邮件处理环节,不能直接归因于 Gmail 界面。
- 收件端原始邮件有两个附件,界面只显示一个:两个附件的数据仍在邮件中,应检查该客户端的解析或展示行为。
如果能下载收件端的原始 .eml 文件,可以用 BytesParser 重新解析,比较附件数量、解码后的大小和摘要。复杂邮件可能包含嵌套结构,需要用 walk() 遍历所有部分,再按 part.is_attachment() 筛选。
Content-ID 能解决吗?
Content-ID 可以唯一标识 MIME 实体,适合需要引用某个邮件部分的场景,但标准并未赋予它强制客户端显示附件的作用。给两份附件设置不同的 Content-ID,无法保证 Gmail 会显示两张附件卡片,因此不能把它当作通用修复方案。RFC 2045,第 7 节
Content-Description 可以补充描述,也无法保证客户端会显示描述,或据此区分同名附件。这些头字段需要经过收件端验证,才能判断是否适用于当前场景。
必须保留原文件名时,可以怎样交付?
如果必须保留两份独立附件及其相同的 filename,可以继续按上述方式构造邮件。客户端是否完整显示它们,发送端无法保证。
如果允许收件人下载后再解压,可以把文件放进 ZIP 的不同目录。两份文件的基本文件名仍然都是 report.pdf,通过目录区分来源:
reports.zip
├── source_1/
│ └── report.pdf
└── source_2/
└── report.pdf
使用 ZIP 时,将原来的附件循环替换为:
from io import BytesIO
from zipfile import ZIP_DEFLATED, ZipFile
buffer = BytesIO()
with ZipFile(buffer, mode="w", compression=ZIP_DEFLATED) as archive:
for index, file in enumerate(files_to_attach, start=1):
archive.writestr(
f"source_{index}/{file['name']}",
Path(file["path"]).read_bytes(),
)
msg.add_attachment(
buffer.getvalue(),
maintype="application",
subtype="zip",
filename="reports.zip",
)
writestr() 可以分别指定归档内路径和文件内容,无须重命名磁盘上的源文件。Python 文档:ZipFile.writestr()
邮件中会包含一个 ZIP 附件,收件人解压后可在不同目录中获得两个同名文件。如果要求它们作为两份独立附件直接显示,目前没有可跨客户端保证效果的 MIME 开关。
备注:内容仅供参考。
