指南 · Python 3 SMTP
产品团队应如何安全地实现 Python 3 SMTP 发信?
应在经过授权的服务端后台任务中实现 Python 3 SMTP 发信,而不是在浏览器或用户可控的代码中。使用 EmailMessage 构建邮件,将信封收件人与可见的邮件头分开,创建经过验证的 SSL 上下文,设置有限的连接超时,并使用 SMTP_SSL 从连接一开始就启用 TLS,或者使用 SMTP.starttls() 后再发送 EHLO 进行显式升级。从机密管理器加载凭据,调用 send_message(),检查被拒收件人的结果,并持久化保存该次尝试的确切结果。只对暂时性失败进行有上限的退避重试,绝不要把 SMTP 受理当作进入收件箱的证明。
定义一个经过授权的邮件操作
从一个经过批准的产品事件开始,例如账户验证、收据、用户请求的提醒或安全通知。在打开 SMTP 连接之前,先存储一个持久化的出站任务。该任务应包含稳定的业务事件键、租户、邮件类别、模板版本、经过批准的信封发件人和收件人、可见的 From 身份、许可或必要性依据,以及当前的抑制检查结果。浏览器、移动端、模板和用户输入都不得选择 SMTP 主机、凭据、信封发件人、任意邮件头或不受限制的收件人。对调用方和租户进行授权,校验地址,限制收件人和附件的数量,并防止换行符注入。每个任务只认领一次,并保留一份只追加的尝试历史。Python 的 smtplib 是一个协议客户端;它不提供业务层面的幂等性、租户隔离、许可、抑制或持久化队列。这些控制属于围绕它的应用层。
使用 EmailMessage 构建结构化邮件
使用 email.message.EmailMessage,而不是拼接邮件头和 MIME 字符串。用经过校验的值设置 From、To、Subject 以及一个稳定的应用关联邮件头,然后调用 set_content 设置纯文本,需要时用 add_alternative 添加 HTML 部分,并且只对明确支持的文件类型和大小使用 add_attachment。从同一个经过批准的模板版本同时生成纯文本和 HTML。根据输出上下文对不可信的值进行转义,避免渲染用户提供的原始 HTML。不要在邮件头、主题、追踪字段或附件名称中放入机密信息、访问令牌、不必要的个人数据或内部数据库键。email 包会按照其策略进行序列化,并可能在展平时生成 MIME 边界,因此,如果之后的完整性控制依赖于确切的字节,请对最终序列化的结果进行签名或计算哈希。将 SMTP 信封分开处理:可见的 To 和 Cc 邮件头是给读者看的,而传输层的收件人列表决定了 RCPT TO 命令。
有意识地在隐式 TLS 和 STARTTLS 之间作出选择
当服务器要求从连接一开始就使用 TLS 时,使用 SMTP_SSL。只有当服务器文档规定的流程要求立即进行 STARTTLS 升级时,才使用 SMTP 建立明文连接。Python 的 smtplib 文档说明,starttls 会让后续的 SMTP 命令在 TLS 中传输,并且客户端之后应再次调用 ehlo。绝不要在必需的 TLS 升级之前进行身份验证。使用 ssl.create_default_context 创建上下文,让证书校验和主机名检查采用安全的客户端默认设置,并通过库的常规连接方式传入预期的服务器主机名。在要求加密的情况下,把不支持 STARTTLS、证书校验失败、主机名不匹配或 TLS 协商失败视为必须终止的情况。不要为了让生产环境跑通而禁用校验或改用未经验证的上下文。逐跳 TLS 保护的是 SMTP 连接,而不是存储的邮件内容、服务商的处理过程、收件方的存储或最终的邮箱。
将凭据保留在服务端并限定其作用域
在运行时从托管的机密服务中加载 SMTP 用户名、密码或令牌。绝不要把它放在源代码管理、Docker 镜像层、提交到 Git 的配置、URL、命令行参数、调试输出、分析数据、异常报告、测试快照、笔记本、工单或提示词中。优先使用限定于单个环境、发件域名或允许的工作负载的凭据,而不是账户级的管理员机密。将生产环境与开发环境和 CI 分开。让轮换成为例行工作:创建替换凭据,更新后台任务,运行一次受控的投递测试,确认身份验证和结果证据,然后吊销旧凭据。将机密的访问权限限定于发信进程,并审计管理员的读取操作。Python 的 login 方法会尝试服务器公布的身份验证机制;应用仍然必须决定服务器、连接安全性、账户和机制是否可以接受。连续的身份验证失败应当暂停相关批次并触发排查,而不是快速重试密码。
使用明确的超时和有上限的连接生命周期
向 SMTP 或 SMTP_SSL 传入有限的 timeout,这样连接和阻塞操作就不会无限期占用后台任务。另外设置外层的任务截止时间和取消策略,因为单个套接字超时并不能完整地控制队列时长。除非访问是串行的且其状态经证明是安全的,否则不要在并发任务之间共享同一个 SMTP 对象。一种简单的设计是:为一批数量有限的邮件打开一个连接,向服务器问候,在需要时建立 TLS,进行身份验证,提交少量邮件,调用 quit,并在出错或达到时长上限后丢弃该连接。复用连接可以减少开销,但在服务器断开、超时或状态只完成一部分之后,会增加结果的不确定性。限制每个连接发送的邮件数,并有计划地重新连接。监控连接延迟、TLS 协商、身份验证、命令延迟、服务器断开和任务时长,但不要记录凭据或邮件内容。SMTP 服务器可能会施加独立于 Python 而变化的限制。
发送一封邮件并保留部分收件人的结果
SMTP.sendmail 使用 from_addr 和 to_addrs 作为传输信封,不会改写邮件头。SMTP.send_message 会序列化一个 EmailMessage,并在未显式提供信封值时推导默认值。在生产代码中,请显式传入经过批准的信封发件人和收件人列表,这样 Bcc 的处理和租户授权都不会有歧义。Python 的文档说明,只要至少有一个收件人被受理,sendmail 就会正常返回,并返回一个字典,列出每个被拒绝的收件人。因此,没有抛出异常并不等于所有收件人都成功。请分别存储已受理和被拒绝的收件人范围,包括状态码和经过脱敏的诊断信息。当只有部分收件人被拒绝时,不要对已受理的收件人重试。在保留共享的邮件尝试记录的同时,把每个收件人视为一个独立授权的结果。之后在 DATA 阶段出现的异常与 RCPT 被拒不同,需要单独分类。
按阶段和持久性对异常进行分类
显式处理 smtplib 的异常,并保留其中的 SMTP 状态码和经过脱敏的服务器消息。SMTPConnectError 和超时可能是暂时性的,但也可能暴露出主机、端口错误、防火墙问题或服务中断。在 STARTTLS 或 SMTPUTF8 之后出现 SMTPNotSupportedError 时,应停止使用要求该功能的配置。SMTPAuthenticationError 需要排查凭据、账户、机制和 TLS,而不是盲目重试。SMTPSenderRefused 和 SMTPRecipientsRefused 需要分别在身份或收件人范围内作出决定。SMTPDataError 表示 DATA 阶段收到了意外的响应,根据增强状态码的不同,可能代表内容、策略、配额问题,或收件方的暂时性行为。将 4xx 响应归类为可进行有上限重试的候选,将 5xx 归类为该次尝试的永久性失败,同时遵循服务商特有的文档说明。使用指数退避、随机抖动、尝试次数和队列时长上限,以及死信状态。在出现抑制、投诉、退订、授权被撤销或收件人无效的证据后,绝不要重试。
核对结果不明确的提交
如果网络超时或断开发生在客户端已经发送邮件数据之后、看到服务器的最终回复之前,结果就是不明确的。即使 Python 抛出了异常,服务器也可能已经接受了投递责任。不要立即创建一个新的逻辑发送。将该尝试标记为未知状态,保留其稳定的事件和追踪标识符,并在可用时查询服务商日志或之后的投递事件。如果 SMTP 服务不提供幂等性或可搜索的关联信息,请根据邮件类别、时效、重复发送的危害和用户体验制定产品层面的决策。安全提醒和密码重置邮件的重复风险与收据或财务通知不同。在记录中保留原始尝试和任何重试的关联。绝不要声称“恰好一次”投递,因为 SMTP 并不提供端到端的这种保证。使用一个受控的服务器测试夹具来测试这一分支,让它在每个协议阶段(包括 DATA 被受理之前和之后)断开连接。
将 SMTP 受理与投递和互动区分开来
send_message 调用成功,表示按照 Python 的文档语义,在所观察的 SMTP 阶段至少有一个收件人被受理。它并不能证明每个收件人都被受理、目标服务器之后保留了这封邮件、邮件进入了收件箱文件夹,或者有人阅读了它。请将服务商提交、收件服务器受理、暂时性或永久性失败、之后的退信、投诉、退订、邮件所在位置和互动作为彼此独立的证据来建模。在可用时接收经过身份验证的服务商事件,对其去重,并将事件发生时间与处理时间分开保存。在之后的发送之前,立即执行永久性退信、投诉和退订的限制。打开和点击不是传输层面的证明,而且可能受到隐私保护技术的影响。按租户安全的群体、模板版本、发件域名、状态类别和时间保留尽量少含隐私信息的汇总指标。针对拒收激增、未知结果、队列时长、TLS 失败、身份验证失败以及异常的收件人扩散设置告警。
在本地测试,不向真实客户发送邮件
对邮件构建、拒绝邮件头注入、收件人授权、移除 Bcc、纯文本和 HTML 版本、Unicode 处理、附件边界和抑制检查进行单元测试。使用受控的本地 SMTP 测试服务器或协议夹具,模拟问候失败、缺少 STARTTLS、证书失败、身份验证错误、部分 RCPT 受理、DATA 4xx 和 5xx 回复、断开连接和延迟响应。不要将已弃用的未验证调试服务用于生产形态的机密信息或客户内容。集成测试应使用专用账户和受控收件人,并有明确配额和清理措施。验证收到的原始邮件、身份验证结果、可见邮件头、回复行为和事件关联。对夹具和日志运行机密扫描。
SendHQ 的适用场景
SendHQ 文档说明其提供限定工作区作用域的邮件 API,用于已验证域名发送、投递事件和抑制记录。本指南介绍 Python 标准库 SMTP 客户端;有关其当前的集成方式和 API 的接口约定,请使用 SendHQ 文档。
常见问题
Python SMTP 凭据可以放在客户端代码中吗?
不可以。将其保存在服务端的机密管理器中,限定较窄的环境和工作负载范围,访问有审计,定期轮换,并且不写入日志。
Python 什么时候应该使用 SMTP_SSL?
当需要从连接一开始就使用 TLS 时,使用 SMTP_SSL。只有在文档规定的、以失败关闭(fail closed)的显式升级流程中,才使用 SMTP 加 starttls。
在 starttls 之后需要再次调用 EHLO 吗?
需要。Python 的 smtplib 文档说明,应在 starttls 之后再次调用 ehlo,以便在受保护的连接中重新获取服务器能力。
send_message 成功是否意味着每个收件人都被受理了?
不是。只要至少有一个收件人被受理,Python 就可能正常返回,并单独返回被拒绝的收件人。请分别持久化和处理每个收件人的结果。
出现 SMTPAuthenticationError 之后应该怎么做?
暂停受影响的配置,并检查 TLS、服务器、账户、机密信息和服务器公布的身份验证机制。盲目重试凭据可能会加剧账户锁定或触发账户被盗的警报信号。
每个 SMTPDataError 都应该重试吗?
不应该。保留确切的状态码和诊断信息,然后区分暂时性的 4xx 情况与永久性的 5xx 策略、内容、配额或配置失败。
SMTP 受理是否决定邮件能否进入收件箱?
不决定。它只是有明确范围的传输证据。之后的中继、收件方过滤、退信、邮箱规则、所在文件夹以及收件人的互动都是彼此独立的结果。
本指南涵盖 SendHQ 特定集成吗?
不能。本指南介绍 Python 标准库 SMTP 客户端。有关 SendHQ 当前的集成方式和 API 的接口约定,请参阅 SendHQ 文档。
参考来源
- Python 3 smtplib 文档 — Python Software Foundation
- Python 3 EmailMessage 文档 — Python Software Foundation
- Python 3 ssl 文档 — Python Software Foundation
- RFC 5321:简单邮件传输协议(SMTP) — RFC Editor
- RFC 3207:基于 TLS 的安全 SMTP 服务扩展 — RFC Editor
- RFC 4954:SMTP Service Extension for Authentication(SMTP 身份验证服务扩展) — RFC Editor