☰
Python发送邮件实战指南:从SMTP原理到自动化报表
2026/10/2 22:34:34 网站建设 项目流程

做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服务”后单独生成一个授权码。这个授权码是专给第三方客户端用的,作用很简单:即使它泄露了,也只影响发信功能,不会让攻击者拿到你的完整账号权限。你可以随时在网页端撤销并重新生成,比改主密码轻量得多。

所以流程上就变成了三步:

  1. 在邮箱网页端的“设置-账户”里找到SMTP服务开关。
  2. 按引导开启服务,通常会要求发一条短信验证。
  3. 获得一段固定格式的授权码,把它当作登录密码填入代码。

顺便提醒一句:这个授权码本质上是邮箱权限的钥匙。别写死在代码里然后上传到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.com465587
163邮箱smtp.163.com465587
126邮箱smtp.126.com465587
企业微信邮箱smtp.exmail.qq.com465587
阿里企业邮箱smtp.mxhichina.com46580/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端口
ConnectionRefusedErrorSMTP服务器地址或端口错误对照第一节的服务器配置表检查

重点说一下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、拿授权码、选库、构造内容、发送、处理异常。把这几步理顺,后面要做的就只是复制粘贴的功夫了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询