diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 1638baae47efbfb3ec7bda61e333e3abc2ab0c3d..1385da9f61eadfb43eaa29b21f23ca37d539bea7 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -12,6 +12,7 @@ 'data': [ 'wizard/invite_view.xml', 'wizard/mail_compose_message_view.xml', + 'wizard/mail_resend_message_views.xml', 'views/mail_message_subtype_views.xml', 'views/mail_tracking_views.xml', 'views/mail_message_views.xml', diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py index f4b6f6b8d5d7d473c934d13a45cff9e7da33a30f..949a715cddb72164834eac10aa84639784740920 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -224,6 +224,7 @@ class MailController(http.Controller): 'needaction_inbox_counter': request.env['res.partner'].get_needaction_count(), 'starred_counter': request.env['res.partner'].get_starred_count(), 'channel_slots': request.env['mail.channel'].channel_fetch_slot(), + 'mail_failures': request.env['mail.message'].message_fetch_failed(), 'commands': request.env['mail.channel'].get_mention_commands(), 'mention_partner_suggestions': request.env['res.partner'].get_static_mention_suggestions(), 'shortcodes': request.env['mail.shortcode'].sudo().search_read([], ['source', 'substitution', 'description']), diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index fb4f9b7609622366983f55b00162779fdc3ec0c7..d8abbd1f06f1a9c383598d06041c9a0edcc04286 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -123,7 +123,7 @@ class MailMail(models.Model): return res @api.multi - def _postprocess_sent_message(self, mail_sent=True): + def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type='NONE'): """Perform any post-processing necessary after sending ``mail`` successfully, including deleting it completely along with its attachment if the ``auto_delete`` flag of the mail was set. @@ -131,20 +131,29 @@ class MailMail(models.Model): :return: True """ - mail_message_notif_ids = [mail.mail_message_id.id for mail in self if mail.notification] - if mail_message_notif_ids: + notif_mails_ids = [mail.id for mail in self if mail.notification] + if notif_mails_ids: notifications = self.env['mail.notification'].search([ - ('mail_message_id', 'in', mail_message_notif_ids), - ('is_email', '=', True)]) - if notifications and mail_sent: - notifications.write({ + ('is_email', '=', True), + ('mail_id', 'in', notif_mails_ids), + ('email_status', 'not in', ('sent', 'canceled')) + ]) + if notifications: + #find all notification linked to a failure + failed = self.env['mail.notification'] + if failure_type != 'NONE': + failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids) + failed.write({ + 'email_status': 'exception', + 'failure_type': failure_type, + 'failure_reason': failure_reason, + }) + messages = notifications.mapped('mail_message_id').filtered(lambda m: m.res_id and m.model) + messages._notify_failure_update() # notify user that we have a failure + (notifications - failed).write({ 'email_status': 'sent', }) - elif notifications: - notifications.write({ - 'email_status': 'exception', - }) - if mail_sent: + if failure_type in ('NONE', 'RECIPIENT'): # if we have another error, we want to keep the mail. mail_to_delete_ids = [mail.id for mail in self if mail.auto_delete] self.browse(mail_to_delete_ids).sudo().unlink() return True @@ -225,7 +234,9 @@ class MailMail(models.Model): # exceptions, it is encapsulated into an Odoo MailDeliveryException raise MailDeliveryException(_('Unable to connect to SMTP Server'), exc) else: - self.browse(batch_ids).write({'state': 'exception', 'failure_reason': exc}) + batch = self.browse(batch_ids) + batch.write({'state': 'exception', 'failure_reason': exc}) + batch._postprocess_sent_message(success_pids=[], failure_type="SMTP") else: self.browse(batch_ids)._send( auto_commit=auto_commit, @@ -242,13 +253,16 @@ class MailMail(models.Model): def _send(self, auto_commit=False, raise_exception=False, smtp_session=None): IrMailServer = self.env['ir.mail_server'] for mail_id in self.ids: + success_pids = [] + failure_type = 'NONE' + processing_pid = None + mail = None try: mail = self.browse(mail_id) if mail.state != 'outgoing': if mail.state != 'exception' and mail.auto_delete: mail.sudo().unlink() continue - # load attachment binary data with a separate read(), as prefetching all # `datas` (binary field) could bloat the browse cache, triggerring # soft/hard mem limits with temporary data. @@ -260,7 +274,10 @@ class MailMail(models.Model): if mail.email_to: email_list.append(mail._send_prepare_values()) for partner in mail.recipient_ids: - email_list.append(mail._send_prepare_values(partner=partner)) + values = mail._send_prepare_values(partner=partner) + values['partner_id'] = partner + email_list.append(values) + # headers headers = {} @@ -285,8 +302,6 @@ class MailMail(models.Model): 'state': 'exception', 'failure_reason': _('Error without exception. Probably due do sending an email without computed recipients.'), }) - mail_sent = False - # build an RFC2822 email.message.Message object and send it without queuing res = None for email in email_list: @@ -305,11 +320,16 @@ class MailMail(models.Model): subtype='html', subtype_alternative='plain', headers=headers) + processing_pid = email.pop("partner_id", None) try: res = IrMailServer.send_email( msg, mail_server_id=mail.mail_server_id.id, smtp_session=smtp_session) + if processing_pid: + success_pids.append(processing_pid) + processing_pid = None except AssertionError as error: if str(error) == IrMailServer.NO_VALID_RECIPIENT: + failure_type = "RECIPIENT" # No valid recipient found for this particular # mail item -> ignore error to avoid blocking # delivery to next recipients, if any. If this is @@ -318,21 +338,19 @@ class MailMail(models.Model): mail.message_id, email.get('email_to')) else: raise - if res: + if res: # mail has been sent at least once, no major exception occured mail.write({'state': 'sent', 'message_id': res, 'failure_reason': False}) - mail_sent = True - - # /!\ can't use mail.state here, as mail.refresh() will cause an error - # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1 - if mail_sent: _logger.info('Mail with ID %r and Message-Id %r successfully sent', mail.id, mail.message_id) - mail._postprocess_sent_message(mail_sent=mail_sent) + # /!\ can't use mail.state here, as mail.refresh() will cause an error + # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1 + mail._postprocess_sent_message(success_pids=success_pids, failure_type=failure_type) except MemoryError: # prevent catching transient MemoryErrors, bubble up to notify user or abort cron job # instead of marking the mail as failed _logger.exception( 'MemoryError while processing mail with ID %r and Msg-Id %r. Consider raising the --limit-memory-hard startup option', mail.id, mail.message_id) + # mail status will stay on ongoing since transaction will be rollback raise except psycopg2.Error: # If an error with the database occurs, chances are that the cursor is unusable. @@ -344,7 +362,7 @@ class MailMail(models.Model): failure_reason = tools.ustr(e) _logger.exception('failed sending mail (id: %s) due to %s', mail.id, failure_reason) mail.write({'state': 'exception', 'failure_reason': failure_reason}) - mail._postprocess_sent_message(mail_sent=False) + mail._postprocess_sent_message(success_pids=success_pids, failure_reason=failure_reason, failure_type='UNKNOWN') if raise_exception: if isinstance(e, AssertionError): # get the args of the original error, wrap into a value and throw a MailDeliveryException diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index d98c98fb1d9b960d490f0ccdd18bfbb26a144fd5..c5d3abda206edd7f801c7d82380fd9a63321c9ae 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -4,11 +4,13 @@ import logging import re +from operator import itemgetter from email.utils import formataddr from odoo import _, api, fields, models, modules, SUPERUSER_ID, tools from odoo.exceptions import UserError, AccessError from odoo.osv import expression +from odoo.tools import groupby _logger = logging.getLogger(__name__) _image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])', re.I) @@ -81,6 +83,9 @@ class Message(models.Model): needaction = fields.Boolean( 'Need Action', compute='_get_needaction', search='_search_needaction', help='Need Action') + has_error = fields.Boolean( + 'Has error', compute='_compute_has_error', search='_search_has_error', + help='Has error') channel_ids = fields.Many2many( 'mail.channel', 'mail_message_mail_channel_rel', string='Channels') # notifications @@ -114,6 +119,9 @@ class Message(models.Model): ('rejected', 'Rejected')], string="Moderation Status", index=True) moderator_id = fields.Many2one('res.users', string="Moderated By", index=True) need_moderation = fields.Boolean('Need moderation', compute='_compute_need_moderation', search='_search_need_moderation') + #keep notification layout informations to be able to generate mail again + layout = fields.Char('Layout', copy=False) # xml id of layout + layout_values = fields.Char('Notification values', copy=False) @api.multi def _get_needaction(self): @@ -131,6 +139,20 @@ class Message(models.Model): return ['&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', False)] return ['&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', True)] + @api.multi + def _compute_has_error(self): + error_from_notification = self.env['mail.notification'].sudo().search([ + ('mail_message_id', 'in', self.ids), + ('email_status', 'in', ('bounce', 'exception'))]).mapped('mail_message_id') + for message in self: + message.has_error = message in error_from_notification + + @api.multi + def _search_has_error(self, operator, operand): + if operator == '=' and operand: + return [('notification_ids.email_status', 'in', ('bounce', 'exception'))] + return ['!', ('notification_ids.email_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False" + @api.depends('starred_partner_ids') def _get_starred(self): """ Compute if the message is starred by the current user. """ @@ -350,7 +372,7 @@ class Message(models.Model): if partner.id in partner_tree] customer_email_data = [] - for notification in message.notification_ids.filtered(lambda notif: notif.res_partner_id.partner_share and notif.res_partner_id.active): + for notification in message.notification_ids.filtered(lambda notif: notif.email_status in ('bounce', 'exception', 'canceled') or (notif.res_partner_id.partner_share and notif.res_partner_id.active)): customer_email_data.append((partner_tree[notification.res_partner_id.id][0], partner_tree[notification.res_partner_id.id][1], notification.email_status)) attachment_ids = [] @@ -375,6 +397,11 @@ class Message(models.Model): return True + @api.multi + def message_fetch_failed(self): + messages = self.search([('has_error', '=', True), ('author_id.id', '=', self.env.user.partner_id.id), ('res_id', '!=', 0), ('model', '!=', False)]) + return messages._format_mail_failures() + @api.model def message_fetch(self, domain, limit=20): return self.search(domain, limit=limit).message_format() @@ -461,6 +488,37 @@ class Message(models.Model): message['module_icon'] = modules.module.get_module_icon(self.env[message['model']]._original_module) return message_values + @api.multi + def _format_mail_failures(self): + """ + A shorter message to notify a failure update + """ + failures_infos = [] + # for each channel, build the information header and include the logged partner information + for message in self: + info = { + 'message_id': message.id, + 'record_name': message.record_name, + 'model_name': self.env['ir.model']._get(message.model).display_name, + 'uuid': message.message_id, + 'res_id': message.res_id, + 'model': message.model, + 'last_message_date': message.date, + 'module_icon': '/mail/static/src/img/smiley/mailfailure.jpg', + 'notifications': dict((notif.res_partner_id.id, (notif.email_status, notif.res_partner_id.name)) for notif in message.notification_ids) + } + failures_infos.append(info) + return failures_infos + + @api.multi + def _notify_failure_update(self): + authors = {} + for author, author_messages in groupby(self, itemgetter('author_id')): + self.env['bus.bus'].sendone( + (self._cr.dbname, 'res.partner', author.id), + {'type': 'mail_failure', 'elements': self.env['mail.message'].concat(*author_messages)._format_mail_failures()} + ) + #------------------------------------------------------ # mail_message internals #------------------------------------------------------ @@ -939,8 +997,16 @@ class Message(models.Model): # remove author from notified partners if not self._context.get('mail_notify_author', False) and self_sudo.author_id: partners_sudo = partners_sudo - self_sudo.author_id - # update message, with maybe custom values + + # list channels and partner by notification type + email_channels = channels_sudo.filtered(lambda channel: channel.email_send) + notif_partners = partners_sudo.filtered(lambda partner: 'inbox' in partner.mapped('user_ids.notification_type')) + email_partner = partners_sudo - notif_partners + + #update message, with maybe custom values message_values = {} + if email_partner: + message_values = {'layout': layout, 'layout_values': repr(values)} if channels_sudo: message_values['channel_ids'] = [(6, 0, channels_sudo.ids)] if partners_sudo: @@ -953,13 +1019,10 @@ class Message(models.Model): # notify partners and channels # those methods are called as SUPERUSER because portal users posting messages # have no access to partner model. Maybe propagating a real uid could be necessary. - email_channels = channels_sudo.filtered(lambda channel: channel.email_send) - notif_partners = partners_sudo.filtered(lambda partner: 'inbox' in partner.mapped('user_ids.notification_type')) - - if email_channels or partners_sudo - notif_partners: + if email_channels or email_partner: partners_sudo.search([ '|', - ('id', 'in', (partners_sudo - notif_partners).ids), + ('id', 'in', (email_partner).ids), ('channel_ids', 'in', email_channels.ids), ('email', '!=', self_sudo.author_id.email or self_sudo.email_from), ])._notify(self, layout=layout, force_send=force_send, send_after_commit=send_after_commit, values=values) diff --git a/addons/mail/models/mail_notification.py b/addons/mail/models/mail_notification.py index e14754b7e8f03135922c6f93fe3433f110786b6d..66f18379565832d59dd63048f8d03f2580392ab1 100644 --- a/addons/mail/models/mail_notification.py +++ b/addons/mail/models/mail_notification.py @@ -20,11 +20,35 @@ class Notification(models.Model): ('ready', 'Ready to Send'), ('sent', 'Sent'), ('bounce', 'Bounced'), - ('exception', 'Exception')], 'Email Status', + ('exception', 'Exception'), + ('canceled', 'Canceled')], 'Email Status', default='ready', index=True) + mail_id = fields.Many2one('mail.mail', 'Mail', index=True) + # it would be technically possible to find notification from mail without adding a mail_id field on notification, + # comparing partner_ids and message_ids, but this will involve to search notifications one by one since we want to match + # bot value. Working with set inclusion, we could have a notif matching message from mail 1 and partner from mail 2, we dont want that. + # The solution would be to iterate over mail or to filter mail after search,... or add a mail_id field on notification to KISS + failure_type = fields.Selection(selection=[ + ("NONE", "No error"), + ("SMTP", "Error while connecting to smtp server"), + ("RECIPIENT", "Invalid email adress"), + ("BOUNCE", "Email address not found"), + ("UNKNOWN", "Unknown error occured"), + ], default='NONE', string='Failure type') + failure_reason = fields.Text('Failure reason', copy=False) @api.model_cr def init(self): self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('mail_notification_res_partner_id_is_read_email_status_mail_message_id',)) if not self._cr.fetchone(): self._cr.execute('CREATE INDEX mail_notification_res_partner_id_is_read_email_status_mail_message_id ON mail_message_res_partner_needaction_rel (res_partner_id, is_read, email_status, mail_message_id)') + + @api.multi + def format_failure_reason(self): + self.ensure_one() + if self.failure_type != 'UNKNOWN': + return dict(type(self).failure_type.selection).get(self.failure_type) + else: + return "Unknow error occured: %s" % (self.failure_reason) + + diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 4cde154e1cbf141caa51430871d1d996f8b3320f..4f89da3b69ee94f0383d00dea8f88e8aad7d9442 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -102,6 +102,12 @@ class MailThread(models.AbstractModel): message_needaction_counter = fields.Integer( 'Number of Actions', compute='_get_message_needaction', help="Number of messages which requires an action") + message_has_error = fields.Boolean( + 'Message Delivery error', compute='_compute_message_has_error', search='_search_message_has_error', + help="If checked, some messages have a delivery error.") + message_has_error_counter = fields.Integer( + 'Number of error', compute='_compute_message_has_error', + help="Number of messages with delivery error") @api.one @api.depends('message_follower_ids') @@ -208,6 +214,26 @@ class MailThread(models.AbstractModel): def _search_message_needaction(self, operator, operand): return [('message_ids.needaction', operator, operand)] + @api.multi + def _compute_message_has_error(self): + self._cr.execute(""" SELECT msg.res_id, COUNT(msg.res_id) FROM mail_message msg + RIGHT JOIN mail_message_res_partner_needaction_rel rel + ON rel.mail_message_id = msg.id AND rel.email_status in ('exception','bounce') + WHERE msg.author_id = %s AND msg.model = %s AND msg.res_id in %s + GROUP BY msg.res_id""", + (self.env.user.partner_id.id, self._name, tuple(self.ids),)) + res = dict() + for result in self._cr.fetchall(): + res[result[0]] = result[1] + + for record in self: + record.message_has_error_counter = res.get(record.id, 0) + record.message_has_error = bool(record.message_has_error_counter) + + @api.model + def _search_message_has_error(self, operator, operand): + return [('message_ids.has_error', operator, operand)] + # ------------------------------------------------------ # CRUD overrides for automatic subscription and logging # ------------------------------------------------------ diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index f6472eafd0b1467cdaef14ce655b07e71bf7a6ce..07bee0bb6e6b31ab2aba939ca76b91c238d8babe 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -140,6 +140,7 @@ class Partner(models.Model): ('mail_message_id', '=', email.mail_message_id.id), ('res_partner_id', 'in', email.recipient_ids.ids)]) notifications.write({ + 'mail_id': email.id, 'is_email': True, 'is_read': True, # handle by email discards Inbox notification 'email_status': 'ready', diff --git a/addons/mail/static/src/img/smiley/mailfailure.jpg b/addons/mail/static/src/img/smiley/mailfailure.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6f0ec91ad80ade20e8d7a1e8b081e654cf77d052 Binary files /dev/null and b/addons/mail/static/src/img/smiley/mailfailure.jpg differ diff --git a/addons/mail/static/src/js/services/chat_manager.js b/addons/mail/static/src/js/services/chat_manager.js index eaedb9cd11e8b82110142872a9cdb91e21ffcd39..8a90fc57482a6008cd96c79c1e5321d81031066a 100644 --- a/addons/mail/static/src/js/services/chat_manager.js +++ b/addons/mail/static/src/js/services/chat_manager.js @@ -119,6 +119,7 @@ var ChatManager = AbstractService.extend({ this.messages = []; this.channels = []; + this.messageFailures = []; this.channelsPreviewDef; this.channelDefs = {}; this.unreadConversationCounter = 0; @@ -285,7 +286,8 @@ var ChatManager = AbstractService.extend({ var self = this; var channelsPreview = _.map(channels, function (channel) { var info; - if (channel.channel_ids && _.contains(channel.channel_ids,"channel_inbox")) { + if ((channel.channel_ids && _.contains(channel.channel_ids, "channel_inbox")) || channel.id === "mail_failure") { + // this is a hack to preview messages and mail failure // map inbox(mail_message) data with existing channel/chat template info = _.pick(channel, 'id', 'body', 'avatar_src', 'res_id', 'model', 'module_icon', @@ -299,7 +301,9 @@ var ChatManager = AbstractService.extend({ info.name = info.record_name || info.subject || info.displayed_author; info.image_src = info.module_icon || info.avatar_src; info.message_id = info.id; - info.id = 'channel_inbox'; + if (channel.id !== "mail_failure") { + info.id = 'channel_inbox'; + } return info; } info = _.pick(channel, 'id', 'is_chat', 'name', 'status', 'unread_counter'); @@ -420,6 +424,14 @@ var ChatManager = AbstractService.extend({ } return result; }, + /** + * Returns a list of mail failures + * + * @return {Object[]} list of channels + */ + getMessageFailures: function () { + return this.messageFailures; + }, /** * Get all listeners of a channel. * @@ -1215,6 +1227,7 @@ var ChatManager = AbstractService.extend({ _.each(result.channel_slots, function (channels) { _.each(channels, self._addChannel.bind(self)); }); + self.messageFailures = result.mail_failures; self.needactionCounter = result.needaction_inbox_counter || 0; self.starredCounter = result.starred_counter || 0; self.moderationCounter = result.moderation_counter; @@ -1635,6 +1648,42 @@ var ChatManager = AbstractService.extend({ }); this.chatBus.trigger('update_moderation_counter'); }, + /** + * Add or remove failure when receiving a failure update message + * @private + * @param {Object} data + */ + _manageMessageFailureNotification: function (data) { + var self = this; + _.each(data.elements, function (updateMessage) { + + var isAddFailure = _.some(updateMessage.notifications, function(notif) { + return notif[0] === 'exception' || notif[0] === 'bounce'; + }); + var res = _.find(self.messageFailures, {'message_id': updateMessage.message_id}); + if (res) { + var index = _.findIndex(self.messageFailures, res); + if (isAddFailure) { + self.messageFailures[index] = updateMessage; + } else { + self.messageFailures.splice(index, 1); + } + } else if (isAddFailure) { + self.messageFailures.push(updateMessage); + } + var message = _.findWhere(self.messages, { id: updateMessage.message_id }); + if (message) { + if (isAddFailure) { + message.customer_email_status = "exception"; + } else{ + message.customer_email_status = "sent"; + } + self._updateMessageNotificationStatus(updateMessage, message); + self.chatBus.trigger('update_message', message); + } + }); + this.chatBus.trigger('update_needaction', this.needactionCounter); + }, /** * Updates channel_inbox when a message has marked as read. * @@ -1738,6 +1787,8 @@ var ChatManager = AbstractService.extend({ this._manageTransientMessageNotification(data); } else if (data.type === 'activity_updated') { this._manageActivityUpdateNotification(data); + } else if (data.type === 'mail_failure') { + this._manageMessageFailureNotification(data); } else { this._manageChatSessionNotification(data); } @@ -1883,7 +1934,28 @@ var ChatManager = AbstractService.extend({ channel.unread_counter = counter; this.chatBus.trigger("update_channel_unread_counter", channel); }, - + /** + * Update the message notification status of message based on update_message + * + * @private + * @param {Object} updateMessage + * @param {Object[]} updateMessage.notifications + */ + _updateMessageNotificationStatus: function (updateMessage, message) { + _.each(updateMessage.notifications, function (notif, id, list) { + var partnerName = notif[1]; + var notifStatus = notif[0]; + var res = _.find(message.customer_email_data, function(entry){ + return entry[0] === parseInt(id); + }); + if (res) { + res[2] = notifStatus; + } else { + message.customer_email_data.push([parseInt(id), partnerName, notifStatus]); + } + }); + }, + //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- diff --git a/addons/mail/static/src/js/systray.js b/addons/mail/static/src/js/systray.js index c62b964472f4a77b7e0ac8101ee219c580ad7cc5..f11531b6eabe9ed0d6483d884b3b834179476fd2 100644 --- a/addons/mail/static/src/js/systray.js +++ b/addons/mail/static/src/js/systray.js @@ -5,8 +5,8 @@ var config = require('web.config'); var core = require('web.core'); var session = require('web.session'); var SystrayMenu = require('web.SystrayMenu'); +var time = require('web.time'); var Widget = require('web.Widget'); - var QWeb = core.qweb; /** @@ -34,19 +34,23 @@ var MessagingMenu = Widget.extend({ this.$filter_buttons = this.$('.o_filter_button'); this.$channels_preview = this.$('.o_mail_navbar_dropdown_channels'); this.filter = false; - var chatBus = this.call('chat_manager', 'getChatBus'); - chatBus.on("update_needaction", this, this.update_counter); - chatBus.on("update_channel_unread_counter", this, this.update_counter); - this.call('chat_manager', 'isReady').then(this.update_counter.bind(this)); + var self = this; + this.call('chat_manager', 'isReady').then(function () { + self.update_counter(); + var chatBus = self.call('chat_manager', 'getChatBus'); + chatBus.on('update_needaction', self, self.update_counter); + chatBus.on('update_channel_unread_counter', self, self.update_counter); + }); return this._super(); }, is_open: function () { return this.$el.hasClass('open'); }, update_counter: function () { + var messageFailures = this.call('chat_manager', 'getMessageFailures'); var needactionCounter = this.call('chat_manager', 'getNeedactionCounter'); var unreadConversationCounter = this.call('chat_manager', 'getUnreadConversationCounter'); - var counter = needactionCounter + unreadConversationCounter; + var counter = needactionCounter + unreadConversationCounter + messageFailures.length; this.$('.o_notification_counter').text(counter); this.$el.toggleClass('o_no_notification', !counter); if (this.is_open()) { @@ -55,7 +59,6 @@ var MessagingMenu = Widget.extend({ }, update_channels_preview: function () { var self = this; - // Display spinner while waiting for channels preview this.$channels_preview.html(QWeb.render('Spinner')); this.call('chat_manager', 'isReady').then(function () { @@ -69,6 +72,28 @@ var MessagingMenu = Widget.extend({ return channel.type !== 'static'; } }); + var formatedFailures = []; + var messageFailures = self.call('chat_manager', 'getMessageFailures'); + _.each(messageFailures, function (messageFailure) { + var model = messageFailure.model; + var existingFailure = _.findWhere(formatedFailures, { model: model }); + if (existingFailure) { + if (existingFailure.res_id !== messageFailure.res_id) { + existingFailure.res_id = null; + existingFailure.record_name = messageFailure.model_name;//for display, will be used as subject + } + existingFailure.unread_counter ++; + } else { + messageFailure = _.clone(messageFailure); + messageFailure.unread_counter = 1; + messageFailure.id = "mail_failure"; + messageFailure.body = "An error occured sending an email"; + messageFailure.date = moment(time.str_to_datetime(messageFailure.last_message_date)); + messageFailure.displayed_author = ""; + formatedFailures.push(messageFailure); + } + }); + channels = _.union(channels, formatedFailures); self.call('chat_manager', 'getMessages', {channelID: 'channel_inbox'}) .then(function (messages) { var res = []; @@ -115,8 +140,10 @@ var MessagingMenu = Widget.extend({ this.call('chat_window_manager', 'openChat'); }, - // Handlers + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- /** * When a channel is clicked on, we want to open chat/channel window * @@ -126,7 +153,7 @@ var MessagingMenu = Widget.extend({ _onClickChannel: function (event) { var self = this; var channelID = $(event.currentTarget).data('channel_id'); - if (channelID === 'channel_inbox') { + if (channelID === 'channel_inbox' || channelID === 'mail_failure') { var resID = $(event.currentTarget).data('res_id'); var resModel = $(event.currentTarget).data('res_model'); if (resModel && resID) { @@ -136,6 +163,16 @@ var MessagingMenu = Widget.extend({ views: [[false, 'form']], res_id: resID }); + } else if (resModel) { + this.do_action({ + name: "Mail failures", + type: 'ir.actions.act_window', + view_mode: 'kanban,list,form', + views: [[false, 'kanban'], [false, 'list'], [false, 'form']], + target: 'current', + res_model: resModel, + domain: [['message_has_error', '=', true]], + }); } else { this.do_action('mail.mail_channel_action_client_chat', {clear_breadcrumbs: true}) .then(function () { diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js index 39d179a715a35ddd257d9442e7d384ed036c199c..54a36e169e4051469587490971d9f201fbd24454 100644 --- a/addons/mail/static/src/js/thread.js +++ b/addons/mail/static/src/js/thread.js @@ -35,15 +35,16 @@ var Thread = Widget.extend({ "click .o_attachment_download": "_onAttachmentDownload", "click .o_attachment_view": "_onAttachmentView", "click .o_thread_message_needaction": function (event) { - var message_id = $(event.currentTarget).data('message-id'); - this.trigger("mark_as_read", message_id); + var messageId = $(event.currentTarget).data('message-id'); + this.trigger("mark_as_read", messageId); }, "click .o_thread_message_moderation": "_onClickMessageModeration", "change .moderation_checkbox": "_onChangeModerationCheckbox", "click .o_thread_message_star": function (event) { - var message_id = $(event.currentTarget).data('message-id'); - this.trigger("toggle_star_status", message_id); + var messageId = $(event.currentTarget).data('message-id'); + this.trigger("toggle_star_status", messageId); }, + "click .o_thread_message_email_exception": "_onClickMailException", "click .o_thread_message_reply": function (event) { this.selected_id = $(event.currentTarget).data('message-id'); this.$('.o_thread_message').removeClass('o_thread_selected_message'); @@ -414,6 +415,18 @@ var Thread = Widget.extend({ var decision = $button.data('decision'); this.trigger_up('message_moderation', { messageID: messageID, decision: decision }); }, + /** + * @private + * @param {MouseEvent} event + */ + _onClickMailException: function (event) { + var messageId = $(event.currentTarget).data('message-id'); + this.do_action('mail.mail_resend_message_action', { + additional_context: { + mail_message_to_resend: messageId + } + }); + }, }); Thread.ORDER = ORDER; diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss index 0529238a36e84dc6cb71fdb5e82d4159b3334aa5..6d8eb8b8254bea44c26bac0b4188a0fd147d27b2 100644 --- a/addons/mail/static/src/scss/thread.scss +++ b/addons/mail/static/src/scss/thread.scss @@ -161,6 +161,7 @@ &.o_thread_message_email_exception { color: red; opacity: 1; + cursor: pointer; } &.o_thread_message_email_bounce { color: red; diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml index 1ec08a236356c77438fb477c83ac8f9cf1a32ca1..1eda1dfec1bd88a4ca59b8d529e99940223d8dc9 100644 --- a/addons/mail/static/src/xml/discuss.xml +++ b/addons/mail/static/src/xml/discuss.xml @@ -231,7 +231,9 @@ <span class="fa fa-mail-reply"/> You: </t> <t t-else=""> - <t t-esc="channel.last_message.displayed_author"/>: + <t t-if="channel.last_message.displayed_author"> + <t t-esc="channel.last_message.displayed_author"/>: + </t> </t> <t t-raw="channel.last_message_preview"/> </div> diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index 20049e89cf6a0050a489175be6bd355606aad2bd..ca8ae9b8319a1c9e69020b626189da2534005e48 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -175,14 +175,19 @@ (from <a t-att-data-oe-id="message.origin_id" href="#">#<t t-esc="message.origin_name"/></a>) </t> <span t-if="options.display_email_icon and message.customer_email_data and message.customer_email_data.length" class="o_thread_tooltip_container"> - <i t-att-class="'o_thread_tooltip o_thread_message_email o_thread_message_email_' + message.customer_email_status + ' fa fa-envelope-o'"/> + <t t-set="fatype" t-value="''"/> + <t t-if="message.customer_email_status === 'sent' or message.customer_email_status === 'ready'"> + <t t-set="fatype" t-value="'-o'"/> + </t> + <i t-att-class="'o_thread_tooltip o_thread_message_email o_thread_message_email_' + message.customer_email_status + ' fa fa-envelope'+ fatype" t-att-data-message-id="message.id"/> <span class="o_thread_tooltip_content"> <t t-foreach="message.customer_email_data" t-as="customer"> <span> - <t t-if="customer[2] == 'sent'"><i class='fa fa-check'/></t> - <t t-if="customer[2] == 'bounce'"><i class='fa fa-exclamation'/></t> - <t t-if="customer[2] == 'exception'"><i class='fa fa-exclamation'/></t> - <t t-if="customer[2] == 'ready'"><i class='fa fa-send-o'/></t> + <t t-if="customer[2] === 'sent'"><i class='fa fa-check'/></t> + <t t-if="customer[2] === 'bounce'"><i class='fa fa-exclamation'/></t> + <t t-if="customer[2] === 'exception'"><i class='fa fa-exclamation'/></t> + <t t-if="customer[2] === 'ready'"><i class='fa fa-send-o'/></t> + <t t-if="customer[2] === 'canceled'"><i class='fa fa-trash-o'/></t> <t t-esc="customer[1]"/> </span> <br /> diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index 22c1783f5a3aaecedcd0184768f915b45312f87b..773e457015211bbb2d343ed7e8df0e8982fc003a 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -56,6 +56,7 @@ MockServer.include({ 'mention_partner_suggestions': [], 'shortcodes': [], 'menu_id': false, + 'mail_failures': [], }); }, diff --git a/addons/mail/wizard/__init__.py b/addons/mail/wizard/__init__.py index 819ae4154f469da3cccd84c5ab670d86bceb8806..e6ba124f4ae7187d58652bb443b5dc535f019257 100644 --- a/addons/mail/wizard/__init__.py +++ b/addons/mail/wizard/__init__.py @@ -3,6 +3,7 @@ from . import invite from . import mail_compose_message +from . import mail_resend_message from . import email_template_preview from . import base_module_uninstall from . import base_partner_merge diff --git a/addons/mail/wizard/mail_resend_message.py b/addons/mail/wizard/mail_resend_message.py new file mode 100644 index 0000000000000000000000000000000000000000..b4480220e4fcf0d0ca87044d0b6c8dc60e0fcd1a --- /dev/null +++ b/addons/mail/wizard/mail_resend_message.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models +from ast import literal_eval +from odoo.exceptions import UserError + +class MailResendMessage(models.TransientModel): + _name = 'mail.resend.message' + _description = 'Email resend wizard' + + mail_message_id = fields.Many2one('mail.message', 'Message', readonly=True) + partner_ids = fields.One2many('mail.resend.partner', 'resend_wizard_id', string='Recipients') + notification_ids = fields.Many2many('mail.notification', string='Notifications', readonly=True) + has_cancel = fields.Boolean(compute='_compute_has_cancel') + partner_readonly = fields.Boolean(compute='_compute_partner_readonly') + + @api.depends("partner_ids") + def _compute_has_cancel(self): + self.has_cancel = self.partner_ids.filtered(lambda p: not p.resend) + + def _compute_partner_readonly(self): + self.partner_readonly = not self.env['res.partner'].check_access_rights('write', raise_exception=False) + + @api.model + def default_get(self, fields): + rec = super(MailResendMessage, self).default_get(fields) + message_id = self._context.get('mail_message_to_resend') + if message_id: + mail_message_id = self.env['mail.message'].browse(message_id) + notification_ids = mail_message_id.notification_ids.filtered(lambda notif: notif.email_status in ('exception', 'bounce')) + partner_readonly = not self.env['res.partner'].check_access_rights('write', raise_exception=False) + partner_ids = [(0, 0, + { + "partner_id": notif.res_partner_id.id, + "name": notif.res_partner_id.name, + "email": notif.res_partner_id.email, + "resend": True, + "message": notif.format_failure_reason(), + } + ) for notif in notification_ids] + rec['partner_readonly'] = partner_readonly + rec['notification_ids'] = [(6, 0, notification_ids.ids)] + rec['mail_message_id'] = mail_message_id.id + rec['partner_ids'] = partner_ids + else: + raise UserError('No message_id found in context') + return rec + + @api.multi + def resend_mail_action(self): + """ Process the wizard content and proceed with sending the related + email(s), rendering any template patterns on the fly if needed. """ + for wizard in self: + "If a partner disappeared from partner list, we cancel the notification" + to_cancel = wizard.partner_ids.filtered(lambda p: not p.resend).mapped("partner_id") + to_send = wizard.partner_ids.filtered(lambda p: p.resend).mapped("partner_id") + notif_to_cancel = wizard.notification_ids.filtered(lambda notif: notif.res_partner_id in to_cancel and notif.email_status in ('exception', 'bounce')) + notif_to_cancel.sudo().write({'email_status': 'canceled'}) + to_send.sudo()._notify(self.mail_message_id, self.mail_message_id.layout, force_send=True, send_after_commit=False, values=literal_eval(self.mail_message_id.layout_values)) + self.mail_message_id._notify_failure_update() + return {'type': 'ir.actions.act_window_close'} + + @api.multi + def cancel_mail_action(self): + for wizard in self: + for notif in wizard.notification_ids: + notif.filtered(lambda notif: notif.email_status in ('exception', 'bounce')).sudo().write({'email_status': 'canceled'}) + wizard.mail_message_id._notify_failure_update() + return {'type': 'ir.actions.act_window_close'} + + + +class PartnerResend(models.TransientModel): + _name = 'mail.resend.partner' + _description = 'Partner with additionnal information for mail resend' + + partner_id = fields.Many2one('res.partner', string='Partner', required=True, ondelete='cascade') + name = fields.Char(related="partner_id.name") + email = fields.Char(related="partner_id.email") + resend = fields.Boolean(string="Send Again", default=True) + resend_wizard_id = fields.Many2one('mail.resend.message', string="Resend wizard") + message = fields.Char(string="Help message") + diff --git a/addons/mail/wizard/mail_resend_message_views.xml b/addons/mail/wizard/mail_resend_message_views.xml new file mode 100644 index 0000000000000000000000000000000000000000..85af6dc4b0960eab4407483d0869a05e7d1a1548 --- /dev/null +++ b/addons/mail/wizard/mail_resend_message_views.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <record id="mail_resend_message_view_form" model="ir.ui.view"> + <field name="name">mail.resend.message.form</field> + <field name="model">mail.resend.message</field> + <field name="groups_id" eval="[(4,ref('base.group_user'))]"/> + <field name="arch" type="xml"> + <form string="Edit Partners"> + <field name="mail_message_id" invisible="1"/> + <field name="notification_ids" invisible="1"/> + <field name="has_cancel" invisible="1"/> + <field name="partner_readonly" invisible="1"/> + <p>Please, select the action to do on each mail and correct the email address if needed. The modified address will be saved on the corresponding contact.</p> + <field name="partner_ids"> + <tree string="Recipient" editable="top" create="0" delete="0"> + <field name="name" readonly="1"/> + <field name="email" attrs="{'readonly': [('parent.partner_readonly', '=', True)]}"/> + <field name="message" readonly="1"/> + <field name="resend" widget="boolean_toggle"/> + </tree> + </field> + <p attrs="{'invisible':[('has_cancel', '==', False)]}"> <span class="fa fa-info-circle"/> Caution: It won't be possible to send this mail again to the recipients you did not select.</p> + <footer> + <button string="Resend to selected" name="resend_mail_action" type="object" class="btn-primary o_mail_send"/> + <button string="Ignore all failure" name="cancel_mail_action" type="object" class="btn-default" /> + <button string="Cancel" class="btn-default" special="cancel" /> + </footer> + </form> + </field> + </record> + <record id="mail_resend_message_action" model="ir.actions.act_window"> + <field name="name">Resend mail</field> + <field name="res_model">mail.resend.message</field> + <field name="type">ir.actions.act_window</field> + <field name="view_type">form</field> + <field name="view_mode">form</field> + <field name="target">new</field> + </record> + + </data> +</odoo> diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py index 2de2b3ec4b12aa5d1ef15127fca3479b8f104c7c..beb4952da1fe6b9c260cbf7dda1cb16a382fcb0f 100644 --- a/addons/mass_mailing/models/mail_mail.py +++ b/addons/mass_mailing/models/mail_mail.py @@ -95,11 +95,13 @@ class MailMail(models.Model): return res @api.multi - def _postprocess_sent_message(self, mail_sent=True): + def _postprocess_sent_message(self, failure_type='NONE', **kwargs): + mail_sent = failure_type == 'NONE' # we consider that a recipient error is a failure with mass mailling and show them as failed for mail in self: if mail.mailing_id: if mail_sent is True and mail.statistics_ids: mail.statistics_ids.write({'sent': fields.Datetime.now(), 'exception': False}) elif mail_sent is False and mail.statistics_ids: mail.statistics_ids.write({'exception': fields.Datetime.now()}) - return super(MailMail, self)._postprocess_sent_message(mail_sent=mail_sent) + return super(MailMail, self)._postprocess_sent_message(failure_type=failure_type, **kwargs) + \ No newline at end of file diff --git a/addons/test_mail/tests/__init__.py b/addons/test_mail/tests/__init__.py index 411d73d5694a28035e139cef78ed674213185758..2d63b95f205c580afc1b0d768c8708c0dc9a21d5 100644 --- a/addons/test_mail/tests/__init__.py +++ b/addons/test_mail/tests/__init__.py @@ -4,6 +4,7 @@ from . import test_mail_activity from . import test_mail_followers from . import test_mail_message from . import test_mail_mail +from . import test_mail_resend from . import test_mail_channel from . import test_mail_gateway from . import test_mail_template diff --git a/addons/test_mail/tests/test_mail_resend.py b/addons/test_mail/tests/test_mail_resend.py new file mode 100644 index 0000000000000000000000000000000000000000..2179006248891d2882634997506cfc9ccb35b35b --- /dev/null +++ b/addons/test_mail/tests/test_mail_resend.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import itertools +from unittest.mock import patch + +from odoo.addons.test_mail.tests import common +from odoo.tests import tagged +from odoo.tools import mute_logger +from odoo import api +from odoo.addons.base.models.ir_mail_server import IrMailServer + + +@tagged('resend_test') +class TestMailResend(common.BaseFunctionalTest, common.MockEmails): + + @classmethod + def setUpClass(cls): + super(TestMailResend, cls).setUpClass() + #Two users + cls.user1 = cls.env['res.users'].with_context(cls._quick_create_user_ctx).create({ + 'name': 'Employee 1', + 'login': 'e1', + 'email': 'e1', # invalid email + 'notification_type': 'email', + 'groups_id': [(6, 0, [cls.env.ref('base.group_user').id])] + }) + cls.user2 = cls.env['res.users'].with_context(cls._quick_create_user_ctx).create({ + 'name': 'Employee 2', + 'login': 'e2', + 'email': 'e2@example.com', + 'notification_type': 'email', + 'groups_id': [(6, 0, [cls.env.ref('base.group_user').id])] + }) + #Two partner + cls.partner1 = cls.env['res.partner'].with_context(cls._quick_create_user_ctx).create({ + 'name': 'Partner 1', + 'email': 'p1' # invalid email + }) + cls.partner2 = cls.env['res.partner'].with_context(cls._quick_create_user_ctx).create({ + 'name': 'Partner 2', + 'email': 'p2@example.com' + }) + + @api.model + def send_email(self, message, *args, **kwargs): + assert '@' in message['To'], self.NO_VALID_RECIPIENT + return message['Message-Id'] + cls.bus_update_failure = [] + def sendone(self, channel, message): + if 'type' in message and message['type'] == 'mail_failure': + cls.bus_update_failure.append((channel, message)) + cls.env['ir.mail_server']._patch_method('send_email', send_email) + cls.env['bus.bus']._patch_method('sendone', sendone) + cls.partners = cls.env['res.partner'].concat(cls.user1.partner_id, cls.user2.partner_id, cls.partner1, cls.partner2) + cls.invalid_email_partners = cls.env['res.partner'].concat(cls.user1.partner_id, cls.partner1) + + def setUp(self): + super(TestMailResend, self).setUp() + TestMailResend.bus_update_failure = [] + + def assertNotifStates(self, states, message): + notif = self.env['mail.notification'].search([('mail_message_id', '=', message.id)], order="res_partner_id asc") + self.assertEquals(tuple(notif.mapped('email_status')), states) + return notif + + def assertBusMessage(self, partners): + partner_ids = [elem[0][2] for elem in self.bus_update_failure] + self.assertEquals(partner_ids, [partner.id for partner in partners]) + self.bus_update_failure.clear() + + @classmethod + def tearDownClass(cls): + # Remove mocks + cls.env['ir.mail_server']._revert_method('send_email') + cls.env['bus.bus']._revert_method('sendone') + super(TestMailResend, cls).tearDownClass() + + def assertEmails(self, *args, **kwargs): + res = super(TestMailResend, self).assertEmails(*args, **kwargs) + self._mails.clear() + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_resend_workflow(self): + cls = TestMailResend + #missconfigured server + @api.model + def connect_failure(**kwargs): + raise Exception + with patch.object(IrMailServer, 'connect', side_effect=connect_failure): + message = self.test_record.sudo().message_post(partner_ids=self.partners.ids, subtype='mail.mt_comment', message_type='notification') + self.assertBusMessage([self.partner_admin]) + self.assertEmails(self.partner_admin, []) + self.assertNotifStates(('exception', 'exception', 'exception', 'exception'), message) + wizard = self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}) + self.assertEqual(wizard.notification_ids.mapped('res_partner_id'), self.partners, "wizard should manage notifications for each failed partner") + wizard.resend_mail_action() + self.assertBusMessage([self.partner_admin] * 3) # three more failure sent on bus, one for each mail in failure and one for resend + self.assertEmails(self.partner_admin, self.partners) + self.assertNotifStates(('exception', 'sent', 'exception', 'sent'), message) + self.user1.write({"email": 'u1@example.com'}) + self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}).resend_mail_action() + self.assertBusMessage([self.partner_admin] * 2) # two more failure update sent on bus, one for failed mail and one for resend + self.assertEmails(self.partner_admin, self.invalid_email_partners) + self.assertNotifStates(('sent', 'sent', 'exception', 'sent'), message) + self.partner1.write({"email": 'p1@example.com'}) + self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}).resend_mail_action() + self.assertBusMessage([self.partner_admin]) # A success update should be sent on bus once the email has no more failure + self.assertEmails(self.partner_admin, self.partner1) + self.assertNotifStates(('sent', 'sent', 'sent', 'sent'), message) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_send_no_failure(self): + self.user1.write({"email": 'u1@example.com'}) + self.partner1.write({"email": 'p1@example.com'}) + message = self.test_record.sudo().message_post(partner_ids=self.partners.ids, subtype='mail.mt_comment', message_type='notification') + self.assertBusMessage([]) # one update for cancell + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_remove_mail_become_canceled(self): + message = self.test_record.sudo().message_post(partner_ids=self.partners.ids, subtype='mail.mt_comment', message_type='notification') + self.assertEmails(self.partner_admin, self.partners) + self.assertBusMessage([self.partner_admin] * 2) # two failure sent on bus, one for each mail + wizard = self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}) + partners = wizard.partner_ids.mapped("partner_id") + self.assertEqual(self.invalid_email_partners, partners) + wizard.partner_ids.filtered(lambda p: p.partner_id == self.partner1).write({"resend": False}) + wizard.resend_mail_action() + self.assertEmails(self.partner_admin, self.user1) + self.assertBusMessage([self.partner_admin] * 2) # two more failure sent on bus, one for failure, one for resend + self.assertNotifStates(('exception', 'sent', 'canceled', 'sent'), message) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_cancel_all(self): + message = self.test_record.sudo().message_post(partner_ids=self.partners.ids, subtype='mail.mt_comment', message_type='notification') + self.assertNotifStates(('exception', 'sent', 'exception', 'sent'), message) + self.assertBusMessage([self.partner_admin] * 2) + wizard = self.env['mail.resend.message'].with_context({'mail_message_to_resend': message.id}).create({}) + wizard.cancel_mail_action() + self.assertNotifStates(('canceled', 'sent', 'canceled', 'sent'), message) + self.assertBusMessage([self.partner_admin]) # one update for cancell