资讯详情

资讯详情

Python邮件自动化实战:SMTP与IMAP实现收发与归档

如果你手上维护着几台服务器或者日常干活经常要通过邮件和客户、同事来回传附件那你大概率会有这样的感觉一封封手动发日报、蹲在邮箱旁边等某个附件实在太浪费人生。我第一次认真去写 Python 自动化的时候也正是从“Python自动收发邮件”这个入口入坑的。这个需求看起来不大但真把它做完以后你会意识到它是所有办公自动化项目里最划算的一块敲门砖技术栈特别通用不依赖任何收费服务几乎能把日常邮件往来里那些重复动作都包掉。这篇文章我尽量少讲虚的直接给出一套可落地的方案先讲清楚SMTP和IMAP这两个协议分别在干什么再分别写发送邮件、接收和解析邮件的模块然后用一个“日报自动发送 附件自动归档”的完整案例把它们串起来。最后我把这几年实操里踩过的坑整理成了一张排查表标出的报错信息、根因和解决办法都是真实见过的。适合这类读者参考Python语法刚入门、想找第一个能实际用的练习项目的人经常要发重复邮件、想要自动化省时间的运营或运维以及想把“邮件收发”接到自己业务系统里的开发同学。1. 做邮件自动化前先搞清楚这套方案解决什么问题1.1 哪些日常场景能直接受益邮件自动化不是一个孤立功能它最大的价值在于“可以被其他脚本触发”。我说几个最常见的落地场景你看看自己有没有遇到过类似的。第一类是自动发送通知。比如服务器磁盘占用超过85%、接口健康检查连续失败、夜间定时任务跑挂了监控脚本直接把告警发到运维邮箱或指定同事邮箱比登录服务器看日志快得多。第二类是定时汇报。每天上班后自动把前一天的销售数据、订单明细、访问量报告生成Excel或PDF再作为附件发给固定名单。这类工作根本不需要人手动处理只要数据源靠谱邮件脚本能把每天早上的重复劳动压缩到0。第三类是自动处理收到的邮件。比如你有一个专门的业务邮箱同事或客户把“订单导出需求”发过来后脚本通过IMAP检测到新邮件自动解析邮件内容并生成结果再自动回复给发件人。整个过程不需要人眼一直盯着收件箱。第四类是附件归档。很多人会用邮箱当作轻量文件传输通道周期性收到的报表、合同扫描件、日志文件如果不及时保存很容易漏。用脚本定时把未读邮件的附件下载到本地目录并按日期归档基本可以告别“附件过期被清理”的尴尬。1.2 为什么是SMTP IMAP而不是去模拟网页版邮箱我在网上看到不少新手想用Python操作邮箱时第一反应是模拟浏览器登录网页版邮箱比如用Selenium点收件箱、读取页面元素。这个思路虽然看起来“通吃所有邮箱”但实际工程里是典型的错误方向网页结构说改就改Cookie和登录验证码难处理更不要说网页里还嵌了一堆异步加载和参数校验。每次邮箱厂商改版你的模拟代码基本就废了。真正稳定可靠的方式是用邮箱服务商公开支持的邮件协议即SMTP发信和IMAP/POP3收信。SMTP负责把邮件从你的脚本运到邮件服务器IMAP负责从邮件服务器读取邮件这些都是国际标准协议几乎所有邮箱都支持。只要通过授权码完成认证你就能像邮件客户端一样收发信件根本不需要关心网页长什么样。这也意味着代码写好后可以同时对接QQ邮箱、163邮箱、企业邮箱甚至自己搭建的邮箱服务通用性很强。1.3 技术栈选型以标准库为主依赖越少越好本项目实现主体不需要安装任何重量级框架Python自带的smtplib、imaplib、email三个标准库就足够覆盖绝大多数收发需求。smtplib负责和SMTP服务器对话把写好的邮件内容通过网络发出去imaplib负责连接IMAP服务器检索和读取邮件email模块负责从MIME格式的邮件中解析发件人、主题、正文和附件。额外会用到的第三方库只有一个schedule用来做定时任务的调度。它是一个轻量级库不需要像Celery那样引入消息队列适合单机脚本场景。如果你完全不想引入第三方依赖也可以直接用time.sleep()写一个死循环效果一样只是代码丑一点我在第5章会给出两种方案。整个工程只需要一个网络环境能连通邮件服务器的机器Windows、Linux、macOS都行能装Python就行。2. 环境准备与邮箱授权码申请2.1 运行环境需要准备什么第一步是确认本机已经安装Python 3.8以上版本。检查命令是python3 --version或python --versionWindows下如果没装去官网下载安装包时记住勾选“Add Python to PATH”不然后面命令行找不到。因为整个项目依赖不多我建议新建一个干净的虚拟环境避免污染全局Python环境。在项目目录下执行python -m venv venv然后激活虚拟环境。Windows下激活命令是venv\Scripts\activateLinux或macOS下是source venv/bin/activate激活后切换到国内PyPI镜像源安装依赖如果网络好也可以跳过这一步然后安装schedulepip install schedule安装后可以用下面这条命令验证是否成功python -c import schedule; print(schedule.__version__)如果能看到版本号环境就准备好了。整个项目开发我用VS Code就能完成不需要额外安装复杂IDE。2.2 邮件服务器地址和端口参数差异不同邮箱服务商的服务器地址和端口有差异必须提前确认否则脚本会报时间超时或SSL握手失败。我用得最多的是QQ邮箱和163邮箱参数整理在这张表里直接用就行。邮箱类型SMTP服务器SMTP SSL端口IMAP服务器IMAP SSL端口网页开启方式QQ邮箱smtp.qq.com465imap.qq.com993设置-账号-开启POP3/SMTP服务163邮箱smtp.163.com465imap.163.com993设置-POP3/SMTP/IMAP-开启服务126邮箱smtp.126.com465imap.126.com993设置-POP3/SMTP/IMAP-开启服务企业微信邮箱smtp.exmail.qq.com465imap.exmail.qq.com993管理后台或客户端开启如果你用的是其他邮箱去帮助中心搜索“SMTP服务器地址”和“IMAP服务器地址”就能找到。这里我特别提醒一个细节不要看到587端口就默认能用。587端口通常对应STARTTLS加密方式需要先建立明文连接再升级加密通道465端口则是直接走SSL。QQ邮箱的465端口很稳定所以我下面的示例代码默认用SMTP_SSL连接465端口逻辑最简单坑也最少。2.3 申请授权码的完整步骤刚接触的人最容易在这里被卡住在脚本里填邮箱密码连接邮箱服务器时却报“535 authentication failed”。这是因为现在主流邮箱为了安全已经禁止第三方程序直接使用登录密码进行SMTP/IMAP认证必须使用授权码也就是“专供第三方应用使用的独立密码”。以QQ邮箱为例完整流程是这样的先用浏览器登录QQ邮箱网页版点击顶部“设置”进入“账号”页面往下翻找到“POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务”这一项把“IMAP/SMTP服务”和“POP3/SMTP服务”都开启开启时一般需要短信验证。验证通过后页面会给你一串授权码类似abcdefghijklmnop通常建议现在就把这串代码复制到自己的本地配置文件里。163邮箱的申请流程也基本一样在设置里开启“IMAP/SMTP服务”并设置客户端授权码即可。授权码和登录密码是两回事登录网页邮箱时用的密码改了不影响已经生成的授权码除非你手动重置。拿到授权码之后我强烈建议不要把授权码直接写死在Python源码里。邮件自动化脚本经常会上传到Git仓库或者发给同事参考一旦授权码泄露别人就能读取你的全部邮件。更稳妥的做法是从环境变量读取import os EMAIL_ADDR os.getenv(EMAIL_ADDR, your_nameqq.com) AUTH_CODE os.getenv(AUTH_CODE, 你的授权码) SMTP_HOST os.getenv(SMTP_HOST, smtp.qq.com) SMTP_PORT int(os.getenv(SMTP_PORT, 465)) IMAP_HOST os.getenv(IMAP_HOST, imap.qq.com) IMAP_PORT int(os.getenv(IMAP_PORT, 993))在Linux服务器上可以通过export EMAIL_ADDR...写入环境变量Windows则可以在系统设置里配置。平时本地调试时我会放在项目目录的.env文件里用python-dotenv读取但千万记得把.env加进.gitignore防止误提交。3. 发信模块把纯文本、HTML和附件交给SMTP3.1 最小可用发信脚本一行一行拆开讲先不搞复杂的类封装我写一个最短可用的发信函数。只要把收件人、主题、正文和授权码填进去就能发出第一封邮件。import os import smtplib from email.mime.text import MIMEText from email.header import Header from email.utils import formataddr EMAIL_ADDR os.getenv(EMAIL_ADDR) AUTH_CODE os.getenv(AUTH_CODE) SMTP_HOST os.getenv(SMTP_HOST, smtp.qq.com) SMTP_PORT int(os.getenv(SMTP_PORT, 465)) def send_text_mail(to_addrs, subject, content): msg MIMEText(content, plain, utf-8) msg[From] formataddr((str(Header(自动化机器人, utf-8)), EMAIL_ADDR)) msg[To] , .join(to_addrs) msg[Subject] Header(subject, utf-8) with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout15) as server: server.login(EMAIL_ADDR, AUTH_CODE) server.send_message(msg)解释几个关键点。MIMEText(content, plain, utf-8)表示构建一个纯文本格式的邮件内容编码方式是UTF-8。msg[From]、msg[To]、msg[Subject]这三行是在设置邮件头部。其中Header函数不是可有可无的装饰邮件头标准规定非ASCII字符必须进行编码如果主题是中文却直接赋值msg[Subject] 测试邮件某些邮箱收到的会是一串乱码。formataddr用于构造“显示名 邮箱地址”格式的地址让收件人看到发件人是“自动化机器人”而不是裸邮件地址。server.login(EMAIL_ADDR, AUTH_CODE)是认证注意这里用的是授权码而不是登录密码。send_message(msg)会自动根据msg里的头部信息构造MIME格式并发送比老教程里常写的sendmail方法省心很多它默认能处理一部分头部规范化问题。3.2 MIME结构为什么邮件看起来像“包裹套包裹”学会了基础发信以后会有个非常自然的疑问如果我想加一个Excel附件能不能直接把Excel内容往MIMEText里塞答案是不行原因要从邮件的MIME结构说起。可以把邮件理解成快递包裹。纯文本邮件相当于只有一个内容物的快递袋MIMEText就是那个内容物。但带附件的邮件相当于一个大纸箱纸箱本身是MIMEMultipart里面装着正文文本、附件文件等若干个独立内容物。SMTP服务器不关心你的快递箱里面有什么它只负责把这个集装箱运到收件人那里。因此构建带附件的邮件核心步骤是先创建MIMEMultipart对象然后往里attach不同的内容。来看一个支持HTML正文和多个附件的基础版本import os from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.application import MIMEApplication from email.utils import formataddr from email.header import Header from pathlib import Path def build_attached_mail(subject, body_html, attachment_paths): msg MIMEMultipart() msg[From] formataddr((str(Header(自动化机器人, utf-8)), EMAIL_ADDR)) msg[Subject] Header(subject, utf-8) html_part MIMEText(body_html, html, utf-8) msg.attach(html_part) for path in attachment_paths: file_path Path(path) if not file_path.exists(): continue part MIMEApplication(file_path.read_bytes()) filename file_path.name part.add_header( Content-Disposition, attachment, filename(utf-8, , filename), ) msg.attach(part) return msg这里我用MIMEApplication来包裹附件二进制数据它比较通用适合Excel、PDF、图片等任意文件类型。如果你希望邮件接收端在预览里直接看到纯文本建议不要只发HTML而是同时挂一个纯文本版本两个都attach进去。很多真正的业务邮件都是这么设计的主要是兼容一些不支持HTML的客户端。3.3 支持多收件人、抄送和密送的实践写法真实业务中很少只给一个人发邮件尤其是日报、周报一收就是十几个人。部分同事需要看正文部分领导需要抄送知悉还有一些外部相关人员不应该暴露给其他收件人这就涉及收件人、抄送和密送的区分。一个容易被忽略的地方是msg[To]是展示给收件人的“收件人列表头字段”而send_message时不传收件人参数的话SMTP服务器只会按照邮件头里的To和Cc字段投递。想要真正实现密送Bcc不要只用msg[Bcc]而是要把密送地址也传给send_message作为实际投递地址。我习惯的写法是提前把所有收件人聚合到同一个列表def send_to_many(to_addrs, cc_addrsNone, bcc_addrsNone, subject, content): msg MIMEText(content, plain, utf-8) msg[From] formataddr((str(Header(自动化机器人, utf-8)), EMAIL_ADDR)) msg[To] , .join(to_addrs) if cc_addrs: msg[Cc] , .join(cc_addrs) msg[Subject] Header(subject, utf-8) all_recipients list(to_addrs) list(cc_addrs or []) list(bcc_addrs or []) with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout15) as server: server.login(EMAIL_ADDR, AUTH_CODE) server.send_message(msg, to_addrsall_recipients)在send_message(msg, to_addrsall_recipients)里指定全部收件人SMTP服务器才会对Bcc地址也执行投递。部分邮箱服务商虽能识别msg[Bcc]并在投递时自动处理但很多实现会把Bcc这个头原样保留在邮件里反而不安全。依赖显式to_addrs参数最稳妥。3.4 发信失败排查先看状态码再查配置发信脚本报错时错误信息五花八门但我建议养成先看状态码的习惯。我列举几个高频出现的情况。最常见的535 5.7.8 authentication failed基本可以断定授权码或用户名出了问题。检查点有三个授权码是否复制完整邮箱地址是否带了多余空格是否用登录密码而不是授权码。第二种是554 DT:SPM这个报错通常不是代码问题而是邮箱服务商的反垃圾系统认为你的邮件内容像垃圾邮件。可能原因包括邮件里带了过多外部链接正文含敏感营销词发送频率太高。遇到这种情况先降低发送频率简化邮件正文确认发件人域名和被认证账号一致。第三种是连接阶段就超时或报ssl.SSLError: WRONG_VERSION_NUMBER。这往往是端口或加密方式不匹配造成的比如用SMTP_SSL连接了587端口。587端口一般需要先执行ehlo()再启动TLS代码要改成server smtplib.SMTP(SMTP_HOST, 587)然后server.starttls()。如果你不想纠结直接统一用465端口加SMTP_SSL。还有一类很迷惑的错误脚本返回发送成功但收件人没收到。先去垃圾箱看一眼很多时候是服务商对陌生IP发送的第一封邮件自动做了拦检。企业邮箱则先检查有没有退信退信头里通常标明被拒原因。4. 收信模块用IMAP拉取并解析真实邮件4.1 POP3与IMAP怎么选收信协议主要有POP3和IMAP两种。POP3的思路是把邮件从服务器下载到本地然后服务器端默认删除或标记为已读更像“把信从信箱取回家”。IMAP的思路则是服务器上的邮件保持不动脚本只读取一份副本并可以同步标记已读/未读状态更像“把远程邮箱当作一个数据库来查询”。对自动化脚本来说IMAP几乎是唯一合理的选择。理由有三个。第一POP3连接后通常会锁定邮箱甚至把邮件标记为删除脚本一旦处理到一半崩溃那封邮件可能就永远消失了IMAP不会破坏服务器上的原始邮件处理失败可以重新拉取天然具备重试能力。第二IMAP支持服务端搜索可以直接在服务器上搜未读邮件、指定发件人、指定时间范围的邮件不需要把所有邮件下载回来再过滤。第三通过IMAP标记状态可以在手机上同步处理进度脚本读过的邮件手机上也能看到是已读状态不会造成混乱。下面的收信例子全部使用IMAP。4.2 三步收信连接邮箱、搜索邮件、拉取内容IMAP的操作流程很固定我把它概括成三步第一步连接服务器并登录第二步选择文件夹并搜索符合条件的邮件第三步逐封拉取邮件原文并解析。先做一个最简单的“读取收件箱里所有未读邮件”的循环骨架。import imaplib import email from email.header import decode_header IMAP_HOST os.getenv(IMAP_HOST, imap.qq.com) IMAP_PORT int(os.getenv(IMAP_PORT, 993)) def fetch_unseen_messages(): messages [] with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as mail: mail.login(EMAIL_ADDR, AUTH_CODE) typ, data mail.select(INBOX) if typ ! OK: return messages typ, data mail.search(None, (UNSEEN)) if typ ! OK: return messages for num in data[0].split(): typ, msg_data mail.fetch(num, (RFC822)) if typ ! OK: continue raw_email msg_data[0][1] msg email.message_from_bytes(raw_email) messages.append(msg) return messages这段代码里有几个值得仔细讲的地方。mail.select(INBOX)是选择收件箱。返回状态如果不是OK说明这个邮箱账号可能没有开启IMAP服务或者权限有问题。mail.search(None, (UNSEEN))用来搜索未读邮件搜索条件必须用IMAP协议规定的格式。服务端搜索是IMAP最大的优势你不需要把所有邮件都下载回来再挨个判断。mail.fetch(num, (RFC822))的作用是抓取指定编号邮件的完整原文RFC822是邮件原始格式的标准名。这里返回的msg_data结构有点绕它通常是[(b1 (RFC822 {538}, raw_bytes), b)]这种形式所以我们要取msg_data[0][1]也就是第二个元素才是原始二进制内容。拿到原始内容后用email.message_from_bytes()交给标准库解析成Message对象。如果你想限定只看最近的邮件可以用mail.search(None, (SINCE 1-Jan-2025 UNSEEN))这种组合条件。日期字符串格式不能随便写必须是日-英文月份缩写-年的格式。4.3 邮件正文解析的完整实现原始邮件Message对象不是一堆人类能直接读的文本它是一个嵌套的树形结构。正文和附件都在树的各个叶子节点里需要通过walk()方法遍历这些节点。真正的坑在于文本编码。邮件正文在传输时可能被编码成base64或quoted-printable如果你直接调用get_payload()得到的往往是一串无法阅读的编码乱码。正确处理方式是调用get_payload(decodeTrue)先解编码成bytes再用邮件头里声明的字符集解码成UTF-8字符串。下面是实用的解析函数。def decode_mime_header(value): if value is None: return parts decode_header(value) result [] for data, charset in parts: if isinstance(data, bytes): result.append(data.decode(charset or utf-8, errorsreplace)) else: result.append(data) return .join(result) def get_email_body(msg): if msg.is_multipart(): 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: try: payload part.get_payload(decodeTrue) charset part.get_content_charset() or utf-8 return payload.decode(charset, errorsreplace) except Exception: continue return else: payload msg.get_payload(decodeTrue) if isinstance(payload, bytes): charset msg.get_content_charset() or utf-8 return payload.decode(charset, errorsreplace) return str(payload)decode_mime_header用来处理主题和发件人姓名它会按照MIME规范把?UTF-8?B?...?这种编码转换成正常文本。get_email_body则负责提取正文。遍历过程中我特意判断了text/plain优先返回纯文本正文。如果一封邮件只有HTML部分没有纯文本可以再做一层判断去取text/html然后用正则或第三方库清洗标签。但日常自动化里纯文本正文通常足够满足自动处理需求比如判断邮件内容是否包含某个关键字。4.4 下载附件与文件名的编码处理下载附件比读取正文更容易踩文件名乱码的坑。附件文件名可能以三种形式存在普通ASCII文件名、base64编码的RFC2047格式、UTF-8编码的RFC2231格式。老式脚本只读part.get_filename()遇到中文名大概率乱码。正确的做法是先取出原始文件名再用decode_mime_header统一解码。import os from pathlib import Path import time def save_attachments(msg, save_dirattachments): save_path Path(save_dir) save_path.mkdir(parentsTrue, exist_okTrue) for part in msg.walk(): filename part.get_filename() if not filename: continue filename decode_mime_header(filename) if os.path.sep in filename or .. in filename: filename Path(filename).name payload part.get_payload(decodeTrue) if payload is None: continue target save_path / filename if target.exists(): target target.with_name( f{target.stem}_{int(time.time())}{target.suffix} ) target.write_bytes(payload) print(f附件已保存: {target})这里有个细节part.get_content_type()返回的可能是application/octet-stream附件本身没有Content-Disposition头或只声明为inline这种情况下我们仅依靠get_filename()是否存在来判断即可它能同时覆盖attachment和inline附件。文件名里可能包含路径分隔符或其他危险字符直接保存可能造成目录穿越我用Path(filename).name做了一次清洗。5. 把收发流程编排成自动任务一个日报加附件归档的完整案例5.1 定时任务怎么选程序写完后日常使用其实有两种方式一种是手动执行一遍比如收到命令后运行python send_mail.py另一种是在每天固定时间自动跑。这里要分清两个概念Python程序内部的while True sleep循环是“程序内轮询”而操作系统的定时任务才是“系统级调度”。如果脚本只是偶尔运行最简单的方式是在Python里用time.sleep实现延时但它的缺点是没有直观的“每天早上9点执行一次”这种表达。如果你安装了schedule库代码会清晰很多import schedule import time schedule.every().day.at(09:00).do(send_daily_report) schedule.every(10).minutes.do(check_inbox_and_archive) while True: schedule.run_pending() time.sleep(1)这个方案的缺点是调度逻辑和业务代码耦合在同一个进程里一旦脚本因为未知异常退出调度就停了。更稳妥的部署方式是把核心业务封装成独立的函数或模块然后在Linux上使用crontab调度Windows上用任务计划程序调度。crontab的写法通常是下面这样表示每天早上9点执行一次0 9 * * * cd /path/to/project /path/to/venv/bin/python send_daily_report.py logs/run.log 21用系统级别定时任务的好处是脚本每次运行是一个全新进程环境干净出问题也不会影响其他任务。对于邮件自动回复这类需要频繁监控的脚本才建议常驻进程配合schedule。5.2 综合脚本结构与核心实现我来写一个能直接改改就用的综合场景案例功能有四个每天9点发送日报邮件邮件正文是当天总结附件是自动生成的报表文件每10分钟检查一次收件箱把主题包含“报表归档”的邮件附件下载到本地目录下载成功后自动回复一封确认邮件所有操作都有日志输出。我把代码组织成模块化结构核心部分展示如下。import logging import os import smtplib import imaplib import email import schedule import time from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.header import Header, decode_header from email.utils import formataddr, parseaddr from pathlib import Path EMAIL_ADDR os.getenv(EMAIL_ADDR) AUTH_CODE os.getenv(AUTH_CODE) SMTP_HOST os.getenv(SMTP_HOST, smtp.qq.com) SMTP_PORT int(os.getenv(SMTP_PORT, 465)) IMAP_HOST os.getenv(IMAP_HOST, imap.qq.com) IMAP_PORT int(os.getenv(IMAP_PORT, 993)) logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.StreamHandler(), logging.FileHandler(mail_automation.log, encodingutf-8), ], ) logger logging.getLogger(__name__)发信模块和收信模块实际上就是前面封装的函数只是我这里在多收件人发送时补充了附件支持。先放发信部分def send_mail_with_attachment(to_addrs, subject, body, attachment_paths): msg MIMEMultipart() msg[From] formataddr((str(Header(日报机器人, utf-8)), EMAIL_ADDR)) msg[To] , .join(to_addrs) msg[Subject] Header(subject, utf-8) msg.attach(MIMEText(body, plain, utf-8)) for path in attachment_paths or []: file_path Path(path) if not file_path.exists(): logger.warning(附件不存在已跳过: %s, path) continue part email.mime.application.MIMEApplication(file_path.read_bytes()) part.add_header( Content-Disposition, attachment, filename(utf-8, , file_path.name), ) msg.attach(part) all_recipients list(to_addrs) with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout15) as server: server.login(EMAIL_ADDR, AUTH_CODE) server.send_message(msg, to_addrsall_recipients) logger.info(邮件发送成功收件人: %s, 主题: %s, to_addrs, subject)收信加自动回复的部分def handle_incoming_mail(): try: with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as mail: mail.login(EMAIL_ADDR, AUTH_CODE) typ, data mail.select(INBOX) if typ ! OK: logger.error(无法打开收件箱: %s, data) return typ, data mail.search(None, (UNSEEN)) if typ ! OK: return for num in data[0].split(): typ, msg_data mail.fetch(num, (RFC822)) if typ ! OK: continue raw_email msg_data[0][1] msg email.message_from_bytes(raw_email) subject decode_mime_header(msg.get(Subject)) from_addr decode_mime_header(msg.get(From)) if 报表归档 not in subject: continue saved save_attachments(msg, save_dirarchive) if saved: reply_addr parseaddr(from_addr)[1] send_mail_with_attachment( [reply_addr], fRe: {subject}, 你的报表已收到并完成自动归档。, [], ) mail.store(num, FLAGS, \\Seen) logger.info(已处理邮件并回复: %s, subject) except Exception as exc: logger.exception(处理收件箱时发生异常: %s, exc) def daily_report_job(): body 这是今日的自动日报。\n详细数据请查收附件。\n send_mail_with_attachment( [managerexample.com], 每日业务日报, body, [reports/daily_report.xlsx], )在handle_incoming_mail中有一个容易被忽略的步骤处理成功后我调用了mail.store(num, FLAGS, \\Seen)手动把邮件标记为已读。如果不做这一步同一封未读邮件会在下一轮检查中被重新处理一遍造成附件重复下载、回复邮件重复发送。只依赖IMAP的未读状态不会自动改变必须由脚本显式标记这是实际项目里最容易踩的坑。为了让这个主流程成为常驻服务启动部分我用schedule调度if __name__ __main__: logger.info(邮件自动化服务已启动) schedule.every().day.at(09:00).do(daily_report_job) schedule.every(10).minutes.do(handle_incoming_mail) while True: schedule.run_pending() time.sleep(5)这段代码跑起来后服务会每5秒看一次是否有到期任务每天9点执行发日报每10分钟扫描一次收件箱。如果你希望每次执行时都从最新状态开始我建议用while True包住run_pending(),配合系统日志文件可以做简单的监控。5.3 日志与异常兜底是长期运行的基本保障很多新手写完脚本后直接丢进服务器就以为万事大吉结果过了两星期发现邮件早就停了因为程序在某个凌晨因为网络超时异常退出而控制台输出没人看。我在这里统一用logging模块把日志同时输出到文件和终端。文件日志的好处是可以配合tail -f实时查看也可以在第二天早上翻查到底哪里出问题。异常兜底则要坚持“尽力而为”的原则单封邮件发送失败不应该导致整个任务崩溃收信时连接超时也不应该让while True循环直接退出去。上面代码的收信部分已经用try/except包裹并调用logger.exception记录完整堆栈。如果你希望异常情况也能第一时间被感知可以在except分支里把错误信息作为邮件发送给自己形成一个简单的监控闭环。我自己的项目里通常还会加一个判断如果连续三次任务失败就不再自动重试而是发送告警邮件避免服务商把问题账号临时
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →