用 Python 自动收发邮件,是我这些年做办公自动化时被问得最多的需求之一。小到一个定时发送周报的脚本,大到一套监控告警系统,收发邮件永远是那个最稳、最通用的“连接器”。这篇文章我想把“发送”和“接收”两条链路完整拆开,从协议选择、代码实现到排查踩坑,一次性讲清楚。
如果你正在做自动化报表、爬虫异常通知、自动读取验证码或者工单流转,这篇文章应该能帮你省掉不少摸索时间。我会尽量少讲空话,把每一步的“为什么这么做”和“实际会遇到什么坑”都交代明白。
1. 自动收发邮件的整体设计与方案选型
1.1 动手前先拆解需求:只发、只收,还是两边都要
很多人一上来就问我“怎么写自动收发邮件的脚本”,但真实需求其实差得挺远。我建议你在写第一行代码前,先把自己到底要干什么画成一条数据流。需求大体可以分成三类:
- 单点发送:每月定时给同事发报表、网站出故障时发告警邮件。这类需求只需要解决“怎么把一封邮件准确送出去”的问题。
- 单点接收:程序去邮箱里自动读取验证码、提取附件、抓取邮件里的订单信息。这类需求重点是“怎么把邮件内容安全地解析出来”。
- 完整闭环:邮件收到后,程序自动解析、自动回复,甚至触发下一个业务动作。这类需求不仅要做收发,还要做好状态管理和异常兜底。
我以前遇到过一个真实场景:同一个项目,需求文档只写了“用 Python 自动收发邮件”,但实际做下来发现,业务方真正想要的是“每天早上自动读取客户发来的附件,解析后写入数据库,再给客户回一封确认邮件”。如果不先拆需求,直接按“写个小脚本收发测试”来设计,后面肯定要重构。
所以我的习惯是:先明确哪一端是主动方,哪一端是数据源,再决定用什么协议、要不要引入第三方库。
1.2 协议选型:为什么发送用 SMTP,接收用 IMAP 而不是 POP3
邮件收发不是 Python 特有的概念,它背后是一套互联网邮件协议。发送端和接收端用的不是同一个协议,这是很多新手第一个困惑点。
- SMTP(Simple Mail Transfer Protocol):负责把邮件从你的程序推送到邮件服务器,再转发到收件人的服务器。它的核心动作是“推送”,就像你去邮局寄信。
- IMAP(Internet Message Access Protocol):负责让你的程序访问服务器上的邮件文件夹,比如收件箱。它的核心动作是“同步”,邮件始终留在服务器上,你在手机上看了,电脑上也会同步成“已读”,就像你把信放在公共信箱里,打开一次更新一次状态。
- POP3(Post Office Protocol 3):也是接收协议,但它更像“把邮件全部下载到本地,再从服务器删掉”。一旦多个设备同时使用,状态管理就容易乱套。
用生活类比解释一下:
SMTP 像是寄信,你把信交给邮局,邮局负责送;IMAP 像是站在自家的收信箱前翻信,看没看过、删没删,服务器都记得清清楚楚;POP3 则像把信箱里的信一股脑全取走,带回家里看,看完了本地改了状态,但邮箱里的“信”可能已经不在原地了。
所以如果你要做“程序自动读取邮件”的功能,优先选 IMAP。它能在服务器端维护已读/未读状态,搜索邮件也更灵活。POP3 不是不能用,但当你需要按时间范围筛选邮件、保留服务器副本、多设备同步状态时,POP3 会非常别扭。
1.3 Python 库选型:标准库 vs 第三方库
Python 生态里收发邮件的库不少,但很多人忽视了一个事实:标准库 smtplib、imaplib、email 已经能完成 90% 的需求,而且不需要装任何第三方依赖。
第三方库确实更“友好”,比如 yagmail 能几行代码发完一封带附件的邮件,imbox 把 IMAP 的搜索和解析封得比较简单。但它们本质上是把标准库包了一层,遇到问题反而要回到底层去排查。
我的建议是分阶段选型:
- 第一次做,或者想彻底掌握原理:直接用标准库。你会被迫理解 MIME 结构、编码方式、IMAP 协议的搜索语法,这些知识是绕不开的。
- 快速交付 Demo 或者内部工具:可以用第三方库,节省开发时间。
- 生产环境:我仍然推荐标准库或基于标准库的自封装。因为收发邮件最大的成本不是写代码,而是排查各种服务商差异和编码怪癖,标准库的调试路径更透明。
后面所有代码示例,我统一用标准库来写。这样你把这些代码粘到任何一台有 Python 3 的机器上,不需要额外安装任何东西就能跑通。
2. SMTP 发送邮件:核心原理与实操要点
2.1 发送邮件的标准流程与最小代码
所有 SMTP 发送程序,本质都是四步:
- 建立到邮箱 SMTP 服务器的连接(通常是加密连接)。
- 登录认证(账号加授权码)。
- 组装一封符合邮件格式的内容。
- 调用发送接口,把邮件交给服务器。
先看一个最简示例,只发纯文本:
import smtplib from email.mime.text import MIMEText from email.header import Header SMTP_HOST = "smtp.example.com" # 你的邮箱服务商提供的SMTP服务器地址 SMTP_PORT = 465 # SSL加密端口 SMTP_USER = "your_account@example.com" SMTP_PASS = "your_auth_code" # 注意是授权码,不是登录密码 msg = MIMEText("这是一封自动化测试邮件。", "plain", "utf-8") msg["From"] = SMTP_USER msg["To"] = "target@example.com" msg["Subject"] = Header("测试邮件", "utf-8") with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=10) as server: server.login(SMTP_USER, SMTP_PASS) server.sendmail(SMTP_USER, ["target@example.com"], msg.as_string())这段代码有四个容易踩的坑:
- 端口:465 对应 SSL 加密;如果服务商支持的是 587,那你需要改用
SMTP()而不是SMTP_SSL(),然后调用.starttls()手动升级到加密通道。我见过不少人拿着 587 端口硬用SMTP_SSL,结果连接超时。 - 授权码:几乎所有主流邮箱服务商,在开启 SMTP 服务后都会给你一个专用授权码。这个授权码的作用是独立于登录密码的凭证,即使泄露也可以单独回收,不影响主密码安全。
- sendmail 的第二个参数:这是一个列表,可以同时发给多个人。如果你只传一个字符串,有些服务商也能接受,但我建议老老实实传列表。
- 超时参数:默认行为可能让你卡很久,加上
timeout=10,连接失败时能快速重试而不是干等。
2.2 MIME 与邮件结构:纯文本、HTML、附件的构造方法
刚上手的人很容易把“邮件”理解成一段字符串。实际上,一封邮件是一个结构化的容器,可能包含纯文本正文、HTML 正文、附件、内嵌图片等。这个结构化标准叫MIME(Multipurpose Internet Mail Extensions)。
我的类比是:MIME 就像快递箱。一个箱子里可以有主商品(正文),也可以塞一些小样(附件)。包裹上贴的快递单(邮件头部)写着发件人、收件人、主题。Python 的email.mime模块就是用来组装这个快递箱的。
发送 HTML 邮件,需要把纯文本和 HTML 都放进去,这样不同邮件客户端能优先生成适合自己的版本:
from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText msg = MIMEMultipart("alternative") msg["From"] = SMTP_USER msg["To"] = target msg["Subject"] = Header("周报", "utf-8") # 纯文本版本,兼容不支持HTML的客户端 text_part = MIMEText("本周数据已更新,详见报表。", "plain", "utf-8") # HTML版本,排版更丰富 html_part = MIMEText("<h3>周报</h3><p>本周数据已更新,详见报表。</p>", "html", "utf-8") msg.attach(text_part) msg.attach(html_part)加附件用MIMEBase或email.mime.application.MIMEApplication:
from email.mime.base import MIMEBase from email import encoders with open("report.xlsx", "rb") as f: part = MIMEBase("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet") part.set_payload(f.read()) encoders.encode_base64(part) part.add_header( "Content-Disposition", "attachment", filename=("utf-8", "", "周报.xlsx") # 处理中文文件名编码 ) msg.attach(part)这里有个关键点:中文附件名不要直接写在filename="周报.xlsx",不然很多客户端看到的是乱码。上面这种("utf-8", "", "周报.xlsx")的写法,是让 Python 帮你按 RFC 2231 生成编码后的文件名。
2.3 发送邮件的关键参数与授权认证
除了代码,发送前你还需要在你自己的邮箱后台做一些设置。
- 打开 SMTP 服务:大多数邮箱默认不开启第三方客户端发信功能。你要在邮箱设置的“账户安全”或“客户端服务”里找到 SMTP/IMAP 开关,手动打开。
- 生成授权码:开启后生成一个独立密码,这个密码专门给小程序或客户端登录使用,不是你的邮箱登录密码。如果程序报“用户名或密码错误”,十有八九是用了登录密码而不是授权码。
- 检查服务器地址和端口:不同服务商的地址和端口各不相同,但你不需要把地址记下来,只需要查看自己的服务商文档。常见的是
smtp.example.com配合 465 或 587。 - 关于发件人名字:如果你希望收件人看到的是“系统助手”而不是纯邮箱地址,可以用
formataddr来构造带显示名字的发件人:
from email.utils import formataddr msg["From"] = formataddr(("系统助手", SMTP_USER))最重要的合规底线在这里:如果你要做批量发送,一定不能购买乱七八糟的“邮件列表”直接群发。绝大多数服务商对单封邮件收件人数、每小时的发信总量都有严格限制,一旦被判定为垃圾邮件,轻则封禁发信权限,重则影响日常业务邮件。只给明确订阅过的人发,才是可持续的做法。
3. 用 IMAP 自动读取与解析邮件
3.1 IMAP 连接与登录实操
接收端我推荐 IMAP。标准库imaplib用起来也比较直接:
import imaplib IMAP_HOST = "imap.example.com" IMAP_PORT = 993 IMAP_USER = "your_account@example.com" IMAP_PASS = "your_auth_code" with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, timeout=10) as conn: conn.login(IMAP_USER, IMAP_PASS) conn.select("INBOX") # 默认收件箱 status, data = conn.search(None, "ALL") if status == "OK": ids = data[0].split() print(f"收件箱中共有 {len(ids)} 封邮件")和 SMTP 一样,这里也要用授权码登录。conn.select("INBOX")表示选择收件箱,后续搜索和读取都在这个文件夹里操作。如果哪天你想读“已发送”,改成conn.select('"Sent"')或服务商对应的文件夹名称,取决于服务器的命名。
有个细节:搜索返回的邮件 ID 是一个空格分隔的字节串,长度可能非常大。如果邮件很多,一次拿全量 ID 再逐个下载,效率会很低。选完文件夹后,尽量用筛选条件缩小范围。
3.2 搜索与筛选:如何准确抓到目标邮件
IMAP 自带的搜索能力相当强,它的语法虽然有点老派,但很实用。下面列几个我常用的条件:
- 只看未读:
conn.search(None, 'UNSEEN') - 按发件人筛:
conn.search(None, 'FROM "boss@example.com"') - 按主题筛:
conn.search(None, 'SUBJECT "周报"') - 按时间筛:
conn.search(None, 'SINCE "01-Dec-2025"') - 组合条件:
conn.search(None, 'UNSEEN FROM "customer@example.com" SINCE "01-Dec-2025"')
搜索完拿到 ID 列表后,用fetch拉取邮件内容。这里我强烈建议用BODY.PEEK这个参数,它表示“帮我取内容,但不要把邮件标记为已读”:
status, data = conn.fetch(b"1,2,3", "(BODY.PEEK[])")如果你不小心用了BODY[],邮件会被自动标记成已读。对自动化脚本来说,这会导致一个很隐蔽的问题:第一次跑完脚本,用户发现收件箱里所有邮件都变成了“已读”,容易造成业务误导。
另一个常见问题:某些 IMAP 服务器对一次search返回的 ID 数量有限制,比如一次返回 512 个。邮件特别多的时候,你可能会发现脚本只处理了前面一部分。解决办法是,先拿全部 ID,再按批fetch:
for i in range(0, len(ids), 50): batch_ids = b",".join(ids[i:i+50]) status, data = conn.fetch(batch_ids, "(BODY.PEEK[])") # 处理当前批次3.3 解析正文附件与编码问题
从 IMAP 拿到的是原始 RFC 822 格式的邮件数据,要用email.parser解析成结构化对象。
import email from email import policy raw_email = data[0][1] msg = email.message_from_bytes(raw_email, policy=policy.default) # 主题 subject = msg["Subject"] # 发件人 from_addr = msg["From"] # 正文 if msg.is_multipart(): for part in msg.walk(): content_type = part.get_content_type() if content_type == "text/plain": body = part.get_content() print(body) else: body = msg.get_content()解析邮件最常遇到的问题就是乱码。乱码来源通常有三个:
- 传输编码:邮件正文可能是 base64 或 quoted-printable 编码过的。如果你直接拿原始字符串看,一定会乱。正确做法是用
get_content()或者get_payload(decode=True),让 Python 帮你解码。 - 字符集不对:中文邮件可能用 UTF-8,但有些老系统发的是 GB2312 或 GBK 编码。如果你固定用 UTF-8 去解码,就会看到一堆符号。
get_content()会根据 Content-Type 里的 charset 声明自动处理,这也是我推荐它的原因。 - 主题和附件名:主题通常经过 RFC 2047 编码,比如
=?utf-8?B?...?=。直接用message["Subject"]取,这个字符串是带编码前缀的,看起来像天书。Python 的policy=policy.default会自动帮你解成正常字符串,这是新版email模块做得很好的地方。
附件解析稍微复杂一点。常见做法是扫描所有 multipart 子部件,找到Content-Disposition为attachment的部分,然后把 payload 写进文件:
for part in msg.walk(): if part.get_content_disposition() == "attachment": filename = part.get_filename() data = part.get_payload(decode=True) with open(filename, "wb") as f: f.write(data)文件名乱码可能是历史遗留问题。新的邮件标准会用编码后的文件名,Python 的get_filename()基本能处理干净。但如果你解析的是老邮件,遇到filename*这类字段,可能需要用email.utils.collapse_rfc2231_value来做兼容。这部分遇到了再查不迟,我现在不想把文章变得像标准文档一样冗长。
3.4 处理邮件的已读、删除、归档状态
自动化读完邮件后,怎么处理状态,这是一个既简单又危险的问题。
- 只读不标记:上面说的
BODY.PEEK就是不改状态的方式。适用于你只想抓数据,不打算影响人工处理流程的场景。 - 标记已读:用
conn.store(id, '+FLAGS', '\\Seen')。标记已读是幂等的,重复跑脚本不会出错。 - 删除邮件:IMAP 的删除是两步。第一步是打上
\Deleted标记,第二步调用expunge()才会真正从文件夹中消失。
conn.store(b"1,2,3", '+FLAGS', '\\Deleted') conn.expunge()关于删除我的建议是:程序里能不做删除就不做删除。删除动作一旦误触发,邮件往往找不回来。就算业务上确实要清空收件箱,也建议先“归档”到自定义文件夹,或者标记已读即可。脚本出问题时的损失,和删错邮件的损失完全不是一个量级。
4. 结合场景:定时任务与自动化工作流
4.1 定时发送报表与通知的代码框架
收发邮件的核心实现搞定之后,下一步就是把它嵌入到业务流程里。最常见的需求是“每天早上 9 点自动发报表”。我可以给你一个简单的框架,不依赖复杂的外部组件,只用一个循环加时间判断:
import time from datetime import datetime def send_daily_report(): # 组装邮件并发送,参考上一部分代码 pass def main(): last_send_date = None while True: now = datetime.now() if now.hour == 9 and now.minute < 5 and last_send_date != now.date(): try: send_daily_report() last_send_date = now.date() print("报表发送成功") except Exception as e: print("发送失败", e) time.sleep(30) if __name__ == "__main__": main()这个框架虽然简单,但已经有幂等保护了:last_send_date保证同一天不会重复发送。生产环境里我会建议把last_send_date存到一个本地文件或数据库,避免程序重启后重复发。
如果你需要更复杂的调度,完全可以用系统的计划任务工具,比如 Linux 的 cron,或者 Windows 的“任务计划程序”。脚本本身只需要做一件事:python send_report.py。
4.2 监控告警与异常通知的实战闭环
一个比较有代表性的完整闭环是:程序监控某个接口,接口挂了自动发告警邮件,同时发一封给自己作为“确认收讫通知”。
第一次做监控告警时,我犯过一个错误:每个异常都立刻发一封邮件,结果某个晚上接口反复抖动,把邮箱发到触发服务商限流。后来改成“累计连续失败 3 次才发一次告警,并且同一告警源 10 分钟内不重复发”,问题立刻得到缓解。这个策略很值得借鉴:
failed_times = 0 last_alert_ts = 0 ALERT_INTERVAL = 600 # 10分钟 FAILED_THRESHOLD = 3 def check_service(): global failed_times, last_alert_ts ok = requests.get("http://your-service.example.com/health").ok if ok: failed_times = 0 return failed_times += 1 if failed_times >= FAILED_THRESHOLD and time.time() - last_alert_ts > ALERT_INTERVAL: send_alert_mail("服务连续异常", f"已连续失败 {failed_times} 次") last_alert_ts = time.time() failed_times = 0这个模式的好处是:既不会错过严重故障,又不会因为瞬时抖动把收件箱刷爆。另外,告警邮件务必把自己的邮箱也放进抄送列表里,方便留底。
4.3 批量处理与自动应答的合规边界
更进阶一点的场景是:程序收到指定发件人的邮件后,自动解析内容,回复确认,或者创建工单。这种流程做起来很爽,但有几条红线我想着重标注:
- 不要对陌生发件人自动回复。自动回复意味着你的邮箱地址和系统行为会被滥用。
- 不要自动执行邮件正文里的指令。如果收件箱是开放的,攻击者完全可以伪造一封“来自管理员”的邮件让程序去执行恶意操作。至少要做发件人白名单校验。
- 不要长期保存敏感邮件内容。如果脚本会下载含有客户隐私的附件,建议处理完后及时清理本地文件,最好对附件进行加密存储。
做批量自动回复时,记录处理日志比实现功能本身更重要。你在线上运行一段时间后,一定会遇到“某封邮件没被处理”的反馈,没有日志,排查起来像大海捞针。
5. 常见问题与排查技巧实录
5.1 认证失败与连接超时排查思路
我统计了一下,自动收发邮件 80% 的问题都出在认证和连接环节。典型报错是:
Authentication failed:账号或密码错误。先检查是否用了授权码,而不是邮箱登录密码。Connection timed out:服务器地址、端口或网络安全策略不对。先用最笨的办法测试端口通不通,在命令行里敲一条命令(比如常见的telnet smtp.example.com 465),如果端口连不通,问题不在代码而在网络环境。SSL: WRONG_VERSION_NUMBER:你连接的是 587 端口,但用了SMTP_SSL。3523 端口必须用starttls流程。
我的排查顺序是:先确认网络通,再确认授权信息,最后才是代码逻辑。很多人在第一环没确认的情况下,反复查代码,浪费时间。
5.2 中文乱码与编码异常
中文乱码是解析邮件时的第一大困扰。我归纳了几种情况:
- 发送端中文乱码:发件时主题要加
Header(),正文在构造MIMEText时要显式传"utf-8"。 - 接收端中文乱码:优先
get_content()而不是直接get_payload()。前者会按字符集自动解码。 - 历史遗留编码:偶尔会遇到服务商返回的邮件头是
ISO-8859-1,但主题实际是 GBK。没有特别完美的通用解法,只能是解析时做异常兜底,比如尝试两种编码。
我自己处理乱码的原则是:程序里永远不要用str(msg)这种粗暴转字符串的方式去取内容,规范解析才是一劳永逸的方案。
5.3 附件文件名乱码或解析失败
附件解析失败往往是因为只处理了Content-Disposition: attachment的部分,但有些内嵌图片的Content-Disposition是inline。如果你是想保存附件,可以用part.get_filename()和part.get_content_disposition()联合判断。
还有种情况是:某些邮件用filename*来标记编码文件名,标准库里get_filename()并没有自动解码。如果你遇到了,可以用email.utils.collapse_rfc2231_value()手动处理。
5.4 平台风控与频率限制
别以为邮件服务器是无限容量的。常见限制包括:
- 同一主题、同一收件人的邮件发送次数过多,会触发“疑似垃圾邮件”风控。
- 每小时发信总量超过阈值,账号可能被临时锁定。
- 单封邮件收件人数量过多,可能直接被服务器拒绝。
应对策略很朴素:降低频率、随机化发送时间、每次发送之间加延时、保留完整发送日志。一旦出现退信,要从退信内容中判断是收件人不存在、对方服务器拒收,还是自己被限流。
5.5 配置与密钥的安全保护
“把邮箱密码直接写在代码里”是我在代码评审中最常见的问题。无论代码多简单,授权码都应该从环境变量或配置文件读取,不要把真实密钥提交到代码仓库。一个最简改动:
import os SMTP_USER = os.getenv("MAIL_USER") SMTP_PASS = os.getenv("MAIL_AUTH_CODE") SMTP_HOST = os.getenv("MAIL_SMTP_HOST", "smtp.example.com")另外,本地调试时建议申请一个专用测试邮箱账号,不要用正在处理人工业务的主邮箱来做自动化实验。测试邮箱被限流或误删邮件,影响都不大。
最后再分享一个小技巧:我第一次做完整“读信自动回复”时,没有直接对接业务,而是先写了两个脚本,一个负责给自己发各种格式的邮件(纯文本、HTML、带附件的、中文文件名的),另一个负责把这些邮件读出来并打印解析结果。两边的格式都跑通之后,再去接真实业务。这一步让我后续排查问题省了非常多的时间——因为收发两端哪个环节出问题,你一眼就能看出来。
如果你正打算写自己的自动化邮件脚本,我真心建议你也按这个顺序来:先打通最小收发链路,再逐步加业务逻辑。邮件自动化不难,但一个小细节的坑就能让整个流程“看上去在跑,实际一直没跑对”。希望这篇文章能帮你少踩几个坑。