[ADD] mail, mass_mailing: allow a user to resend a mail with failures

When a mail is in failure state, a red envelope appears next to the message
in a thread but it was difficult to send the mail again.
This tasks will allow users to send mail again easily, or mark notification
as cancelled if the user want to ignore this failure.
A notification will appear in sender systray while mail are in failure.

Task: #46158
PR: #24628
This commit is contained in:
XavierDo
2018-06-07 11:46:28 +02:00
parent aa7fb8c723
commit 133eeb1bbf
21 changed files with 593 additions and 57 deletions
+1
View File
@@ -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',
+1
View File
@@ -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']),
+43 -25
View File
@@ -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
+70 -7
View File
@@ -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)
+25 -1
View File
@@ -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)
+26
View File
@@ -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
# ------------------------------------------------------
+1
View File
@@ -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',
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -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
//--------------------------------------------------------------------------
+46 -9
View File
@@ -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 () {
+17 -4
View File
@@ -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;
+1
View File
@@ -161,6 +161,7 @@
&.o_thread_message_email_exception {
color: red;
opacity: 1;
cursor: pointer;
}
&.o_thread_message_email_bounce {
color: red;
+3 -1
View File
@@ -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>
+10 -5
View File
@@ -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 />
@@ -56,6 +56,7 @@ MockServer.include({
'mention_partner_suggestions': [],
'shortcodes': [],
'menu_id': false,
'mail_failures': [],
});
},
+1
View File
@@ -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
+84
View File
@@ -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")
@@ -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>
+4 -2
View File
@@ -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)
+1
View File
@@ -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
+141
View File
@@ -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