邮件分类 Agent 的两层设计:建模裁决与动作风控

作者:袖梨 2026-09-17

把邮件交给 Agent 自动整理,看似只是读取文本并移动文件夹,真正上线后却会碰到误判、隐私明文落库、重复搬运甚至永久删信等问题。要让系统可靠运行,关键不是继续堆分类规则,而是同时建模邮件状态与动作风险,并为分类裁决和 IMAP 操作建立清晰的安全边界。

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

邮件自动分类Agent封面_1920x817.png

邮件自动分类看着是 AI Agent 里最成熟的一类:输入是文本,动作只是挪个文件夹。但凡是真用在跑的,几乎都踩过同一类坑——把老板的发票判成垃圾、把含身分证的邮件落库明文、一个 expunge 永久删信、连接断了还以为自己成功。

这些坑的根不在「怎么分类」,而在两层:分类层对「邮件对象」和「规则冲突」建模错了,动作层对邮件落下的每一刀都是不可逆、且可能重复的。本文先讲清原理,再逐坑给根因与可运行代码。


邮件自动分类Agent实现

整个 Agent 就是一个循环,两层职责严格分离:

拉取新邮件 UID → [分类层] 裁决去哪/是否保护 → [动作层] 执行移动/删除 → 落库/写回 IMAP
  • 分类层01_mail_classifier.py):只产出 Verdict(去哪、是否保护、脱敏后正文),不碰真实邮件。三层安检:白名单 → 规则优先级裁决 → 落库前脱敏。
  • 动作层02_action_safety.py):执行真实 IMAP 操作。关键设计——每一个操作都可拦截、可找回、可去重、失败必可见
  • 两层用 Verdict 这个数据结构解耦:分类层出错,动作层再安全也是「安全地做错事」,所以安检在分类层就做掉。

邮件的原理

邮件是带状态的对象

一封邮件至少有:UID(邮箱内稳定唯一标识)、Seen/Flagged 等 flags、当前 folder 归属、发件人可信度、附件列表。把它建模成 subject + body 的字符串,如果只能按关键词分类,忽略发件人身份、既有标签、归属文件夹,就会出现「把老板邮件判成垃圾」这类错。

正确建模:邮件 = 带 UID、flags、folder 归属、发件人可信度的对象;分类是对「这个对象该去哪」的裁决,不是对「文字像不像垃圾」的猜测。(全链路如何从邮件对象经分类层、动作层落到 IMAP) 01-pipeline@2x.png

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:误标垃圾,把老板邮件判成垃圾

症状:规则「正文含中奖/优惠就丢垃圾箱」,老板发「发票已开,优惠码见附件」也进垃圾箱。

原因:没对发件人可信度建模——只看了文本,没看是谁发的。

解法是用开关控制,且必须排在所有垃圾规则之前——白名单优先

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@、正文满是「优惠/中奖」的邮件,仍判 normalprotected=True白名单不是锦上添花,是分类层重要控制开关。

02-mislabel-spam@2x.png

坑 2:隐私泄露,含身分证邮件落库明文

症状:为做智能归档把分类后 body 落库,忘了里的身分证/银彳卡/api_key,等合规审计才发现整库明文 PII。比误标更糟——误标能捞回,明文泄露是既成事实。

原因:落库前脱敏这道工序被默认省略。分类层既然拿到 body,它就该是唯一「写任何外部存储之前」的关卡。三类脱敏,保留格式用于审计:

ID_CARD_RE  = re.compile(r"bd{17}[dXx]b")
BANK_CARD_RE = re.compile(r"bd{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 中一个不剩。

03-privacy-leak@2x.png

坑 3:规则冲突,同一封邮件随顺序漂移

症状:「发票→」和「中奖→垃圾箱」两条规则,来一封「发票与中奖通知」同时命中。若实现是「遍历 rules 谁先 append 谁先赢」,改配置顺序行为就变,今天进明天进垃圾箱。

原因:规则间缺确定性裁决序。解法不是「小心写顺序」,而是把裁决序从「代码执行顺序」提升为「显式优先级字段」:

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)的邮件稳定落「」。裁决序必须来自数据,不能来自代码顺序。

04-rule-conflict@2x.png

问题B:动作层对真实邮件落下的每一刀

坑 4:一个 expunge,永久删掉重要邮件

症状:实现「删垃圾」把 expunge 当「清空回收站」随手调,某封本该进 Trash 的邮件连同 Deleted 永久抹掉,不可恢复。

