☰
Python自动收发邮件实战:从SMTP/IMAP到定时调度完整指南
2026/9/26 14:16:44 网站建设 项目流程

你有没有经历过这样的早晨:手机弹出一堆邮件提醒,真正重要的其实就一两封,剩下的全是系统通知、自动抄送和无关紧要的周报。我是被某次值班夜的告警邮件折腾怕了,才下决心把所有和邮件相关的重复劳动全交给脚本。这篇文章不绕弯子,直接讲透用Python自动收发邮件的完整思路——从SMTP发信、IMAP收信,到附件解析、定时调度,再到我实际跑生产环境踩过的那些坑。不管你之前写没写过Python,只要照下面的思路和代码走,都能搭一套属于你自己的邮件自动化小工具。

核心关键词就两个:Python、自动收发邮件。搞懂这两件事,你能少做很多"打开网页登录邮箱再点发送"的机械操作。

1. 动手前先分清SMTP和IMAP:协议选型不搞清,后面全是坑

很多新手一上来就搜"Python发邮件代码",抄到一段就跑,结果换了邮箱服务商就报错,连错误信息都看不懂。这背后的根本原因,是没搞清楚邮件收发其实是两套完全不同的协议。

1.1 发信和收信是两套完全不同的协议

SMTP(Simple Mail Transfer Protocol)负责发信,可以理解成邮局门口的收件窗口——你把信递进去,邮局负责投递,之后的事就不归你管了。Python里对应的标准库是smtplib。

IMAP(Internet Message Access Protocol)负责收信,更像是你的私人档案管理员——邮件始终保存在服务器上,你可以随时翻阅、标记已读、归类、下载附件,所有操作状态都会同步回服务器。Python里对应的标准库是imaplib。

很多人习惯把收信也归到SMTP,或者把POP3和IMAP混为一谈。实际上POP3更像是"把信从信箱里全部搬回家里",搬完服务器上就没了;而IMAP是"信放服务器上,你想看哪封看哪封"。对自动化场景来说,IMAP明显更合适,因为你可以在服务器上维护已读/未读状态,还能在脚本里做多轮处理,不用每次全量重新下载。

1.2 各家邮箱的服务地址与授权码:新手第一道坎

发信和收信都需要连到具体的服务器地址和端口。我实测下来,国内常用邮箱的配置大概是这样的:

邮箱服务商SMTP服务器SMTP SSL端口IMAP服务器IMAP SSL端口
QQ邮箱smtp.qq.com465imap.qq.com993
163邮箱smtp.163.com465imap.163.com993
126邮箱smtp.126.com465imap.126.com993
新浪邮箱smtp.sina.com465imap.sina.com993
Gmailsmtp.gmail.com465imap.gmail.com993

这里有一个关键概念:授权码。你登录SMTP/IMAP服务时用的不是邮箱登录密码,而是邮箱服务商单独给你生成的一串专用密码。以QQ邮箱为例,需要在设置里找到"账户",开启POP3/SMTP服务,然后按提示用短信验证,拿到一个16位左右的授权码。163邮箱也是类似操作。这一步是新手最常卡住的地方——很多人直接填登录密码,结果一直报" authentication failed"。

1.3 标准库就够了,最多再加一个schedule

Python自带的smtplib、imaplib、email三个标准库已经能满足收发邮件、解析MIME、处理附件的大部分需求,不需要额外安装什么重量级依赖。我自己完整的邮件自动化脚本,核心依赖也就是这三个库加一个schedule做定时调度。

如果你的环境还没装Python,直接去官网下载3.8以上版本即可,Windows、macOS、Linux都有对应安装包。用之前可以用python -V确认一下版本。本文所有代码在Python 3.10上测试通过,3.8以上应该都没问题。

2. 发送邮件:从一行发信代码到可复用的完整模块

