From f99332def3315127c3a8524a2a9ad765d1d59858 Mon Sep 17 00:00:00 2001 From: Carlos Roca Date: Fri, 4 Sep 2026 16:15:38 +0200 Subject: [PATCH] [IMP] mail_tracking: Reactive tracking status in the chatter The tracking status shown in the chatter only refreshed on a page reload. A mail.tracking.email state change happens outside of any user session and doesn't touch mail.notification, so core's _notify_message_notification_update() is never triggered for it and nothing is pushed to the bus. Notify the involved users on every state write instead. The store has to be built once per recipient because is_failed_message is computed for the reader, and the refresh is aggregated in cr.precommit so that a message with several recipients emits a single notification instead of one per email sent. Furthermore, MessageTracking captured props.partner_trackings in setup(), which isn't re-run when the props change. As tracking_status() returns a brand new list on every call, the component kept rendering the trackings the message had when it was created. Read them from the props on each render. --- mail_tracking/models/mail_message.py | 86 +++++++++++++++++-- mail_tracking/models/mail_tracking_email.py | 10 ++- .../message_tracking/message_tracking.esm.js | 13 ++- 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/mail_tracking/models/mail_message.py b/mail_tracking/models/mail_message.py index 944821e7a..be34092ab 100644 --- a/mail_tracking/models/mail_message.py +++ b/mail_tracking/models/mail_message.py @@ -5,6 +5,7 @@ from email.utils import getaddresses from odoo import api, fields, models +from odoo.exceptions import AccessError from odoo.osv import expression from odoo.tools import email_split @@ -305,15 +306,84 @@ def get_failed_messsage_info(self, ids, model): ] return res + def _tracking_status_vals(self): + """Tracking data consumed by the chatter and the failed messages widget. + + Beware that ``is_failed_message`` is computed for ``self.env.user``, so + these values must be built once per recipient. + """ + self.ensure_one() + return { + "partner_trackings": self.tracking_status(), + "mail_tracking_needs_action": self.mail_tracking_needs_action, + "is_failed_message": self.is_failed_message, + } + + def _tracking_status_recipients(self): + """Users whose web client is showing the tracking status of a message.""" + self.ensure_one() + partners = self.author_id | self.notification_ids.res_partner_id + users = partners.with_context(active_test=False).user_ids + return users.filtered(lambda one: not one._is_public()) + + def _notify_tracking_status_update(self): + """Queue a refresh of the tracking status in the involved web clients. + + Tracking states change outside of any user session (Mailgun webhooks, + the open pixel, the SMTP layer) and they don't touch ``mail.notification``, + so core's ``_notify_message_notification_update()`` is never triggered for + them and the chatter would keep the stale status until a page reload. + + Emails are sent one at a time, so a message with several recipients would + emit one notification per recipient. The refresh is aggregated here and + sent once per message at the end of the transaction instead. + """ + messages = self.sudo().exists().filtered("mail_tracking_ids") + if not messages: + return + precommit = self.env.cr.precommit + if "mail_tracking.status_update" not in precommit.data: + precommit.data["mail_tracking.status_update"] = set() + env = self.env + + @precommit.add + def send_tracking_status_update(): + message_ids = env.cr.precommit.data.pop( + "mail_tracking.status_update", () + ) + env["mail.message"].browse(message_ids)._send_tracking_status_update() + + precommit.data["mail_tracking.status_update"].update(messages.ids) + + def _send_tracking_status_update(self): + """Push the current tracking status to the web clients showing the messages.""" + for message in self.sudo().exists(): + for user in message._tracking_status_recipients(): + # 'is_failed_message' depends on the reader, so the store has to + # be built within each recipient's own environment. + message_as_user = message.with_user(user) + try: + vals = message_as_user._tracking_status_vals() + except AccessError: # pragma: no cover + continue + store = Store() + store.add(message_as_user, vals) + user.partner_id._bus_send_store(store) + + def _message_notifications_to_store(self, store: Store): + """Refresh the tracking data along with the notification statuses. + + Core pushes this to the web client whenever the notifications of a + message change (a delivery failure, a resend...), so it's the right + place to keep the failed messages widget up to date. + """ + res = super()._message_notifications_to_store(store) + for message in self.filtered("mail_tracking_ids"): + store.add(message, message._tracking_status_vals()) + return res + def _extras_to_store(self, store: Store, format_reply): res = super()._extras_to_store(store, format_reply=format_reply) for message in self: - store.add( - message, - { - "partner_trackings": message.tracking_status(), - "mail_tracking_needs_action": message.mail_tracking_needs_action, - "is_failed_message": message.is_failed_message, - }, - ) + store.add(message, message._tracking_status_vals()) return res diff --git a/mail_tracking/models/mail_tracking_email.py b/mail_tracking/models/mail_tracking_email.py index 36c1dcde0..f8b3ceea1 100644 --- a/mail_tracking/models/mail_tracking_email.py +++ b/mail_tracking/models/mail_tracking_email.py @@ -135,8 +135,14 @@ def create(self, vals_list): def write(self, vals): res = super().write(vals) state = vals.get("state") - if state and state in self.env["mail.message"].get_failed_states(): - self.mapped("mail_message_id").write({"mail_tracking_needs_action": True}) + if state: + messages = self.mapped("mail_message_id") + if state in self.env["mail.message"].get_failed_states(): + messages.write({"mail_tracking_needs_action": True}) + # The state is usually written from a webhook, the open pixel or the + # SMTP layer, none of which triggers the core notification update, so + # the chatter has to be refreshed explicitly. + messages._notify_tracking_status_update() return res @api.model diff --git a/mail_tracking/static/src/components/message_tracking/message_tracking.esm.js b/mail_tracking/static/src/components/message_tracking/message_tracking.esm.js index c48f823e2..07ebbfb11 100644 --- a/mail_tracking/static/src/components/message_tracking/message_tracking.esm.js +++ b/mail_tracking/static/src/components/message_tracking/message_tracking.esm.js @@ -5,7 +5,18 @@ export class MessageTracking extends Component { static props = ["message", "partner_trackings", "skip_track_links?"]; setup() { this.message = useState(this.props.message); - this.partner_trackings = useState(this.props.partner_trackings); + } + /** + * Read the trackings from the props on every render. + * + * `tracking_status()` builds a brand new list every time it's computed, so + * the message record gets a different array each time the server pushes an + * update. A reference stored in `setup()` (which isn't re-run when the props + * change) would keep rendering the trackings the message had when the + * component was created. + */ + get partner_trackings() { + return this.props.partner_trackings; } _onTrackingStatusClick(event) { const tracking_email_id = event.currentTarget.dataset.tracking;