From 97ca45e4f348f819f18fdff3693d030d36c2c734 Mon Sep 17 00:00:00 2001 From: std-odoo Date: Wed, 16 Nov 2022 08:21:23 +0000 Subject: [PATCH] [IMP] mail, mass_mailing: store the bounce email and allow the user to read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Purpose ======= The bounce emails aren't stored in Odoo, which can complicate the debugging of the email sending. Now, we store this bounce email, and we allow the user to read it from the interface, so he can easily find the issue when an email sending fail. Specifications ============== The bounce email is stored on the mail notification for standard emails sending, and on the mailing traces when using mass mailing. For some email providers (e.g. Yahoo), the "Final-Recipient" header is not present. Normally, it allows us to retrieve the original recipient of the email which bounced and then the partner. So if this header is not there in a bounce email, we take the first recipient of the parent . Change the way that we parse the email body, for the bounce email. For most email providers, the first mail body is the one that contains the error and the next one contains the parent email body. So, the current logic might ignore this body for Outlook and Yahoo. Task-2116296 closes odoo/odoo#105923 Related: odoo/enterprise#34051 Signed-off-by: Warnon Aurélien (awa) --- addons/mail/__manifest__.py | 1 + addons/mail/models/mail_notification.py | 1 + addons/mail/models/mail_thread.py | 190 +++++++++++++----- addons/mail/views/mail_notification_views.xml | 2 +- addons/mail/wizard/mail_resend_message.py | 82 +++++--- .../mail/wizard/mail_resend_message_views.xml | 3 +- .../mail/wizard/mail_resend_partner_views.xml | 46 +++++ addons/mass_mailing/models/mail_thread.py | 8 +- addons/mass_mailing/models/mailing_trace.py | 12 +- addons/mass_mailing/tests/common.py | 8 +- .../views/mailing_trace_views.xml | 3 + addons/test_mail/tests/test_mail_gateway.py | 55 ++++- .../test_mass_mailing/tests/test_mailing.py | 6 + 13 files changed, 334 insertions(+), 83 deletions(-) create mode 100644 addons/mail/wizard/mail_resend_partner_views.xml 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 @@
                             
                             
                             
-                            
                             
+                            
+                    
+                    
+                        
+                            
+                            
+                            
+                            
+                        
+                    
+                    
+                        
+                            
+                        
+                    
+                
+            
+        
+    
+    
+        Resend Email
+        mail.resend.partner
+        ir.actions.act_window
+        form
+        current
+    
+
+
diff --git a/addons/mass_mailing/models/mail_thread.py b/addons/mass_mailing/models/mail_thread.py
index 78fe220f218..189831441c7 100644
--- a/addons/mass_mailing/models/mail_thread.py
+++ b/addons/mass_mailing/models/mail_thread.py
@@ -57,11 +57,13 @@ class MailThread(models.AbstractModel):
         super(MailThread, self)._routing_handle_bounce(email_message, message_dict)
 
         bounced_email = message_dict['bounced_email']
-        bounced_msg_id = message_dict['bounced_msg_id']
+        bounced_msg_ids = message_dict['bounced_msg_ids']
         bounced_partner = message_dict['bounced_partner']
 
-        if bounced_msg_id:
-            self.env['mailing.trace'].set_bounced(domain=[('message_id', 'in', bounced_msg_id)])
+        if bounced_msg_ids:
+            self.env['mailing.trace'].set_bounced(
+                domain=[('message_id', 'in', bounced_msg_ids)],
+                bounce_message=tools.html2plaintext(message_dict.get('body') or ''))
         if bounced_email:
             three_months_ago = fields.Datetime.to_string(datetime.datetime.now() - datetime.timedelta(weeks=13))
             stats = self.env['mailing.trace'].search(['&', '&', ('trace_status', '=', 'bounce'), ('write_date', '>', three_months_ago), ('email', '=ilike', bounced_email)]).mapped('write_date')
diff --git a/addons/mass_mailing/models/mailing_trace.py b/addons/mass_mailing/models/mailing_trace.py
index 02d88e5a648..9fa467177bc 100644
--- a/addons/mass_mailing/models/mailing_trace.py
+++ b/addons/mass_mailing/models/mailing_trace.py
@@ -91,14 +91,16 @@ class MailingTrace(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)"),
         # mass mode
         ("mail_bl", "Blacklisted Address"),
-        ("mail_optout", "Opted Out"),
         ("mail_dup", "Duplicated Email"),
+        ("mail_optout", "Opted Out"),
     ], string='Failure type')
+    failure_reason = fields.Text('Failure reason', copy=False, readonly=True)
     # Link tracking
     links_click_ids = fields.One2many('link.tracker.click', 'mailing_trace_id', string='Links click')
     links_click_datetime = fields.Datetime('Clicked On', help='Stores last click datetime in case of multi clicks.')
@@ -157,9 +159,13 @@ class MailingTrace(models.Model):
         traces.write({'trace_status': 'reply', 'reply_datetime': fields.Datetime.now()})
         return traces
 