发信是邮件自动化的第一个高频场景。最简单的场景就是你有个脚本跑完了,想发个通知邮件给自己或团队。下面从最朴素的写法开始,一步步封装成能用在生产环境里的函数。

2.1 最朴素的发信脚本:先跑通再说

先看一段能直接运行的最小实现:

import smtplib from email.mime.text import MIMEText from email.header import Header sender = 'your_name@qq.com' auth_code = '你的授权码' receiver = 'someone@example.com' msg = MIMEText('这是一封自动化测试邮件', 'plain', 'utf-8') msg['Subject'] = Header('Python自动发信测试', 'utf-8') msg['From'] = sender msg['To'] = receiver with smtplib.SMTP_SSL('smtp.qq.com', 465) as server: server.login(sender, auth_code) server.sendmail(sender, [receiver], msg.as_string()) print('邮件已发送')

这段代码里,MIMEText第一个参数是正文内容,第二个参数'plain'表示纯文本,第三个参数'utf-8'声明字符集。Header的作用是把中文主题做RFC 2047编码,避免邮件客户端显示乱码。SMTP_SSL直接走SSL加密连接,465端口是QQ邮箱的标准加密端口。

这里有个细节值得注意:sendmail的第二个参数是接收者列表,而不是字符串。也就是说即使你只发给一个人,也要写成[receiver]。这个格式在后面的群发场景里会自然用到。

2.2 正文升级为HTML:让通知邮件好看一点

纯文本邮件能表达信息,但如果你想在正文里放一个简单的表格、加粗重点、或者加个超链接,就需要HTML正文。改动其实很小,把MIMEText的第二个参数从'plain'换成'html'即可:

html_content = """ <html> <body> <h3>服务巡检结果</h3> <p>以下服务状态异常:</p> <ul> <li>订单服务:<b style="color:red;">超时</b></li> <li>支付回调:<b style="color:red;">失败</b></li> </ul> </body> </html> """ msg = MIMEText(html_content, 'html', 'utf-8')

如果你的收件人邮件客户端比较老,或者有些客户端默认屏蔽HTML,可以同时提供纯文本和HTML两个版本,用MIMEMultipart('alternative')组合起来。不过就我观察,现在主流邮件客户端对HTML的支持已经很完善,自己用的话直接上HTML就行,没必要做双版本。

2.3 带附件的邮件:文件名编码是个隐藏的坑

发附件最常见的需求是发送报表,比如Excel、PDF、日志文件。核心代码是把文件读成二进制,再用MIMEApplication包装成附件:

from email.mime.multipart import MIMEMultipart from email.mime.application import MIMEApplication import os msg = MIMEMultipart() msg['Subject'] = Header('周报附件', 'utf-8') msg['From'] = sender msg['To'] = receiver # 正文部分 msg.attach(MIMEText('请查收附件', 'plain', 'utf-8')) # 附件部分 file_path = 'report.xlsx' with open(file_path, 'rb') as f: part = MIMEApplication(f.read()) file_name = os.path.basename(file_path) part.add_header('Content-Disposition', 'attachment', filename=('utf-8', '', file_name)) msg.attach(part)

注意add_header里filename参数的三元组写法,它告诉邮件客户端"这个文件名是用utf-8编码的"。如果直接写filename=file_name,遇到中文文件名就很容易在对方邮件里看到一堆等号开头乱码。这个坑我一开始也踩过,后来统一用这种写法,再没出过问题。

2.4 封装成可复用模块:参数化、带日志、支持抄送

单发一封邮件容易,但日常使用你需要一个能反复调用的函数。我最终用的发信封装大概长这样:

