邮件自动分类 Agent:分类即建模,动作即风险

邮件自动分类看着是 AI Agent 里最成熟的一类:输入是文本,动作只是挪个文件夹。但凡是真用在跑的,几乎都踩过同一类坑------把老板的发票判成垃圾、把含身份证的邮件落库明文、一个 expunge 永久删信、连接断了还以为自己成功。
这些坑的根不在「怎么分类」,而在两层:分类层 对「邮件对象」和「规则冲突」建模错了,动作层对邮件落下的每一刀都是不可逆、且可能重复的。本文先讲清原理,再逐坑给根因与可运行代码。
邮件自动分类Agent实现
整个 Agent 就是一个循环,两层职责严格分离:
css
拉取新邮件 UID → [分类层] 裁决去哪/是否保护 → [动作层] 执行移动/删除 → 落库/写回 IMAP
- 分类层 (
01_mail_classifier.py):只产出Verdict(去哪、是否保护、脱敏后正文),不碰真实邮件。三层安检:白名单 → 规则优先级裁决 → 落库前脱敏。 - 动作层 (
02_action_safety.py):执行真实 IMAP 操作。关键设计------每一个操作都可拦截、可找回、可去重、失败必可见。 - 两层用
Verdict这个数据结构解耦:分类层出错,动作层再安全也是「安全地做错事」,所以安检在分类层就做掉。
邮件的原理
邮件是带状态的对象
一封邮件至少有:UID(邮箱内稳定唯一标识)、\Seen/\Flagged 等 flags、当前 folder 归属、发件人可信度、附件列表。把它建模成 subject + body 的字符串,如果只能按关键词分类,忽略发件人身份、既有标签、归属文件夹,就会出现「把老板邮件判成垃圾」这类错。
正确建模:邮件 = 带 UID、flags、folder 归属、发件人可信度的对象 ;分类是对「这个对象该去哪」的裁决,不是对「文字像不像垃圾」的猜测。(全链路如何从邮件对象经分类层、动作层落到 IMAP) 
IMAP 的「移动/删除」和本地文件相反
这是动作层翻车的原因。先建立对比,再展开:
| 操作 | IMAP 实际语义 |
|---|---|
move = 剪切 |
无 move 原语;move = COPY 到目标 + 给原件标 \Deleted |
delete = 没了 |
\Deleted 只是标记,邮件还在,可撤销 |
rm 之后能找回? |
只有 EXPUNGE 真删,且不可恢复 |
COPY保留原件 :把 A 复制到 B,A 里那份还在。所谓「移动」=COPY到目标 + 标\Deleted。\Deleted可撤销:标了标记还能看到、能救回。EXPUNGE不可恢复 :执行后带标记邮件被永久抹掉;一次expunge可能清掉整个文件夹所有被标记邮件。
所以「删除走回收站 + 绝不 expunge」在邮件 Agent 里是铁律,而非选项。
触发是 at-least-once,必然重复
邮件触发源(IMAP IDLE、轮询、网关 webhook)天然 at-least-once:网络抖动重放、轮询间隔重叠都会让同一封邮件进两遍。UID 稳定,但「收到就动」会对同一 UID 移动/删除两次。消费者必须自己把 at-least-once 收敛成「有效一次」------这就是 UID 幂等的支点。
连接会断,断了变「假成功」
IMAP 是长连接 + 网络协议,超时/被踢/TLS 重协商都会让连接在两次操作间断掉。若动作层写 except: pass,失败不报错、日志全绿------你以为分类好了,其实什么都没动,
问题A:分类层把「该保护」和「该拦」搞反
坑 1:误标垃圾,把老板邮件判成垃圾
症状:规则「正文含中奖/优惠就丢垃圾箱」,老板发「发票已开,优惠码见附件」也进垃圾箱。
原因:没对发件人可信度建模------只看了文本,没看是谁发的。
解法是用开关控制,且必须排在所有垃圾规则之前------白名单优先:
python
def classify(self, mail: Mail) -> Verdict:
masked = mask_text(mail.body)
if mail.from_addr in self.whitelist: # 白名单优先于一切评分/规则
return Verdict(mail.uid, "normal", mail.folder,
reason=f"白名单 {mail.from_addr}", masked_body=masked, protected=True)
...
self-test 验证:一封 from=boss@、正文满是「优惠/中奖」的邮件,仍判 normal 且 protected=True。白名单不是锦上添花,是分类层重要控制开关。

