做Python自动化的人迟早会碰到“发邮件”这个需求。我最早写这东西是为了半夜盯服务器CPU飙了要把日志推到我邮箱后来做爬虫每天把更新数据整理成Excel发给同事再后来写公司内部的报表工具直接定时往收件人邮箱塞PDF。绕来绕去核心其实就一件事用Python代码把一封邮件通过SMTP协议投递出去。这篇文章不聊虚的把从原理、选库到实际代码和避坑流程完整过一遍。内容我按自己的使用习惯组织涉及的都是真实跑过的方案稍微有点长但看下来应该能把“python发送邮件”这件事彻底搞明白。1. 先搞清楚一封邮件从脚本到收件箱的完整路径1.1 SMTP是什么为什么Python发邮件绕不开它邮件系统里有两个常用协议发信用SMTPSimple Mail Transfer Protocol收信用POP3或IMAP。Python发邮件走的是SMTPplainly 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.com465587163邮箱smtp.163.com465587126邮箱smtp.126.com465587企业微信邮箱smtp.exmail.qq.com465587阿里企业邮箱smtp.mxhichina.com46580/25注意25端口在很多机房和公司网络里是默认屏蔽的所以脚本里我一般直接用465的SSL方案最省事。2. 库的选择smtplib、email还是yagmail2.1 官方组合smtplibemail灵活但代码量大标准库方案由两部分组成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授权码, hostsmtp.qq.com, port465) 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你的授权码, hostsmtp.qq.com, port465 ) yag.send( to收件人qq.com, subjectPython发送邮件测试, contents[这是一封来自Python脚本的邮件纯文本正文。] )如果想把正文变成HTML格式直接把HTML字符串放进contents就行yagmail会自动识别html_body h3今日数据已更新/h3 p点击下方链接查看详情/p a hrefhttps://example.com查看报告/a yag.send(to收件人qq.com, subject数据日报, contents[html_body])这里有个容易忽略的点contents指定为列表时你可以把纯文本、HTML片段、附件都塞进去yagmail会按顺序组织邮件内容。这个设计比标准库更符合直觉。3.2 标准版smtplibemail发送带附件邮件如果不想引入第三方依赖或者需要精细控制邮件结构标准库方案长这样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, maintypeapplication, subtypevnd.ms-excel, filenamereport.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, subtypehtml)同时保留set_content作为纯文本兜底。3.3 批量发送与多收件人一次给一群人发先说多收件人。最简单的方式是把收件地址用逗号拼成一个字符串msg[To] aexample.com, bexample.com, cexample.com或者更规范一点用列表再拼to_list [aexample.com, bexample.com, cexample.com] msg[To] , .join(to_list)不过要注意这里“多收件人”是写在邮件头里的收件人之间互相能看到对方。如果是营销通知类邮件更合适的是用Bcc密送保护收件人隐私。批量发送就是循环调用。但这里有个硬教训不要一次性把几百上千封邮件全塞进循环里跑。我见过有同事写脚本给全公司发通知同一台服务器、同样的标题、同样的内容循环没加任何间隔跑到一百多封的时候直接被SMTP服务器限流后面全部失败。我的习惯是循环里加随机延时并且控制单批数量import time import random to_users [aexample.com, bexample.com, cexample.com] for user in to_users: try: yag.send(touser, subject通知, contents[正文]) print(f已发送 {user}) except Exception as e: print(f发送失败 {user}: {e}) time.sleep(random.uniform(3, 6))这个速度看起来慢但胜在稳。真实业务中宁可花十分钟发完也比十分钟内全被退回来强。3.4 参数说明与安全习惯上面代码里的核心参数其实就几个host和portSMTP服务器地址和端口直接对应第一节的表格。user和password邮箱账号和授权码。to、subject、contents收件人、主题、内容。至于安全习惯我必须强调一条授权码不要写死在代码里。我见过太多人把授权码直接粘在脚本里然后传到Git仓库结果被爬虫扫到邮箱马上被拿来发垃圾邮件。比较稳妥的做法是用环境变量import os yag yagmail.SMTP( useros.getenv(EMAIL_ACCOUNT), passwordos.getenv(EMAIL_AUTH_CODE), hostsmtp.qq.com, port465, )在运行脚本前设置环境变量或者放到config.py里并加入.gitignore。这花不了多少时间但能避免后面很惨的教训。4. 进阶玩法从发送邮件到自动报表4.1 HTML卡片邮件内嵌图片而不是附件很多自动化脚本不只是发纯文本而是希望邮件看起来像一张“卡片”带标题、表格、按钮和图片。用HTML完全可以做到但有几个坑一是邮件客户端的CSS支持非常有限flex、grid之类的东西经常失效最好用传统的table布局。二是图片如果作为附件发送很多客户端默认不显示要显示在正文里需要以内嵌方式引用。yagmail处理内嵌图片非常顺手contents [ h3今日销售数据/h3, img srccid:chart width600, chart.png, # yagmail会自动把它作为内嵌图片放入cid:chart ] yag.send(to..., subject销售日报, contentscontents)这里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, indexFalse) # 3. 发送邮件并附上文件 yag yagmail.SMTP(user..., password..., hostsmtp.qq.com, port465) yag.send( to[aexample.com], subject销售报表, contents[今天的报表在附件里请查收。, report.xlsx], )整个过程简单到不像话。如果数据量不大甚至可以不用生成文件直接把DataFrame转成HTML表格发过去html_table df.to_html(indexFalse) yag.send(to..., subject销售报表, contents[html_table])收件人在邮箱里就能直接预览表格数据比下载附件再打开要舒服得多。这也是为什么很多日报系统直接用HTML邮件而不是Excel附件。4.3 定时执行crontab与Windows计划任务代码写完后通常还要定时跑。Linux服务器上直接用crontab0 9 * * * /usr/bin/python3 /home/user/send_report.py /home/user/send_report.log 21意思是每天早上9点跑一次输出日志写到文件里。Windows下就用“任务计划程序”新建任务时“程序/脚本”填pythonw.exe这样不会弹出黑窗口参数填脚本绝对路径触发器里设置时间即可。我自己更喜欢的做法是让脚本自带日志和异常捕获import logging logging.basicConfig( filenamesend_report.log, levellogging.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_filesNone): 通用邮件发送函数 yag yagmail.SMTP( useros.getenv(EMAIL_ACCOUNT), passwordos.getenv(EMAIL_AUTH_CODE), hostsmtp.qq.com, port465, ) contents [body] if attach_files: if isinstance(attach_files, str): attach_files [attach_files] contents.extend(attach_files) yag.send(toto_list, subjectsubject, contentscontents)这样调用方永远是一行send_mail(账户异常提醒, 检测到多次登录失败请注意账号安全, [opsexample.com]) send_mail(月度报表, 见附件, [bossexample.com], report.xlsx)后面如果换邮箱服务商、调整发送频率、增加抄送参数只需要改这一个函数调用方完全无感。这个封装习惯长期来看能省大量时间。5. 常见问题与排查实录5.1 高频报错速查表写这part之前我翻了下自己过往笔记下面这些报错基本是出现频率最高的每一条我都踩过或帮别人排查过报错信息最可能的原因解决办法SMTPAuthenticationError: 535授权码错误、账号未开启SMTP回到邮箱设置确认SMTP已开启重新生成授权码并检查是否填错smtplib.SMTPConnectError: 501发件人格式不对或中文未正确编码用formataddrHeader处理发件人显示名smtplib.SMTPResponseException: 554邮件被反垃圾规则拦截缩短正文、去掉敏感词、降低发送频率、换主题措辞ssl.SSLError: WRONG_VERSION_NUMBER端口和加密方式不匹配465端口用SMTP_SSL587端口用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] sendertest.com msg[To] receivertest.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、拿授权码、选库、构造内容、发送、处理异常。把这几步理顺后面要做的就只是复制粘贴的功夫了。