一个词把 9133 封邮件标记成已读:IMAP RFC822 的副作用
作者收到的 bug 报告来自自己:"邮件不工作了,过滤有问题。"过滤其实完全正常——真正发生的是一个 bot 把个人 Gmail 收件箱 9143 封邮件中的 9133 封标记为已读,每封到达后几秒就被标,持续数月。原因是一个词。
一个词
邮件适配器每 15 秒轮询收件箱,获取新邮件:
status, msg_data = imap.uid("fetch", uid, "(RFC822)")
RFC822 不是 peek。 按 RFC 3501,抓取 RFC822 会副作用式设置 \Seen 标志——读消息就会标记已读,这正是规范原样书写的定义。修复前测量:总计 9143,SEEN 9133,UNSEEN 10;最新 30 封里 29 封已标 SEEN。
修复是请求同样的字节但不带副作用:
status, msg_data = imap.uid("fetch", uid, "(BODY.PEEK[])")
同样的内容,不写标志。
为什么过滤背了锅
这比标志本身更有意思。适配器有发件人白名单,正常;有自动邮件过滤器(List-Unsubscribe、Precedence: bulk、Auto-Submitted),也正常。每段过滤逻辑都正确。但过滤发生在分发(dispatch)时,在 fetch 之后。所以 bot 不感兴趣的邮件序列是:fetch 它 → \Seen 被设置 → 判断不是允许发件人 → 丢弃。系统故意忽略的邮件反而都被标记已读。过滤器越有效,它造成的隐形破坏越大。而可见症状是"我在意的邮件看起来已被读",于是所有人盯着过滤逻辑查。
更深一层的教训
IMAP 里"读邮件"和"取邮件"是两个操作:BODY[] 会改变状态,BODY.PEEK[] 是纯读取。这类规范里"获取即副作用"的语义不止 IMAP 有——任何轮询型集成都要区分"读"与"消费"。轮询适配器的正确姿势:先取元数据(ENVELOPE/FLAGS)过滤,命中后再用 PEEK 取正文,让"不感兴趣的邮件"不产生任何状态变更;对要保留"未读"语义的邮件,永远用 PEEK 而非裸 fetch。
实践上把这条写进代码评审清单:所有 IMAP/POP 读取都用 BODY.PEEK[],除非你确实要标记已读;用 flags 变更(STORE +FLAGS \Seen)做显式已读语义,而不是依赖 fetch 副作用。