Python SMTP 中继投递:587 的 STARTTLS 还是 465 的 TLS?
587 的 STARTTLS 和 465 的隐式 TLS 都能承载 Python 投递,但只有一种与你的中继匹配。比较握手方式与失败模式。
两种连接方式都把同一封邮件送到同一个中继账户,差别在于 TLS 从哪里开始,而不在于发送什么。一种以明文开场,在连接内升级;另一种从第一个字节起就加密。正是这一点差别,决定了严格的中继策略会接受什么、中间设备能观察到什么,以及你将花一下午读哪条错误信息。当私有网络中的应用把邮件交给内部投递服务时,我这样在两者之间做选择。
两种方式真正分歧的地方
两种情况下任务是相同的:以已批准的账户通过认证、声明允许的发件人、列出收件人,并等待中继承担责任。变化的是操作顺序。587 端口以明文 SMTP 会话开始,在 EHLO 响应中通告 STARTTLS,只有客户端请求时才升级。465 端口则在说出任何 SMTP 命令之前就要求 TLS 握手。
投递和中继是两件不同的工作,所以端口号并不是可以互换的习惯。RFC 6409 把邮件投递描述为一项独立的服务,RFC 8314 则涵盖现代中继大多在 465 端口提供的隐式 TLS 变体。如果你的中继只记录了其中一种,那份文档就优先于你在上一家公司养成的任何偏好。
587 上的 STARTTLS 给你什么
升级发生在 SMTP 会话内部,所以中继可以通告它,而客户端可以拒绝在缺少它时继续。在 Python 中,当服务器不提供该扩展时 starttls() 会抛出异常,这把降级企图变成可见的失败,而不是静默的明文登录。这一行为正是该模式的主要理由,值得有意识地依赖,而不是碰巧依赖。
暴露窗口很小但真实:初始 EHLO 交换是可读的,升级之前不携带任何凭据。在应用子网与内部中继之间的私有路径上,这个窗口通常可以接受。在你无法控制的路径上,这正是有些团队坚持另一种模式的原因。
剥离 STARTTLS 能力的拦截设备会造成投递失败,而不是回退。这正是你想要的结果,也是发现路径上有东西在改写 SMTP 的最快方式。
465 上的隐式 TLS 改变了什么
完全不存在明文阶段。套接字一打开客户端就进行 TLS 握手,用自己的信任库验证证书,然后才交换 EHLO、AUTH 和邮件。没有任何东西能降级会话,因为根本不存在可协商的明文状态,这消除了一整类拦截故障。
换来的是可诊断性。在 465 上以明文 SMTP 应答的中继,或者用错误证书名终止 TLS的负载均衡器,会在握手处失败,给你 SSL 错误而不是邮件错误。发生这种情况时,问题在应用之下,再怎么构造邮件也修不好。
一个投递函数,两种传输方式
两种模式之间代码几乎相同,所以选择应当来自中继策略,而不是库的便利。先导出设置,再运行一个按配置端口选择传输方式的脚本。
export SMTP_HOST=smtp.relay.internal export SMTP_PORT=587 export SMTP_USER=svc-notify export [email protected] export [email protected] export SMTP_PASSWORD="$(cat /run/secrets/smtp_password)"
import os, smtplib, ssl
from email.message import EmailMessage
ctx = ssl.create_default_context()
msg = EmailMessage()
msg["From"] = os.environ["MAIL_FROM"]
msg["To"] = os.environ["MAIL_TEST_TO"]
msg["Subject"] = "relay submission test"
msg.set_content("One authorized test message from the app.")
host = os.environ["SMTP_HOST"]
port = int(os.environ.get("SMTP_PORT", "587"))
if port == 465:
client = smtplib.SMTP_SSL(host, port, context=ctx, timeout=15)
else:
client = smtplib.SMTP(host, port, timeout=15)
client.ehlo()
client.starttls(context=ctx)
client.ehlo()
with client:
client.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
refused = client.send_message(msg)
print("refused:", refused)
create_default_context() 验证证书链和主机名,所以私有 CA 必须安装到信任库,而不是用未验证的上下文绕过。send_message() 返回中继拒收的收件人字典;空字典意味着它全部接受。十五秒超时只是起点,并不是针对你的网络调好的值。
把密码留在镜像之外、日志之外。运行时挂载并读入环境的密钥没问题;把环境回显到日志行不行,对 AUTH 交换做调试跟踪也不行。
按中继策略和路径选择,而不是按习惯
三个条件决定。第一,中继实际通告什么:只在 587 上提供 STARTTLS 的服务无法在 465 上访问,无论你多喜欢隐式 TLS。第二,应用与中继之间的路径是否足够可信,能接受可读的 EHLO 交换。第三,中间是否有东西改写或代理 SMTP,因为这决定降级风险是理论上的还是现实中的。
当中继两者都提供时,无人值守的服务我选 465,有人盯着首次部署的服务我选 587,纯粹因为失败信息指向不同层次。这个偏好是我的,不是策略。
无论选哪种,都要把端口、信任材料和发件人域名与部署一起记录下来。然后发送一封获准的测试邮件,确认中继已接受,并把投递结果与之后的投递报告作为独立记录保存。成功交给中继是应用责任的终点,并不是邮件到达收件箱的证明。