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')