先说结论
很多人理解 Odoo 邮件网关时,会把重心放在:
- 这封邮件命中了哪个 alias;
- 是不是创建了记录;
- 是不是挂回原线程。
这些当然重要,但从 /home/ubuntu/odoo-temp/addons/mail/models/mail_thread.py 看,真正成熟的邮件入口还要继续处理另一半问题:
如果这封邮件是退信怎么办?如果之前退信过、现在又收到了对方正常来信怎么办?如果有人直接写到 catchall,或者回复了一封 bounce 通知,又该怎么避免线程被污染?
也就是说,incoming alias 不是“命中路由就结束”,而是和 bounce 处理、线程恢复、反循环保护共同组成一套边界治理。
message_route() 开头就先判断 bounce,不是偶然
在 message_route() 里,官方的处理顺序非常值得注意。
在真正进入 reply / alias / fallback 路由前,源码先做:
- 如果
message_dict.get('is_bounce'),就_routing_handle_bounce()然后直接return [] - 否则继续
_routing_reset_bounce()
这说明 Odoo 的思路非常明确:
bounce 不是普通来信的一个附带状态
它是要 抢先分流 的特殊类型。
因为退信邮件的目标,不是进入业务线程继续协作,而是:
- 更新送达状态;
- 找到被退回的对象;
- 修正联系人的 bounce 计数;
- 必要时阻止后续错误路由。
如果把 bounce 当普通来信处理,很快就会出现:
- 退信正文被挂进 chatter;
- 业务对象被错误更新;
- catchall 线程被退信风暴污染。
_detect_is_bounce() 说明 bounce 识别本身就是网关能力
源码对 bounce 的识别,并不只是看正文里有没有“Undelivered Mail”。
它会结合:
- 收件地址是否命中
bounce_alias@domain - 头信息与邮件结构
- 原始 message payload
这说明在 Odoo 里,退信并不是“SMTP 层已经处理完的东西”,而是应用层仍然要理解的事件。
为什么这点重要
因为业务系统真正关心的不是“技术上收到了一个退信包”,而是:
- 哪个联系人邮箱不可达;
- 哪次通知失败了;
- 哪条业务记录应该被标记有送达问题;
- 后续如果对方恢复正常,是否该清掉旧的失败状态。
这已经超出了纯粹 SMTP 配置范畴。
_routing_handle_bounce() 真正在做“失败归因”
这段代码非常值得仔细看。
它会尝试拿到:
bounced_emailbounced_partnerbounced_msg_idsbounced_message
然后分几条路径传播退信信息:
- 如果能定位到原始业务对象,就在对应记录上触发
_message_receive_bounce(); - 如果某些带黑名单 / bounce 逻辑的模型能按
email_normalized找到记录,也会逐个传播; - 如果能定位到原始
mail.message,还会把相关 notification 标记成bounce/mail_bounce。
这说明 bounce 处理的关键不是“告诉用户失败了”,而是:
把一次技术失败准确归因到联系人、通知和业务对象上。
如果归因不到位,退信就只是日志; 归因准确,它才会变成可治理的业务信号。
_routing_reset_bounce() 说明 Odoo 把“恢复送达”也当成一等公民
这段逻辑很容易被忽略,但其实特别成熟。
在非 bounce 来信进入时,Odoo 会根据发件人地址:
- 找到那些
message_bounce > 0且email_normalized相同的记录; - 调
_message_reset_bounce(normalized_from)。
这背后的设计思想非常好理解:
- 如果这个邮箱现在都能主动给我发邮件了;
- 那么之前积累的“它不可达”状态,就不该继续无脑维持。
也就是说,Odoo 不把 bounce 当永久污点,而把它当 可恢复的送达信号。
这对真实运营很重要。
否则业务会越来越偏:
- 对方邮箱明明恢复了;
- 系统还一直认为它失联;
- 销售、招聘、项目协作的触达判断都会被旧状态拖偏。
catchall 不是收件垃圾桶,而是线程兜底入口
message_route() 对 catchall 的态度一直很强硬。
如果发现:
- 直接写到 catchall;
- 或 catchall 混着其他不可路由地址一起写;
源码可能直接调用 _routing_create_bounce_email() 回弹,并 return []。
这和 bounce 处理放在一起看,逻辑就更清楚了:
- catchall 主要是为了接住已有线程的回复;
- 它不是业务新入口;
- 也不应该承接模糊、不明归属的新邮件。
所以官方宁可把这种邮件退回去,也不愿让它们污染线程边界。
为什么还要专门忽略“回复 bounce 通知”的邮件
源码里还有个很细的保护:
- 如果
references里出现-loop-detection-bounce-email@这样的标记,就认为这是在回复 bounce 通知,直接忽略。
这一步看似小,价值却很大。
因为退信邮件最容易引发的不是一次性错误,而是:
- 自动系统再回一封;
- 对方邮箱又自动答复;
- 两边不断回环;
- 业务线程、SMTP 和队列一起被刷爆。
官方显然非常清楚这个问题,所以专门在回弹时种一个可识别引用,再在后续入口处短路掉。
incoming alias 与 bounce 其实在守同一条边界
把这些代码连起来看,就能发现一个很有意思的事实:
- alias 命中是在决定“这封外部邮件值不值得进入业务系统”;
- bounce 归因是在决定“失败信号该不该反向写回业务系统”;
- bounce reset 是在决定“旧的失败标签是否该撤销”;
- catchall / loop guard 是在决定“异常邮件是否该继续污染线程”。
它们看起来像四件事,实际上都在守同一条边界:
外部邮件世界和内部业务线程世界,应该怎样安全、干净、可恢复地互相映射。
实战里最该怎么排
如果现场出现“收件 alias 有时正常、有时被退”“明明邮箱恢复了系统还说退信”“catchall 好像吞邮件”这类问题,我建议按这个顺序查:
- 先确认来信是不是先被识别成 bounce,而不是普通 incoming mail;
- 检查
_routing_handle_bounce()是否成功归因到 partner / notification / 原业务对象; - 看非 bounce 来信时
_routing_reset_bounce()是否被触发,旧状态有没有被清掉; - 确认是不是直接写到了 catchall,或 catchall 与不可路由地址混写导致被回弹;
- 检查是否碰到了 loop-detection 保护,把对 bounce 通知的回复直接忽略了。
一句话记忆
Odoo 的 incoming alias 不只是“把邮件收进来”,它还必须和 bounce 识别、失败归因、状态恢复与反循环保护一起工作,才能真正守住业务线程边界。
DISCUSSION
评论区