import smtplib import logging from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.mime.application import MIMEApplication from email.header import Header import os SMTP_SERVER = 'smtp.qq.com' SMTP_PORT = 465 SENDER = 'your_name@qq.com' AUTH_CODE = '你的授权码' def send_mail(subject, content, receivers, content_type='plain', cc=None, bcc=None, attachments=None): msg = MIMEMultipart() msg['From'] = SENDER msg['To'] = ', '.join(receivers) if cc: msg['Cc'] = ', '.join(cc) if bcc: msg['Bcc'] = ', '.join(bcc) msg['Subject'] = Header(subject, 'utf-8') msg.attach(MIMEText(content, content_type, 'utf-8')) if attachments: for path in attachments: if not os.path.exists(path): logging.warning(f'附件不存在: {path}') continue with open(path, 'rb') as f: part = MIMEApplication(f.read()) part.add_header('Content-Disposition', 'attachment', filename=('utf-8', '', os.path.basename(path))) msg.attach(part) send_targets = receivers[:] if cc: send_targets += cc if bcc: send_targets += bcc with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT) as server: server.login(SENDER, AUTH_CODE) server.sendmail(SENDER, send_targets, msg.as_string()) logging.info(f'邮件发送成功: {subject}')

抄送(Cc)和密送(Bcc)的区别需要说明一下:收件人能看到Cc里有哪些人,但看不到Bcc里的人。实际使用中,只有在sendmail的参数里把Cc和Bcc的地址全部带上,这些人才会真正收到邮件,单在msg['Cc']里写是不够的。另外,授权码这类敏感信息不建议直接硬编码在代码里,我个人的习惯是放到环境变量或独立的配置文件里,脚本用os.getenv()读取,避免代码泄漏时连邮箱权限一起泄漏。

3. 接收邮件:用IMAP把邮件变成结构化数据

发送只是自动化的一半。真正让我觉得"值回票价"的,是把收件箱里的邮件批量拉下来、解析成结构化数据。比如每天早上自动整理前一天未读邮件,提取主题、发件人、正文,再生成一份摘要发给自己。

3.1 连接邮箱、选文件夹、搜索邮件

import imaplib conn = imaplib.IMAP4_SSL('imap.qq.com', 993) conn.login('your_name@qq.com', '你的授权码') conn.select('INBOX') status, messages = conn.search(None, 'UNSEEN') mail_ids = messages[0].split()

这里select('INBOX')表示选收件箱。你也可以选其他文件夹,比如已发送、垃圾邮件、自定义标签文件夹。search的第一个参数是字符集,一般传None;第二个参数是搜索条件,'UNSEEN'表示未读邮件,'ALL'表示所有邮件。

返回的mail_ids是一个字节串列表,每个元素是一封邮件的服务端ID。这个ID是字符串形式,后面所有针对单封邮件的操作都要用到它。如果想取最近的几天邮件,也可以配合日期条件,比如search(None, 'SINCE 01-Jan-2024'),不过对自动化场景来说,按未读状态筛选通常最实用。

3.2 用email库解析MIME结构

拿到邮件ID之后,用conn.fetch把它拉下来:

status, data = conn.fetch(mail_ids[-1], '(RFC822)') raw_email = data[0][1] msg = email.message_from_bytes(raw_email)

data[0][1]是邮件的原始字节内容,RFC822是MIME邮件的标准格式。接下来用email.message_from_bytes把它解析成一个Message对象。这个对象是一棵MIME树——邮件正文、附件、内嵌图片都以不同形式挂在树上。理解这一点很重要:你不能简单地把整个邮件当作文本去匹配,需要遍历它的每一个part。

3.3 解码发件人、主题:两个常用的处理函数

邮件头里的主题和发件人,经常是被编码过的。尤其是中文主题,会变成=?UTF-8?B?...?=这样的形式。直接打印肯定乱码,必须解码:

from email.header import decode_header from email.utils import parseaddr def decode_mime_header(raw): if not raw: return '' parts = decode_header(raw) decoded = [] for data, charset in parts: if isinstance(data, bytes): decoded.append(data.decode(charset or 'utf-8', errors='replace')) else: decoded.append(data) return ''.join(decoded) from_addr = parseaddr(msg.get('From'))[1] subject = decode_mime_header(msg.get('Subject'))

