先说结论
很多人会把 Odoo 的“稍后发送”理解成一个功能按钮背后的单一队列。
其实不是。
从官方源码看,Odoo 把“延迟”拆成了两种完全不同的事:
- 消息正文本身晚点再发;
- 消息现在就入线程,但通知晚点再送达。
对应两条模型链路:
mail.scheduled.messagemail.message.schedule
一句话说:
Odoo 不是简单地把“发送时间”做成一个字段,而是把“什么时候形成业务消息”和“什么时候打扰收件人”拆开处理。
这正是协同系统设计得比较成熟的表现。
这篇主要看哪里
核心源码在:
addons/mail/models/mail_scheduled_message.pyaddons/mail/models/mail_thread.py
可以重点看这些方法:
mail.scheduled.message.create()mail.scheduled.message._post_message()mail.scheduled.message._post_messages_cron()mail.thread._notify_thread()mail.thread._is_notification_scheduled()
只要把这几段连起来,Odoo 的“延迟协同”思路就很清楚了。
第一条链:mail.scheduled.message 是把正文延后生成
mail.scheduled.message 的模型注释写得很直白:
它保存的是由 composer 生成的发帖参数,目的是把消息的 posting 延后。
这里的 posting 不是“延迟发邮件附件通知”那么简单,而是:
- 目标业务记录上暂时还没有那条消息;
- 到了时间,系统再以创建者身份调用
message_post(); - 那时消息才真正进入业务线程。
这意味着:
在触发时间到来之前
- 业务对象 Chatter 里还没有这条消息;
- 其他协作者也看不到它;
- 附件只是先挂在
mail.scheduled.message上。
到了触发时间
源码会:
- 用
create_uid重新检查权限; - 把附件转入真实消息;
- 用原作者、原正文、原收件人去
message_post(); - 成功后再删掉 scheduled message 本身。
所以 mail.scheduled.message 的本质是:
延迟创建业务线程消息。
第二条链:mail.message.schedule 是消息先存在,通知晚点送
另一条链藏在 mail_thread.py 的 _notify_thread() 里。
这里会先算好 recipients_data,然后检查:
scheduled_date_is_notification_scheduled()
如果判定通知时间在未来,它不会立刻走:
- inbox
- web push
而是创建一条:
mail.message.schedule
注意这和 mail.scheduled.message 完全不是一回事。
这里的前提是:
- 消息已经创建出来了;
- 它已经是业务线程的一部分;
- 只是通知动作延后执行。
也就是说,协作留痕和打扰用户,被拆成了两个时点。
这在团队协作里很有价值。
比如:
- 你想先把消息写进任务历史;
- 但不想现在打扰所有人;
- 于是让通知在更合适的时间发出去。
为什么 Odoo 要故意拆成两套,而不是做一个统一延迟队列
因为这两件事在协作语义上完全不同。
延迟正文
关注的是:
- 什么时候把事件正式写入业务历史;
- 什么时候让协作者在 Chatter 里看到它;
- 什么时候建立 reply-to、subtype、followers 等上下文。
延迟通知
关注的是:
- 这条消息现在已经存在;
- 但 inbox / email / push 什么时候送更合适;
- 如何减少打扰而不牺牲可追溯性。
如果把两者强行揉成一种机制,系统会很难同时满足:
- 留痕准确;
- 提醒节奏合理;
- 权限与失败处理清晰。
mail.scheduled.message 为什么要以创建者身份重新发
源码里 _post_message() 有一个很关键的动作:
scheduled_message.with_user(message_creator)._check()- 然后目标记录
.with_user(message_creator).message_post(...)
这说明 Odoo 不是“到点后由系统超级用户代发”。
它故意保留原创建者视角,重新检查:
- 这个人现在还有没有发帖权限;
- 附件是否还能正确归档;
- 原上下文是否仍可用。
这背后是很现实的协作问题:
- 人可能离职了;
- 权限可能被撤掉了;
- 记录状态可能变了;
- 计划中的消息不一定还应该被允许发送。
所以 Odoo 的默认设计是:
定时消息不是“系统替你完成意图”,而是“在未来时点再次验证你是否仍有资格完成这次发帖”。
失败时为什么不用 message_post(),而改用 message_notify()
这也是一处非常漂亮的边界设计。
如果定时消息发送失败,源码会给原作者发:
self.env['mail.thread'].message_notify(...)
而不是往目标记录上再 message_post() 一条“发送失败”。
原因很合理:
- 失败提醒是系统告警,不是业务正文;
- 不应该污染目标记录 Chatter;
- 原作者才是最需要知道的人。
这也再次说明 Odoo 在协同设计上很在意正文语义和提醒语义的分层。
这两套机制分别适合什么场景
用 mail.scheduled.message 思路,适合:
- 晚点再把评论正式发到任务、项目、商机;
- 让某条留言在将来某个时刻才进入业务历史;
- 希望在发送前保留可编辑、可取消空间。
用 mail.message.schedule 思路,适合:
- 消息现在就应该入线程;
- 但通知不该立刻打扰所有人;
- 希望把留痕和提醒节奏解耦。
如果业务上分不清这两种需求,协同体验通常会出问题:
- 要么历史留得太晚;
- 要么用户被提醒得太早;
- 要么失败以后根本不知道该通知谁。
开发时最容易犯的误区
误区一:把“稍后通知”做成“稍后创建消息”
结果就是:
- 业务线程在关键时间点上缺历史;
- 别人看不到已经确定的协作内容;
- 后续动作难以引用这条消息。
误区二:把“稍后发正文”做成“先发再静默”
这样做虽然看上去也减少了打扰,但会让业务对象提前出现本不该出现的内容。
误区三:忽略未来权限变化
Odoo 在定时正文发送时重新检查创建者权限,这一点很多二开没做到。
如果直接用系统权限兜底,短期省事,长期就会留下非常难解释的安全和责任问题。
一句话记住
在 Odoo 的协同世界里,“稍后发送”至少有两层含义:
是让消息晚点进入业务历史,还是让消息先存在、提醒晚点抵达。
mail.scheduled.message 解决前者,mail.message.schedule 解决后者。
看懂这组拆分,你会更容易设计出既不吵人、又不丢上下文的协作流程。
DISCUSSION
评论区