diff --git a/mail_tracking/README.rst b/mail_tracking/README.rst index 70744a2a..30c40daf 100644 --- a/mail_tracking/README.rst +++ b/mail_tracking/README.rst @@ -167,6 +167,16 @@ Contributors - Agustín Payen Sandoval +- `Trobz `__: + + - Tris Doan + +Other credits +------------- + +The migration of this module from 17.0 to 18.0 was financially supported +by Camptocamp. + Maintainers ----------- diff --git a/mail_tracking/__manifest__.py b/mail_tracking/__manifest__.py index 51591765..8932df7c 100644 --- a/mail_tracking/__manifest__.py +++ b/mail_tracking/__manifest__.py @@ -7,7 +7,7 @@ { "name": "Email tracking", "summary": "Email tracking system for all mails sent", - "version": "17.0.1.0.0", + "version": "18.0.1.0.0", "category": "Social Network", "website": "https://github.com/OCA/mail", "author": ("Tecnativa, Odoo Community Association (OCA)"), diff --git a/mail_tracking/controllers/mailbox.py b/mail_tracking/controllers/mailbox.py index 29623a4f..1cdaadde 100644 --- a/mail_tracking/controllers/mailbox.py +++ b/mail_tracking/controllers/mailbox.py @@ -3,6 +3,7 @@ from odoo.http import request, route from odoo.addons.mail.controllers.mailbox import MailboxController +from odoo.addons.mail.tools.discuss import Store class MailTrackingMailBoxController(MailboxController): @@ -19,4 +20,9 @@ def discuss_failed_messages( around=around, limit=limit, ) - return {**res, "messages": res["messages"].message_format()} + messages = res.pop("messages") + return { + **res, + "data": Store(messages, for_current_user=True).get_result(), + "messages": Store.many_ids(messages), + } diff --git a/mail_tracking/models/mail_mail.py b/mail_tracking/models/mail_mail.py index e72c2237..ca8c2ae6 100644 --- a/mail_tracking/models/mail_mail.py +++ b/mail_tracking/models/mail_mail.py @@ -19,7 +19,7 @@ def _tracking_email_prepare(self, email): email_to = COMMASPACE.join(email_to_list) return { "name": self.subject, - "timestamp": "%.6f" % ts, + "timestamp": f"{ts:.6f}", "time": fields.Datetime.to_string(dt), "mail_id": self.id, "mail_message_id": self.mail_message_id.id, @@ -28,14 +28,16 @@ def _tracking_email_prepare(self, email): "sender": self.email_from, } - def _prepare_outgoing_list(self, recipients_follower_status=None): + def _prepare_outgoing_list( + self, mail_server=False, recipients_follower_status=None + ): """Creates the mail.tracking.email record and adds the image tracking to the email. Please note that because we can't add mail headers in this function, the added tracking image will later (IrMailServer.build_email) also be used to extract the mail.tracking.email record id and to set the X-Odoo-MailTracking-ID header there. """ - emails = super()._prepare_outgoing_list(recipients_follower_status) + emails = super()._prepare_outgoing_list(mail_server, recipients_follower_status) for email in emails: vals = self._tracking_email_prepare(email) tracking_email = self.env["mail.tracking.email"].sudo().create(vals) diff --git a/mail_tracking/models/mail_message.py b/mail_tracking/models/mail_message.py index 996d2695..2c9de463 100644 --- a/mail_tracking/models/mail_message.py +++ b/mail_tracking/models/mail_message.py @@ -8,6 +8,8 @@ from odoo.osv import expression from odoo.tools import email_split +from odoo.addons.mail.tools.discuss import Store + class MailMessage(models.Model): _inherit = "mail.message" @@ -59,30 +61,42 @@ def _compute_is_failed_message(self): needs_action and involves_me and has_failed_trackings ) - def _search_is_failed_message(self, operator, value): + @api.model + def _search_is_failed_message(self, operator, operand): """Search for messages considered failed for the active user. Be notice that 'notificacion_ids' is a record that change if the user mark the message as readed. """ - # FIXME: Due to ORM issue with auto_join and 'OR' we construct the domain - # using an extra query to get valid results. - # For more information see: https://github.com/odoo/odoo/issues/25175 - notification_partner_ids = self.search( - [("notification_ids.res_partner_id", "=", self.env.user.partner_id.id)] + pid = self.env.user.partner_id.id + self.flush_model( + ["author_id", "mail_tracking_ids", "mail_tracking_needs_action"] + ) + self.env["mail.notification"].flush_model(["mail_message_id", "res_partner_id"]) + self.env["mail.tracking.email"].flush_model(["state"]) + is_involve = expression.OR( + [ + [ + ("notification_ids.res_partner_id", "=", pid), + ], + [ + ("author_id", "=", pid), + ], + ] ) - return expression.normalize_domain( + domain = expression.AND( [ - ( - "mail_tracking_ids.state", - "in" if value else "not in", - list(self.get_failed_states()), - ), - ("mail_tracking_needs_action", "=", True), - "|", - ("author_id", "=", self.env.user.partner_id.id), - ("id", "in", notification_partner_ids.ids), + [ + ( + "mail_tracking_ids.state", + "in" if operand else "not in", + list(self.get_failed_states()), + ), + ("mail_tracking_needs_action", "=", True), + ], + is_involve, ] ) + return domain def _tracking_status_map_get(self): """Map tracking states to be used in chatter""" @@ -267,7 +281,7 @@ def get_failed_messages(self): def set_need_action_done(self): """This will mark the messages to be ignored in the tracking issues filter""" - self.check_access_rule("read") + self.check_access("read") self.mail_tracking_needs_action = False self._notify_message_notification_update() @@ -286,29 +300,15 @@ def get_failed_messsage_info(self, ids, model): ] return res - def _message_notification_format(self): - """Add info for the web client""" - formatted_notifications = super()._message_notification_format() - for notification in formatted_notifications: - message = self.filtered( - lambda x, notification=notification: x.id == notification["id"] - ) - notification.update( + 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, - } + }, ) - return formatted_notifications - - def _message_format_extras(self, format_reply): - """Add info for the web client""" - res = super()._message_format_extras(format_reply) - res.update( - { - "partner_trackings": self.tracking_status(), - "mail_tracking_needs_action": self.mail_tracking_needs_action, - "is_failed_message": self.is_failed_message, - } - ) return res diff --git a/mail_tracking/models/mail_thread.py b/mail_tracking/models/mail_thread.py index 6198c0a6..9584c041 100644 --- a/mail_tracking/models/mail_thread.py +++ b/mail_tracking/models/mail_thread.py @@ -6,7 +6,7 @@ from lxml import etree from odoo import _, api, fields, models -from odoo.tools import email_split, email_split_and_format +from odoo.tools.mail import email_split, email_split_and_format class MailThread(models.AbstractModel): diff --git a/mail_tracking/models/mail_tracking_email.py b/mail_tracking/models/mail_tracking_email.py index cda2f335..8deadbba 100644 --- a/mail_tracking/models/mail_tracking_email.py +++ b/mail_tracking/models/mail_tracking_email.py @@ -11,7 +11,7 @@ from odoo import _, api, fields, models, tools from odoo.exceptions import AccessError from odoo.fields import Command -from odoo.tools import email_split +from odoo.tools import SQL, email_split _logger = logging.getLogger(__name__) @@ -139,85 +139,124 @@ def write(self, vals): self.mapped("mail_message_id").write({"mail_tracking_needs_action": True}) return res - def _find_allowed_tracking_ids(self): - """Filter trackings based on related records ACLs""" - # Admins passby this filter - if not self.ids or self.env.user.has_group("base.group_system"): - return self.ids - # Override ORM to get the values directly - self._cr.execute( - """ - SELECT id, mail_message_id, partner_id - FROM mail_tracking_email WHERE id IN %s - """, - (tuple(self.ids),), - ) - msg_linked = self._cr.fetchall() - if not msg_linked: - return [] - _, msg_ids, partner_ids = zip(*msg_linked, strict=True) - # Filter messages with their ACL rules avoiding False values fetched in the set - msg_ids = self.env["mail.message"]._search( - [("id", "in", [x for x in msg_ids if x])] - ) - partner_ids = self.env["res.partner"]._search( - [("id", "in", [x for x in partner_ids if x])] - ) - return [ - track_id - for track_id, mail_msg_id, partner_id in msg_linked - if (mail_msg_id in msg_ids) # We can read the linked message - or ( - not mail_msg_id and partner_id in partner_ids - ) # No linked mail.message but we can read the linked partner - or (not any({mail_msg_id, partner_id})) # No linked record - ] - @api.model def _search( self, - args, + domain, offset=0, limit=None, order=None, - access_rights_uid=None, ): - """Filter ids based on related records ACLs""" - allowed = super()._search( - args, offset, limit, order, access_rights_uid=access_rights_uid - ) + """Override that adds specific access rights of mail.tracking.email, to remove + ids uid could not see according to our custom rules. Please refer to + _check_access() for more details about those rules. + """ + query = super()._search(domain, offset, limit, order) if not self.env.user.has_group("base.group_system"): - ids = self.browse(allowed)._find_allowed_tracking_ids() - allowed = self.browse(ids)._as_query(order) - return allowed - - def check_access_rule(self, operation): - """Rely on related messages ACLs""" - super().check_access_rule(operation) - allowed_ids = self._search([("id", "in", self._find_allowed_tracking_ids())]) - disallowed_ids = set(self.exists().ids).difference(set(allowed_ids)) - if not disallowed_ids: - return - raise AccessError( + records = self.browse(query) + allowed_ids = self._get_allowed_ids(records.ids) + return self.browse(allowed_ids)._as_query(order) + + return query + + def _make_access_error(self, operation: str) -> AccessError: + return AccessError( _( - "The requested operation cannot be completed due to security " - "restrictions. Please contact your system administrator.\n\n" - "(Document type: %(desc)s, Operation: %(operation)s)" - ) - % {"desc": self._description, "operation": operation} - + " - ({} {}, {} {})".format( - _("Records:"), list(disallowed_ids), _("User:"), self._uid + "The requested operation cannot be completed due to security restrictions. " # noqa: E501 + "Please contact your system administrator.\n\n" + "(Document type: %(type)s, Operation: %(operation)s)\n\n" + "Records: %(records)s, User: %(user)s", + type=self._description, + operation=operation, + records=self.ids[:6], + user=self.env.uid, ) ) + def _get_forbidden_access(self) -> api.Self: + """Return the subset of ``self`` that does not satisfy the specific + conditions for messages. + """ + + forbidden = self.browse() + allowed_ids = self._get_allowed_ids(self.ids) + + trackings_to_check = [ + tracking_id for tracking_id in self.ids if tracking_id not in allowed_ids + ] + + if not trackings_to_check: + return forbidden + forbidden += self.browse(trackings_to_check) + return forbidden + + def _check_access(self, operation): + """Access rules of mail.tracking.email: + - read: if + - Those with a linked mail.message that the user can read + - Those with a linked mail.mail that the user can read + - Those with no message/mail link but a linked partner that the user can + read. + - Those with no linked records. + """ + result = super()._check_access(operation) + if not self: + return result + + # discard forbidden records, and check remaining ones + trackings = self - result[0] if result else self + if trackings and (forbidden := trackings._get_forbidden_access()): + if result: + result = (result[0] + forbidden, result[1]) + else: + result = (forbidden, lambda: forbidden._make_access_error(operation)) + return result + def read(self, fields=None, load="_classic_read"): - """Override to explicitly call check_access_rule, that is not called + """Override to explicitly call check_access, that is not called by the ORM. It instead directly fetches ir.rules and apply them. """ - if not self.env.user.has_group("base.group_system"): - self.check_access_rule("read") + self.check_access("read") return super().read(fields=fields, load=load) + def _get_allowed_ids(self, ids): + allowed_ids = set() + self.env.cr.execute( + SQL( + """ SELECT id, mail_message_id, mail_id, partner_id + FROM "mail_tracking_email" + WHERE id = ANY (%s) + """, + (ids,), + ) + ) + result = self.env.cr.fetchall() + for id_, mail_msg_id, mail_id, partner_id in result: + msg_ids = ( + self.env["mail.message"].search([("id", "=", mail_msg_id)]).ids + if mail_msg_id + else [] + ) + mail_ids = ( + self.env["mail.mail"].search([("id", "=", mail_id)]).ids + if mail_id + else [] + ) + partner_ids = ( + self.env["res.partner"].search([("id", "=", partner_id)]).ids + if partner_id + else [] + ) + + if ( + (mail_msg_id in msg_ids) + or (mail_id in mail_ids) + or (not any({mail_msg_id, mail_id}) and partner_id in partner_ids) + or (not any({mail_msg_id, mail_id, partner_id})) + ): + allowed_ids.add(id_) + return allowed_ids + @api.model def email_is_bounced(self, email): if not email: @@ -353,9 +392,9 @@ def smtp_error(self, mail_server, smtp_server, exception): else: values.update( { - "error_smtp_server": tools.ustr(smtp_server), + "error_smtp_server": smtp_server, "error_type": exception.__class__.__name__, - "error_description": tools.ustr(exception), + "error_description": tools.exception_to_unicode(exception), } ) self.sudo()._partners_email_bounced_set("error") @@ -369,7 +408,7 @@ def tracking_img_add(self, email): content = re.sub( r']*data-odoo-tracking-email=["\'][0-9]*["\'][^>]*>', "", content ) - body = tools.append_content_to_html( + body = tools.mail.append_content_to_html( content, tracking_url, plaintext=False, container_tag="div" ) email["body"] = body @@ -400,7 +439,7 @@ def _tracking_sent_prepare(self, mail_server, smtp_server, message, message_id): self.sudo().write({"state": "sent"}) return { "recipient": message["To"], - "timestamp": "%.6f" % ts, + "timestamp": f"{ts:.6f}", "time": fields.Datetime.to_string(dt), "tracking_email_id": self.id, "event_type": "sent", @@ -414,7 +453,7 @@ def _event_prepare(self, event_type, metadata): if method and callable(method): return method(self, metadata) else: # pragma: no cover - _logger.info("Unknown event type: %s" % event_type) + _logger.info(f"Unknown event type: {event_type}") return False def _concurrent_events(self, event_type, metadata): diff --git a/mail_tracking/models/res_users.py b/mail_tracking/models/res_users.py index 35d4d50b..203b8c43 100644 --- a/mail_tracking/models/res_users.py +++ b/mail_tracking/models/res_users.py @@ -6,7 +6,15 @@ class ResUsers(models.Model): _inherit = "res.users" - def _init_messaging(self): - values = super()._init_messaging() - values["failed_counter"] = self.env["mail.message"].get_failed_count() - return values + def _init_messaging(self, store): + res = super()._init_messaging(store) + store.add( + { + "failed": { + "id": "failed", + "model": "mail.box", + "counter": self.env["mail.message"].get_failed_count(), + } + } + ) + return res diff --git a/mail_tracking/readme/CONTRIBUTORS.md b/mail_tracking/readme/CONTRIBUTORS.md index 2f60406d..322dfeab 100644 --- a/mail_tracking/readme/CONTRIBUTORS.md +++ b/mail_tracking/readme/CONTRIBUTORS.md @@ -9,3 +9,5 @@ - Asma Elferkhsi - [Vauxoo](https://www.vauxoo.com): - Agustín Payen Sandoval +- [Trobz](https://www.trobz.com): + - Tris Doan diff --git a/mail_tracking/readme/CREDITS.md b/mail_tracking/readme/CREDITS.md new file mode 100644 index 00000000..83b3ec91 --- /dev/null +++ b/mail_tracking/readme/CREDITS.md @@ -0,0 +1 @@ +The migration of this module from 17.0 to 18.0 was financially supported by Camptocamp. diff --git a/mail_tracking/static/description/index.html b/mail_tracking/static/description/index.html index 9ea113be..10a799f2 100644 --- a/mail_tracking/static/description/index.html +++ b/mail_tracking/static/description/index.html @@ -384,7 +384,8 @@

