Python自动收发邮件这件事,听起来好像很老派,但真正落地过的人都知道,它其实是自动化里性价比极高的一块。我最早接触这个需求,是因为当时要每天给一批客户发报表,几十封邮件手动点发送,复制粘贴就能把人磨疯。后来把整套流程改成Python脚本自动发,顺手又把收件箱里的自动回复做了分类归档,整个效率才算真正提上来。这篇文章不打算堆概念,而是把我实际做过的发送、接收、解析、定时调度这套东西从头到尾过一遍,包含完整代码和踩坑记录,希望能帮你绕开我走过的那段弯路。
适合谁看?如果你的工作里也有固定发报告、群发通知、监控告警的需求,或者想定时检查某个邮箱、自动下载邮件附件,这篇基本可以直接拿来用。新手也不用怕,协议层面我只讲够用的那点基础知识,更多是带着你一步步把代码跑起来,对照自己的邮箱配置改一改就能用。
1. 为什么需要Python自动收发邮件
1.1 邮件自动化的典型场景
很多人一听到“Python收发邮件”,第一反应是“群发垃圾邮件”。其实真正工作里用得最多的是这几种场景:
- 定时发送报表:每天上午9点,把昨天的销售数据、服务器状态、日报汇总自动发给指定的人。
- 监控告警:服务宕机、磁盘满了、接口响应超时,脚本检测到异常后立刻发邮件通知运维。
- 批量通知:比如系统批量给用户发送重置密码邮件、验证码邮件、订单状态通知。
- 收取并处理邮件:定时登录某个业务邮箱,把邮件里的附件下载下来、把验证码提取出来、或者把重要邮件自动转发给相关负责人。
- 自动化测试回归:测试环境里验证邮件发送功能是否正常,自动检查发件箱和收件箱的往返链路。
这些场景有一个共同点:机械、重复、固定频率。人去做很容易漏、容易烦,但脚本做却能稳定执行。我之前在某公司做内部系统时,运维组每周都要手动查一遍服务器磁盘和日志备份,后来我写了一个Python脚本,每天定时发一份健康检查邮件,运维组的同事说那段时间是他们最省心的时候。
1.2 传输协议选型:SMTP、POP3、IMAP怎么搭配
邮件自动化的基础是协议,Python里发的和收的其实是两套完全不同的东西,很多人第一次搞混就在这里。
发送邮件用的是SMTP(Simple Mail Transfer Protocol),它只负责“把邮件送出去”,不负责把邮件存到你收件箱。你可以把它理解成邮局门口的邮筒,你把信投进去,邮局负责分发,但至于收件人怎么看信,跟它没关系。Python里的smtplib就是干这个的。
接收邮件有两种主流协议:POP3和IMAP。POP3的逻辑是“把服务器上的邮件下载到本地”,下载完可以设置是否删除服务器上的原始邮件;IMAP的逻辑是“邮件留在服务器上,本地客户端同步操作”,它支持已读状态、文件夹管理,更适合多设备同步。如果你只是想把邮件拉到本地做解析,POP3也够用;但如果你希望脚本处理完后邮件还能在邮箱里保留、并且能标记已读,那IMAP更合适。
我实际推荐直接用IMAP,因为它比POP3更智能,后面写自动处理脚本时也灵活得多。它们的默认端口通常是:
| 协议 | 用途 | 非加密端口 | SSL/TLS加密端口 |
|---|---|---|---|
| SMTP | 发送邮件 | 25 | 465或587 |
| POP3 | 接收邮件 | 110 | 995 |
| IMAP | 接收邮件 | 143 | 993 |
现在主流邮箱基本都强制要求加密连接,所以代码里直接写SSL端口是最稳妥的,避免被服务商拒绝或内容被明文截获。
2. 环境准备与发送邮件核心实现
2.1 标准库选型与授权码机制
Python收发邮件最大的优势就是不用装第三方库,官方自带的smtplib和email两个标准库就能搞定大部分需求。smtplib负责和邮件服务器对话,email库负责构造邮件内容——主题、发件人、收件人、正文、附件这些都要靠它组织成标准格式。
需要去邮箱设置里提前做一件事:开启SMTP/POP3/IMAP服务,然后获取一个“授权码”。很多新人在这一步就被卡住了,因为他们下意识地拿邮箱登录密码去登录SMTP服务器,结果三次失败后被锁。这个问题后面会细说,但至少你要知道:现在几乎所有主流邮箱都不允许用明文密码做第三方客户端登录,而是要求在安全设置里生成一个专门的授权码,这个授权码可以随时重置,泄露了也能单独撤销,比直接暴露密码安全得多。
我建议把授权码写在环境变量里,或者写在一个单独的不入库的配置文件里,不要硬编码进脚本。虽然这是个很小的习惯,但真到了脚本要部署到服务器、或者交给别的同事维护时,差别非常大。
2.2 纯文本邮件与HTML邮件发送实操
先看一段最基础的发送代码,逻辑非常简单:
import smtplib from email.mime.text import MIMEText from email.header import Header sender = "your_name@example.com" receiver = "target@example.com" subject = "这是一封测试邮件" content = "正文内容,你可以写很多话进去。" msg = MIMEText(content, "plain", "utf-8") msg["From"] = sender msg["To"] = receiver msg["Subject"] = Header(subject, "utf-8") try: server = smtplib.SMTP_SSL("smtp.example.com", 465) server.login(sender, "你的授权码") server.sendmail(sender, [receiver], msg.as_string()) server.quit() print("发送成功") except Exception as e: print("发送失败:", e)这段代码里有两个细节值得注意。第一,msg["Subject"]用Header(subject, "utf-8")包装,是为了防止中文主题在投递过程中变成乱码。第二,sendmail的第二个参数是收件人列表,即使你只发给一个人,也建议写成列表形式,因为后面做群发时这个数据结构不用改。
HTML邮件的构造方式略有不同,要用MIMEMultipart把纯文本和HTML两个版本都放进去。为什么要放两个版本?因为有些客户端默认不加载HTML,只展示纯文本;而HTML版本则可以渲染出排版和样式。邮件客户端会按自己的偏好选择展示哪个。构造示例:
from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText msg = MIMEMultipart("alternative") msg["From"] = sender msg["To"] = receiver msg["Subject"] = Header("带样式的通知邮件", "utf-8") text_content = "如果你看到这封纯文本内容,说明客户端不支持HTML展示。" html_content = """ <html> <body> <h2>这里是一个通知标题</h2> <p>正文部分可以包含<b>加粗</b>、<a href="https://example.com">链接</a>等元素。</p> </body> </html> """ msg.attach(MIMEText(text_content, "plain", "utf-8")) msg.attach(MIMEText(html_content, "html", "utf-8"))之后调用smtplib发送的代码和上面一样。这类邮件适合通知类、报表概览类场景,不过要克制一点,不要用太花哨的样式,很多邮件客户端对背景图、自定义字体支持很差,反而容易让内容显示异常。
2.3 带附件与图片的邮件
实际办公中,发附件是最常见的需求。PDF报表、Excel数据、日志文件,都希望脚本自动打包发过去。附件本质上就是把一段二进制数据挂载到邮件里,用MIMEApplication就能实现:
from email.mime.application import MIMEApplication msg = MIMEMultipart() msg["From"] = sender msg["To"] = receiver msg["Subject"] = Header("月度报表", "utf-8") msg.attach(MIMEText("详情见附件。", "plain", "utf-8")) file_path = "MonthlyReport.pdf" with open(file_path, "rb") as f: attachment = MIMEApplication(f.read()) attachment.add_header( "Content-Disposition", "attachment", filename=("utf-8", "", file_path) ) msg.attach(attachment)附件文件名如果有中文,最好用filename=("utf-8", "", 文件名)这种写法,让编码标准兼容大多数客户端。如果你要发多个附件,就循环读取每个文件然后attach即可。
还有一个容易忽略的坑:附件过大。各家邮箱对附件大小都有限制,普遍在20MB到50MB之间。如果脚本要发大文件,要么先压缩成zip,要么改走网盘链接,不要把几十MB的文件硬塞进邮件里。我试过发几MB的Excel附件没问题,超过20MB就有邮件服务商直接把信退回来了。
3. 自动接收邮件与内容解析
3.1 imaplib接收邮件的完整流程
收邮件相对发邮件来说要复杂一点,因为你需要处理的数据结构更多。以IMAP为例,标准步骤大概是:连接服务器并登录、选择文件夹(通常是收件箱INBOX)、搜索符合条件(比如未读)的邮件、逐封下载邮件内容、解析邮件对象、可选地标记已读或移动邮件。
import imaplib mail = imaplib.IMAP4_SSL("imap.example.com", 993) mail.login("your_name@example.com", "你的授权码") mail.select("INBOX") # 搜索所有未读邮件 status, messages = mail.search(None, "UNSEEN") if status != "OK": print("搜索失败") for msg_id in messages[0].split(): # 拉取邮件内容 _, data = mail.fetch(msg_id, "(RFC822)") raw_email = data[0][1] print("取到邮件,编号:", msg_id)这里推荐用UNSEEN去搜索,而不是把全部邮件都拉下来处理,因为业务邮箱里的历史邮件往往非常多,全量拉取一次不仅慢,还会造成大量重复处理。脚本首次上线时可以先全量跑一遍,之后改成只处理未读邮件,跑完直接标记已读,就不会重复了。
退出时记得调用mail.logout(),否则连接会一直挂在服务器上。虽然不至于有什么严重后果,但曾遇到过连接过多导致邮箱临时拒绝登录的情况,所以这个好习惯值得养成。
3.2 邮件主题解码与正文提取
从IMAP取到的是原始邮件,需要用email库的message_from_bytes解析成邮件对象,然后再从里面提取发件人、主题、正文和附件。这中间最容易出问题的是编码解码。
直接读msg["Subject"]很可能得到一串带等号和问号的编码字符串,因为中文主题在传输时会经过编码。正确做法是用decode_header来解:
from email.header import decode_header def decode_str(s): if not s: return "" parts = decode_header(s) result = "" for part, charset in parts: if isinstance(part, bytes): result += part.decode(charset or "utf-8", errors="replace") else: result += part return result subject = decode_str(msg["Subject"]) from_ = decode_str(msg.get("From"))正文提取就需要遍历邮件内容。一个邮件可能是纯文本、可能是HTML、也可能是多部分嵌套的复合结构。我之前写了一个递归加迭代的提取函数,直接兼容多种邮件结构:
import email def get_text_content(msg): if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == "text/plain": charset = part.get_content_charset() or "utf-8" return part.get_payload(decode=True).decode(charset, errors="replace") else: if msg.get_content_type() == "text/plain": charset = msg.get_content_charset() or "utf-8" return msg.get_payload(decode=True).decode(charset, errors="replace") return ""有人会问为什么优先取text/plain而不是text/html。因为纯文本内容在处理、搜索关键词、写入日志时更方便,也不容易受HTML标签干扰。如果你要的是网页排版信息,也可以改成优先解析html,不过做关键词匹配我就用纯文本。
附件提取的逻辑是在msg.walk()里判断每个part的Content-Disposition是否为attachment,是的话就把payload解码后写到本地文件。注意附件名同样要用decode_header解一遍,否则中文文件名会变成乱码。
def save_attachments(msg, save_dir="attachments/"): import os os.makedirs(save_dir, exist_ok=True) saved = [] for part in msg.walk(): if part.get_content_disposition() == "attachment": filename = decode_str(part.get_filename()) if not filename: continue data = part.get_payload(decode=True) file_path = os.path.join(save_dir, filename) with open(file_path, "wb") as f: f.write(data) saved.append(file_path) return saved3.3 实现自动筛选与关键词告警
当你能拿到邮件正文和发件人地址之后,很多事情就变得顺理成章了。比如我做过一个内部需求:某个系统每天会发送大量通知邮件,但里面偶尔混入一些异常告警,负责人不可能一直盯着邮箱。我写了一个脚本,定时登录邮箱,把未读邮件下来后按发件人分组,再对正文做关键词匹配,一旦出现“ERROR”“Failed”“异常”这类词,就立刻再发一封醒目的汇总告警邮件给负责人。
核心匹配逻辑大概是这样:
keywords = ["ERROR", "Failed", "500", "timeout"] def is_important(content): return any(k.lower() in content.lower() for k in keywords) for msg_id in messages[0].split(): _, data = mail.fetch(msg_id, "(RFC822)") mail.store(msg_id, "+FLAGS", "\\Seen") msg = email.message_from_bytes(data[0][1]) content = get_text_content(msg) if is_important(content): send_alert_mail(decode_str(msg["From"]), subject, content)这里有一个细节:处理完一封邮件后立刻标记已读,这样即使脚本中间崩溃,重启后也不会把同一封邮件再告警一遍。当然,你如果希望保留未读状态做二次处理,可以去掉store那行。
4. 定时任务调度与真实场景整合
4.1 定时调度的三种方式对比
脚本写好了,总不能一直手动跑。我实际用下来有三种常见做法,各有各的适用场景。
- 系统自带计划任务:Windows的任务计划程序,Linux的crontab。优点是不需要额外安装任何东西,脚本挂了还有系统日志可查;缺点是配置起来不算直观,尤其是Windows的界面选项多,容易把配置弄错。
- Python的schedule库:纯Python实现,
pip install schedule就能用,代码里直接声明执行周期,非常直观。但要注意它只适合“进程还活着”的情况,如果脚本因为异常退出,定时任务也就停了,所以需要配合循环和异常兜底。 - 专门的Docker容器或服务化部署:适合正式环境,把脚本包装成容器,由容器平台负责重启和调度。这是最稳健的方案,但搭建成本更高。
我个人的选择是:测试环境用schedule库快速验证,正式环境要么用cron,要么包装成一个小服务。下面用schedule库演示最简用法:
import schedule import time def job(): print("执行定时任务") # 这里调用你上面的发送或接收函数 schedule.every().day.at("09:00").do(job) schedule.every().hour.do(job) while True: schedule.run_pending() time.sleep(1)4.2 案例:服务器磁盘监控与自动告警
纸上谈兵没意思,我把自己实际部署过的一个案例完整放出来。需求是这样的:某台服务器每天凌晨检查各磁盘分区使用率,如果超过90%就发告警邮件给运维组;如果正常,不发邮件,只把结果写入日志。
import smtplib import shutil import logging from email.mime.text import MIMEText from email.header import Header logging.basicConfig(filename="disk_monitor.log", level=logging.INFO) def send_alert(subject, content): sender = "monitor@example.com" receiver = ["ops@example.com"] msg = MIMEText(content, "plain", "utf-8") msg["From"] = sender msg["To"] = ",".join(receiver) msg["Subject"] = Header(subject, "utf-8") with smtplib.SMTP_SSL("smtp.example.com", 465) as server: server.login(sender, "授权码") server.sendmail(sender, receiver, msg.as_string()) def check_disk(): for path in ["/", "/data"]: usage = shutil.disk_usage(path) percent = usage.used / usage.total * 100 logging.info(f"磁盘 {path}: 使用率 {percent:.1f}%") if percent > 90: send_alert( f"磁盘使用率告警: {path}", f"分区 {path} 当前使用率已达 {percent:.1f}%, 请及时清理。" ) check_disk()这个案例其实已经把前面所有的知识点都串起来了:构造邮件、SMTP发送、异常日志。真实使用时你还要加上异常捕获,比如磁盘检测本身失败时也要告警,否则监控就成了聋子的耳朵。
4.3 案例:日报自动汇总与发送
另一个经常遇到的需求是日报汇总。假设你每天早上需要从一个接口拿到昨天的业务数据,生成一份HTML摘要邮件,发给团队里所有相关人。
import requests def get_yesterday_data(): # 这里的接口地址只是示例, 替换成你自己的 resp = requests.get("https://api.example.com/report/yesterday") resp.raise_for_status() return resp.json() def build_html(data): html = "<h2>昨日业务汇总</h2>" for key, value in data.items(): html += f"<p>{key}: {value}</p>" return html def send_daily_report(): data = get_yesterday_data() html_content = build_html(data) # 构造 multipart 邮件并发送, 代码同上 ...这种模式可以衍生出很多变体,比如周末不发送、节假日使用预置模板、失败时重试三次。实际项目里每个需求差别很大,但核心流程都是固定的:取数、渲染、发送、记录日志。
有时候日报不只要HTML正文,还要带一个Excel附件。生成Excel可以用pandas或openpyxl,生成后直接挂在附件里发送,那封信就同时包含可视化的HTML概要和可编辑的Excel明细。这个组合在职场里非常受欢迎,因为领导一眼看到结论,同事又能拿到数据源去二次处理。
5. 实战中踩过的坑与排查手册
5.1 连接失败与认证失败的排查
我把自己和身边朋友实际遇到过的报错整理成了一张表,遇到问题直接对照排查即可:
| 报错信息 | 常见原因 | 解决办法 |
|---|---|---|
smtplib.SMTPAuthenticationError | 授权码错误、或用了登录密码 | 去邮箱设置里重新生成授权码,不要用账号密码 |
ConnectionRefusedError | 端口号写错或服务器不支持SSL | 确认使用465或587端口,确认服务商协议开启 |
TimeoutError | 网络不通或服务器连接慢了 | 检查防火墙、换网络环境,或适当调大超时时间 |
smtplib.SMTPRecipientsRefused | 收件人地址格式不对或该地址被拒收 | 确认收件人邮箱地址拼写,看看是否被服务商拉了黑名单 |
imaplib.IMAP4.error | IMAP服务未开启或授权码异常 | 登录邮箱网页端,确认IMAP服务处于开启状态 |
| 邮件进入垃圾箱 | 发送内容被服务商判定为垃圾邮件 | 降低发送频率,优化文案,减少链接和附件数量 |
这里有必要多说一句认证失败。很多人第一反应是“我密码明明没错”,但邮箱服务商让填的其实是一串授权码,两者不是一回事。授权码通常需要登录网页版邮箱,在安全设置或第三方客户端设置里才能生成。换一台电脑、换一个客户端,都需要重新确认授权码是否复制完整,因为它不会明文显示在列表里,通常只显示生成时间和状态。
5.2 编码、乱码与内容解析异常
中文乱码是我收到过最多的问题。邮件编码这里水很深,但只要记住一句话:构造邮件时全部显式指定utf-8,解析时先按声明的charset解,没有声明就按utf-8再按gbk兜底,基本能覆盖绝大多数场景。
主题文件名的乱码,用前面写的decode_str函数都能处理。正文乱码则要看邮件是纯文本、HTML还是Base64,但get_payload(decode=True)会自动做Base64解码,所以通常只需要关心charset。
还有一个问题容易被忽略:某些客户端发送的HTML邮件里,正文不是直接文本而是带很多样式标签和数据,这种情况你从HTML里提取纯文本会很费劲。我后来直接用正则或html2text库,先把HTML转成纯文本,再做关键词匹配,省了很多事。
import re def html_to_text(html): html = re.sub(r"<style.*?>.*?</style>", "" , html, flags=re.S) html = re.sub(r"<script.*?>.*?</script>", "", html, flags=re.S) html = re.sub(r"<[^>]+>", " ", html) html = re.sub(r"\s+", " ", html) return html.strip()5.3 邮件自动化要小心的安全边界
最后聊一点安全方面的体会。Python收发邮件本身技术难度不大,但如果部署到生产环境,有几个地方必须注意。
授权码不要写在代码仓库里。我见过有人把授权码提交到内部代码仓库然后忘了撤回,虽然仓库限定内部使用,但谁也不能保证哪天仓库权限被扩大或账号被泄露。正确的做法是用环境变量,或者用一个读取权限严格控制的外部配置项。
发送频率要克制。同一账号短时间内大量发送邮件,很容易被邮件服务商当作异常行为,轻则进垃圾箱,重则临时封禁发件功能。我实际测试过,普通的通知邮件控制在每分钟10封以内比较安全,群发场景还要额外控制节奏。如果是真实的大规模营销或通知需求,建议申请专门的邮件服务API,而不要硬刚SMTP。
异常处理要完整。不要只用try...except捕获一下打印就完事。生产脚本里至少要有:发送失败后的重试机制、异常退出后的日志记录、重试多少次仍未成功时的告警渠道。否则监控脚本自己挂了,运维都不知道监控已经失效了,这就本末倒置了。
跑了一圈下来,我最大的体会是:Python收发邮件本身不难,难的是把各种边界情况想清楚。比如收件人邮箱满了会退信、内容里带太多外链容易被判垃圾邮件、线程池发多了会被服务商临时限流,这些在测试环境里都看不出来,只有真实跑一段时间才能遇到。如果你刚开始做邮件自动化,建议先小范围试用一周,把发送频率、失败重试、日志留全,再逐步扩大规模。我下一步还打算把整块逻辑封装成统一的邮件服务模块,支持模板、附件白名单和定时策略,等落地了再回来分享。