原因:把 IMAP 删除当本地文件删除。防误删要三个开关叠加,缺一不可:

  1. 白名单优先:命中白名单,任何移动/删除都碰不了。
  2. dry-run 默认开启:没显式 --apply 只报告不执行,防手滑的最后保鲜。
  3. 删除走回收站,绝不 expungeCOPY 进 Trash + 标 Deleted,原件仍在可恢复。
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 里就是定时炸弹。

05-wrong-delete@2x.png

坑 5:连接断了,Agent 还以为成功

症状:连接两次操作间断开,move/deleteConnectionError;若写 except: pass,日志全绿,几百封卡在 INBOX 才发现。

原因:与真实世界交界处(IMAP 连接)没建模。解法不是「多 try 几次」,而是失败必须显式可见——定义 ActionAlert,任何 IMAP 异常 wrap 抛出,绝不吞:

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——连接失败绝不能变假成功06-silent-failure@2x.png

坑 6:重复触发,同一封邮件搬 N 次

症状:at-least-once 下同一封进动作层两遍,第一次已移到「」,第二次再对同 UID move,报错或搬到别处,行为不可预期。

根因:动作层没有「这封处理过」的记忆。解法是独立的 UID 幂等闸——和防抖不同,邮件这里只有 UID 维度、没有时间窗口:

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:连续两次对 u3move,第二次 skipped:idempotent,IMAP 命令只发 2 条不是 4 条。processed 生产里要跨进程持久化(Redis/DB),否则进程重启幂等失效——本文用内存集合演示原理,生产请换持久存储。 07-idempotent-dup@2x.png


小结一下

大症状问题共同原因
上线就分错A. 分类层误标/泄露/冲突把邮件当「无状态文本」建模
搞丢/假动邮件B. 动作层误删/静默/重复对「不可逆动作」没有分层兜底

解法不是「更努力调模型」,而是承认:分类层产出可信、可保护、已脱敏的裁决;动作层让每一刀可拦截、可找回、可去重、失败可见。两层间的边界(白名单优先 + dry-run + 回收站 + 幂等 + 失败告警),才是邮件 Agent 活过第一周的关键。


代码复现(3 个脚本 + 数据源)

代码:github.com/beverlyLee/…

纯 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+):

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 真实执行)的真实输出,可直接对照第 ④ 坑:

=== 阶段二:apply(真实执行,删除走回收站) ===
uid from                label       action                dest
u1  [email protected]    normal      protected:whitelist   INBOX
u2  [email protected]        classified  moved:              
u3  [email protected]            normal      keep:INBOX            INBOX
u4  [email protected]        spam        deleted:Trash         垃圾箱
u5  [email protected]       normal      keep:INBOX            INBOX
[OK] 进入 Trash 的邮件:['u4']
[OK] 被 expunge 永久删除的邮件:[](应为空)

关键在最后一行:expunged 为空——删除动作只落到 Trash,没有触发永久删除。三个脚本全绿时输出 ALL_SELFTESTS_PASSED03DEMO_OK)且退出码 0;想接真实邮箱只改 FakeIMAP 这一层(换成 imaplib.IMAP4_SSL 薄封装),另两个脚本不动,细节见 code/mail-agent/README.md


结尾

若读过《文件坚控 Agent 为什么总掉链子》《AI 客服为什么翻车》,会发现同一原因:Agent 不可靠往往不是模型不够聪明,而是它和真实世界的交界处(工具调用、文件系统事件、邮件协议)没被建模。客服翻在「工具返回了它就信」,文件坚控翻在「事件来了它就动」,邮件 Agent 翻在「邮件是文本、IMAP 是本地文件」两个错觉上

下一个最易翻车的是「替你做决定并自动执行跨系统动作」的——自动回复、自动下单、自动改库。交界更复杂,兜底更难,错的代价不再是一封邮件,而是一笔钱、一条记录。这个系列接着拆。


参考来源

  • IMAP4rev1 协议规范:RFC 3501——EXPUNGE 见 §6.4.3、STORE 见 §6.4.6、COPY 见 §6.4.7、Deleted 语义见 §2.3.2。

互动时间

如果这篇帮你避开了一次 expunge,点个赞——这类坑一次就够疼,让更多正在写邮箱 Agent 的人先看到。把「白名单优先 → dry-run → 回收站 → UID 幂等 → 失败告警」这五道闸存成 checklist,下次接到任何「让 Agent 动真实数据」的需求,直接照着对。你踩过最狠的一次 Agent 翻车是什么——误删、假成功、还是重复执行?评论区说说。

相关文章

精彩推荐