[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
+1
View File
@@ -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',
+1
View File
@@ -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)"),
+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
# ------------------------------------------------------
@@ -34,7 +34,7 @@
<field name="is_read"/>
<field name="read_date"/>
<field name="failure_type"/>
<field name="failure_reason"/>
<field name="failure_reason" class="o_text_overflow"/>
</group>
</group>
</sheet>
+59 -23
View File
@@ -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()
@@ -16,8 +16,9 @@
<field name="name" readonly="1"/>
<field name="email" attrs="{'readonly': [('parent.partner_readonly', '=', True)]}"/>
<field name="message" readonly="1" class="text-wrap"/>
<field name="partner_id" invisible="1"/>
<field name="resend" widget="boolean_toggle"/>
<button name="action_open_resend_partner" type="object"
string="See Error Details" class="btn-link"/>
</tree>
</field>
<footer>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="mail_resend_partner_view_form" model="ir.ui.view">
<field name="name">mail.resend.partner.view.form</field>
<field name="model">mail.resend.partner</field>
<field name="arch" type="xml">
<form string="Partner">
<header>
<button name="action_resend" type="object" string="Resend" class="oe_highlight"
attrs="{'invisible': [('failure_reason', '=', False)]}"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="action_open_record" type="object" icon="fa-user">
<span>Open Record</span>
</button>
</div>
<group col="2">
<group colspan="1">
<field name="notification_id" invisible="1"/>
<field name="partner_readonly" invisible="1"/>
<field name="name" readonly="1" required="1"/>
<field name="email" attrs="{'readonly': [('partner_readonly', '=', True)]}"/>
</group>
</group>
<notebook>
<page string="Failure Reason" name="page_failure_reason">
<field name="failure_reason"
class="text-wrap w-100 overflow-auto"
nolabel="1"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="mail_resend_partner_action" model="ir.actions.act_window">
<field name="name">Resend Email</field>
<field name="res_model">mail.resend.partner</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="target">current</field>
</record>
</data>
</odoo>
+5 -3
View File
@@ -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')
+9 -3
View File
@@ -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):
+6 -2
View File
@@ -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)
@@ -90,6 +90,9 @@
<field name="sent_datetime" attrs="{'invisible' : [('sent_datetime', '=', False)]}"/>
<field name="links_click_datetime" attrs="{'invisible' : [('links_click_datetime', '=', False)]}"/>
<field name="open_datetime" attrs="{'invisible' : [('open_datetime', '=', False)]}"/>
<field name="failure_reason"
attrs="{'invisible' : [('failure_reason', '=', False)]}"
class="o_text_overflow"/>
<field name="reply_datetime" attrs="{'invisible' : [('reply_datetime', '=', False)]}"/>
</group>
<group string="Mailing">
+52 -3
View File
@@ -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 <mail.notification> 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 <what@ever.com>', '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 """
@@ -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')