parseaddr能把"张三 <zhangsan@example.com>"拆成姓名和邮箱地址,第二个元素是纯粹的地址。decode_header会返回一个列表,列表里每个元素是(内容, 字符集)的元组,需要逐个解码再拼接。这两个函数建议直接抄进自己的工具库里,后面会反复用。

3.4 提取正文和下载附件:完整示例

一封邮件可能同时包含纯文本、HTML、附件。我用一个walk()遍历所有part,按Content-Type区分:

import email def parse_mail(msg): text_content = '' html_content = '' attachments = [] for part in msg.walk(): content_type = part.get_content_type() disposition = str(part.get('Content-Disposition') or '') if content_type == 'text/plain' and 'attachment' not in disposition: text_content += part.get_payload(decode=True).decode( part.get_content_charset() or 'utf-8', errors='replace') elif content_type == 'text/html' and 'attachment' not in disposition: html_content += part.get_payload(decode=True).decode( part.get_content_charset() or 'utf-8', errors='replace') elif 'attachment' in disposition: filename = part.get_filename() if filename: filename = decode_mime_header(filename) data = part.get_payload(decode=True) attachments.append((filename, data)) return text_content, html_content, attachments

这个函数的逻辑很直白:text/plain和text/html都是正文的候选,有附件则把字节流和文件名存下来。真正在生产里跑的时候,我会优先取纯文本,如果纯文本为空再取HTML,然后用正则去掉HTML标签当作降级方案。

用上面的解析函数,再加上前面搜索未读邮件的逻辑,拉取最近5封未读邮件并打印摘要、下载附件的完整过程就是这样的:

for mail_id in mail_ids[-5:]: status, data = conn.fetch(mail_id, '(RFC822)') msg = email.message_from_bytes(data[0][1]) from_addr = parseaddr(msg.get('From'))[1] subject = decode_mime_header(msg.get('Subject')) text, html, attachments = parse_mail(msg) print(f'发件人: {from_addr}') print(f'主题: {subject}') print(f'正文前100字: {text[:100]}') for filename, data in attachments: with open(filename, 'wb') as f: f.write(data) print(f'已下载附件: {filename}') # 标记为已读 conn.store(mail_id, '+FLAGS', '\\Seen')

如果你不想每次都处理同一批邮件,记得用conn.store(mail_id, '+FLAGS', '\\Seen')把它标记成已读。否则下次脚本跑起来,UNSEEN搜索还是会把这些邮件拉一遍。同理,store还可以用来打删除标记('\\Deleted'),配合conn.expunge()可以实现自动清理。

3.5 用IMAP的邮件状态管理做增量处理

IMAP比POP3好用的另一个点是状态同步。你可以维护一个"已处理"标记,即使不输删除,下次也不会重复处理。做法可以在邮件头中加自定义标记,也可以直接用IMAP的Flags。我个人的经验是:如果只是做周期性的摘要整理,用\\Seen标记就足够;如果要区分"已读但没处理"和"已处理归档",建议把邮件移动到另一个文件夹,比如conn.copy(mail_id, 'Processed')然后删掉原邮件。文件夹的名字可以自己定,前提是先在客户端或服务端管理页面建好。

4. 自动化调度:让脚本自己跑起来,而不是你手动去执行

如果你的脚本要每隔几分钟收一次告警邮件、每天定时发日报,那就必须解决"谁在什么时间启动脚本"的问题。这一步做得不好,前面写得再漂亮的代码也会沦为半自动工具。

4.1 本地定时调度的两种推荐方式

我见过有人直接在脚本里写死循环加time.sleep(3600),进程常驻后台。这能跑,但不够优雅——脚本一崩就断,重启还得记得拉起,代码里还多了一堆调度逻辑。我更推荐用系统级调度的方式:

  • Linux/macOS:用crontab。编辑当前用户任务:
crontab -e

然后加一行:

0 9 * * * /usr/bin/python3 /home/user/send_daily_report.py >> /home/user/mail_automation.log 2>&1

这行的意思是每天9点整执行一次脚本,标准输出和错误都写入日志文件。关于cron的时间格式,分 时 日 月 周,想不起来就查表,别凭感觉写。

  • Windows:用任务计划程序。创建一个基本任务,触发器选"每天",操作选"启动程序",程序填Python的完整路径,参数填脚本完整路径。注意Windows下路径带空格要用引号包起来。

如果你的需求间隔比较短(比如每30秒检查一次队列),crontab最小粒度是1分钟,此时可以考虑schedule库搭配长驻进程。schedule的用法非常简单:

import schedule import time schedule.every().day.at("09:00").do(send_daily_report) schedule.every(30).minutes.do(check_mail_queue) while True: schedule.run_pending() time.sleep(1)

这个方案适合单机小场景,优点是跨平台、逻辑直观;缺点是进程不能挂。日志打好,配合系统服务管理起来更靠谱。

4.2 典型场景1:监控告警自动发邮件

我在服务器上跑了几个定时巡检脚本,其中一个检查磁盘和水位线。逻辑很简单:用shutil.disk_usage拿磁盘使用率,超过90%就触发告警邮件:

import shutil from send_mail import send_mail usage = shutil.disk_usage('/') percent = usage.used / usage.total * 100 if percent > 90: send_mail('磁盘使用率告警', f'当前磁盘使用率: {percent:.1f}%', ['ops@example.com'])

这个模式可以无限扩展:检查网站HTTP状态码、数据库连接数、证书过期时间、爬虫任务是否正常结束,全部往里套。我自己的经验是,把每个检查点做成独立函数,返回(是否异常, 描述),再用一个汇总任务统一收集、合并发邮件。不然一个监控点一封邮件,夜里能被自己机器的告警吵醒。

4.3 典型场景2:收件箱自动整理与日报生成

定时收邮件这个场景,最实用的做法是:每天早上9点跑一次脚本,把过去24小时未读邮件全部拉下来解析,按发件域名分组统计数量,找出主题含"紧急"或"告警"的邮件,生成一份文本摘要,再通过发信模块发到自己的另一个常用邮箱。

实现思路不难,就是把第3节的收信代码和第二节的发信代码串起来。关键点在于避免重复处理:用\\Seen标记之外,我建议在本地维护一个processed_ids集合,存到SQLite或文件里,每次处理前先比对一下,双保险。这个设计在IMAP服务器状态同步异常时特别有用,能防止同一封邮件被处理两遍。

4.4 日志、重试与异常不吞掉

自动化脚本最忌讳的就是"异常被静默吞掉"。你写了个except Exception: pass,脚本是没报错,可邮件也没发出去,等用户发现时已经晚了。

我个人的底线是:任何异常都必须写进日志。可以用Python标准库logging模块,配置一个RotatingFileHandler,让日志自动按天或按大小轮转。

对于那种"网络抖动导致SMTP连接失败"的偶发问题,加重试机制:

import time def send_mail_with_retry(func, *args, retries=3, delay=10, **kwargs): for i in range(retries): try: return func(*args, **kwargs) except Exception as e: logging.warning(f'第{i+1}次失败: {e}') if i < retries - 1: time.sleep(delay) else: logging.error(f'重试{retries}次仍失败') raise

重试要控制频率,别陷入紧循环。SMTP连接失败重试3次、每次间隔10秒是我验证过比较稳妥的配置。

5. 实战排雷:这些坑我每个都踩过,每个都能浪费你半天

最后这部分是全文最有价值的经验沉淀。很多问题不是官方文档里写不清,而是你没有踩到那一刻根本不会意识到。

5.1 授权码填成登录密码,或者账号写成了ID