坑 2:隐私泄露,含身份证邮件落库明文
症状:为做智能归档把分类后 body 落库,忘了里的身份证/银行卡/api_key,等合规审计才发现整库明文 PII。比误标更糟------误标能捞回,明文泄露是既成事实。
原因:落库前脱敏这道工序被默认省略。分类层既然拿到 body,它就该是唯一「写任何外部存储之前」的关卡。三类脱敏,保留格式用于审计:
python
ID_CARD_RE = re.compile(r"\b\d{17}[\dXx]\b")
BANK_CARD_RE = re.compile(r"\b\d{16,19}\b")
SECRET_RE = re.compile(r"(?:api[_-]?key|secret|token|password)\s*[:=]\s*\S+", re.I)
def mask_text(text):
text = ID_CARD_RE.sub(lambda m: m.group(0)[:4] + "***********" + m.group(0)[-2:], text) # 前4后2
text = BANK_CARD_RE.sub(lambda m: m.group(0)[:4] + "********" + m.group(0)[-4:], text) # 前4后4
text = SECRET_RE.sub(lambda m: m.group(0).split("=")[0].split(":")[0] + "=***REDACTED***", text)
return text
Verdict.masked_body 才是可安全落库的那份。self-test 断言原文里的三样 PII 在 masked_body 中一个不剩。

坑 3:规则冲突,同一封邮件随顺序漂移
症状:「发票→账单」和「中奖→垃圾箱」两条规则,来一封「发票与中奖通知」同时命中。若实现是「遍历 rules 谁先 append 谁先赢」,改配置顺序行为就变,今天进账单明天进垃圾箱。
原因:规则间缺确定性裁决序。解法不是「小心写顺序」,而是把裁决序从「代码执行顺序」提升为「显式优先级字段」:
python
self.rules = sorted(rules, key=lambda r: r.priority) # priority 越小越优先
def classify(self, mail):
for r in self.rules: # 已按 priority 排定
if self._matches(mail, r.pattern):
return Verdict(mail.uid, "spam" if r.is_spam else "classified",
r.folder, reason=f"命中[{r.name}](P{r.priority})", masked_body=masked)
self-test:同时含「发票」(P1)和「中奖」(P5)的邮件稳定落「账单」。裁决序必须来自数据,不能来自代码顺序。
问题B:动作层对真实邮件落下的每一刀
坑 4:一个 expunge,永久删掉重要邮件
症状:实现「删垃圾」把 expunge 当「清空回收站」随手调,某封本该进 Trash 的邮件连同 \Deleted 永久抹掉,不可恢复。
原因:把 IMAP 删除当本地文件删除。防误删要三个开关叠加,缺一不可:
- 白名单优先:命中白名单,任何移动/删除都碰不了。
dry-run默认开启 :没显式--apply只报告不执行,防手滑的最后保险。- 删除走回收站,绝不 expunge :
COPY进 Trash + 标\Deleted,原件仍在可恢复。
python
def delete(self, mail):
if mail.uid in self.processed: return "skipped:idempotent"
if self._whitelisted(mail): return "protected:whitelist"
if self.dry_run: return "dry-run:no-op"
try:
self.imap.health_check()
self.imap.copy(mail.uid, mail.folder, self.trash) # COPY 进回收站
self.imap.store_del(mail.uid, mail.folder) # 标 \Deleted(可撤销)
# 关键:不调用 expunge(),所以没有永久删除
except Exception as e:
raise ActionAlert(f"delete({mail.uid}) 失败: {e}")
self.processed.add(mail.uid)
return f"deleted:{self.trash}"
self-test 验证:白名单原样保留;dry-run 不发任何 IMAP 命令;--apply 时删进了 Trash 且 expunged 列表为空。expunge 在事件驱动的 Agent 里就是定时炸弹。

