每到月底我就得挨个登录邮箱收报表、回复客户、转发给协作方,久而久之实在顶不住,索性用Python把所有收发动作全部脚本化,现在只要跑一条命令,邮件自动发、自动收、按主题归类、异常自动重试。这个项目看起来简单,真正落地时要处理的细节比想象中多不少:授权码怎么拿、SSL端口用哪个、中文主题为什么乱码、IMAP和POP3怎么选、重复拉取怎么防。这篇就把我用Python自动收发邮件的全过程拆开讲一遍,从协议基础到完整代码,照着改就能用,适合每天和邮件打交道的运营、开发、测试同事参考。
1. 项目目标与技术选型
1.1 自动收发邮件能解决什么
先明确一件事:所谓自动收发邮件,本质是让程序代替人去做两类动作。一类是“发”——把固定模板的内容、报表附件、通知消息按名单批量发出去,不再人工逐封填写;另一类是“收”——定时连接邮箱服务器,把新邮件拉下来,解析正文和附件,然后交给下游流程去处理,比如提取订单号、同步客服工单、统计打卡记录。
我最早的需求是每天早上把前一天的运营数据表发给几位协作人,再回收各区域的回执邮件。人工操作每天要花十几分钟,还容易漏发、发错。脚本化之后只需要在服务器上定时触发,十分钟的重复劳动直接归零。如果你的场景也是“有规律、可描述的收发动作”,那这个项目就适配你;如果只是偶尔手动发一两封,那没必要写脚本,直接客户端就好。
1.2 为什么选Python而不是其他方案
选Python有几个很现实的原因。第一,标准库自带smtplib和imaplib,一个负责发信走SMTP协议,一个负责收信走IMAP或POP3协议,不需要额外安装第三方包就能跑通全流程;第二,Python处理字符串和编码非常顺手,处理邮件头、主题、附件文件名这些容易乱码的地方,标准库的email模块可以少踩很多坑;第三,后续接办公自动化、数据分析、企业微信通知等扩展,Python的生态也方便。
对比一下其他方案:用打工机器人平台的图形化流程确实也可以,但灵活度和可控性差一些,而且平台一旦调整规则就受影响;用Shell加命令行工具收发邮件也不是不行,但解析复杂HTML邮件和附件时会很痛苦;用Java写也可以,但同等功能下代码量和依赖管理明显重。综合来看,Python适合快速落地、方便维护、扩展灵活,所以这个项目选型上没有纠结。
2. 动手前的基础:邮箱授权码与协议分工
2.1 第三方邮箱需要先搞到授权码
在写第一行代码之前,先要解决认证问题。现在主流邮箱一般不再支持单纯的账号密码在第三方客户端登录,而是要求使用“授权码”。授权码是你在邮箱网页端手动生成的一串专用密码,只用于登录第三方应用,可以随时撤销,比主密码安全一些。
申请授权码的大致路径是:登录网页邮箱 -> 进入设置 -> 找到“客户端专用密码”或“授权码”选项 -> 按要求开启服务后生成。开启的动作往往包括两步:先开启SMTP/IMAP服务,再生成授权码。需要注意,有的服务商会把授权码和绑定IP关联,如果服务器IP变了可能需要重新生成;有的则是一次性展示,务必先复制保存再关闭页面。
2.2 smtplib、imaplib、email模块的职责划分
写代码前把三个模块的分工理清楚,后面就不会混乱。smtplib负责“运输”,它跟邮件服务器的SMTP端口建立连接,执行登录、发送指令、断连;email包下的MIMEText、MIMEMultipart等类负责“内容构造”,把文本、HTML、图片、附件组装成符合邮件格式的对象;imaplib和poplib负责“接收”,连上收件服务器后按条件拉取邮件原文。
这套组合里有一个容易忽略的点:smtplib只管传输,不管内容格式,你传入的msg.as_string()必须已经是格式化好的RFC822字符串,而这一步正是交给email模块来完成的。很多新手报错554或发送失败,往往不是网络问题,而是主题或收件人字段格式不对,根源就是对这两个模块的职责没有分清楚。收件方向也一样:imaplib.fetch拿到的是原始字节流,必须再交给email.message_from_bytes解析成邮件对象,才能提取主题、正文和附件。
3. 用Python发送邮件:从最小示例到完整版
3.1 一个能跑的最小发送示例
找一个空目录创建send_mail.py,先写一个最精简的版本,验证连接和发送通道是否通:
import smtplib from email.mime.text import MIMEText smtp_server = "smtp.example.com" smtp_port = 465 sender = "sender@example.com" password = "your-auth-code" recipient = "receiver@example.com" msg = MIMEText("这是一封Python自动发送的测试邮件。", "plain", "utf-8") msg["From"] = sender msg["To"] = recipient msg["Subject"] = "测试邮件" with smtplib.SMTP_SSL(smtp_server, smtp_port) as server: server.login(sender, password) server.sendmail(sender, [recipient], msg.as_string()) print("发送成功")这里用SMTP_SSL直连465端口,相对省事,很多云服务器也开放这个端口。如果你所在网络环境仅开放587端口,那就要改用SMTP加starttls()的方式:
with smtplib.SMTP(smtp_server, 587) as server: server.starttls() server.login(sender, password) server.sendmail(sender, [recipient], msg.as_string())两者只差一个建立加密链路的时机。465是SSL直接加密,587先明文连接再通过STARTTLS升级为加密通道。具体用哪个,要去看邮箱服务商公开的服务器配置页面,我在项目里统一写成了带端口参数的函数,方便切换。
3.2 构造纯文本、HTML和附件邮件
纯文本邮件适合内部告警,但报表场景通常需要更好的可读性。我建议用MIMEMultipart把正文、附件组装到一起。下面是一个带HTML正文和一个Excel附件的完整示例:
import smtplib import os from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.application import MIMEApplication from email.header import Header smtp_server = "smtp.example.com" smtp_port = 465 sender = "sender@example.com" password = "your-auth-code" # 组装邮件对象 msg = MIMEMultipart() msg["From"] = Header("数据自动助手 <sender@example.com>") msg["To"] = "receiver@example.com" msg["Subject"] = Header("2024年11月运营日报", "utf-8") html = """ <html> <body> <h3>请查收本月运营日报</h3> <p>本月核心指标如下:</p> <table border="1"> <tr><td>日活</td><td>12.3w</td></tr> <tr><td>新增用户</td><td>3.2w</td></tr> </table> <p>详细数据见附件。</p> </body> </html> """ msg.attach(MIMEText(html, "html", "utf-8")) attachment_path = "monthly_report.xlsx" with open(attachment_path, "rb") as f: part = MIMEApplication(f.read()) part.add_header("Content-Disposition", "attachment", filename=("utf-8", "", os.path.basename(attachment_path))) msg.attach(part) with smtplib.SMTP_SSL(smtp_server, smtp_port) as server: server.login(sender, password) server.sendmail(sender, ["receiver@example.com"], msg.as_string()) print("带附件的邮件发送成功")这里有三处细节值得单独说。一是Header("数据自动助手 <...>")这种写法可以给发件人起一个友好显示名,收件人看到的发件人不是裸邮箱而是“数据自动助手”,体验好很多。二是附件文件名如果包含中文,add_header里的("utf-8", "", 文件名)是标准做法,不加这个参数很多客户端会显示乱码。三是HTML正文里不要直接拼外部图片链接,部分邮件客户端会拦截,尽量用CID内嵌或者直接给附件。
3.3 群发、抄送、密送的实现与陷阱
群发不是简单的循环调用sendmail,更稳妥的做法是构建一封邮件,把多个收件人放进收件人列表,然后一次发送。这样所有收件人看到的是同一封邮件,而不是每个收件人收到一封单独投递的副本。sendmail支持传入列表参数:
recipients = ["a@example.com", "b@example.com", "c@example.com"] cc_list = ["boss@example.com"] # 写入To和Cc字段,收件端才能正确按角色展示 msg["To"] = ",".join(recipients) msg["Cc"] = ",".join(cc_list) with smtplib.SMTP_SSL(smtp_server, smtp_port) as server: server.login(sender, password) # 信封收件人包含to和cc,sendmail的第二个参数负责实际投递 server.sendmail(sender, recipients + cc_list, msg.as_string())这里有个容易踩的坑:msg["To"]是显示在信头里的收件人,而sendmail第二个参数才是真正控制投递的“信封收件人”。如果只设置了msg["To"]却忘了在sendmail里传入,发送会失败或对方根本收不到。抄送和密送的区别是:密送要把收件人写在sendmail的信封参数里但不要写入邮件信头,收件人之间就互相看不到密送地址。群发大量用户时还需要控制发送频率,我一般在循环里加上sleep,间隔一两秒,避免触发服务商的频率限制。
4. 用Python接收邮件:IMAP与POP3的正确姿势
4.1 收到邮件前先选对协议
收邮件前首先要回答一个基础问题:用IMAP还是POP3。IMAP的特点是邮件保留在服务器上,客户端只是同步状态,适合多设备、需要历史存档的场景;POP3的特点是客户端把邮件拉下来后服务器可以选择删除,适合单机、需要释放服务器空间的场景。此外IMAP支持在服务器端做文件夹管理和状态标记,可以给已读、打标、移动到特定文件夹,这对自动化处理非常重要。
如果只是做一次性数据采集,POP3够用;但做持续运行的自动化任务,我强烈建议IMAP。因为脚本任务大概率需要“抓完一批后标记为已读”或“把源文件移动到归档文件夹”,这些动作在IMAP里可以直接操作,POP3则很难优雅处理。下面所有示例都基于imaplib。
4.2 拉取最近邮件并解析主题和正文
下面是连接IMAP服务器、拉取未读邮件、解析主题和纯文本正文的代码:
import imaplib import email from email.header import decode_header def decode_header_value(value): if value is None: return "(无主题)" parts = decode_header(value) result = [] for content, charset in parts: if isinstance(content, bytes): result.append(content.decode(charset or "utf-8", errors="replace")) else: result.append(content) return "".join(result) def get_text_from_message(message): if message.is_multipart(): for part in message.walk(): content_type = part.get_content_type() if content_type == "text/plain": payload = part.get_payload(decode=True) charset = part.get_content_charset() or "utf-8" return payload.decode(charset, errors="replace") else: payload = message.get_payload(decode=True) charset = message.get_content_charset() or "utf-8" return payload.decode(charset, errors="replace") return "" imap_server = "imap.example.com" username = "receiver@example.com" password = "your-auth-code" conn = imaplib.IMAP4_SSL(imap_server) conn.login(username, password) conn.select("INBOX") status, data = conn.search(None, "UNSEEN") if status != "OK": print("检索失败") else: msg_nums = data[0].split() print(f"当前未读邮件数量: {len(msg_nums)}") # 只处理最近5封,防止一次性拉太多 for num in msg_nums[-5:]: fetch_status, msg_data = conn.fetch(num, "(RFC822)") raw_email = msg_data[0][1] message = email.message_from_bytes(raw_email) subject = decode_header_value(message.get("Subject")) from_addr = decode_header_value(message.get("From")).strip("<>\"'") body = get_text_from_message(message) print("---") print(f"发件人: {from_addr}") print(f"主题: {subject}") print(f"正文预览: {body[:100]}") conn.logout()需要注意,conn.search(None, "UNSEEN")返回的是邮件序号列表,fetch再用这些序号去取完整内容。这里的搜索条件是UNSEEN,即未读邮件。如果你的业务规则不同,可以改成按日期搜索,例如SINCE "01-Nov-2024"。解析主题时用decode_header必须遍历parts,因为主题可能被拆成多段编码;解析正文时优先取text/plain,避免复杂的HTML混杂导致后续处理出错。
4.3 保存附件并标记已读
自动下载附件是收件流程里最实用的能力,比如接收自动生成的报表、收集指定格式的提交文件。拉取邮件后,遍历message.walk(),找到filename存在且Content-Disposition为attachment的部分,写出文件即可。同时利用IMAP的STORE命令把邮件标记为已读:
import os def download_attachments(message, download_dir="attachments"): if not os.path.exists(download_dir): os.makedirs(download_dir) saved = [] for part in message.walk(): content_disposition = str(part.get("Content-Disposition", "")) if "attachment" not in content_disposition: continue filename = part.get_filename() if filename: decoded = decode_header_value(filename) raw_data = part.get_payload(decode=True) file_path = os.path.join(download_dir, decoded) with open(file_path, "wb") as f: f.write(raw_data) saved.append(file_path) return saved # 在循环体内用完附件下载后,标记已读 conn.store(num, "+FLAGS", "\\Seen")标记已读需要谨慎做。如果任务还在调试期,先不要加store标记,避免漏处理时邮件被标记为已读后搜不到。我会把“读取和处理”和“标记已读”分成两个阶段,先用一个参数开关控制,确认处理逻辑稳定后再默认开启。
4.4 幂等策略:避免重复处理同一封邮件
自动化任务最大的隐患不是跑不起来,而是跑起来后重复处理。收件任务每五分钟跑一次,如果网络抖动或脚本中途崩溃,重启后可能把同一封邮件再跑一遍,造成重复回复、重复入库。解决思路有三个层次:一是利用IMAP的已读标记,处理完就标记已读,下次搜索UNSEEN就不会再撞上;二是在本地维护一个处理过的邮件ID列表,把message["Message-ID"]保存到SQLite或普通文件;三是把整个处理流程做成幂等操作,比如数据库插入用了唯一键、重复插入自动忽略。
最简单也最有效的组合是“已读标记+本地记录”,前者限制搜索范围,后者防止重启后刚标记但未实际完成处理的边界情况。我在项目里保存了一个processed_ids.txt,每次处理前先判断ID是否在集合中,处理成功后追加写入。代码量只增加几行,但能防御很多诡异的重放场景。
5. 封装自动化任务:定时、日志和重试
5.1 自动回复与自动转发的小型业务实现
收发邮件真正值钱的是把两者串成一条自动化流程。举一个我实际做过的例子:用户提交咨询邮件后,系统自动回复一封“已收到,预计24小时内回复”的邮件,同时把原邮件和附件转发给客服组邮箱。这个流程如果手动做,每封邮件要复制粘贴、添加附件、再给组邮箱转发,但脚本只需两步:收件线程解析邮件,发件线程构造回信和转发信。关键代码可以抽象成一个函数:
def reply_and_forward(original_message, original_attachments): reply_to = original_message.get("Reply-To") or original_message.get("From") subject = original_message.get("Subject") # 构造自动回复 reply = MIMEMultipart() reply["From"] = sender reply["To"] = reply_to reply["Subject"] = f"Re: {subject}" reply.attach(MIMEText("已收到您的邮件,我们会在1个工作日内回复。", "plain", "utf-8")) # 构造转发 forward = MIMEMultipart() forward["From"] = sender forward["To"] = "support@example.com" forward["Subject"] = f"[转] {subject}" for attachment_path in original_attachments: # 重新把附件挂到转发邮件 with open(attachment_path, "rb") as f: part = MIMEApplication(f.read()) part.add_header("Content-Disposition", "attachment", filename=("utf-8", "", os.path.basename(attachment_path))) forward.attach(part) forward.attach(MIMEText(f"原邮件主题:{subject}\n请根据附件处理。", "plain", "utf-8")) return reply, forward这种流程非常适合服务型团队接入,原理不复杂,难的是把异常兜住:如果原邮件没有Reply-To字段怎么办、原附件已经重名怎么处理、回信失败是否要通知人工。这些边界条件必须在一开始就铺好。
5.2 用本地定时调度触发收发任务
脚本本身不产生时机,需要靠调度器驱动。我一般分成两层:外层用系统自带定时任务,内层用sched或schedule库在进程内管任务,这样既方便在后台常驻,也便于手动执行调试。
import schedule import time def job_send_report(): send_mail_with_attachment("monthly_report.xlsx") def job_check_inbox(): process_new_mails() schedule.every().day.at("09:00").do(job_send_report) schedule.every().hour.do(job_check_inbox) while True: schedule.run_pending() time.sleep(30)注意,schedule库不是真正的分布式定时任务框架,进程崩溃或服务器重启后没人拉起来。生产环境更稳妥的做法是让外层定时任务直接调用脚本文件,脚本跑完就退出。比如设为每十分钟执行一次python check_inbox.py,简单可靠,也方便复制到其他机器。我在部署阶段通常先手动跑一整天确认流程,再加到系统定时任务里。
5.3 日志、告警、重试机制
没有日志的自动化脚本等于没有黑匣子。发送和接收的每一步,建议把结果写进本地日志文件,至少包含时间、收件人、主题、成功失败、异常堆栈。我习惯用标准库logging,同时在异常分支里做重试:
import logging import time logging.basicConfig( filename="mail_bot.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" ) def send_with_retry(send_func, retries=3, delay=5): for attempt in range(1, retries + 1): try: result = send_func() logging.info("send success: %s", result) return result except Exception as exc: logging.warning("attempt %s failed: %s", attempt, exc) if attempt < retries: time.sleep(delay) raise RuntimeError("发送失败,重试仍无法成功")重试不是盲目执行,收件的重试要特别小心,同一封邮件在重试时可能已经被上次的STORE标成已读,导致搜不到。解决办法是重试失败时先不标已读,把异常塞到另一个failed文件夹。发送邮件相对简单,幂等性没那么敏感,失败后隔几分钟重发一次即可。日志文件和重试参数最好都做成可配置项,别写死在业务代码里。
6. 常见问题与避坑记录
6.1 常见报错速查表
smtplib.SMTPAuthenticationError 授权码错误或者服务未开通 核对授权码是否复制完整,重新生成后更新配置
554 5.7.1 发件人地址被认为是垃圾邮件来源,或SPF记录异常 检查发件域名是否配置了SPF/DKIM,避免使用随机发信域名
TimeoutError 网络到目标服务器端口不通 测试端口连通性,确认465/993端口是否被防火墙拦截
imaplib.IMAP4.error: b'Unknown command' 搜索参数写法不对 检查search参数的格式,注意关键字大小写,示例:
conn.search(None, 'ALL')UnicodeDecodeError 解析主题或正文时编码判断错误 解码时使用
errors="replace",并优先从邮件头获知charset参数
这段速查表是我实际踩过的问题汇总,其中最麻烦的是反垃圾策略。发送频率偏高时,服务商可能临时拒绝连接或要求额外验证,表现是前几封正常、后面突然报554。我的对策是群发时随机排一下收件人顺序,每封间隔1到2秒,同时把邮件主题放到一个模板池里轮换,降低批量特征。成本不高,但效果立竿见影。
6.2 中文乱码问题的一揽子方案
中文乱码主要出现在两个位置:邮件主题和附件文件名。主题乱码多半是因为构造时直接赋值给msg["Subject"]而没有用Header包装,或者MIMEText构造时没有指定utf-8。附件文件名乱码则是文件名的编码方式不完全符合邮件标准。我的统一处理方式如下:
- 主题一律用
Header("中文主题", "utf-8") MIMEText构造时三个参数都写:(正文, "plain"/"html", "utf-8")- 附件文件名用
add_header("Content-Disposition", "attachment", filename=("utf-8", "", "文件名.xlsx")) - 解析时用
decode_header,不要直接拿原始字节decode
6.3 从脚本到服务形态的扩展观察
自动收发邮件这件事,往上走可以接一堆东西。我在项目中额外接了一个简易的统计看板:每收到一封邮件,把发件人、主题、时间写入数据库,按小时汇总展示;每天自动发送的报表量也通过日志汇总。后面你还可以扩展出异常告警触发即时消息通知、根据邮件内容自动创建工单、定时把归档邮件导出到本地备份,这些都属于同一个基础能力的延伸。只要收发两条通道是稳定的,扩展只是加业务函数的事。
最后再分享一个个人习惯:脚本里所有服务器配置、授权码、收件人名单,全部抽到单独的配置文件中,不要在代码里出现明文账号。密码和授权码就算只存在自用服务器上,也建议至少做一层环境变量或keyring管理,否则代码一旦传到协作仓库,等于把邮箱凭证交了出去。这个项目本质上是纪律性很强的活儿,越往后越会发现,真正能稳定跑下去的系统不靠炫技,靠的是把边界问题一个个提前想清楚。