做Python自动化的人,迟早会碰到“发邮件”这个需求。我最早写这东西是为了半夜盯服务器,CPU飙了要把日志推到我邮箱;后来做爬虫,每天把更新数据整理成Excel发给同事;再后来写公司内部的报表工具,直接定时往收件人邮箱塞PDF。绕来绕去,核心其实就一件事:用Python代码,把一封邮件通过SMTP协议投递出去。
这篇文章不聊虚的,把从原理、选库到实际代码和避坑流程完整过一遍。内容我按自己的使用习惯组织,涉及的都是真实跑过的方案,稍微有点长,但看下来应该能把“python发送邮件”这件事彻底搞明白。
1. 先搞清楚:一封邮件从脚本到收件箱的完整路径
1.1 SMTP是什么,为什么Python发邮件绕不开它
邮件系统里有两个常用协议,发信用SMTP(Simple Mail Transfer Protocol),收信用POP3或IMAP。Python发邮件走的是SMTP,plainly speaking,你的脚本就是一个SMTP客户端,负责把邮件内容交给发件人的邮件服务器。
这个链路可以类比成去邮局寄信:脚本是写信人,你的邮箱服务器是家门口的邮局,对方邮箱服务器是收件人楼下的信箱,而SMTP协议就是邮局之间的运输规则。你的代码把信投进“自己家邮局”的邮筒,剩下的事情(中转、投递、退信)由邮件服务器之间完成。
为什么要说这个?因为很多报错都是这条链路中某个环节断了。比如你连不上smtp.qq.com,多半是“家门口邮局”不允许匿名投递;对方长时间收不到,可能是你投递的这封邮件被反垃圾系统拦截在半路。理解了链路,排查起来才会有方向。
实现上,Python标准库提供的smtplib就是干这个的,它负责发起TCP连接、完成SMTP对话、发送数据。而邮件内容本身——主题、发件人、收件人、正文、附件——由email模块来构造。这两个库是官方组合,后面细说。
1.2 授权码是怎么回事,为什么不能用邮箱登录密码
新手最容易踩的第一个坑,就是拿邮箱登录密码去login(),结果报SMTPAuthenticationError。
现在的邮箱服务商基本都不允许客户端直接用主密码登录SMTP,而是要求开启“SMTP服务”后单独生成一个授权码。这个授权码是专给第三方客户端用的,作用很简单:即使它泄露了,也只影响发信功能,不会让攻击者拿到你的完整账号权限。你可以随时在网页端撤销并重新生成,比改主密码轻量得多。
所以流程上就变成了三步:
- 在邮箱网页端的“设置-账户”里找到SMTP服务开关。
- 按引导开启服务,通常会要求发一条短信验证。
- 获得一段固定格式的授权码,把它当作登录密码填入代码。
顺便提醒一句:这个授权码本质上是邮箱权限的钥匙。别写死在代码里然后上传到GitHub,更别在群里贴出来,后面我会给一个安全存法。
1.3 10分钟开启QQ/163邮箱SMTP服务
以最常见的QQ邮箱为例,网页端登录后按这个路径走:
- 进入“设置” → “账户”页签。
- 往下找“POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务”。
- 找到“SMTP服务”,点击开启。
- 按提示用手机发送一条指定内容的短信,完成验证后页面会给出授权码。
- 把授权码复制保存下来,注意只显示一次,丢了只能重新生成。
163邮箱类似,在“设置 → POP3/SMTP/IMAP”里开启SMTP,设置客户端授权密码。126邮箱操作路径也差不多。
开启服务后,你还需要确认SMTP服务器地址和端口。我整理了几个常用邮箱的配置,直接用下面的表格:
| 邮箱 | SMTP服务器 | SSL端口 | STARTTLS端口 |
|---|---|---|---|
| QQ邮箱 | smtp.qq.com | 465 | 587 |
| 163邮箱 | smtp.163.com | 465 | 587 |
| 126邮箱 | smtp.126.com | 465 | 587 |
| 企业微信邮箱 | smtp.exmail.qq.com | 465 | 587 |
| 阿里企业邮箱 | smtp.mxhichina.com | 465 | 80/25 |
注意:25端口在很多机房和公司网络里是默认屏蔽的,所以脚本里我一般直接用465的SSL方案,最省事。
2. 库的选择:smtplib、email还是yagmail
2.1 官方组合smtplib+email:灵活但代码量大
标准库方案由两部分组成:smtplib负责网络传输,email负责构造邮件对象。
为什么说它“灵活但代码量大”?因为邮件本质上是MIME格式的文本,你要手动处理纯文本、HTML、附件、内嵌图片等不同的MIME类型,还要处理中文编码、发件人显示名、多收件人格式。一旦邮件结构复杂,代码就会显得很碎。
举个例子,光是一个中文发件人显示名,就需要用email.header.Header包装一下,否则对方收件箱里看到的是一串乱码或者问号。标准库把底层能力给全了,但代价就是把很多细节暴露给你。
不过它也有不可替代的优势:不需要安装任何第三方包,内网环境、离线环境都能跑,而且对MIME的控制能力最强。比如要做一些特殊格式的邮件,yagmail反而绕不过去。
2.2 yagmail:适合快速交付
yagmail是对smtplib的封装,目标就是“让发邮件变成三行代码的事”。安装很简单:
pip install yagmail看下它的用法:
import yagmail yag = yagmail.SMTP(user="你的邮箱@qq.com", password="授权码", host="smtp.qq.com", port=465) yag.send(to="收件人@qq.com", subject="测试", contents=["这是正文"])看到差别了吗?原来要处理的一大堆MIME细节,yagmail全帮你包了。尤其是“添加附件”这个操作,在标准库下要写好几行,在yagmail里只需要把文件路径加进contents列表:
yag.send(to="收件人@qq.com", subject="报表", contents=["正文", "report.xlsx", "chart.png"])这句话能帮你省掉大批模板代码。实际项目里我经常先用yagmail快速调通业务逻辑,如果后面真遇到复杂的精调需求,再换标准库。
2.3 我的选型建议
不同场景我用过不同方案,直接说结论:
| 库 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| smtplib + email | 标准库自带、控制粒度细 | 代码多、需手动处理细节 | 正式项目、离线环境、复杂邮件 |
| yagmail | 代码量小、附件方便 | 需要第三方依赖、控制力弱 | 快速开发、脚本工具、测试场景 |
| smtplib直接裸用 | 最少依赖 | 要自己拼SMTP命令 | 学习协议原理、调试连接 |
如果只是一个内部小工具,建议无脑用yagmail;如果是公司里要长期维护、发给客户的正式邮件,我建议用标准库。但我自己大部分时候还是先yagmail跑通,再根据情况决定要不要重写。
3. 上代码:三套可直接复制的发送方案
3.1 最简版本:yagmail发送文本与HTML邮件
先来一个真正的“hello world”:
import yagmail yag = yagmail.SMTP( user="你的邮箱@qq.com", password="你的授权码", host="smtp.qq.com", port=465 ) yag.send( to="收件人@qq.com", subject="Python发送邮件测试", contents=["这是一封来自Python脚本的邮件,纯文本正文。"] )如果想把正文变成HTML格式,直接把HTML字符串放进contents就行,yagmail会自动识别:
html_body = """ <h3>今日数据已更新</h3> <p>点击下方链接查看详情:</p> <a href="https://example.com">查看报告</a> """ yag.send(to="收件人@qq.com", subject="数据日报", contents=[html_body])这里有个容易忽略的点:contents指定为列表时,你可以把纯文本、HTML片段、附件都塞进去,yagmail会按顺序组织邮件内容。这个设计比标准库更符合直觉。
3.2 标准版:smtplib+email发送带附件邮件
如果不想引入第三方依赖,或者需要精细控制邮件结构,标准库方案长这样:
import smtplib from email.message import EmailMessage from email.header import Header from email.utils import formataddr # 构造邮件对象 msg = EmailMessage() msg["From"] = formataddr((str(Header("你的名字", "utf-8")), "你的邮箱@qq.com")) msg["To"] = "收件人@qq.com" msg["Subject"] = "带附件的报表" # 纯文本正文 msg.set_content("这是邮件的纯文本正文。") # 添加附件(注意要用二进制模式读取) with open("report.xlsx", "rb") as f: file_data = f.read() msg.add_attachment( file_data, maintype="application", subtype="vnd.ms-excel", filename="report.xlsx", ) # 发送 with smtplib.SMTP_SSL("smtp.qq.com", 465) as server: server.login("你的邮箱@qq.com", "你的授权码") server.send_message(msg)几个值得注意的细节:
formataddr((str(Header("你的名字", "utf-8")), "邮箱"))是为了让发件人显示名正常显示中文,直接写"名字 <邮箱>"很容易出编码问题。send_message()会自动把邮件对象序列化成合适的MIME格式,不要手动去拼。- 如果正文也想用HTML,用
msg.add_alternative(html_body, subtype="html"),同时保留set_content作为纯文本兜底。
3.3 批量发送与多收件人:一次给一群人发
先说多收件人。最简单的方式是把收件地址用逗号拼成一个字符串:
msg["To"] = "a@example.com, b@example.com, c@example.com"或者更规范一点,用列表再拼:
to_list = ["a@example.com", "b@example.com", "c@example.com"] msg["To"] = ", ".join(to_list)不过要注意,这里“多收件人”是写在邮件头里的,收件人之间互相能看到对方。如果是营销通知类邮件,更合适的是用Bcc(密送),保护收件人隐私。
批量发送就是循环调用。但这里有个硬教训:不要一次性把几百上千封邮件全塞进循环里跑。我见过有同事写脚本给全公司发通知,同一台服务器、同样的标题、同样的内容,循环没加任何间隔,跑到一百多封的时候直接被SMTP服务器限流,后面全部失败。
我的习惯是循环里加随机延时,并且控制单批数量:
import time import random to_users = ["a@example.com", "b@example.com", "c@example.com"] for user in to_users: try: yag.send(to=user, subject="通知", contents=["正文"]) print(f"已发送 {user}") except Exception as e: print(f"发送失败 {user}: {e}") time.sleep(random.uniform(3, 6))这个速度看起来慢,但胜在稳。真实业务中,宁可花十分钟发完,也比十分钟内全被退回来强。
3.4 参数说明与安全习惯
上面代码里的核心参数其实就几个:
host和port:SMTP服务器地址和端口,直接对应第一节的表格。user和password:邮箱账号和授权码。to、subject、contents:收件人、主题、内容。
至于安全习惯,我必须强调一条:授权码不要写死在代码里。我见过太多人把授权码直接粘在脚本里然后传到Git仓库,结果被爬虫扫到,邮箱马上被拿来发垃圾邮件。
比较稳妥的做法是用环境变量:
import os yag = yagmail.SMTP( user=os.getenv("EMAIL_ACCOUNT"), password=os.getenv("EMAIL_AUTH_CODE"), host="smtp.qq.com", port=465, )在运行脚本前设置环境变量,或者放到config.py里并加入.gitignore。这花不了多少时间,但能避免后面很惨的教训。
4. 进阶玩法:从发送邮件到自动报表
4.1 HTML卡片邮件,内嵌图片而不是附件
很多自动化脚本不只是发纯文本,而是希望邮件看起来像一张“卡片”,带标题、表格、按钮和图片。用HTML完全可以做到,但有几个坑:
一是邮件客户端的CSS支持非常有限,flex、grid之类的东西经常失效,最好用传统的<table>布局。二是图片如果作为附件发送,很多客户端默认不显示,要显示在正文里,需要以内嵌方式引用。
yagmail处理内嵌图片非常顺手:
contents = [ "<h3>今日销售数据</h3>", "<img src='cid:chart' width='600'>", "chart.png", # yagmail会自动把它作为内嵌图片放入cid:chart ] yag.send(to=..., subject="销售日报", contents=contents)这里cid:chart就是引用你顺手塞进contents的chart.png,渲染时图片会在邮件正文里正常显示,而不是作为附件躺在底部。如果用标准库,就需要构造MIMEMultipart('related')加MIMEImage,代码长很多,日常我都是直接用yagmail。
4.2 自动生成Excel报表并作为附件发送
这应该是最多人问“python写入excel”之后真正想做的事:程序跑完数据,存成xlsx,再发邮件。
示例流程:
import pandas as pd import yagmail # 1. 准备数据 data = { "城市": ["北京", "上海", "广州"], "销售额": [100, 200, 150], } df = pd.DataFrame(data) # 2. 写入Excel df.to_excel("report.xlsx", index=False) # 3. 发送邮件并附上文件 yag = yagmail.SMTP(user="...", password="...", host="smtp.qq.com", port=465) yag.send( to=["a@example.com"], subject="销售报表", contents=["今天的报表在附件里,请查收。", "report.xlsx"], )整个过程简单到不像话。如果数据量不大,甚至可以不用生成文件,直接把DataFrame转成HTML表格发过去:
html_table = df.to_html(index=False) yag.send(to=..., subject="销售报表", contents=[html_table])收件人在邮箱里就能直接预览表格数据,比下载附件再打开要舒服得多。这也是为什么很多日报系统直接用HTML邮件而不是Excel附件。
4.3 定时执行:crontab与Windows计划任务
代码写完后,通常还要定时跑。Linux服务器上直接用crontab:
0 9 * * * /usr/bin/python3 /home/user/send_report.py >> /home/user/send_report.log 2>&1意思是每天早上9点跑一次,输出日志写到文件里。Windows下就用“任务计划程序”,新建任务时“程序/脚本”填pythonw.exe(这样不会弹出黑窗口),参数填脚本绝对路径,触发器里设置时间即可。
我自己更喜欢的做法是让脚本自带日志和异常捕获:
import logging logging.basicConfig( filename="send_report.log", level=logging.INFO, format="%(asctime)s %(message)s", ) def main(): try: # 生成报表、发送邮件 pass logging.info("发送成功") except Exception as e: logging.error(f"发送失败: {e}") if __name__ == "__main__": main()定时任务跑起来后,第一件事就是看日志有没有关键报错。没有日志的定时脚本就是黑盒,出了故障只能干瞪眼。
4.4 封装通用发送函数,以后复用
实际项目里,发送邮件的逻辑不应该散落在各个脚本里。我习惯封装成一个通用函数,放到公共模块:
import os import yagmail def send_mail(subject, body, to_list, attach_files=None): """通用邮件发送函数""" yag = yagmail.SMTP( user=os.getenv("EMAIL_ACCOUNT"), password=os.getenv("EMAIL_AUTH_CODE"), host="smtp.qq.com", port=465, ) contents = [body] if attach_files: if isinstance(attach_files, str): attach_files = [attach_files] contents.extend(attach_files) yag.send(to=to_list, subject=subject, contents=contents)这样调用方永远是一行:
send_mail("账户异常提醒", "检测到多次登录失败,请注意账号安全", ["ops@example.com"]) send_mail("月度报表", "见附件", ["boss@example.com"], "report.xlsx")后面如果换邮箱服务商、调整发送频率、增加抄送参数,只需要改这一个函数,调用方完全无感。这个封装习惯,长期来看能省大量时间。
5. 常见问题与排查实录
5.1 高频报错速查表
写这part之前我翻了下自己过往笔记,下面这些报错基本是出现频率最高的,每一条我都踩过或帮别人排查过:
| 报错信息 | 最可能的原因 | 解决办法 |
|---|---|---|
SMTPAuthenticationError: 535 | 授权码错误、账号未开启SMTP | 回到邮箱设置确认SMTP已开启;重新生成授权码,并检查是否填错 |
smtplib.SMTPConnectError: 501 | 发件人格式不对,或中文未正确编码 | 用formataddr+Header处理发件人显示名 |
smtplib.SMTPResponseException: 554 | 邮件被反垃圾规则拦截 | 缩短正文、去掉敏感词、降低发送频率、换主题措辞 |
ssl.SSLError: WRONG_VERSION_NUMBER | 端口和加密方式不匹配 | 465端口用SMTP_SSL,587端口用starttls()升级 |
TimeoutError/Connection timed out | 服务器端口被防火墙或运营商屏蔽 | 换465端口测试;确认公司网络是否开放外网SMTP端口 |
ConnectionRefusedError | SMTP服务器地址或端口错误 | 对照第一节的服务器配置表检查 |
重点说一下554,这是最头大的一类报错。它不代表你的代码有语法错误,而是对方邮件服务器“拒绝接收”这封信。不同服务商返回的554具体文本还不一样,有的写“Message rejected”,有的写“Antispam”。处理思路是:缩短内容、减少外链、降低频率,然后把发件方换成信誉更好的邮箱再试。
5.2 发是发出去了,但对方收不到/进垃圾箱
代码没报错、邮件显示发送成功,但对方就是收不到,八成是进了垃圾箱。这种情况比直接报错还难受,因为你会完全无感。
常见原因有几个:
- 发件域名的SPF记录不合格,或没有配置DKIM签名,对方邮件系统判定为伪造发件人。
- 发件IP信誉差,比如用刚买的云主机、长期没人用的老服务器IP发信。
- 内容里包含“发票”、“代开”、“点击链接”等营销敏感词。
- 图片占比过高,且没有任何纯文本内容。
- 收件方误报过你的邮件为垃圾邮件。
解决方案也很明确:
- 优先用腾讯、网易这类成熟邮箱服务商的SMTP,不要自建SMTP服务器。
- 给公司正式收件人发邮件时,用企业邮箱域名作为发件人,个人QQ邮箱适合发给自己或测试。企业邮箱通常自带SPF/DKIM配置,能省心很多。
- 邮件内容不要太像广告,适当加入纯文本段落,纯HTML+图片很容易被垃圾邮件过滤器盯上。
- 测试送达率时,别只发一个邮箱,用多个不同服务商的邮箱互发对比看结果。
5.3 没有真实邮箱,怎么在本地测试脚本
有时候开发环境里没有真实邮箱,或者不想拿真实账号去试。这时候可以在本地起一个虚拟SMTP服务,专门接收脚本发送的邮件,方便查看内容。
如果装了aiosmtpd,直接跑:
pip install aiosmtpd python -m aiosmtpd -n -l localhost:1025然后在脚本里把SMTP服务器指向本地:
import smtplib from email.message import EmailMessage msg = EmailMessage() msg["From"] = "sender@test.com" msg["To"] = "receiver@test.com" msg["Subject"] = "本地测试" msg.set_content("hello") with smtplib.SMTP("localhost", 1025) as server: server.send_message(msg)虚拟服务不会真正把邮件发出去,而是在终端里打印出邮件内容。这样开发阶段就能验证邮件结构、附件是否正常,完全不用污染真实邮箱。等代码稳定了,再换成真实SMTP服务器跑一遍。
6. 最后分享几个实操心得
6.1 授权码与密钥管理:别把邮箱密码写进代码
上面已经说过别把授权码提交到Git仓库,我再补充一个细节:如果脚本要交给同事共用,尽量不要直接传递授权码文件,而是给一个config.py.example模板,让大家各自填自己的配置。团队协作时,邮箱账号往往代表一个真实身份,小心处理不会错。
6.2 频率、频率、频率:批量发送必须控节奏
不管是给100个客户发报表,还是给群成员发通知,发送频率永远是第一原则。我见过最典型的翻车是:脚本没有延时地循环发送,触发当日限流后,后续发送全部失败,而且账号被标记为风险账号,之后一段时间的发信都进垃圾箱。批量前先小规模测试,循环里加随机延时,这是成本最低的保命做法。
6.3 如果只是为了自己看,用企业邮箱或QQ邮箱小号更稳妥
最后说一下发件账号的选择。给自己发测试邮件、日志提醒,QQ邮箱小号就够了;给客户或领导发正式报告,最好用企业邮箱域名。企业邮箱在送达率、身份识别上都比个人邮箱有优势,而且对方收到时能看到你的企业域名,观感专业很多。
拿我自己来说,内部工具脚本用QQ邮箱的专用授权码,对外正式内容走企业邮箱。这套组合跑了这么久,基本上没再被邮件问题困扰过。“python如何发送邮件”这个需求,本质是个成熟且固定的流程:开SMTP、拿授权码、选库、构造内容、发送、处理异常。把这几步理顺,后面要做的就只是复制粘贴的功夫了。