坑 5:连接断了,Agent 还以为成功
症状:连接两次操作间断开,move/delete 抛 ConnectionError;若写 except: pass,日志全绿,几百封卡在 INBOX 才发现。
原因:与真实世界交界处(IMAP 连接)没建模。解法不是「多 try 几次」,而是失败必须显式可见 ------定义 ActionAlert,任何 IMAP 异常 wrap 抛出,绝不吞:
python
class ActionAlert(Exception): pass
def move(self, mail, dest):
try:
self.imap.health_check() # 断连这里就抛 ConnectionError
self.imap.copy(...); self.imap.store_del(...)
except Exception as e:
raise ActionAlert(f"move({mail.uid}->{dest}) 失败: {e}")
self-test 把 FakeIMAP.connected=False,断言 move 必抛 ActionAlert------连接失败绝不能变假成功 。 
坑 6:重复触发,同一封邮件搬 N 次
症状:at-least-once 下同一封进动作层两遍,第一次已移到「账单」,第二次再对同 UID move,报错或搬到别处,行为不可预期。
根因:动作层没有「这封处理过」的记忆。解法是独立的 UID 幂等闸------和防抖不同,邮件这里只有 UID 维度、没有时间窗口:
python
def move(self, mail, dest):
if mail.uid in self.processed: return "skipped:idempotent" # 同 uid 只处理一次
...
self.processed.add(mail.uid)
return f"moved:{dest}"
self-test:连续两次对 u3 调 move,第二次 skipped:idempotent,IMAP 命令只发 2 条不是 4 条。processed 生产里要跨进程持久化(Redis/DB),否则进程重启幂等失效------本文用内存集合演示原理,生产请换持久存储。 
小结一下
| 大症状 | 问题 | 共同原因 |
|---|---|---|
| 上线就分错 | A. 分类层误标/泄露/冲突 | 把邮件当「无状态文本」建模 |
| 搞丢/假动邮件 | B. 动作层误删/静默/重复 | 对「不可逆动作」没有分层兜底 |
解法不是「更努力调模型」,而是承认:分类层产出可信、可保护、已脱敏的裁决;动作层让每一刀可拦截、可找回、可去重、失败可见。两层间的边界(白名单优先 + dry-run + 回收站 + 幂等 + 失败告警),才是邮件 Agent 活过第一周的关键。
代码复现(3 个脚本 + 数据源)
纯 Python 标准库、零第三方依赖、不需要真实邮箱账号、不发任何网络请求:
| 脚本 | 覆盖 |
|---|---|
01_mail_classifier.py |
分类层:Mail/Rule/Verdict + 白名单优先 + 优先级裁决 + mask_text |
02_action_safety.py |
动作层:Actioner + FakeIMAP + ActionAlert + 三道闸 |
03_demo.py |
端到端四阶段演示(导入前两个脚本) |
数据源 :全部内置合成,脚本里硬编码,无需下载、无需联网------5 封样例邮件(u1--u5,覆盖白名单命中 / 规则冲突 / 含 PII / 纯垃圾 / 正常通知)、2 条规则、白名单、5 个垃圾关键词。文中的身份证 1101...651X、银行卡、api_key 均为占位伪造值,不含任何真实个人信息 ;IMAP 用 FakeIMAP 内存模拟,可注入 connected=False 确定性复现断连。
运行(环境 Python 3.8+):
bash
cd code/mail-agent
python 01_mail_classifier.py --self-test # 分类层三坑
python 02_action_safety.py --self-test # 动作层三坑
python 03_demo.py # 端到端四阶段(推荐先看这个)
python 03_demo.py --self-test # 等价,供 CI 断言
03_demo.py 阶段二(--apply 真实执行)的真实输出,可直接对照第 ④ 坑:
scss
=== 阶段二:apply(真实执行,删除走回收站) ===
uid from label action dest
u1 boss@company.com normal protected:whitelist INBOX
u2 vendor@x.com classified moved:账单 账单
u3 hr@x.com normal keep:INBOX INBOX
u4 spam@unk.com spam deleted:Trash 垃圾箱
u5 noreply@x.com normal keep:INBOX INBOX
[OK] 进入 Trash 的邮件:['u4']
[OK] 被 expunge 永久删除的邮件:[](应为空)
关键在最后一行:expunged 为空------删除动作只落到 Trash,没有触发永久删除。三个脚本全绿时输出 ALL_SELFTESTS_PASSED(03 为 DEMO_OK)且退出码 0;想接真实邮箱只改 FakeIMAP 这一层(换成 imaplib.IMAP4_SSL 薄封装),另两个脚本不动,细节见 code/mail-agent/README.md。
结尾
若读过《文件监控 Agent 为什么总掉链子》《AI 客服为什么翻车》,会发现同一原因:Agent 不可靠往往不是模型不够聪明,而是它和真实世界的交界处(工具调用、文件系统事件、邮件协议)没被建模。客服翻在「工具返回了它就信」,文件监控翻在「事件来了它就动」,邮件 Agent 翻在「邮件是文本、IMAP 是本地文件」两个错觉上。
下一个最易翻车的是「替你做决定并自动执行跨系统动作」的------自动回复、自动下单、自动改库。交界更复杂,兜底更难,错的代价不再是一封邮件,而是一笔钱、一条记录。这个系列接着拆。
参考来源
互动时间
如果这篇帮你避开了一次 expunge,点个赞------这类坑一次就够疼,让更多正在写邮箱 Agent 的人先看到。把「白名单优先 → dry-run → 回收站 → UID 幂等 → 失败告警」这五道闸存成 checklist,下次接到任何「让 Agent 动真实数据」的需求,直接照着对。你踩过最狠的一次 Agent 翻车是什么------误删、假成功、还是重复执行?评论区说说。
