[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:
@@ -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
|
||||
# ------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user