去年年底我接到一个很不起眼的需求:每天凌晨跑完数据统计后,把结果整理成报表,自动发到几个同事邮箱。需求听上去不难,但真正动手用Python做SMTP发送邮件信息时,我才发现整条链路里藏着不少容易被忽略的细节。从协议本身到编码方式、从端口选择到服务商的安全策略,任何一个环节没处理对,脚本就可能在一个不起眼的角落悄悄报错。这篇文章是我把这套发信工具从零写到能上线的完整记录,适合刚接触Python网络编程的读者,也适合想把监控告警、报表分发这类重复发信任务自动化的人。
1. 先弄懂邮件投递链路,再谈Python发信
1.1 一封邮件从发件人到收件箱,实际要经过几个角色
在动手写代码之前,我花了一点时间把邮件发送链路补了一遍。一封邮件从发件人电脑到收件人邮箱,至少要经过三个角色:发件客户端、发件服务器、收件服务器。我们的Python脚本扮演的是发件客户端,负责把邮件消息递交给发件方服务器;后续的路由、中转、投递和存取,都是邮件服务商的事。所以脚本能做的只是“把这封邮件完整地交出去”,至于对方服务器收不收、进的是收件箱还是垃圾箱,SMTP协议本身并不保证。
SMTP是应用层协议,最初主要跑在25端口上,后来为了防垃圾邮件和安全需求,逐步加入了认证和加密机制,于是有了587和465这两个常用端口。整个发送过程可以想象成一次需要出示身份证明的投递:脚本和服务器建立TCP连接后,先向服务器自报家门,完成登录认证,再用两条命令分别说明发件人和收件人,最后把整封邮件内容通过DATA命令交出去。这一串交互很像两个人聊天,每发一条命令服务器都会返回一个状态码,smtplib只是把这些底层动作封装成了方法。
我建议刚开始不要急着照抄代码,而是先找一次真实发送过程用抓包工具看一眼。不需要完全看懂每一条数据,只需要体会“发一封信会在TCP连接上产生多轮命令和响应”。这样后面遇到抽象错误时,就能快速知道是哪个阶段出了问题。比如常见的认证失败报错,其实发生在登录阶段,如果你能对着命令顺序理解,就不会去DATA阶段找问题。
1.2 smtplib和email为什么是两个库,分别负责什么
Python标准库里和发邮件相关的核心模块有两个:smtplib和email。它们是两个层面的东西。smtplib管的是“怎么把消息交出去”,负责TCP连接、TLS加密、登录认证、发信命令这些传输层动作;email管的是“消息长什么样”,负责把邮件头、正文、附件组织成符合邮件格式规范的结构。说得直白一点,smtplib是快递员,email是打包员,正常流程是先用email打包,再叫smtplib来取走。
很多新手教程习惯同时导入这两个模块,却很少解释原因。我见过有人只用smtplib发送,直接在sendmail的msg参数里传一句“subject: hi\nthis is body”,结果标题和正文都没被正确解析,因为没有邮件头结构。也有人只构造了email的消息对象,却一直在本地循环里打印,压根没连接服务器。记住这个分工,后面所有示例你都不会看晕:email模块负责产出msg.as_string(),smtplib负责把它sendmail出去。
1.3 为什么不用HTTP或者别的协议,非要走SMTP
你可能会问,调用一个HTTP接口也能触达邮件服务商,为什么还要用SMTP?答案很直接:SMTP是邮件递交的通用语言,任何一家标准邮件服务商都支持,不需要绑定某个厂商特有的接口;而且邮箱的常规客户端入口普遍就是SMTP、IMAP、POP3,额外推送类的接口往往要申请权限。用SMTP写一次,将来换到另一家邮箱服务商,最多改改服务器地址、端口和账号密码,整体逻辑完全不用重写。
换句话说,SMTP带来的不是花哨能力,而是最大程度的可移植性。对绝大多数报表通知、告警推送场景来说,你根本不需要复杂的接口文档,只需要一个邮箱账号、一个授权码、一段几十行的Python代码,就能把消息稳定送出去。这也是为什么我给自己的自动化任务选发信通道时,第一选择永远是SMTP。
2. 最小可用的文本邮件Demo:跑通一次SMTP发送
2.1 写代码前必须确认的三件事
在写代码之前,需要先把三件事确认好:发件邮箱已经开启SMTP服务;手里有一串可用的授权码;知道SMTP服务器地址和端口。绝大多数邮箱服务商都在设置页面里提供了SMTP开关,开启后一般会生成一段授权码。授权码通常和网页登录密码不同,是专门给第三方客户端使用的凭据。我第一次做的时候以为直接用登录密码就行,结果一直报认证失败,后来才发现很多服务商默认禁止密码方式登录第三方客户端。
服务器地址一般是“smtp.你的邮箱服务商域名”这种格式,端口则常见为465或587。如果只是开发测试,建议选一个长期会用的邮箱,并且把授权码妥善保管。授权码一旦在网页端重置,脚本里的旧码就会全部失效。这个信息不要写死在代码里,我建议从环境变量读取,既避免误提交,也方便在多个环境之间切换。
2.2 第一版发送代码与逐行拆解
下面这段代码是最小可用的纯文本邮件发送示例,所有服务器地址和邮箱都是占位符,替换成你自己的信息即可运行。
import smtplib import os from email.mime.text import MIMEText smtp_host = os.getenv("SMTP_HOST", "smtp.example.com") smtp_port = int(os.getenv("SMTP_PORT", "465")) sender = os.getenv("SMTP_USER", "report_sender@example.com") auth_code = os.getenv("SMTP_AUTH_CODE", "") if not auth_code: raise RuntimeError("请先设置SMTP_AUTH_CODE环境变量") receiver = "boss@example.com" msg = MIMEText("这是来自Python脚本的测试邮件。", "plain", "utf-8") msg["From"] = "Report Sender <report_sender@example.com>" msg["To"] = "boss@example.com" msg["Subject"] = "Python SMTP测试邮件" server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) try: server.login(sender, auth_code) server.sendmail(sender, [receiver], msg.as_string()) print("邮件已提交") finally: server.quit()这段代码有几个值得注意的点。MIMEText的第二个参数是内容类型,第三个参数声明字符集为utf-8;把中文内容塞进MIMEText时,这个charset决定了邮件正文的编码方式。msg["From"]里的显示名可以不等于login的账号,但不少服务商会检查信封发件人和登录账号是否一致,为了避免退信风险,建议保持两者一致。sendmail的第二个参数需要传列表,因为收件人可能不止一个;千万不要传字符串,某些版本的smtplib会因此表现得很奇怪。
另外,SMTP_SSL表示建立连接后直接走TLS加密,对应的是465端口。如果你习惯先普通连接再升级加密,请参考第4章中587端口那部分,两种写法不能混用。timeout=10是连接超时时间,不设置的话,网络异常时脚本可能卡住非常久才报错。
2.3 第一次运行时可能遇到的三个小情况
第一次运行,最可能遇到的就是认证失败。报错信息通常类似SMTPAuthenticationError,优先检查三件事:授权码是否抄对、SMTP服务开关是否真的生效、账号是否开启了额外的多因素认证。大部分认证问题不是代码问题,而是凭据问题。
第二个常见情况是证书校验失败。这类报错通常是本地系统时间不准,或者服务器证书链不完整导致的。个人测试时可以通过关闭校验来临时跳过,但生产环境千万不要这样做,正常手段是让运行环境信任服务商提供的根证书。
第三个情况是发送成功但对方没收到。第一步不是改代码,而是去垃圾箱看一眼。新邮箱账号频繁发信,很容易被对方服务商当作可疑行为。如果只是开发阶段的测试邮件,这个现象尤其明显。不要一上来就怀疑加密逻辑,先确认邮件到底送没送到。
3. 真实业务里的MIME组装:HTML正文、附件与中文编码
3.1 为什么业务邮件不能只用一段纯文本
纯文本能跑通,但真实业务场景里,邮件往往要有标题样式、表格、附件。你当然可以把一段带HTML标签的文本塞进MIMEText,但这有两个问题:一是邮件客户端解析纯文本时不会渲染HTML标签,兼容性很差;二是一个邮件对象很难同时表达“正文”和“附件”两个独立部分。MIME就是来解决这个问题。
MIME的全称是多用途互联网邮件扩展,它可以像打包快递一样,把多个独立部分装进同一个邮件对象,每个部分有自己的Content-Type,比如text/plain、text/html、application/pdf。我有一段时间为了省事,只用MIMEText把CSV内容塞进正文,结果别人既不方便看也不方便下载。后来改成附件方案,才意识到正文和附件分开的意义:正文适合快速阅读,附件适合程序解读。这也是为什么很多自动报表邮件既有几句摘要,又带一个csv附件。
3.2 用MIMEMultipart组装HTML正文和附件
这里演示一个典型场景:发送一封订单日报,正文是HTML表格,同时附带一个csv文件。
import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.application import MIMEApplication msg = MIMEMultipart() msg["From"] = "Report Sender <report_sender@example.com>" msg["To"] = "boss@example.com" msg["Subject"] = "订单日报" html = """<html><body> <h2>订单日报</h2> <table border="1"> <tr><th>状态</th><th>数量</th></tr> <tr><td>已完成</td><td>120</td></tr> <tr><td>处理中</td><td>8</td></tr> </table> </body></html>""" msg.attach(MIMEText(html, "html", "utf-8")) with open("order_report.csv", "rb") as fp: attachment = MIMEApplication(fp.read(), _subtype="csv") attachment.add_header("Content-Disposition", "attachment", filename="order_report.csv") msg.attach(attachment) with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) as server: server.login(sender, auth_code) server.sendmail(sender, [receiver], msg.as_string())关键变化是外层从MIMEText变成了MIMEMultipart。在multipart内部,用attach方法不断装入独立part。最顶层的Content-Type会变成multipart/mixed,邮件客户端看到这个类型就会自动拆解各个部分。如果只需要一封HTML邮件,其实可以直接用MIMEText(html, "html", "utf-8"),没必要包一层multipart;包了反而可能让一些老旧客户端解析出问题。
对于附件,我一般用MIMEApplication,因为它不像MIMEText那样假定内容是文本,读取字节后指定子类型即可。CSV对应subtype="csv",PDF对应"pdf",图片通常用专门的MIMEImage类。如果遇到不常见的类型,比如新版Excel文件,需要写很长的子类型名,最好先把邮件发给自己测试一遍,再发送给业务方。
3.3 中文昵称和中文文件名怎么处理
上面的代码用了英文昵称Report Sender,是因为邮件头字段如果直接放中文,需要按邮件规范做编码。标准库在设置msg["From"]时通常能自动处理,但为了稳妥,我推荐用email.utils里的formataddr来构造带中文显示名的发件人地址。
from email.utils import formataddr msg["From"] = formataddr(("日报机器人", "report_sender@example.com"))formataddr返回的是已经编码好的字符串,邮件客户端会正确还原成中文显示名。中文文件名也有类似问题:Content-Disposition头里的filename参数如果不处理,部分邮件客户端可能显示成乱码。在Python 3.6及以上版本中,很多情况标准库会自动编码,但为了兼容不同客户端,遇到中文文件名最好显式声明编码方式。我的习惯是能避开就避开,附件命名尽量用英文加日期,比如order_report_20250601.csv,既能减少编码问题,也让文件名在各类客户端里表现稳定。
3.4 标题和正文的乱码机制并不一样
为什么有时候标题中文正常,正文却乱码?因为邮件头和邮件正文走的是两套机制。邮件头里的非ASCII文本要做MIME编码,邮件正文则由Content-Type里的charset声明字符集。标题乱码通常是Header编码没做好,正文乱码通常是charset写错或者实际内容根本不是UTF-8。
我的排查方法很简单:把msg.as_string()输出到文件,用文本编辑器打开检查。如果看到类似=?utf-8?B?...?=的编码串,那是邮件头正常的Base64编码,不是乱码;如果看到UTF-8字节被按别的编码解读而产生的乱码,那就要检查MIMEText的charset参数。还有一个容易被忽略的点:HTML邮件里的CSS兼容性。不同邮件客户端对CSS的支持差异很大,如果邮件要发给客户,最好先发到一个覆盖多种客户端的测试列表里检查,而不是只在自己常用的客户端里看一眼就发布。
4. 高频踩坑现场:认证失败、端口混淆、超时限流的排查链路
4.1 认证失败的常见原因和排查顺序
脚本如果一直稳定运行,某天突然开始报认证失败,不要把问题全部堆在代码上。按照我这个顺序排查:先确认授权码是否被重置;再确认账号是否因为异地登录、安全风控之类的原因被临时锁定;然后确认服务器地址有没有被填错或者指向了别的主机;最后再用抓包工具看认证阶段服务器具体返回了什么。大部分时候,问题出在凭据和账号状态,代码不需要动。
有一个经典误操作是登录密码和授权码混用。某些服务商鼓励用户使用授权码,但SMTP认证阶段真正用的是授权码,不是网页登录密码。两者一旦搞反,报错信息看起来几乎一样。如果你通过环境变量管理凭据,还要注意脚本进程是否还在用旧的环境变量值。我自己就吃过这个亏:改完授权码后忘了重启定时任务,导致脚本一直拿旧的授权码去登录。
4.2 465和587不是随便换的:SMTPS与STARTTLS的差异
端口混淆是新手最容易踩的坑。465端口是SMTPS,建立TCP连接后直接进行TLS握手,所以要用SMTP_SSL;587端口通常先以明文建立连接,再通过STARTTLS升级为加密通道,所以要用SMTP配合starttls。如果在587端口上用SMTP_SSL,经常会收到“非预期EOF”;反过来在465上用SMTP再starttls,又可能直接连接被拒。
| 端口 | 连接方式 | Python入口 |
|---|---|---|
| 465 | 直接TLS | smtplib.SMTP_SSL |
| 587 | 先明文再STARTTLS | smtplib.SMTP + starttls |
| 25 | 默认非加密,多数服务商已关闭 | 不建议使用 |
587端口的标准写法是这样的:
import smtplib server = smtplib.SMTP(smtp_host, 587, timeout=10) server.ehlo() server.starttls() server.ehlo() server.login(sender, auth_code) server.sendmail(sender, [receiver], msg.as_string()) server.quit()很多教程会把ehlo调用两次。第一次是为了让服务器返回支持的扩展能力,starttls之后再次ehlo,是为了在加密会话下重新协商参数。有些服务商省略第二次也能通过,但为了兼容性,我建议保留两步。
4.3 超时、限流和批量发送的重试设计
SMTP发送看起来是同步的,实际上网络抖动、服务器繁忙都可能让sendmail卡上几十秒。我的做法是给连接设置timeout,并对整段发送过程做多次重试。重试时不要无脑循环,而是采用指数退避:第一次失败等1秒,第二次等2秒,第三次等4秒。连续三次失败则记录到失败列表,不再做无意义的重复尝试。
import time import socket import smtplib last_error = None for attempt in range(3): try: with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) as server: server.login(sender, auth_code) server.sendmail(sender, [receiver], msg.as_string()) break except (smtplib.SMTPException, socket.timeout) as exc: last_error = exc time.sleep(2 ** attempt) else: raise last_error还要特别注意服务商的限流策略。同一个账号在短时间内发送大量邮件,很容易被临时限制。我之前跑过一个几千收件人的通知任务,发到一半就开始报限流错误。现在的习惯是单账号连续发送时,每封之间sleep 0.5到1秒;数量太大就拆成多个账号分时段发送,并且把失败的地址写进重试表。
4.4 被判定为垃圾邮件的自查清单
邮件提交成功并不代表一定进收件箱。根据我的经验,以下几项最容易触发过滤:发件域名是免费邮箱且从未发过信;From显示名和登录账号不一致;Subject里堆满营销类敏感词;正文只有HTML没有任何纯文本版本;收件人列表里有大量无效地址。针对这些,我通常的做法是:固定用一个只发通知的邮箱,不要多个账号轮流当发件人;正文同时提供HTML和纯文本两个版本;批量发送前先清洗地址;发送频率保持平稳。
在代码层面,可以构造双层MIME结构:外层是multipart/mixed,用来装附件;内层是multipart/alternative,用来同时表达纯文本正文和HTML正文。这样邮件客户端既能展示漂亮排版,又能在不支持HTML时回退到纯文本。结构虽然比单个MIMEText复杂一点,但这是自动发信脚本走向生产环境时值得付出的成本。
5. 从可用到好用:模板化邮件、批量发送与可审计运维
5.1 把HTML正文从代码里剥离:用模板渲染
当邮件内容开始频繁变化,把整段HTML字符串塞进代码里就是灾难。最简单的方案是使用string.Template,把动态内容用占位符标记,再用substitute填充。Python标准库自带这个能力,不需要额外依赖。如果你的模板里有循环、条件判断这类复杂逻辑,可以引入成熟的模板引擎,渲染起来更灵活。
from string import Template tpl = Template(""" <html><body> <h2>$title</h2> <p>今日新增订单:$order_count 单</p> <p>异常单:$error_count 单</p> </body></html> """) html = tpl.substitute(title="业务日报", order_count=120, error_count=3)注意substitute要求所有占位变量都必须提供,否则会抛异常;如果有些变量可能为空,用safe_substitute更温和。渲染完成后,再走MIME组装流程即可。模板化和代码分离之后,邮件改版不再需要动Python逻辑,这个收益在频繁调整报表格式时非常明显。
5.2 批量发送的正确姿势:连接复用、限速和失败登记
批量发送最忌讳在for循环里每次新建连接,这会让服务器把脚本当成恶意程序。正确做法是建立一个长连接,在连接内循环发送多封邮件。每个收件人可能对应不同正文,所以消息对象需要每轮重新构造。发送间隔保持在0.5秒以上,单封失败不要中断整个任务,把失败地址记录到列表,最后统一汇总。
failed = [] with smtplib.SMTP_SSL(smtp_host, 465, timeout=10) as server: server.login(sender, auth_code) for addr in recipient_list: try: msg = build_message(addr) server.send_message(msg) time.sleep(0.5) except smtplib.SMTPException: failed.append(addr)这里有一个容易被忽视的细节:send_message和sendmail不同。send_message会从消息对象里自动解析To、From等头部,作为信封收件人;sendmail则需要手动传入发件人和收件人地址列表。如果你使用email.message.EmailMessage这种新式消息对象,推荐用send_message,它能正确处理包含中文昵称的地址。如果继续用sendmail,就需要自己从消息头里取地址,这会让代码多一层出错的机会。
5.3 调试利器:把消息对象落盘成.eml文件再检查
遇到诡异问题,与其在代码里反复print,不如把消息对象原样保存成.eml文件,再用任意邮件客户端打开。保存只需要一行:
with open("debug.eml", "w", encoding="utf-8") as f: f.write(msg.as_string())如果客户端打开eml后内容和预期一致,说明问题出在传输阶段;如果打开就已经不对了,说明消息构造有误。有一次我以为是附件没加进去,结果检查eml文件时附件正常,真正原因是服务商对附件大小或者文件类型做了限制。这种排查路径比反复猜测高效得多。
另外建议给发信脚本配上日志。不要随手print,用logging模块按级别记录:INFO记录每次发送的目标,ERROR记录异常堆栈。邮件发送属于可重复任务,日志越结构化,后续报警和追溯越容易。我自己会为每天的任务单独留一份日志,文件名带上日期,方便回溯。
5.4 凭据管理的三个实操原则
日常运维里最大的安全隐患是邮箱凭据被提交进版本控制。我见过不止一次,有人在配置文件里写明文授权码,然后整个项目被推到共享仓库,过几天邮箱就开始收到陌生登录提醒。解决方式很简单:授权码从环境变量读取;本地开发用不进版本库的.env文件;生产环境放在配置中心或密钥管理服务里;脚本启动时校验必填项是否齐全,缺失就直接报错退出。
除了凭据管理,还有两个容易忽视的体验问题。自动邮件正文里最好带上生成时间和数据来源,否则收件人看到邮件不知道数据是什么时候的;如果发送失败,也要有通道让负责人知道,不能只靠脚本退出码,因为没人会一直盯着定时任务。这几条做完,这套SMTP发送工具才算从“能发”变成“能长期用”。
我在实际项目中还有一个习惯:把发信逻辑封装成一个独立的函数,输入收件人、主题、正文模板和附件路径,返回发送结果和失败原因。这样不管是接在定时任务里,还是临时在调试脚本中调用,都很顺手。等到哪天需要增加新的报表或告警通知,只需要写一份模板、调一次函数,不用再把整套SMTP流程重新踩一遍。