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;