-    def set_bounced(self, domain=None):
+    def set_bounced(self, domain=None, bounce_message=False):
         traces = self + (self.search(domain) if domain else self.env['mailing.trace'])
-        traces.write({'trace_status': 'bounce'})
+        traces.write({
+            'failure_reason': bounce_message,
+            'failure_type': 'mail_bounce',
+            'trace_status': 'bounce',
+        })
         return traces
 
     def set_failed(self, domain=None, failure_type=False):
diff --git a/addons/mass_mailing/tests/common.py b/addons/mass_mailing/tests/common.py
index ca5c2671bbf..bc3dae83571 100644
--- a/addons/mass_mailing/tests/common.py
+++ b/addons/mass_mailing/tests/common.py
@@ -112,6 +112,9 @@ class MassMailCase(MailCase, MockLinkTracker):
             if 'failure_type' in recipient_info or status in ('error', 'cancel', 'bounce'):
                 self.assertEqual(recipient_trace.failure_type, recipient_info['failure_type'])
 
+            if 'failure_reason' in recipient_info:
+                self.assertEqual(recipient_trace.failure_reason, recipient_info['failure_reason'])
+
             if check_mail:
                 if author is None:
                     author = self.env.user.partner_id
@@ -162,13 +165,14 @@ class MassMailCase(MailCase, MockLinkTracker):
             'to': 'bounce@test.example.com',  # TDE check: bounce alias ?
             'message_id': tools.generate_tracking_message_id('MailTest'),
             'bounced_partner': self.env['res.partner'].sudo(),
-            'bounced_message': self.env['mail.message'].sudo()
+            'bounced_message': self.env['mail.message'].sudo(),
+            'body': 'This is the bounce email',
         }
         if bounce_base_values:
             parsed_bounce_values.update(bounce_base_values)
         parsed_bounce_values.update({
             'bounced_email': trace.email,
-            'bounced_msg_id': [trace.message_id],
+            'bounced_msg_ids': [trace.message_id],
         })
         self.env['mail.thread']._routing_handle_bounce(False, parsed_bounce_values)
 
diff --git a/addons/mass_mailing/views/mailing_trace_views.xml b/addons/mass_mailing/views/mailing_trace_views.xml
index 29de31fffc2..d45952f66de 100644
--- a/addons/mass_mailing/views/mailing_trace_views.xml
+++ b/addons/mass_mailing/views/mailing_trace_views.xml
@@ -90,6 +90,9 @@
                             
                             
                             
+                            
                             
                         
                         
diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py
index 5b68e17b7f3..1bf39510c55 100644
--- a/addons/test_mail/tests/test_mail_gateway.py
+++ b/addons/test_mail/tests/test_mail_gateway.py
@@ -27,7 +27,7 @@ class TestEmailParsing(TestMailCommon):
         """ Incoming email containing a wrong Content-Type as described in RFC2046/section-3 """
         received_mail = self.from_string(test_mail_data.MAIL_MULTIPART_BINARY_OCTET_STREAM)
         with self.assertLogs('odoo.addons.mail.models.mail_thread', level="WARNING") as capture:
-            extracted_mail = self.env['mail.thread']._message_parse_extract_payload(received_mail)
+            extracted_mail = self.env['mail.thread']._message_parse_extract_payload(received_mail, {})
 
         self.assertEqual(len(extracted_mail['attachments']), 1)
         attachment = extracted_mail['attachments'][0]
@@ -102,7 +102,7 @@ class TestEmailParsing(TestMailCommon):
         )
         res = self.env['mail.thread'].message_parse(self.from_string(mail))
 
-        self.assertEqual(res['bounced_msg_id'], [msg_id], "Message-Id is not extracted from Text/RFC822-Headers attachment")
+        self.assertEqual(res['bounced_msg_ids'], [msg_id], "Message-Id is not extracted from Text/RFC822-Headers attachment")
 
     def test_message_parse_extract_bounce_rfc822_headers_qp(self):
         # Incoming bounce for unexisting Outlook address
@@ -126,7 +126,7 @@ class TestEmailParsing(TestMailCommon):
         msg = self.env['mail.thread']._message_parse_extract_bounce(self.from_string(incoming_bounce), msg_dict)
         self.assertEqual(msg['bounced_email'], partner.email, "The sender email should be correctly parsed")
         self.assertEqual(msg['bounced_partner'], partner, "A partner with this email should exist")
-        self.assertEqual(msg['bounced_msg_id'][0], message.message_id, "The sender message-id should correctly parsed")
+        self.assertEqual(msg['bounced_msg_ids'][0], message.message_id, "The sender message-id should correctly parsed")
         self.assertEqual(msg['bounced_message'], message, "An existing message with this message_id should exist")
 
     def test_message_parse_plaintext(self):
@@ -1016,6 +1016,33 @@ class TestMailgateway(TestMailCommon):
         self.assertEqual(self.partner_1.message_bounce, 0)
         self.assertEqual(self.test_record.message_bounce, 0)
 