这是新手报错频率最高的点。QQ邮箱、163邮箱开启SMTP/IMAP服务后生成的授权码,和你日常登录邮箱的密码完全是两回事。另外还有一类隐藏坑:企业邮箱账号有时是zhangsan而不是zhangsan@company.com,或者需要在账号后面加后缀。我处理过一个案例,同事把公司邮箱地址写成了姓名拼音,SMTP直接拒绝认证。解决方法是去邮箱服务商的帮助中心查一下"客户端配置"页,上面会明确写账号、端口、加密方式。

5.2 高频发送被限制与判定垃圾邮件的边界

这个必须认真提醒:不要用脚本做营销群发,更不要大规模骚扰式发信。邮件服务商对单次发信数量、发送频率都有反垃圾策略,短时间发几十上百封,轻则触发验证码要求,重则直接封禁SMTP权限。即使是你自己的合法业务通知,也应该控制频率、控制单封收件人数,并且尽量让收件人主动订阅、有退订渠道。

如果你的自动化脚本只是给自己或团队发告警通知,那完全不用担心这个问题。真到了需要批量发信的业务场景,应该去用专业邮件服务商的API,而不是自己搭SMTP群发。专业服务有完善的额度管理、退订机制和送达率统计,这才是合规路线。

5.3 中文乱码的根因:字符集声明不一致

中文乱码大概是邮件自动化里最折磨人的问题。根因通常是:正文用utf-8编码,但MIMEText没声明字符集;或者主题用Header包了一层,但忘记指定编码;或者收件人邮件客户端默认按GB2312解析。

统一解法就三条:

  • 所有正文一律写MIMEText(content, 'plain', 'utf-8')或MIMEText(content, 'html', 'utf-8'),字符集参数绝不能省。
  • 主题统一用Header(subject, 'utf-8')。
  • 附件文件名用filename=('utf-8', '', filename)三元组写法。

按这三条走,中文基本不会再出问题。万一还有乱码,先用邮件客户端查看"原始邮件"信息,看Content-Type里的charset到底是什么,再对症下药。

5.4 IMAP连接超时与断线重连

IMAP和SMTP不一样,它保持长连接时服务端可能主动断开会话,尤其连接挂在后台很久不用的时候。表现就是脚本跑着跑着突然卡住,过了一段时间抛TimeoutError。解决思路是:

  • 关键操作前先检查连接状态,必要时重连。
  • 长任务分批次fetch,不要一次把几千封邮件全拉回来。
  • 如果是定时任务,干脆每次执行都重新登录,处理完主动conn.logout()。相比维护长连接,这种"短连接式"的方案简单得多,本来定时任务也不是高并发场景,登录开销完全可以忽略。

5.5 测试邮件别发给自己

最后一个小提醒:开发调试阶段千万别拿生产收件人地址当测试对象,也别反复给自己发同样的测试邮件。一是容易触发防滥用策略,二是会把真实邮件混在测试里,影响排查。我当时专门申请了一个测试邮箱,把所有自动化脚本的收件人都指向它,测没问题再改真实收件人。

收个尾:先跑通最小闭环,再逐步加码

这套邮件自动化体系我已经在自己的服务器上跑了大半年,每天早上的日报汇总、磁盘告警、服务状态异常通知全靠它。刚开始那几天我也是一封一封手动点邮件,后来发现人的注意力不该耗在这上面,于是从最简单的发信脚本开始,跑通了加收信解析,再加定时调度,再慢慢补齐重试和日志,整个过程没有一步到位。如果你想做类似的事情,我的建议是把第一封测试邮件发出去作为起点,哪怕正文只有一句"hello",只要通了,后面的路就顺了。遇到报错优先去邮箱服务商帮助中心查端口和协议配置,十个坑里有九个都在那里。先跑起来,再迭代,这才是做自动化最务实的路径。

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

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

立即咨询