先说结论
很多人理解邮件网关时,会把问题说得很简单:
- 发到 Odoo 的邮箱
- 系统创建一条记录
- 之后邮件继续挂在上面
但 addons/mail/models/mail_thread.py 里的 message_route() 告诉我们,
Odoo 真正先解决的不是“怎么创建记录”,而是:
这封邮件到底属于继续一条已有协作线程,还是应该开启一条新线程;如果两者都不成立,它甚至应该被挡在系统外。
所以邮件网关的核心不是“收邮件”,而是“判边界”。
边界判对了,邮件协作才顺;边界判错了,Chatter 会变得又脏又乱。
一、message_route() 为什么是真正的入口
很多人更熟悉 message_new()、message_update(),
因为它们看起来像“创建记录”和“更新记录”。
但从整体链路看,真正的总开关是 message_route()。
它先做的是路由判断:
- 这是不是对已有
mail.message.message_id的回复 - 收件地址有没有命中
mail.alias - 如果没有,能不能走 fallback model
- 如果还不行,而且又触到了 catchall / 不可路由地址,是否该 bounce
所以 message_new() 和 message_update() 其实都只是 路由结果。
它们不是第一判断。
二、为什么“回复旧线程”优先级这么高
message_route() 一上来就会根据:
ReferencesIn-Reply-To
去找历史 mail.message。
一旦能找到,它就倾向把这封邮件视为:
- 回复一条已有协作上下文
这很合理。
因为邮件协作最怕的事之一,就是同一条对话被系统误切成两条记录。
如果客户明明是在回原来的讨论, 系统却因为主题改了、收件人多了、正文格式不同了,就重新建档, 团队就会马上失去完整上下文。
所以 Odoo 先尊重 thread continuity, 再考虑要不要新建对象。
三、为什么转发到别的 alias 时,原回复关系会被打断
这段源码特别值得注意。
如果系统发现:
- 这封邮件看上去像是在回复旧消息
- 但收件地址命中了 另一个模型 的 alias
它会把这件事当成 forward,而不是 reply。
也就是说,旧的 reply 语义会被取消, 路由重新回到 alias 创建链路。
这背后是一个非常聪明的边界判断:
- 回复旧线程,说明你想继续原协作对象
- 但如果你把邮件转发到了另一个业务入口,说明你要让邮件改投别的业务语义
比如:
- 原来在任务线程里讨论
- 现在把邮件转给销售线索 alias
系统若还执着于“它在回复旧 Message-Id”, 反而会把新业务入口压扁掉。
所以 Odoo 选择:
一旦目标 alias 指向不同模型,就优先尊重新的业务入口,而不是旧的回复血缘。
四、alias 为什么不只是“邮箱名映射模型”
当 message_route() 找到 alias 后,
它返回的 route 不只是模型名,还包括:
alias_force_thread_idalias_defaultsuser_id- alias 本身
这说明 alias 在 Odoo 里承担的是完整入口配置:
- 要落到哪个模型
- 是新建还是强制挂到固定线程
- 新建时要带哪些默认值
- 网关使用哪个用户上下文去处理
所以 alias 不是“收件地址的翻译表”, 而更像“业务协作入口定义”。
这也是为什么邮件协同做得成熟的团队,会认真设计 alias,而不是只把它当技术配置。
五、为什么 catchall 不能被直接当业务入口
message_route() 对 catchall 的态度很明确:
- 如果邮件直接写到 catchall,可能直接 bounce
- 如果邮件写到 catchall 加其他不可路由地址,也可能 bounce
很多人第一次看到这里,会觉得系统太严格。
但这其实是在保护协作边界。
catchall 的意义通常是:
- 承接 reply-to
- 兜底接住已有对话的回信
它不是让所有未知新邮件都无脑冲进系统。
如果把 catchall 当万能入口,就会出现:
- 大量无法归属的邮件进入系统
- 垃圾对话污染 Chatter
- 协作对象和消息线程混乱绑定
所以 Odoo 宁可 bounce, 也不愿意让“无归属新邮件”轻易混进业务线程。
六、message_new() 与 message_update() 的分工边界
当路由判定完成后,后续就清楚了:
- 已有线程 →
message_update() - 新线程 →
message_new()
这两个方法表面很简单, 但语义边界非常重要。
message_new() 负责的是
- 这封邮件应该生成一个新的业务对象
- 需要用主题、发件人、alias 默认值等初始化记录
message_update() 负责的是
- 这封邮件属于已有对象
- 只是在这个对象上继续协作
也就是说, 创建与续帖不是“数据写法不同”, 而是两种完全不同的协作判断。
七、为什么 _message_route_process() 还要再做一层控制
_message_route_process() 里还有几个很值得注意的动作:
- 处理新建 / 更新失败时,alias 可能被标记为 invalid 并反弹错误邮件
- 进入
message_post()前会区分内部 note 与外部 comment - 会延迟写入部分
partner_ids,避免重复通知 - 若是新建线程,还会使用 creation subtype
这说明 mail gateway 不是“路由完就结束”。
Odoo 还要继续保证:
- 发帖语义正确
- 通知不要重复
- 失败可以回弹给入口配置
- 外部邮件不要轻易把系统用户污染成关注者或作者关系
这整套处理都在围绕同一个目标:
让邮件协作像继续一条业务对话,而不是像往数据库塞一段文本。
八、邮件网关和 Chatter 的真正边界是什么
很多实施里最常见的误区,是把邮件网关与 Chatter 的关系理解成:
- 邮件只是 Chatter 的另一种输入法
这只说对了一半。
更准确的理解应该是:
- Chatter 是业务对象上的协作线程
- 邮件网关是把外部邮件翻译进这个线程体系的边界层
它要先决定:
- 外部邮件是否配得上进入某条业务线程
- 该进入旧线程还是新线程
- 若没有清楚归属,是否干脆拒绝
所以 gateway 的价值不是“导入内容”, 而是“守住业务线程的边界完整性”。
九、实战里最容易踩的坑
1. 以为 alias 命中就一定新建记录
其实回复旧线程优先级更高;而命中不同模型 alias 时又可能反转成 forward 语义。
2. 把 catchall 当万能收件箱
这样最容易把协作边界冲烂。
3. 不理解 message_new() / message_update() 是路由结果,不是入口判断
后续定制就容易写反。
4. 只看邮件主题,不看 References / In-Reply-To
很容易误判为什么系统挂到旧线程上。
5. 遇到邮件“没进系统”就当 bug
有时是 Odoo 在主动保护线程边界。
一句话记忆法
Odoo 邮件网关首先不是在回答“要不要建记录”,而是在回答“这封邮件属于继续哪条协作线程、还是该新开入口、还是根本不该进来”。
DISCUSSION
评论区