Email tracking

  • Credits
  • @@ -491,10 +492,19 @@

    Contributors

  • Agustín Payen Sandoval
  • +
  • Trobz:
      +
    • Tris Doan
    +
  • + + +
    +

    Other credits

    +

    The migration of this module from 17.0 to 18.0 was financially supported +by Camptocamp.

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association diff --git a/mail_tracking/static/src/components/failed_message/failed_message.esm.js b/mail_tracking/static/src/components/failed_message/failed_message.esm.js index c5dd28c5..dbc748c8 100644 --- a/mail_tracking/static/src/components/failed_message/failed_message.esm.js +++ b/mail_tracking/static/src/components/failed_message/failed_message.esm.js @@ -1,4 +1,3 @@ -/** @odoo-module **/ import {AvatarCardPopover} from "@mail/discuss/web/avatar_card/avatar_card_popover"; import {FailedMessageReview} from "@mail_tracking/components/failed_message_review/failed_message_review.esm"; import {MessageTracking} from "@mail_tracking/components/message_tracking/message_tracking.esm"; @@ -6,8 +5,7 @@ import {RelativeTime} from "@mail/core/common/relative_time"; import {url} from "@web/core/utils/urls"; import {usePopover} from "@web/core/popover/popover_hook"; import {useService} from "@web/core/utils/hooks"; - -const {Component, useState} = owl; +import {Component, toRaw, useState} from "@odoo/owl"; export class FailedMessage extends Component { static props = ["message", "onUpdate?", "reloadParentView"]; @@ -21,8 +19,8 @@ export class FailedMessage extends Component { }; setup() { + this.store = useState(useService("mail.store")); this.avatarCard = usePopover(AvatarCardPopover); - this.threadService = useState(useService("mail.thread")); this.state = useState({showDetails: false}); this.message = useState(this.props.message); this.orm = useService("orm"); @@ -32,31 +30,25 @@ export class FailedMessage extends Component { [this.message.id], ]); // Debugger - const thread = this.env.services["mail.thread"].getThread( - this.message.model, - this.message.id - ); - this.env.services["mail.thread"].fetchNewMessages(thread); + this.thread.fetchNewMessages(); this.props.reloadParentView(); } retryFailedMessage() { + const message = toRaw(this.message); this.env.services.action.doAction("mail.mail_resend_message_action", { additionalContext: { - mail_message_to_resend: this.message.id, + mail_message_to_resend: message.id, }, onClose: async () => { // Check if message is still 'failed' after Retry await this.orm.call("mail.message", "get_failed_messages", [ - [this.message.id], + [message.id], ]); }, }); } async onClickJump() { - await this.env.messageHighlight?.highlightMessage( - this.message, - this.message.originThread - ); + await this.env.messageHighlight?.highlightMessage(this.message, this.thread); } toggleDetails() { this.state.showDetails = !this.state.showDetails; @@ -89,16 +81,14 @@ export class FailedMessage extends Component { ) { return url("/mail/static/src/img/email_icon.png"); } - return this.threadService.avatarUrl( - this.message.author, - this.message.originThread - ); + if (this.message.author) { + return this.message.author.avatarUrl; + } + + return this.store.DEFAULT_AVATAR; } get thread() { - return this.threadService.getThread( - this.message.res_model, - this.message.res_id - ); + return this.props.message.thread; } get failed_recipients() { const error_states = ["error", "rejected", "spam", "bounced", "soft-bounced"]; diff --git a/mail_tracking/static/src/components/failed_message_review/failed_message_review.esm.js b/mail_tracking/static/src/components/failed_message_review/failed_message_review.esm.js index fb7ffaee..c5b859b2 100644 --- a/mail_tracking/static/src/components/failed_message_review/failed_message_review.esm.js +++ b/mail_tracking/static/src/components/failed_message_review/failed_message_review.esm.js @@ -1,5 +1,5 @@ -/** @odoo-module **/ import {useService} from "@web/core/utils/hooks"; +import {browser} from "@web/core/browser/browser"; const {Component, useState} = owl; @@ -8,7 +8,6 @@ export class FailedMessageReview extends Component { static template = "mail_tracking.FailedMessageReview"; setup() { - this.threadService = useState(useService("mail.thread")); this.message = useState(this.props.message); this.orm = useService("orm"); } @@ -16,15 +15,7 @@ export class FailedMessageReview extends Component { await this.orm.call("mail.message", "set_need_action_done", [ [this.message.id], ]); - // Debugger - const thread = this.env.services["mail.thread"].getThread( - this.message.model, - this.message.id - ); - this.env.services["mail.thread"].fetchNewMessages(thread); - if (this.props.reloadParentView) { - this.props.reloadParentView(); - } + browser.location.reload(); } retryFailedMessage() { this.env.services.action.doAction("mail.mail_resend_message_action", { @@ -40,10 +31,7 @@ export class FailedMessageReview extends Component { }); } get thread() { - return this.threadService.getThread( - this.message.res_model, - this.message.res_id - ); + return this.props.message.thread; } get failed_recipients() { const error_states = ["error", "rejected", "spam", "bounced", "soft-bounced"]; diff --git a/mail_tracking/static/src/components/failed_messages_panel/failed_messages_panel.esm.js b/mail_tracking/static/src/components/failed_messages_panel/failed_messages_panel.esm.js index f137a819..16b5f08f 100644 --- a/mail_tracking/static/src/components/failed_messages_panel/failed_messages_panel.esm.js +++ b/mail_tracking/static/src/components/failed_messages_panel/failed_messages_panel.esm.js @@ -1,4 +1,3 @@ -/* @odoo-module */ import {ActionPanel} from "@mail/discuss/core/common/action_panel"; import {MessageCardList} from "@mail/core/common/message_card_list"; import {_t} from "@web/core/l10n/translation"; 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 adcf2e3c..c48f823e 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 @@ -1,4 +1,3 @@ -/** @odoo-module **/ const {Component, useState} = owl; export class MessageTracking extends Component { @@ -9,7 +8,7 @@ export class MessageTracking extends Component { this.partner_trackings = useState(this.props.partner_trackings); } _onTrackingStatusClick(event) { - var tracking_email_id = $(event.currentTarget).data("tracking"); + const tracking_email_id = event.currentTarget.dataset.tracking; event.preventDefault(); return this.env.services.action.doAction({ type: "ir.actions.act_window", @@ -18,7 +17,7 @@ export class MessageTracking extends Component { res_model: "mail.tracking.email", views: [[false, "form"]], target: "new", - res_id: tracking_email_id, + res_id: parseInt(tracking_email_id), }); } } diff --git a/mail_tracking/static/src/core/chatter/chatter.esm.js b/mail_tracking/static/src/core/chatter/chatter.esm.js index 5538a5d1..46bdf395 100644 --- a/mail_tracking/static/src/core/chatter/chatter.esm.js +++ b/mail_tracking/static/src/core/chatter/chatter.esm.js @@ -1,5 +1,4 @@ -/** @odoo-module */ -import {Chatter} from "@mail/core/web/chatter"; +import {Chatter} from "@mail/chatter/web_portal/chatter"; import {FailedMessage} from "@mail_tracking/components/failed_message/failed_message.esm"; import {FailedMessagesPanel} from "@mail_tracking/components/failed_messages_panel/failed_messages_panel.esm"; import {patch} from "@web/core/utils/patch"; diff --git a/mail_tracking/static/src/core/discuss/discuss_app_model.esm.js b/mail_tracking/static/src/core/discuss/discuss_app_model.esm.js deleted file mode 100644 index 8d0e8e50..00000000 --- a/mail_tracking/static/src/core/discuss/discuss_app_model.esm.js +++ /dev/null @@ -1,15 +0,0 @@ -/** @odoo-module */ - -import {DiscussApp} from "@mail/core/common/discuss_app_model"; -import {Record} from "@mail/core/common/record"; -import {patch} from "@web/core/utils/patch"; - -/** @type {import("@mail/core/web/discuss_app_model").DiscussApp} */ -const DiscussAppPatch = { - setup() { - super.setup(); - this.failed = Record.one("Thread"); - }, -}; - -patch(DiscussApp.prototype, DiscussAppPatch); diff --git a/mail_tracking/static/src/core/discuss/discuss_sidebar_mailboxes.xml b/mail_tracking/static/src/core/discuss/discuss_sidebar_mailboxes.xml index e972f2f4..9ec314dd 100644 --- a/mail_tracking/static/src/core/discuss/discuss_sidebar_mailboxes.xml +++ b/mail_tracking/static/src/core/discuss/discuss_sidebar_mailboxes.xml @@ -1,8 +1,8 @@ @@ -18,9 +18,7 @@ t-inherit-mode="extension" > - - - + - + diff --git a/mail_tracking/static/src/core/message/message.esm.js b/mail_tracking/static/src/core/message/message.esm.js index af046715..7b0180cd 100644 --- a/mail_tracking/static/src/core/message/message.esm.js +++ b/mail_tracking/static/src/core/message/message.esm.js @@ -1,4 +1,3 @@ -/** @odoo-module **/ import {FailedMessageReview} from "@mail_tracking/components/failed_message_review/failed_message_review.esm"; import {Message} from "@mail/core/common/message"; import {MessageTracking} from "@mail_tracking/components/message_tracking/message_tracking.esm"; diff --git a/mail_tracking/static/src/core/search/failed_message_search_hook.esm.js b/mail_tracking/static/src/core/search/failed_message_search_hook.esm.js index 099c98f9..b93bdd23 100644 --- a/mail_tracking/static/src/core/search/failed_message_search_hook.esm.js +++ b/mail_tracking/static/src/core/search/failed_message_search_hook.esm.js @@ -1,17 +1,16 @@ -/* @odoo-module */ import {onWillUnmount, useState} from "@odoo/owl"; import {useSequential} from "@mail/utils/common/hooks"; import {useService} from "@web/core/utils/hooks"; export function useFailedMessageSearch(thread) { - const threadService = useService("mail.thread"); + const store = useService("mail.store"); const sequential = useSequential(); const state = useState({ thread, async filter_failed() { this.searching = true; const {count, loadMore, messages} = await sequential(() => - threadService.filter_failed(this.thread) + store.filter_failed(this.thread) ); this.searched = true; this.searching = false; diff --git a/mail_tracking/static/src/services/messaging_service_patch.esm.js b/mail_tracking/static/src/services/messaging_service_patch.esm.js deleted file mode 100644 index 0780ef11..00000000 --- a/mail_tracking/static/src/services/messaging_service_patch.esm.js +++ /dev/null @@ -1,24 +0,0 @@ -/** @odoo-module */ -import {Messaging} from "@mail/core/common/messaging_service"; -import {patch} from "@web/core/utils/patch"; -import {_t} from "@web/core/l10n/translation"; - -/** @type {import("@mail/core/common/messaging_service").Messaging} */ -const MessagingPatch = { - setup() { - super.setup(...arguments); - this.store.discuss.failed = { - id: "failed", - model: "mail.box", - name: _t("Failed"), - type: "mailbox", - counter: 0, - }; - }, - initMessagingCallback(data) { - super.initMessagingCallback(data); - this.store.discuss.failed.counter = data.failed_counter; - }, -}; - -patch(Messaging.prototype, MessagingPatch); diff --git a/mail_tracking/static/src/services/store_service_patch.esm.js b/mail_tracking/static/src/services/store_service_patch.esm.js new file mode 100644 index 00000000..1d32c72f --- /dev/null +++ b/mail_tracking/static/src/services/store_service_patch.esm.js @@ -0,0 +1,43 @@ +import {Store} from "@mail/core/common/store_service"; +import {Record} from "@mail/core/common/record"; +import {rpc} from "@web/core/network/rpc"; +import {patch} from "@web/core/utils/patch"; +import {_t} from "@web/core/l10n/translation"; + +// As in the original +const FETCH_LIMIT = 30; + +const StoreServicePatch = { + setup() { + super.setup(...arguments); + this.failed = Record.one("Thread"); + }, + + onStarted() { + super.onStarted(...arguments); + this.failed = { + id: "failed", + model: "mail.box", + name: _t("Failed"), + }; + }, + + async filter_failed(thread) { + const {data} = await rpc(thread.getFetchRoute(), { + ...thread.getFetchParams(), + }); + + const messages = data["mail.message"].filter( + (message) => message.is_failed_message + ); + + const count = messages?.length; + return { + count, + loadMore: messages.length === FETCH_LIMIT, + messages: this.Message.insert(messages, {html: true}), + }; + }, +}; + +patch(Store.prototype, StoreServicePatch); diff --git a/mail_tracking/static/src/services/thread_model_patch.esm.js b/mail_tracking/static/src/services/thread_model_patch.esm.js new file mode 100644 index 00000000..42a76531 --- /dev/null +++ b/mail_tracking/static/src/services/thread_model_patch.esm.js @@ -0,0 +1,12 @@ +import {Thread} from "@mail/core/common/thread_model"; +import "@mail/chatter/web_portal/thread_model_patch"; +import {patch} from "@web/core/utils/patch"; + +patch(Thread.prototype, { + getFetchRoute() { + if (this.model === "mail.box" && this.id === "failed") { + return `/mail/failed/messages`; + } + return super.getFetchRoute(...arguments); + }, +}); diff --git a/mail_tracking/static/src/services/thread_service_patch.esm.js b/mail_tracking/static/src/services/thread_service_patch.esm.js deleted file mode 100644 index f9476877..00000000 --- a/mail_tracking/static/src/services/thread_service_patch.esm.js +++ /dev/null @@ -1,30 +0,0 @@ -/** @odoo-module */ - -import {ThreadService} from "@mail/core/common/thread_service"; -import {patch} from "@web/core/utils/patch"; - -// As in the original -const FETCH_LIMIT = 30; - -/** @type {import("@mail/core/common/thread_service").ThreadService} */ -const ThreadServicePatch = { - /** - * @param {Thread} thread - */ - async filter_failed(thread) { - var {messages, count} = await this.rpc(this.getFetchRoute(thread), { - ...this.getFetchParams(thread), - }); - messages = messages.filter((message) => { - return message.is_failed_message; - }); - count = messages?.length; - return { - count, - loadMore: messages.length === FETCH_LIMIT, - messages: this.store.Message.insert(messages, {html: true}), - }; - }, -}; - -patch(ThreadService.prototype, ThreadServicePatch); diff --git a/mail_tracking/tests/test_mail_tracking.py b/mail_tracking/tests/test_mail_tracking.py index a4c255e2..2e23417d 100644 --- a/mail_tracking/tests/test_mail_tracking.py +++ b/mail_tracking/tests/test_mail_tracking.py @@ -6,11 +6,16 @@ from werkzeug.exceptions import BadRequest -from odoo import http +from odoo import SUPERUSER_ID, http +from odoo.exceptions import AccessError from odoo.fields import Command +from odoo.tests import tagged from odoo.tests.common import TransactionCase from odoo.tools import mute_logger +from odoo.addons.base.tests.common import HttpCaseWithUserDemo +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.mail.tools.discuss import Store from odoo.addons.mail_tracking.controllers.main import BLANK, MailTrackingController mock_send_email = "odoo.addons.base.models.ir_mail_server." "IrMailServer.send_email" @@ -104,12 +109,11 @@ def test_message_post(self): self.assertTrue(tracking_email) self.assertEqual(tracking_email.state, "sent") # message_dict read by web interface - message_dict = message.message_format()[0] - self.assertTrue(message_dict["history_partner_ids"]) + message_dict = Store(message, for_current_user=True).get_result() # First partner is recipient - partner_id = message_dict["history_partner_ids"][0] - self.assertEqual(partner_id, self.recipient.id) - status = message_dict["partner_trackings"][0] + partner_id = message_dict["mail.message"][0]["recipients"][0] + self.assertEqual(partner_id["id"], self.recipient.id) + status = message_dict["mail.message"][0]["partner_trackings"][0] # Tracking status must be sent and # mail tracking must be the one search before self.assertEqual(status["status"], "sent") @@ -178,23 +182,25 @@ def test_message_post_show_aliases(self): "body": "

    This is another test message

    ", } ) - message_dict, *_ = message.message_format() + message_dict = Store(message, for_current_user=True).get_result() + partner_trackings = message_dict["mail.message"][0]["partner_trackings"] self.assertTrue( any( [ tracking["recipient"] == "customer-invoices@test.com" - for tracking in message_dict["partner_trackings"] + for tracking in partner_trackings ] ) ) def _check_partner_trackings_cc(self, message): - message_dict = message.message_format()[0] - self.assertEqual(len(message_dict["partner_trackings"]), 3) + message_dict = Store(message, for_current_user=True).get_result() + partner_trackings = message_dict["mail.message"][0]["partner_trackings"] + self.assertEqual(len(partner_trackings), 3) # mail cc foundPartner = False foundNoPartner = False - for tracking in message_dict["partner_trackings"]: + for tracking in partner_trackings: if tracking["partner_id"] == self.sender.id: foundPartner = True self.assertTrue(tracking["isCc"]) @@ -222,9 +228,9 @@ def test_email_cc(self): ) # suggested recipients recipients = self.recipient._message_get_suggested_recipients() - suggested_mails = {email[1] for email in recipients[self.recipient.id]} + suggested_mails = {recipient["email"] for recipient in recipients} self.assertIn("unnamed@test.com", suggested_mails) - self.assertEqual(len(recipients[self.recipient.id]), 3) + self.assertEqual(len(recipients), 3) # Repeated Cc recipients message = self.env["mail.message"].create( { @@ -243,16 +249,17 @@ def test_email_cc(self): if message.is_thread_message(): self.env[message.model].browse(message.res_id)._notify_thread(message) recipients = self.recipient._message_get_suggested_recipients() - self.assertEqual(len(recipients[self.recipient.id]), 3) + self.assertEqual(len(recipients), 3) self._check_partner_trackings_cc(message) def _check_partner_trackings_to(self, message): - message_dict = message.message_format()[0] - self.assertEqual(len(message_dict["partner_trackings"]), 4) + message_dict = Store(message, for_current_user=True).get_result() + partner_trackings = message_dict["mail.message"][0]["partner_trackings"] + self.assertEqual(len(partner_trackings), 4) # mail cc foundPartner = False foundNoPartner = False - for tracking in message_dict["partner_trackings"]: + for tracking in partner_trackings: if tracking["partner_id"] == self.sender.id: foundPartner = True elif tracking["recipient"] == "support+unnamed@test.com": @@ -276,9 +283,9 @@ def test_email_to(self): ) # suggested recipients recipients = self.recipient._message_get_suggested_recipients() - suggested_mails = {email[1] for email in recipients[self.recipient.id]} + suggested_mails = {recipient["email"] for recipient in recipients} self.assertIn("support+unnamed@test.com", suggested_mails) - self.assertEqual(len(recipients[self.recipient.id]), 3) + self.assertEqual(len(recipients), 3) # Repeated To recipients message = self.env["mail.message"].create( { @@ -298,7 +305,7 @@ def test_email_to(self): if message.is_thread_message(): self.env[message.model].browse(message.res_id)._notify_thread(message) recipients = self.recipient._message_get_suggested_recipients() - self.assertEqual(len(recipients[self.recipient.id]), 4) + self.assertEqual(len(recipients), 4) self._check_partner_trackings_to(message) # Catchall + Alias alias_domain_id = self.env["mail.alias.domain"].create( @@ -312,8 +319,8 @@ def test_email_to(self): } ) recipients = self.recipient._message_get_suggested_recipients() - self.assertEqual(len(recipients[self.recipient.id]), 2) - suggested_mails = {email[1] for email in recipients[self.recipient.id]} + self.assertEqual(len(recipients), 2) + suggested_mails = {recipient["email"] for recipient in recipients} self.assertNotIn("support+unnamed@test.com", suggested_mails) def test_failed_message(self): @@ -473,6 +480,7 @@ def mock_error_function(*args, **kwargs): mock_client.return_value = False controller.mail_tracking_open(db, tracking.id, False) + @mute_logger("odoo.addons.mail_tracking.controllers.main") def test_db_env_no_cr(self): http.request.env = None db = self.env.cr.dbname @@ -705,3 +713,155 @@ def assert_tracking_tag_side_effect(*args, **kwargs): self.assertEqual( "data-odoo-tracking-email not found", tracking.error_description ) + + def test_search_is_failed_message(self): + user_employee_1 = mail_new_test_user( + self.env, + groups="base.group_user", + login="employee1", + name="employee_1", + ) + partner_employee = user_employee_1.partner_id + user_employee_2 = mail_new_test_user( + self.env, + groups="base.group_user", + login="employee2", + name="employee_2", + ) + message = self.env["mail.message"].create( + { + "subject": "Message test", + "author_id": self.sender.id, + "email_from": self.sender.email, + "message_type": "comment", + "model": "res.partner", + "res_id": partner_employee.id, + "partner_ids": [Command.link(partner_employee.id)], + "body": "

    This is a test message

    ", + } + ) + if message.is_thread_message(): + self.env[message.model].browse(message.res_id)._notify_thread(message) + # Search tracking created + tracking_email = self.env["mail.tracking.email"].search( + [ + ("mail_message_id", "=", message.id), + ("partner_id", "=", partner_employee.id), + ] + ) + # Force error state + tracking_email.state = "error" + + # employee_1 should read/search failed msg + failed_msg = message.with_user(user_employee_1).read( + fields=["is_failed_message"] + ) + self.assertTrue(failed_msg[0]["is_failed_message"]) + self.assertTrue( + self.env["mail.message"] + .with_user(user_employee_1) + .search( + [ + ("is_failed_message", "=", True), + ] + ) + ) + self.assertFalse( + self.env["mail.message"] + .with_user(user_employee_2) + .search( + [ + ("is_failed_message", "=", True), + ] + ) + ) + + +@tagged("-at_install", "post_install") +class TestAccessTrackingEmail(HttpCaseWithUserDemo, TestMailTracking): + def _get_tracking_email( + self, user=SUPERUSER_ID, mail_msg_id=False, mail_id=False, partner_id=False + ): + domain = [] + if mail_msg_id: + domain.append(("mail_message_id", "=", mail_msg_id)) + if mail_id: + domain.append(("mail_id", "=", mail_id)) + if partner_id: + domain.append(("partner_id", "=", partner_id)) + result = self.env["mail.tracking.email"].with_user(user).search(domain) + return result + + def test_access_tracking_email(self): + if "hr.employee" in self.env: + self.admin_user = self.env.ref("base.user_admin") + user_employee_1 = mail_new_test_user( + self.env, + groups="base.group_user", + login="employee1", + name="employee 1", + ) + employee_1 = self.env["hr.employee"].create( + [ + { + "name": "employee 1", + "user_id": user_employee_1.id, + }, + ] + ) + user_employee_2 = mail_new_test_user( + self.env, + groups="base.group_user", + login="employee2", + name="employee 2", + ) + + # Create message + message = self.env["mail.message"].create( + { + "subject": "Confidential Message", + "body": "Confidential message", + "author_id": self.sender.id, + "email_from": self.sender.email, + "model": "hr.employee", + "res_id": employee_1.id, + "partner_ids": [(6, 0, [user_employee_1.partner_id.id])], + } + ) + if message.is_thread_message(): + self.env[message.model].browse(message.res_id)._notify_thread(message) + # Search tracking created + tracking_email = self._get_tracking_email( + mail_msg_id=message.id, partner_id=user_employee_1.partner_id.id + ) + # ensure tracking exists + self.assertTrue(tracking_email) + # Addmin should be able to read/search the tracking email + tracking_email.with_user(self.admin_user).read() + self.assertTrue( + self._get_tracking_email( + mail_msg_id=message.id, + partner_id=user_employee_1.partner_id.id, + ) + ) + + # employee 1 should be able to read/search the tracking email + tracking_email.with_user(user_employee_1).read() + self.assertTrue( + self._get_tracking_email( + user=user_employee_1, + mail_msg_id=message.id, + partner_id=user_employee_1.partner_id.id, + ) + ) + + # employee 2 should not be able to read/search the tracking email + with self.assertRaises(AccessError): + tracking_email.with_user(user_employee_2).read() + self.assertFalse( + self._get_tracking_email( + user=user_employee_2, + mail_msg_id=message.id, + partner_id=user_employee_1.partner_id.id, + ) + ) diff --git a/mail_tracking/views/mail_tracking_email_view.xml b/mail_tracking/views/mail_tracking_email_view.xml index a6ec2f34..3a417a1a 100644 --- a/mail_tracking/views/mail_tracking_email_view.xml +++ b/mail_tracking/views/mail_tracking_email_view.xml @@ -48,7 +48,7 @@