ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python自动发邮件完全指南:SMTP、授权码与定时推送实战

Python自动发邮件完全指南:SMTP、授权码与定时推送实战 上个月为了盯一个行业数据我搭了爬虫每天凌晨自动跑。数据倒是没出问题可每天早上到了公司还得手动把数据导成Excel、打开邮箱、新建邮件、上传附件、写两句话、点发送。重复了两周实在受不了了干脆把“发邮件”这件事也交给Python脚本。这一改每天省下来的时间差不多有半小时而且半夜跑完的报表会准时躺在我邮箱里。这篇内容就是我把Python发邮件从零到能用的完整梳理。不只给一个能跑的最小示例还会讲清楚授权码、端口、编码、附件中文名这些容易栽跟头的地方以及定时推送、重试补发、批量分发这些真实业务里马上会用到的进阶玩法。无论你是刚学完Python基础还是正想把爬虫结果、量化信号、自动报表推送出去都能直接抄作业。1. 准备工作装环境、开SMTP、拿授权码1.1 Python环境其实不需要装第三方库先说个很多人不知道的点发送邮件这件事Python标准库就够了不需要安装任何第三方包。核心用到两个模块一个叫smtplib负责和邮件服务器打交道完成登录、发送、退出这些网络动作另一个叫email负责把邮件内容组织成标准格式比如发件人、收件人、标题、正文、附件这些部分。环境安装我就默认你已经搞定了。还没装的话到Python官网下载对应操作系统的安装包Windows安装时记得勾选Add Python to PATH这一步忘掉后面在命令行敲pip会很痛苦。Linux一般自带python3macOS也可以用自带的或者通过包管理器装一个干净版本。代码编辑器用VSCode、PyCharm哪怕记事本也行重点是解决发信逻辑本身。有个老生常谈的提醒写代码之前建议先建虚拟环境。不过因为这里用的是标准库不涉及依赖管理所以如果你只是为了跑个发送脚本直接在系统环境里写也没问题。真正需要虚拟环境的时候是你后面在同一个项目里引入requests、pandas、schedule这些第三方库的时候。1.2 邮箱侧配置授权码的来龙去脉代码写起来很快但第一次跑通卡住的往往不是代码而是邮箱设置。你需要去邮箱网页版的设置中心找到POP3/SMTP服务相关的开关把它打开。为什么不直接用登录密码做SMTP认证因为第三方客户端如果拿了你的登录密码相当于把整个邮箱的权限都交出去了。所以主流邮箱都会提供“授权码”它也是一串密码但只允许用于SMTP/POP这类协议登录并且可以随时作废重新生成不影响账号本身的登录密码。不同邮箱的入口不一样QQ邮箱设置 - 账户 - POP3/IMAP/SMTP服务开启后会给一串授权码。163邮箱设置 - POP3/SMTP - 开启服务新增授权码。Gmail等国外邮箱普通密码之外要开启两步验证然后生成“应用专用密码”App Password。我个人强烈建议授权码不要直接硬编码在代码里更不要提交到Git仓库。配置成环境变量是最省事也最安全的方式比如在命令行里用export MAIL_AUTH_CODE你的授权码脚本里通过os.getenv(MAIL_AUTH_CODE)读取。一旦哪天授权码泄露到公开仓库去邮箱设置页把它作废一分钟就能解决不用连登录密码一起改。1.3 端口选型465和587到底用哪个SMTP协议涉及三个常见端口25、465、587。很多教程只告诉你“填465”但没有解释为什么这个端口最稳。端口加密方式使用场景备注25无加密或STARTTLS升级传统SMTP很多云厂商和公司防火墙直接封出站25新手最容易在这里卡住465SSL/TLS全程加密个人项目首选用smtplib.SMTP_SSL直连代码最简单587STARTTLS先明文后升级部分企业邮箱强制代码要先starttls()再login()我的建议是默认用465。原因很简单全程加密不容易被网络层截获连接建立方式在smtplib里就是对口的SMTP_SSL少一个starttls步骤也少一类“明明login成功却发不出去”的诡异问题。如果你的公司邮箱只允许587或者邮件服务商明确要求走587代码就改成先普通连接再升级加密import smtplib server smtplib.SMTP(smtp.exmail.qq.com, 587, timeout10) server.starttls() server.login(sender, auth_code)还有一个很容易忽略的点不同邮箱的SMTP服务器地址不一样。QQ邮箱是smtp.qq.com网易是smtp.163.com企业微信邮箱是smtp.exmail.qq.com。写错了会一直报连接超时或者gaierror域名解析失败排查半天发现只是域名拼错了。2. 第一封邮件的完整拆解从代码到原理2.1 最小可用的发信脚本先把完整代码放上来你照着改掉邮箱和授权码基本就能发出第一封邮件了。import smtplib import os from email.mime.text import MIMEText from email.header import Header from email.utils import formataddr smtp_server smtp.qq.com smtp_port 465 sender 你的邮箱qq.com auth_code os.getenv(MAIL_AUTH_CODE, 在这里填授权码) receivers [收件人邮箱example.com] msg MIMEText(你好这是一封来自 Python 的测试邮件。, plain, utf-8) msg[From] formataddr((str(Header(发件人昵称, utf-8)), sender)) msg[To] ,.join(receivers) msg[Subject] Header(测试邮件, utf-8) try: with smtplib.SMTP_SSL(smtp_server, smtp_port, timeout10) as server: server.login(sender, auth_code) server.sendmail(sender, receivers, msg.as_string()) print(发送成功) except Exception as e: print(发送失败:, e)这段代码大概四十多行但每行都有它存在的理由。建议先照着跑一次再往下看为什么这么写。2.2 每一行代码在干什么MIMEText是构造邮件正文的核心类。第一个参数是正文内容第二个参数plain表示纯文本第三个参数utf-8告诉邮件客户端正文是UTF-8编码这样中文才不会乱码。如果后面发HTML邮件这里第二个参数换成html就行。msg[From]和msg[To]是邮件头字段收件人客户端会直接展示这些内容。formataddr的作用是组装出发件人昵称 邮箱地址这种标准格式Header(发件人昵称, utf-8)是专门处理非ASCII昵称的避免中文直接放在头字段里导致编码问题。msg[Subject]同理中文章题一定要用Header包一下。server.login(sender, auth_code)这里第一参数是邮箱完整地址不是昵称也不是QQ号第二参数是授权码不是登录密码。这两个写错一个就会报认证失败后面第四部分我会详细说这个坑。server.sendmail(sender, receivers, msg.as_string())是真正把信投递出去的动作。这里要理解一个关键区别msg[From]、msg[To]只是邮件头展示给收件人看的而sendmail的第二参数才是SMTP信封地址决定服务器把这封信实际投递到哪个邮箱。所以如果你构建邮件头时只写了To但sendmail的收件人列表漏了这封信依然发不出去。用with语句管理连接是Python 3之后推荐的做法发送完成自动关闭连接不用手动quit()。如果你看到某些老教程里写server smtplib.SMTP(...)加server.connect()再加server.quit()那是老写法能用但比较啰嗦一旦中途异常还容易把连接留在那。2.3 发出去之后怎么验证真的进了收件箱控制台打印“发送成功”不代表邮件一定到达了收件箱。我踩过的验证流程是这样发给自己另一个邮箱比如QQ发到163检查标题、正文、昵称有没有乱码。打开邮件源码快速搜索Content-Type: text/plain; charsetutf-8确认编码设置正确。如果发送成功但一直收不到先去垃圾箱看。个人邮箱发给自己确实有概率被归到垃圾箱这个跟邮箱服务商的反垃圾策略有关等一下第四部分专门说。打开smtplib的调试日志能看到底层收发过程with smtplib.SMTP_SSL(smtp_server, smtp_port, timeout10) as server: server.set_debuglevel(1) server.login(sender, auth_code) server.sendmail(sender, receivers, msg.as_string())日志里会出现类似250 OK、535 Authentication failed这样的状态码。250表示上一条命令执行成功535是认证失败550多半是收件人地址被拒绝。学会看这个日志后面排查各种发送问题都会快很多。3. 邮件内容升级HTML正文、内嵌图片和附件3.1 HTML正文能排版但别高估兼容性纯文本邮件在业务里基本不够用。日报、量化信号、爬虫监控至少需要一个带标题、带表格、带高亮的HTML正文。使用方式很简单把MIMEText第二个参数改成html就可以了。但如果你只是写一个带HTML标签的字符串丢进去实际收信时有两个隐患要处理。第一个隐患是老客户端不支持HTML渲染。解决方式是构造一个multipart/alternative结构的邮件先附纯文本版本再附HTML版本。邮件客户端支持HTML就显示HTML不支持就回退到纯文本from email.mime.multipart import MIMEMultipart msg MIMEMultipart(alternative) plain_text 这是一封HTML邮件你的客户端不支持HTML时会看到这段文字。 html_text h2日报/h2p今日数据正常。/p msg.attach(MIMEText(plain_text, plain, utf-8)) msg.attach(MIMEText(html_text, html, utf-8))顺序不能反纯文本放前面HTML放后面。第二个隐患是邮件客户端的CSS兼容性。办公场景最常用的Outlook渲染引擎用的是Word那一套不是浏览器内核。你在网页上写得再漂亮的flex布局、grid布局到Outlook里全部失效。邮件正文排版老老实实用table标签CSS样式全部写成内联style属性图片宽度写死整体宽度控制在600px以内。3.2 图片内嵌CID不是随便写个路径就行邮件里的图片有两种做法。一种是引用网络图片链接比如img srchttps://example.com/chart.png。但很多客户端的默认策略是“先拦截外链图片等用户手动点击才加载”万一赶上一封需要快速传达数据的日报收件人看到一片空白就麻烦了。另一种是先把图片作为附件嵌进邮件然后在HTML里通过cid引用。这才是可靠的方案from email.mime.image import MIMEImage msg MIMEMultipart(related) html_body htmlbodyimg srccid:chart1 width600/body/html msg.attach(MIMEText(html_body, html, utf-8)) with open(chart.png, rb) as f: img MIMEImage(f.read()) img.add_header(Content-ID, chart1) img.add_header(Content-Disposition, inline, filenamechart.png) msg.attach(img)Content-ID的值不一定要带尖括号但两边保持风格一致最稳。容易踩的坑是Content-Disposition写成attachment这样图片不会内嵌显示而是变成下载附件。一定要写成inline。如果邮件同时需要HTML正文和附件msg应该用默认的MIMEMultipart()也就是mixed类型然后把HTML作为子部分再加附件。新手容易搞混related和mixed区分标准就一条有图片需要内嵌显示先考虑related有附件需要下载用mixed两者都有HTML部分用related包一层外面再用mixed挂附件。3.3 附件中文文件名两个常见乱码根源附件是Python发邮件里报错率最高的地方而且问题经常不是“发不出去”而是“发出去文件名乱码”。最常见的错误写法是直接把中文文件名塞进Content-Dispositionatt[Content-Disposition] fattachment; filename{filename}这种写法在部分客户端里中文直接变问号在另一些客户端里可能乱码。正确做法是用MIMEApplication并在add_header里用带编码的filename参数让标准库帮你生成filename*格式的头字段from email.mime.application import MIMEApplication with open(业务报表.xlsx, rb) as f: payload f.read() att MIMEApplication( payload, _subtypevnd.openxmlformats-officedocument.spreadsheetml.sheet, ) att.add_header(Content-Disposition, attachment, filename(utf-8, , 业务报表.xlsx)) msg.attach(att)add_header的filename参数传(utf-8, , 文件名)这个元组会自动生成符合RFC2231规范的编码这是多语言附件名最稳的做法。第二个坑是二进制文件误用MIMEText。有人图省事对Excel文件也用MIMEText包装。但MIMEText内部会做字符集转换还会把\n转成\r\n对二进制数据往往造成不可逆的损伤。Excel打开就报“文件已损坏”多半是这个原因。二进制文件一律用MIMEApplication处理。3.4 多人发送To、Cc、Bcc的正确姿势同一封邮件发给多个人常见写法有三种抄送(Cc)、密送(Bcc)、直接多收件人。代码层面要同时改两个地方只改一处就会出问题。to_list [aexample.com, bexample.com] cc_list [managerexample.com] bcc_list [secretexample.com] msg[To] ,.join(to_list) msg[Cc] ,.join(cc_list) all_recipients to_list cc_list bcc_list server.sendmail(sender, all_recipients, msg.as_string())注意两个细节msg[To]和msg[Cc]是邮件头收件人能看得到。Bcc绝对不能写进邮件头否则所有人都能看到密送列表。Bcc地址只需要放进sendmail的收件人列表SMTP协议会投递但不会出现在邮件头里。sendmail的收件人列表必须包含To、Cc、Bcc所有地址。漏掉Bcc密送的人就收不到漏掉Cc抄送的人就收不到而且发件人自己还以为是发出去了。还有一个容易踩的异常如果to_list为空列表sendmail第二个参数传[]很多服务端会直接抛异常。批量发送程序里发信前一定要先做空列表判断不要以为调用方一定会传合法数据。4. 真实业务里常见的坑和完整的排查链路4.1 授权码失效排查从一个535错误说起有次线上日报脚本突然不发了日志里的报错是smtplib.SMTPAuthenticationError: (535, bLogin Fail. Please enter your authorization code...)这个错误的含义很明确SMTP登录认证失败。完整排查链路我按顺序走一遍。第一步检查login(sender, auth_code)两个参数。第一参数常见错误是填了昵称或者手机号必须是邮箱完整地址。第二参数常见错误是直接用了登录密码或者把环境变量名拼错读了个None。调试时可以临时打印len(auth_code)只要不是None或长度异常这一步基本没问题。第二步检查授权码是不是复制的时候带了空格或者隐藏字符。这个字符问题很隐蔽日志里根本看不出来。解决方式是去邮箱设置页重新生成一次性授权码直接复制不要手打。第三步确认授权码有没有过期。授权码不是永久有效的有些服务商的授权码会在安全策略调整、异常登录、长期未使用后自动失效。重新生成即可。第四步如果是公司邮箱还有一层可能是账号被安全策略锁定了需要联系IT管理员重置。个人邮箱基本不会遇到这层。还有个技术人员容易忽略的操作失败重试机制千万别把认证错误也重试。反复用错误的授权码尝试登录容易被服务商判定为“异常登录”把账号临时冻结本来一分钟能解决的问题变成一小时。所以下面第五部分的重试函数里SMTPAuthenticationError我直接抛出不做重试就是这个原因。4.2 发送频率过高被限流怎么判断、怎么规避批量发信时最容易撞上的错误码是SMTPDataError、SMTPServerDisconnected以及服务端返回的554或421。简而言之你的发送频率超过服务商阈值了。有一个很典型的表现循环发到第几十封时突然开始报错前几封都正常重启脚本后又能发几封然后又挂。这说明不是代码逻辑问题而是被服务端限流了。规避策略分两层。程序层面循环发送时加间隔比如每封间隔三到五秒甚至加一点随机抖动避免出现规律的发送节奏。连接层面不要一个连接从头发到尾建议每二三十封主动断开重连减轻服务端压力。如果你要发的是真正意义的营销邮件、群发邮件个人邮箱的配额完全撑不住应该走正规邮件服务商或者使用支持SMTP的企业邮箱并且提前确认该账号的日发送配额。用个人邮箱给几百个陌生人发推广信这在任何服务商那里都会被认定是垃圾邮件行为技术上再优化也绕不过去。4.3 邮件进了垃圾箱先检查这三个因素邮件发送成功、对方也确实收到了但躺在垃圾箱里。这种事比发送失败更让人头疼因为你根本追踪不到。我的经验是先检查三个因素。第一发件方域名信誉。用个人邮箱的SMTP服务器发信发件域名是“你的好友”成功概率比自建邮件服务器高得多。最忌讳的事是自己搭个Postfix用服务器IP直接发信除非你已经配置好了SPF和DKIM否则几乎百分百进垃圾箱还会污染服务器IP的声誉。第二内容本身是否触发反垃圾规则。正文里大量使用“立即购买”“点击领取”这类词汇、全大写字母、大量感叹号、带一个不存在的退订链接都会提高进垃圾箱的概率。日报类内容走的是“稳定发送、内容克制”路线基本不会撞上这些规则。第三是否被收件人一方主动拦截。有些公司邮箱会把外部域名邮件默认分类或者收件人自己设了过滤器。这种情况就纯靠收件人了技术改变不了。调试阶段有个实用技巧先给自己和小号各发一封确认不是必进垃圾箱的配置问题再大规模发送。如果发现进垃圾箱先换内容再换发件邮箱一步步缩小变量。4.4 服务器时区坑报告时间总差8小时定时任务部署在服务器上之后最容易发现的一个问题是邮件里显示的生成时间比北京时间早了8个小时。原因很简单服务器默认时区是UTC。不要在脚本里随手用datetime.now()生成时间戳它会取系统当前时区。正确做法是显式指定时区from datetime import datetime from zoneinfo import ZoneInfo now datetime.now(ZoneInfo(Asia/Shanghai))Python 3.9及以上版本直接用zoneinfo是首选之前的版本可以用pytz替代。邮件头的Date字段生成标准RFC2822格式也可以交给标准库from email.utils import format_datetime msg[Date] format_datetime(now)这个问题坑在本地开发环境大概率是东八区根本没机会暴露。只有部署到云服务器才出现而且一旦出现报表里的所有时间都会偏移导致收件人误判数据时效。定时任务上线的检查清单里务必加一项打印当前时间和北京时间对一下。4.5 Outlook里HTML样式碎掉别把网页排版思路带进来给公司内部发日报最容易收到一条反馈“表格变形了。”这是邮件HTML和网页HTML的本质差异。邮箱客户端不是浏览器Outlook用的是Word渲染引擎它对CSS的支持停留在非常原始的阶段。为了安全我整理了一份发HTML邮件表格的底线规则布局只用table tr td不要用div、不要用flex、不要用grid。所有CSS写成内联样式比如stylewidth: 100%; border-collapse: collapse;。图片指定具体宽高用width和height属性。标题、关键数字不要依赖样式呈现纯文本必须可读。另一个原则是重要信息一定要有纯文本兜底。也就是说HTML版本再好看前面也要放一份纯文本版本走multipart/alternative结构。数据日报的核心是数字准确、一眼能看到关键指标不是设计还原度。5. 进阶玩法定时发送、断线重试、批量分发5.1 定时任务schedule、cron还是APScheduler很多人问“怎么让脚本每天早上9点自动发邮件”可选方案有三个适用场景完全不同。Python库sched是标准库但接口偏底层一般不会用它做定时任务。schedule这个第三方库最简单几行代码就能跑import schedule import time schedule.every().day.at(09:30).do(send_daily_report) while True: schedule.run_pending() time.sleep(10)代码很直观但它有个致命限制脚本必须常驻后台。服务器一重启、进程被回收定时任务就没了。如果对稳定性要求不高比如个人开发机跑个提醒脚本可以这么干。真正稳定的是系统级定时器。Linux用crontab -e30 9 * * * cd /path/to/project /usr/bin/python3 send_report.py report.log 21注意两个细节/usr/bin/python3要用绝对路径因为cron环境的PATH很精简直接写python3可能找不到解释器输出重定向必须加否则cron报错你根本看不到。Windows就用“任务计划程序”触发条件选“按计划执行”操作里指向python.exe和脚本路径。至于APScheduler它比schedule强大很多支持持久化任务、多进程调度、错过任务补跑适合定时任务很多、逻辑复杂的项目。但如果你只需要每天早上发一封日报cron完全够用没必要引入额外依赖。再说一个容易混淆的需求事件触发邮件而不是定时邮件。比如量化策略盘中触发了止损信号爬虫监控到页面数据异常这时候是需要“立刻发邮件”的不走定时器直接在检测到事件的代码路径里调用发送函数。定时器和事件触发是两回事别混着设计。5.2 带退避重试的发送函数网络请求和SMTP交互都可能遇到瞬时故障比如服务器短暂不可用、连接被重置。直接失败会影响日报任务的连续性所以我习惯封装一个带重试逻辑的发送函数。import smtplib import time import os def send_with_retry(send_func, retries3, base_delay5): for attempt in range(retries): try: send_func() return True except smtplib.SMTPAuthenticationError: raise except ( smtplib.SMTPConnectError, smtplib.SMTPServerDisconnected, ConnectionError, TimeoutError, ): if attempt retries - 1: raise delay base_delay * (2 ** attempt) # 退避5秒、10秒、20秒 print(f第{attempt 1}次发送失败{delay}秒后重试...) time.sleep(delay)两个设计点要注意第一认证错误不重试。SMTPAuthenticationError代表配置层面的问题重试一万次结果一样还可能触发账号锁定。网络类错误才值得重试因为它们通常是暂时的。第二退避间隔按指数增长不要固定延迟。固定延迟如果太短频繁重试等于给服务端压力指数退避给了服务端恢复的时间窗口。重试次数三到五次足够超过这个次数基本可以认为问题不是“闪断级别”的了。还有一个容易忽视的细节重试之前发送函数内部要重新创建SMTP连接对象。如果你把同一个server对象循环复用第一次连接断开后后续调用大概率继续失败。5.3 批量分发速率控制比代码本身更重要批量场景下最常见的困境是逻辑上循环调用发送函数很直观但跑着跑着就被限流了。我现在的做法是批量发送前先想清楚三个问题。第一总量是否在配额以内。个人邮箱一天能发多少服务商没有完全公开但“用来做业务群发”这档子事肯定超出了合理范围。真要发几百封直接换企业邮箱或专门的通知服务不要在个人账号上死磕。第二发送节奏要可控。示例就是每封间隔加随机抖动避免机械匀速触发反垃圾算法import random import time for i, record in enumerate(records): send_mail(record) time.sleep(random.uniform(3, 6))第三不要一个连接连续发几百封。我的习惯是每发二三十封就主动重连一次。另外不要在一封邮件的To栏里塞几十个地址收件人一旦点“全部回复”地址列表就全泄露出去了。更合理的做法是每封邮件单独设置To字段看起来像单独发信也更不容易触发群发特征。5.4 把DataFrame直接变成邮件正文爬虫跑到数据、清洗成DataFrame之后怎么快速变成邮件正文很多人会先写Excel再发附件但收件人如果只是想快速扫一眼数据正文表格远比附件方便。pandas的DataFrame自带一个to_html方法import pandas as pd df.to_html(indexFalse, escapeTrue, border0)但直接用生成的HTML发信有几个现实问题。第一默认生成的表格样式很粗糙列宽不控制、长文本不带截断。第二to_html生成的CSS如果是外部style块在邮件客户端里不一定生效。稳妥做法是手动把关键样式内联或者限制列数和单元格长度只把最重要的几列放邮件正文完整数据放附件。还有一个更实际的经验邮件正文表格最多放五六列超过这个宽度在手机端和Outlook里都很难看。如果DataFrame有十几列要么只挑关键列要么直接发附件。我处理日报的方式是先构造一个HTML格式的摘要表放最重要的指标再附上完整Excel。这样收件人在正文里能一眼看到核心数据需要明细时再下载附件两不耽误。最后再分享一个调试习惯。我现在新写任何一封自动邮件固定流程都是先set_debuglevel(1)打开SMTP日志然后发给自己确认进的是收件箱而不是垃圾箱最后才敢把脚本正式交给定时任务。整个流程走顺之后真正需要动脑子的通常是邮箱配置和反垃圾策略而不是MIMEText和smtplib本身的API。掌握这两条链路Python发邮件这件事就算彻底拿下了。
返回列表