+    @mute_logger('odoo.addons.mail.models.mail_thread')
+    def test_message_process_bounce_missing_final_recipient(self):
+        """The Final-Recipient header is missing, the partner must be found thanks to the original mail message."""
+        email = test_mail_data.MAIL_BOUNCE.replace('Final-Recipient', 'XX')
+        email = email.replace('Original-Recipient', 'XX')
+
+        self.assertEqual(self.partner_1.message_bounce, 0)
+        self.assertEqual(self.test_record.message_bounce, 0)
+
+        # no notification to find, won't be able to find the correct recipient
+        extra = self.fake_email.message_id
+        record = self.format_and_process(email, self.partner_1.email_formatted, 'bounce.test@%test.com', subject='Undelivered Mail Returned to Sender', extra=extra)
+        self.assertFalse(record)
+        self.assertEqual(self.partner_1.message_bounce, 0)
+        self.assertEqual(self.test_record.message_bounce, 0)
+
+        # the partner will be found in the  res_partner_id
+        extra = self.fake_email.message_id
+        self.env['mail.notification'].create({
+            "res_partner_id": self.partner_1.id,
+            "mail_message_id": self.fake_email.id,
+        })
+        record = self.format_and_process(email, self.partner_1.email_formatted, 'bounce.test@%test.com', subject='Undelivered Mail Returned to Sender', extra=extra)
+        self.assertFalse(record)
+        self.assertEqual(self.partner_1.message_bounce, 1)
+        self.assertEqual(self.test_record.message_bounce, 1)
+
     @mute_logger('odoo.addons.mail.models.mail_thread')
     def test_message_process_bounce_multipart_alias(self):
         """ Multipart/report bounce correctly make related partner bounce """
@@ -1036,6 +1063,11 @@ class TestMailgateway(TestMailCommon):
         self.assertEqual(self.partner_1.message_bounce, 0)
         self.assertEqual(self.test_record.message_bounce, 0)
 
+        notification = self.env['mail.notification'].create({
+            'mail_message_id': self.fake_email.id,
+            'res_partner_id': self.partner_1.id,
+        })
+
         bounced_mail_id = 4442
         bounce_email_to = '%s@%s' % ('bounce.test', 'test.com')
         extra = self.fake_email.message_id
@@ -1043,6 +1075,12 @@ class TestMailgateway(TestMailCommon):
         self.assertFalse(record)
         self.assertEqual(self.partner_1.message_bounce, 1)
         self.assertEqual(self.test_record.message_bounce, 1)
+        self.assertIn(
+            'This is the mail system at host mail2.test.ironsky.',
+            notification.failure_reason,
+            msg='Should store the bounce email body on the notification')
+        self.assertEqual(notification.failure_type, 'mail_bounce')
+        self.assertEqual(notification.notification_status, 'bounce')
 
     @mute_logger('odoo.addons.mail.models.mail_thread')
     def test_message_process_bounce_multipart_alias_whatever_from(self):
@@ -1070,6 +1108,17 @@ class TestMailgateway(TestMailCommon):
         self.assertEqual(self.partner_1.message_bounce, 0)
         self.assertEqual(self.test_record.message_bounce, 1)
 
+        # The local part of the FROM is not "MAILER-DAEMON", and the Content type is slightly
+        # different. Thanks to the report type, it still should be detected as a bounce email.
+        email = test_mail_data.MAIL_BOUNCE.replace('multipart/report;', 'multipart/report:')
+        email = email.replace('MAILER-DAEMON@mail2.test.ironsky', 'email@mail2.test.ironsky')
+        self.assertIn('report-type=delivery-status', email)
+        extra = self.fake_email.message_id
+        record = self.format_and_process(email, 'Whatever ', 'groups@test.com', subject='Undelivered Mail Returned to Sender', extra=extra)
+        self.assertFalse(record)
+        self.assertEqual(self.partner_1.message_bounce, 0)
+        self.assertEqual(self.test_record.message_bounce, 2)
+
     @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink')
     def test_message_process_bounce_records_channel(self):
         """ Test blacklist allow to multi-bounce and auto update of mail.channel """
diff --git a/addons/test_mass_mailing/tests/test_mailing.py b/addons/test_mass_mailing/tests/test_mailing.py
index df06c911a85..9f7cec41480 100644
--- a/addons/test_mass_mailing/tests/test_mailing.py
+++ b/addons/test_mass_mailing/tests/test_mailing.py
@@ -117,6 +117,12 @@ class TestMassMailing(TestMassMailCommon):
         mailing.invalidate_recordset()
         self.assertMailingStatistics(mailing, expected=5, delivered=4, sent=5, opened=1, clicked=1, bounced=1)
         self.assertEqual(recipients[1].message_bounce, 1)
+        self.assertMailTraces([{
+            'email': 'test.record.01@test.example.com',
+            'failure_reason': 'This is the bounce email',
+            'failure_type': 'mail_bounce',
+            'trace_status': 'bounce',
+        }], mailing, recipients[1], check_mail=False)
 
     @users('user_marketing')
     @mute_logger('odoo.addons.mail.models.mail_mail')