[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:
@@ -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',
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user