[IMP] mail, mass_mailing: store the bounce email and allow the user to read it

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
<mail.message>.

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) <awa@odoo.com>
This commit is contained in:
std-odoo
2023-03-06 17:47:29 +01:00
parent a25ab87abe
commit 97ca45e4f3
13 changed files with 334 additions and 83 deletions
+143 -47
View File
@@ -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 -> <pre/>
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 <mail.message> 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 <mail.message> 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 <mail.message> (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 <mail.message> which is the parent of the given email.
:param msg_dict: The dict values already parsed
:return: The <mail.message> 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
# ------------------------------------------------------