diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py
index b8d619314d7..1ad510bff57 100644
--- a/addons/mail/__manifest__.py
+++ b/addons/mail/__manifest__.py
@@ -65,6 +65,7 @@ For more specific needs, you may also assign custom-defined actions
'wizard/mail_blacklist_remove_views.xml',
'wizard/mail_compose_message_views.xml',
'wizard/mail_resend_message_views.xml',
+ 'wizard/mail_resend_partner_views.xml',
'wizard/mail_template_preview_views.xml',
'wizard/mail_wizard_invite_views.xml',
'wizard/mail_template_reset_views.xml',
diff --git a/addons/mail/models/mail_notification.py b/addons/mail/models/mail_notification.py
index eb765c9ef3c..6c4497df79b 100644
--- a/addons/mail/models/mail_notification.py
+++ b/addons/mail/models/mail_notification.py
@@ -38,6 +38,7 @@ class MailNotification(models.Model):
# generic
("unknown", "Unknown error"),
# mail
+ ("mail_bounce", "Bounce"),
("mail_email_invalid", "Invalid email address"),
("mail_email_missing", "Missing email address"),
("mail_smtp", "Connection failed (outgoing mail server problem)"),
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index f738f3bef27..6260ed86759 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -28,7 +28,7 @@ from markupsafe import Markup
from odoo import _, api, exceptions, fields, models, tools, registry, SUPERUSER_ID, Command
from odoo.exceptions import MissingError, AccessError
from odoo.osv import expression
-from odoo.tools import is_html_empty, html_escape
+from odoo.tools import is_html_empty, html_escape, html2plaintext
from odoo.tools.misc import clean_context, split_every
_logger = logging.getLogger(__name__)
@@ -710,7 +710,7 @@ class MailThread(models.AbstractModel):
"""
bounced_record, bounced_record_done = False, False
bounced_email, bounced_partner = message_dict['bounced_email'], message_dict['bounced_partner']
- bounced_msg_id, bounced_message = message_dict['bounced_msg_id'], message_dict['bounced_message']
+ bounced_msg_ids, bounced_message = message_dict['bounced_msg_ids'], message_dict['bounced_message']
if bounced_email:
bounced_model, bounced_res_id = bounced_message.model, bounced_message.res_id
@@ -732,14 +732,18 @@ class MailThread(models.AbstractModel):
self.env['mail.notification'].sudo().search([
('mail_message_id', '=', bounced_message.id),
('res_partner_id', 'in', bounced_partner.ids)]
- ).write({'notification_status': 'bounce'})
+ ).write({
+ 'failure_reason': html2plaintext(message_dict.get('body') or ''),
+ 'failure_type': 'mail_bounce',
+ 'notification_status': 'bounce',
+ })
if bounced_record:
_logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email from %s replying to %s (model %s ID %s)',
- message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_id, bounced_model, bounced_res_id)
+ message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_ids, bounced_model, bounced_res_id)
elif bounced_email:
_logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email from %s replying to %s (no document found)',
- message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_id)
+ message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_ids)
else:
_logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email.',
message_dict['email_from'], message_dict['to'], message_dict['message_id'])
@@ -986,7 +990,6 @@ class MailThread(models.AbstractModel):
if not isinstance(message, EmailMessage):
raise TypeError('message must be an email.message.EmailMessage at this point')
catchall_alias = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias")
- bounce_alias = self.env['ir.config_parameter'].sudo().get_param("mail.bounce.alias")
fallback_model = model
# get email.message.Message variables for future processing
@@ -1005,7 +1008,6 @@ class MailThread(models.AbstractModel):
# author and recipients
email_from = message_dict['email_from']
- email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower()
email_to = message_dict['to']
email_to_localparts = [
e.split('@', 1)[0].lower()
@@ -1019,17 +1021,8 @@ class MailThread(models.AbstractModel):
]
rcpt_tos_valid_localparts = [to for to in rcpt_tos_localparts]
- # 0. Handle bounce: verify whether this is a bounced email and use it to collect bounce data and update notifications for customers
- # Bounce alias: if any To contains bounce_alias@domain
- # Bounce message (not alias)
- # See http://datatracker.ietf.org/doc/rfc3462/?include_text=1
- # As all MTA does not respect this RFC (googlemail is one of them),
- # we also need to verify if the message come from "mailer-daemon"
- # If not a bounce: reset bounce information
- if bounce_alias and any(email == bounce_alias for email in email_to_localparts):
- self._routing_handle_bounce(message, message_dict)
- return []
- if message.get_content_type() == 'multipart/report' or email_from_localpart == 'mailer-daemon':
+ # Handle bounce: verify whether this is a bounced email and use it to collect bounce data and update notifications for customers
+ if message_dict.get('is_bounce'):
self._routing_handle_bounce(message, message_dict)
return []
self._routing_reset_bounce(message, message_dict)
@@ -1175,7 +1168,7 @@ class MailThread(models.AbstractModel):
post_params = dict(subtype_id=subtype_id, partner_ids=partner_ids, **message_dict)
# remove computational values not stored on mail.message and avoid warnings when creating it
- for x in ('from', 'to', 'cc', 'recipients', 'references', 'in_reply_to', 'bounced_email', 'bounced_message', 'bounced_msg_id', 'bounced_partner'):
+ for x in ('from', 'to', 'cc', 'recipients', 'references', 'in_reply_to', 'is_bounce', 'bounced_email', 'bounced_message', 'bounced_msg_ids', 'bounced_partner'):
post_params.pop(x, None)
new_msg = False
if thread._name == 'mail.thread': # message with parent_id not linked to record
@@ -1363,10 +1356,10 @@ class MailThread(models.AbstractModel):
body = etree.tostring(root, pretty_print=False, encoding='unicode')
return {'body': body, 'attachments': attachments}
- def _message_parse_extract_payload(self, message, save_original=False):
+ def _message_parse_extract_payload(self, message, message_dict, save_original=False):
"""Extract body as HTML and attachments from the mail message"""
attachments = []
- body = u''
+ body = ''
if save_original:
attachments.append(self._Attachment('original_email.eml', message.as_string(), {}))
@@ -1382,12 +1375,17 @@ class MailThread(models.AbstractModel):
body = tools.ustr(body, encoding, errors='replace')
if message.get_content_type() == 'text/plain':
# text/plain ->
- body = tools.append_content_to_html(u'', body, preserve=True)
+ body = tools.append_content_to_html('', body, preserve=True)
else:
alternative = False
mixed = False
- html = u''
+ html = ''
for part in message.walk():
+ if message_dict.get('is_bounce') and body:
+ # bounce email, keep only the first body and ignore
+ # the parent email that might be added at the end
+ # (e.g. for outlook / yahoo bounce email)
+ break
if part.get_content_type() == 'binary/octet-stream':
_logger.warning("Message containing an unexpected Content-Type 'binary/octet-stream', assuming 'application/octet-stream'")
part.replace_header('Content-Type', 'application/octet-stream')
@@ -1444,14 +1442,22 @@ class MailThread(models.AbstractModel):
* bounced_email: email that bounced (normalized);
* bounce_partner: res.partner recordset whose email_normalized =
bounced_email;
- * bounced_msg_id: list of message_ID references (<...@myserver>) linked
+ * bounced_msg_ids: list of message_ID references (<...@myserver>) linked
to the email that bounced;
- * bounced_message: if found, mail.message recordset matching bounced_msg_id;
+ * bounced_message: if found, mail.message recordset matching bounced_msg_ids;
"""
if not isinstance(email_message, EmailMessage):
raise TypeError('message must be an email.message.EmailMessage at this point')
email_part = next((part for part in email_message.walk() if part.get_content_type() in {'message/rfc822', 'text/rfc822-headers'}), None)
+ if not email_part:
+ # In the case of a bounce message (e.g. bounce message of GMX), the "rfc822"
+ # email part might not be always present. In that case we fallback to "multipart/report".
+ email_part = next(
+ (part for part in email_message.walk() if part.get_content_type() == 'multipart/report'),
+ None,
+ )
+
dsn_part = next((part for part in email_message.walk() if part.get_content_type() == 'message/delivery-status'), None)
bounced_email = False
@@ -1465,7 +1471,7 @@ class MailThread(models.AbstractModel):
if bounced_email:
bounced_partner = self.env['res.partner'].sudo().search([('email_normalized', '=', bounced_email)])
- bounced_msg_id = False
+ bounced_msg_ids = False
bounced_message = self.env['mail.message'].sudo()
if email_part:
if email_part.get_content_type() == 'text/rfc822-headers':
@@ -1473,17 +1479,54 @@ class MailThread(models.AbstractModel):
email_payload = message_from_string(email_part.get_content(), policy=email.policy.SMTP)
else:
email_payload = email_part.get_payload()[0]
- bounced_msg_id = tools.mail_header_msgid_re.findall(tools.decode_message_header(email_payload, 'Message-Id'))
- if bounced_msg_id:
- bounced_message = self.env['mail.message'].sudo().search([('message_id', 'in', bounced_msg_id)])
+ bounced_message, bounced_msg_ids = self._get_bounced_message_data(email_payload, message_dict)
+
+ if bounced_message and not bounced_partner and len(bounced_message.notification_ids.res_partner_id) == 1:
+ # if the original recipient was not found,
+ # try to find the recipient based on parent notification
+ bounced_partner = bounced_message.notification_ids.res_partner_id[0]
+ bounced_email = bounced_partner.email
return {
'bounced_email': bounced_email,
'bounced_partner': bounced_partner,
- 'bounced_msg_id': bounced_msg_id,
+ 'bounced_msg_ids': bounced_msg_ids,
'bounced_message': bounced_message,
}
+ def _message_parse_is_bounce(self, message, message_dict):
+ """Return True if the given email is a bounce email.
+
+ Bounce alias: if any To contains bounce_alias@domain
+ Bounce message (not alias)
+ See http://datatracker.ietf.org/doc/rfc3462/?include_text=1
+ As all MTA does not respect this RFC (googlemail is one of them),
+ we also need to verify if the message come from "mailer-daemon"
+ """
+ # detection based on email_to
+ bounce_alias = self.env['ir.config_parameter'].sudo().get_param("mail.bounce.alias")
+ email_to = message_dict['to']
+ email_to_localparts = [
+ e.split('@', 1)[0].lower()
+ for e in (tools.email_split(email_to) or [''])
+ ]
+ if bounce_alias and any(email == bounce_alias for email in email_to_localparts):
+ return True
+
+ email_from = message_dict['email_from']
+ email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower()
+
+ # detection based on email_from
+ if email_from_localpart == 'mailer-daemon':
+ return True
+
+ # detection based on content type
+ content_type = message.get_content_type()
+ if content_type == 'multipart/report' or 'report-type=delivery-status' in content_type:
+ return True
+
+ return False
+
@api.model
def message_parse(self, message, save_original=False):
""" Parses an email.message.Message representing an RFC-2822 email
@@ -1507,6 +1550,7 @@ class MailThread(models.AbstractModel):
'body': unified_body,
'references': references,
'in_reply_to': in-reply-to,
+ 'is_bounce': True if it has been detected as a bounce email
'parent_id': parent mail.message based on in_reply_to or references,
'is_internal': answer to an internal message (note),
'date': date,
@@ -1578,26 +1622,78 @@ class MailThread(models.AbstractModel):
stored_date = datetime.datetime.now()
msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
- parent_ids = False
- if msg_dict['in_reply_to']:
- parent_ids = self.env['mail.message'].search(
- [('message_id', '=', msg_dict['in_reply_to'])],
- order='create_date DESC, id DESC',
- limit=1)
- if msg_dict['references'] and not parent_ids:
- references_msg_id_list = tools.mail_header_msgid_re.findall(msg_dict['references'])
- parent_ids = self.env['mail.message'].search(
- [('message_id', 'in', [x.strip() for x in references_msg_id_list])],
- order='create_date DESC, id DESC',
- limit=1)
- if parent_ids:
- msg_dict['parent_id'] = parent_ids.id
- msg_dict['is_internal'] = parent_ids.subtype_id and parent_ids.subtype_id.internal or False
+ parent_message = self._get_parent_message(msg_dict)
+ if parent_message:
+ msg_dict['parent_id'] = parent_message.id
+ msg_dict['is_internal'] = bool(parent_message.subtype_id and parent_message.subtype_id.internal)
- msg_dict.update(self._message_parse_extract_payload(message, save_original=save_original))
- msg_dict.update(self._message_parse_extract_bounce(message, msg_dict))
+ msg_dict['is_bounce'] = self._message_parse_is_bounce(message, msg_dict)
+ msg_dict.update(self._message_parse_extract_payload(message, msg_dict, save_original=save_original))
+ if msg_dict['is_bounce']:
+ msg_dict.update(self._message_parse_extract_bounce(message, msg_dict))
return msg_dict
+ def _get_bounced_message_data(self, message, message_dict):
+ """Find the original and the bounced email references based on an incoming email.
+
+ :param message: The EmailMessage object, part of the incoming email
+ First Content type: 'message/rfc822' or 'text/rfc822-headers'
+ :param message_dict: The dict values already parsed
+ :return:
+ A tuple with
+ - The (or empty recordset if nothing has been found)
+ - The list of references ids used to find the bounced mail message
+ """
+ reference_ids = []
+ headers = ('Message-Id', 'X-Microsoft-Original-Message-ID')
+ for header in headers:
+ value = tools.decode_message_header(message, header)
+ references = tools.mail_header_msgid_re.findall(value)
+ reference_ids.extend([reference.strip() for reference in references])
+
+ if reference_ids:
+ bounced_message = self.env['mail.message'].search(
+ [('message_id', 'in', reference_ids)],
+ order='create_date DESC, id DESC', limit=1)
+
+ if bounced_message:
+ return bounced_message, reference_ids
+
+ reference_ids.extend(tools.mail_header_msgid_re.findall(message_dict['in_reply_to']))
+ reference_ids.extend(tools.mail_header_msgid_re.findall(message_dict['references']))
+
+ if message_dict.get('parent_id'):
+ # Parent based on References, In-Reply-To, etc
+ # has already been searched (see @_get_parent_message)
+ bounced_message = self.env['mail.message'].browse(message_dict['parent_id'])
+ return bounced_message, reference_ids
+
+ return self.env['mail.message'], reference_ids
+
+ def _get_parent_message(self, msg_dict):
+ """Find the which is the parent of the given email.
+
+ :param msg_dict: The dict values already parsed
+ :return: The or None if nothing has been found
+ """
+ in_reply_to = msg_dict.get('in_reply_to').strip()
+ if in_reply_to:
+ parent = self.env['mail.message'].search(
+ [('message_id', '=', in_reply_to)],
+ order='create_date DESC, id DESC', limit=1)
+ if parent:
+ return parent
+
+ reference_ids = tools.mail_header_msgid_re.findall(msg_dict.get('references') or '')
+ if reference_ids:
+ parent = self.env['mail.message'].search(
+ [('message_id', 'in', [x.strip() for x in reference_ids])],
+ order='create_date DESC, id DESC', limit=1)
+ if parent:
+ return parent
+
+ return None
+
# ------------------------------------------------------
# RECIPIENTS MANAGEMENT TOOLS
# ------------------------------------------------------
diff --git a/addons/mail/views/mail_notification_views.xml b/addons/mail/views/mail_notification_views.xml
index df5f1d4321a..21e5290154c 100644
--- a/addons/mail/views/mail_notification_views.xml
+++ b/addons/mail/views/mail_notification_views.xml
@@ -34,7 +34,7 @@
-
+
diff --git a/addons/mail/wizard/mail_resend_message.py b/addons/mail/wizard/mail_resend_message.py
index 49068a88783..c92c153445b 100644
--- a/addons/mail/wizard/mail_resend_message.py
+++ b/addons/mail/wizard/mail_resend_message.py
@@ -34,13 +34,16 @@ class MailResendMessage(models.TransientModel):
if message_id:
mail_message_id = self.env['mail.message'].browse(message_id)
notification_ids = mail_message_id.notification_ids.filtered(lambda notif: notif.notification_type == 'email' and notif.notification_status in ('exception', 'bounce'))
- partner_ids = [Command.create({
- "partner_id": notif.res_partner_id.id,
- "name": notif.res_partner_id.name,
- "email": notif.res_partner_id.email,
+ partner_values = [({
+ "notification_id": notif.id,
"resend": True,
"message": notif.format_failure_reason(),
}) for notif in notification_ids]
+
+ # mail.resend.partner need to exist to be able to execute an action
+ partner_ids = self.env['mail.resend.partner'].create(partner_values).ids
+ partner_commands = [Command.link(partner_id) for partner_id in partner_ids]
+
has_user = any(notif.res_partner_id.user_ids for notif in notification_ids)
if has_user:
partner_readonly = not self.env['res.users'].check_access_rights('write', raise_exception=False)
@@ -49,7 +52,7 @@ class MailResendMessage(models.TransientModel):
rec['partner_readonly'] = partner_readonly
rec['notification_ids'] = [Command.set(notification_ids.ids)]
rec['mail_message_id'] = mail_message_id.id
- rec['partner_ids'] = partner_ids
+ rec['partner_ids'] = partner_commands
else:
raise UserError(_('No message_id found in context'))
return rec
@@ -60,26 +63,14 @@ class MailResendMessage(models.TransientModel):
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")
+ to_send = wizard.partner_ids.filtered(lambda p: p.resend)
notif_to_cancel = wizard.notification_ids.filtered(lambda notif: notif.notification_type == 'email' and notif.res_partner_id in to_cancel and notif.notification_status in ('exception', 'bounce'))
notif_to_cancel.sudo().write({'notification_status': 'canceled'})
if to_send:
- message = wizard.mail_message_id
- record = self.env[message.model].browse(message.res_id) if message.is_thread_message() else self.env['mail.thread']
-
- email_partners_data = []
- recipients_data = self.env['mail.followers']._get_recipient_data(None, 'comment', False, pids=to_send.ids)[0]
- for pid, pdata in recipients_data.items():
- if pid and pdata.get('notif', 'email') == 'email':
- email_partners_data.append(pdata)
-
- record._notify_thread_by_email(
- message, email_partners_data,
- resend_existing=True,
- send_after_commit=False
- )
-
- self.mail_message_id._notify_message_notification_update()
+ # this will update the notification already
+ to_send.action_resend()
+ else:
+ self.mail_message_id._notify_message_notification_update()
return {'type': 'ir.actions.act_window_close'}
def cancel_mail_action(self):
@@ -94,9 +85,54 @@ class PartnerResend(models.TransientModel):
_name = 'mail.resend.partner'
_description = 'Partner with additional information for mail resend'
- partner_id = fields.Many2one('res.partner', string='Partner', required=True, ondelete='cascade')
+ notification_id = fields.Many2one('mail.notification', string='Notification', required=True, ondelete='cascade')
+ partner_id = fields.Many2one('res.partner', string='Partner', related='notification_id.res_partner_id')
name = fields.Char(related='partner_id.name', string='Recipient Name', related_sudo=False, readonly=False)
email = fields.Char(related='partner_id.email', string='Email Address', related_sudo=False, readonly=False)
+ failure_reason = fields.Text('Failure Reason', related='notification_id.failure_reason')
resend = fields.Boolean(string='Try Again', default=True)
resend_wizard_id = fields.Many2one('mail.resend.message', string="Resend wizard")
message = fields.Char(string='Error message')
+ partner_readonly = fields.Boolean('Partner Readonly', related='resend_wizard_id.partner_readonly')
+
+ def action_open_record(self):
+ self.ensure_one()
+ message = self.notification_id.mail_message_id
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': message.model,
+ 'res_id': message.res_id,
+ 'view_ids': [(False, 'form')],
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+ def action_open_resend_partner(self):
+ self.ensure_one()
+ action = self.env['ir.actions.act_window']._for_xml_id('mail.mail_resend_partner_action')
+ action['res_id'] = self.id
+ return action
+
+ def action_resend(self):
+ message = self.resend_wizard_id.mail_message_id
+ if len(message) != 1:
+ raise UserError(_('All partners must belong to the same message'))
+
+ recipients_data = self.env['mail.followers']._get_recipient_data(None, 'comment', False, pids=self.partner_id.ids)
+ email_partners_data = [
+ pdata
+ for pid, pdata in recipients_data[0].items()
+ if pid and pdata.get('notif', 'email') == 'email'
+ ]
+
+ record = self.env[message.model].browse(message.res_id) if message.is_thread_message() else self.env['mail.thread']
+ record._notify_thread_by_email(
+ message, email_partners_data,
+ resend_existing=True,
+ send_after_commit=False
+ )
+
+ message._notify_message_notification_update()
+
+ if len(self) == 1:
+ return self.action_open_record()
diff --git a/addons/mail/wizard/mail_resend_message_views.xml b/addons/mail/wizard/mail_resend_message_views.xml
index 6d8b884e8ca..d185c4f4304 100644
--- a/addons/mail/wizard/mail_resend_message_views.xml
+++ b/addons/mail/wizard/mail_resend_message_views.xml
@@ -16,8 +16,9 @@
-
+