[MOV][REF] mail: add failure type on mail.mail model and move computation in mail

RATIONALE

Currently most of mail related failure types are computed in mass mailing.
This is mainly due to historical reasons. We could move some of this
computation directly at mail level and store failure information directly
in mail.mail records.

SPECIFICATIONS

Overall purpose is to get near SMS implementation where base composer prepare
more advanced pre computation: blacklist, optout, seen list.

Detect and store failure types directly on mail.mail: blacklist, optout,
duplicates, missing email, wrong email, ...

State computation is moved from mass mailing to mail. It is also improved as
currently everything is based on "first recipient found". This is globally
valid for mass mailing who uses default recipients (customer). However when
dealing with generic mailing this is not true anymore.

We choose to store and update status on mail.mail records only when having
a single recipient. When several recipients are defined on a given mail.mail
we cannot really decide a status prior to sending it and skip this computation.

Mass mailing override now uses information from mail.mail to update its linked
traces as computation is now delegated to base composer model.

LINKS

Task ID-2377974
Community PR odoo#61467
Enterprise PR odoo/enterprise#14633
Upgrade PR odoo/upgrade#1907
This commit is contained in:
Thibault Delavallée
2021-08-18 13:38:17 +00:00
parent 8c16737135
commit 213d5d396c
3 changed files with 127 additions and 69 deletions
+15 -3
View File
@@ -50,12 +50,24 @@ class MailMail(models.Model):
('exception', 'Delivery Failed'),
('cancel', 'Cancelled'),
], 'Status', readonly=True, copy=False, default='outgoing')
auto_delete = fields.Boolean(
'Auto Delete',
help="This option permanently removes any track of email after it's been sent, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.")
failure_type = fields.Selection(selection=[
# generic
("unknown", "Unknown error"),
# mail
("mail_email_invalid", "Invalid email address"),
("mail_email_missing", "Missing email"),
("mail_smtp", "Connection failed (outgoing mail server problem)"),
# mass mode
("mail_bl", "Blacklisted Address"),
("mail_optout", "Opted Out"),
("mail_dup", "Duplicated Email"),
], string='Failure type')
failure_reason = fields.Text(
'Failure Reason', readonly=1, copy=False,
help="Failure reason. This is usually the exception thrown by the email server, stored to ease the debugging of mailing issues.")
auto_delete = fields.Boolean(
'Auto Delete',
help="This option permanently removes any track of email after it's been sent, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.")
scheduled_date = fields.Char('Scheduled Send Date',
help="If set, the queue manager will send the email after the date. If not set, the email will be send as soon as possible.")
+106 -16
View File
@@ -7,6 +7,7 @@ import re
from odoo import _, api, fields, models, tools, Command
from odoo.exceptions import UserError
from odoo.tools import email_re
def _reopen(self, res_id, model, context=None):
@@ -350,17 +351,6 @@ class MailComposer(models.TransientModel):
records = self.env[self.model].browse(res_ids)
reply_to_value = records._notify_get_reply_to(default=self.email_from)
blacklisted_rec_ids = set()
if mass_mail_mode and issubclass(type(self.env[self.model]), self.pool['mail.thread.blacklist']):
self.env['mail.blacklist'].flush(['email'])
self._cr.execute("SELECT email FROM mail_blacklist WHERE active=true")
blacklist = {x[0] for x in self._cr.fetchall()}
if blacklist:
targets = self.env[self.model].browse(res_ids).read(['email_normalized'])
# First extract email from recipient before comparing with blacklist
blacklisted_rec_ids.update(target['id'] for target in targets
if target['email_normalized'] in blacklist)
for res_id in res_ids:
# static wizard (mail.message) values
mail_values = {
@@ -412,15 +402,115 @@ class MailComposer(models.TransientModel):
attachment_ids,
{'model': 'mail.message', 'res_id': 0}
)['attachment_ids']
# Filter out the blacklisted records by setting the mail state to cancel -> Used for Mass Mailing stats
if res_id in blacklisted_rec_ids:
mail_values['state'] = 'cancel'
# Do not post the mail into the recipient's chatter
mail_values['notification'] = False
results[res_id] = mail_values
results = self._process_state(results)
return results
def _process_recipient_values(self, mail_values_dict):
# Preprocess res.partners to batch-fetch from db if recipient_ids is present
# it means they are partners (the only object to fill get_default_recipient this way)
recipient_pids = [
recipient_command[1]
for mail_values in mail_values_dict.values()
# recipient_ids is a list of x2m command tuples at this point
for recipient_command in mail_values.get('recipient_ids') or []
if recipient_command[1]
]
recipient_emails = {
p.id: p.email
for p in self.env['res.partner'].browse(set(recipient_pids))
} if recipient_pids else {}
recipients_info = {}
for record_id, mail_values in mail_values_dict.items():
mail_to = []
if mail_values.get('email_to'):
mail_to += email_re.findall(mail_values['email_to'])
# if unrecognized email in email_to -> keep it as used for further processing
if not mail_to:
mail_to.append(mail_values['email_to'])
# add email from recipients (res.partner)
mail_to += [
recipient_emails[recipient_command[1]]
for recipient_command in mail_values.get('recipient_ids') or []
if recipient_command[1]
]
mail_to = list(set(mail_to))
recipients_info[record_id] = {
'mail_to': mail_to,
'mail_to_normalized': [
tools.email_normalize(mail)
for mail in mail_to
if tools.email_normalize(mail)
]
}
return recipients_info
def _process_state(self, mail_values_dict):
recipients_info = self._process_recipient_values(mail_values_dict)
blacklist_ids = self._get_blacklist_record_ids(mail_values_dict)
optout_emails = self._get_optout_emails(mail_values_dict)
done_emails = self._get_done_emails(mail_values_dict)
for record_id, mail_values in mail_values_dict.items():
recipients = recipients_info[record_id]
# when having more than 1 recipient: we cannot really decide when a single
# email is linked to several to -> skip that part. Mass mailing should
# anyway always have a single recipient per record as this is default behavior.
if len(recipients['mail_to']) > 1:
continue
mail_to = recipients['mail_to'][0] if recipients['mail_to'] else ''
mail_to_normalized = recipients['mail_to_normalized'][0] if recipients['mail_to_normalized'] else ''
# prevent sending to blocked addresses that were included by mistake
# blacklisted or optout or duplicate -> cancel
if record_id in blacklist_ids:
mail_values['state'] = 'cancel'
mail_values['failure_type'] = 'mail_bl'
# Do not post the mail into the recipient's chatter
mail_values['notification'] = False
elif optout_emails and mail_to in optout_emails:
mail_values['state'] = 'cancel'
mail_values['failure_type'] = 'mail_optout'
elif done_emails and mail_to in done_emails:
mail_values['state'] = 'cancel'
mail_values['failure_type'] = 'mail_dup'
# void of falsy values -> error
elif not mail_to:
mail_values['state'] = 'cancel'
mail_values['failure_type'] = 'mail_email_missing'
elif not mail_to_normalized or not email_re.findall(mail_to):
mail_values['state'] = 'cancel'
mail_values['failure_type'] = 'mail_email_invalid'
elif done_emails is not None:
done_emails.append(mail_to)
return mail_values_dict
def _get_blacklist_record_ids(self, mail_values_dict):
blacklisted_rec_ids = set()
if self.composition_mode == 'mass_mail' and issubclass(type(self.env[self.model]), self.pool['mail.thread.blacklist']):
self.env['mail.blacklist'].flush(['email'])
self._cr.execute("SELECT email FROM mail_blacklist WHERE active=true")
blacklist = {x[0] for x in self._cr.fetchall()}
if blacklist:
targets = self.env[self.model].browse(mail_values_dict.keys()).read(['email_normalized'])
# First extract email from recipient before comparing with blacklist
blacklisted_rec_ids.update(target['id'] for target in targets
if target['email_normalized'] in blacklist)
return blacklisted_rec_ids
def _get_done_emails(self, mail_values_dict):
seen_list = self._context.get('mass_mailing_seen_list') or []
return seen_list
def _get_optout_emails(self, mail_values_dict):
opt_out_list = self._context.get('mass_mailing_opt_out_list') or []
return opt_out_list
def _onchange_template_id(self, template_id, composition_mode, model, res_id):
""" - mass_mailing: we cannot render, so return the template values
- normal mode: return rendered values
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools
from odoo.tools import email_re
from odoo import fields, models
class MailComposeMessage(models.TransientModel):
@@ -40,70 +39,27 @@ class MailComposeMessage(models.TransientModel):
'attachment_ids': [(6, 0, self.attachment_ids.ids)],
})
# Preprocess res.partners to batch-fetch from db
# if recipient_ids is present, it means they are partners
# (the only object to fill get_default_recipient this way)
recipient_partners_ids = []
read_partners = {}
for res_id in res_ids:
mail_values = res[res_id]
if mail_values.get('recipient_ids'):
# recipient_ids is a list of x2m command tuples at this point
recipient_partners_ids.append(mail_values.get('recipient_ids')[0][1])
read_partners = self.env['res.partner'].browse(recipient_partners_ids)
partners_email = {p.id: p.email for p in read_partners}
opt_out_list = self._context.get('mass_mailing_opt_out_list')
seen_list = self._context.get('mass_mailing_seen_list')
recipients_info = self._process_recipient_values(res)
mass_mail_layout = self.env.ref('mass_mailing.mass_mailing_mail_layout', raise_if_not_found=False)
for res_id in res_ids:
mail_values = res[res_id]
if mail_values.get('body_html') and mass_mail_layout:
mail_values['body_html'] = mass_mail_layout._render({'body': mail_values['body_html']}, engine='ir.qweb', minimal_qcontext=True)
mail_values = res[res_id]
if mail_values.get('email_to'):
mail_to = mail_values['email_to']
else:
partner_id = (mail_values.get('recipient_ids') or [(False, '')])[0][1]
mail_to = partners_email.get(partner_id)
mail_to_normalized = tools.email_normalize(mail_to)
# prevent sending to blocked addresses that were included by mistake
# blacklisted or optout or duplicate -> cancel
failure_type = False
if mail_values.get('state') == 'cancel':
failure_type = 'mail_bl'
elif opt_out_list and mail_to in opt_out_list:
mail_values['state'] = 'cancel'
failure_type = 'mail_optout'
elif seen_list and mail_to in seen_list:
mail_values['state'] = 'cancel'
failure_type = 'mail_dup'
# void of falsey values -> error
elif not mail_to:
mail_values['state'] = 'cancel'
failure_type = 'mail_email_missing'
elif not mail_to_normalized or not email_re.findall(mail_to):
mail_values['state'] = 'cancel'
failure_type = "mail_email_invalid"
elif seen_list is not None:
seen_list.add(mail_to)
trace_vals = {
'model': self.model,
'res_id': res_id,
'mass_mailing_id': mass_mailing.id,
# if mail_to is void, keep falsy values to allow searching / debugging traces
'email': mail_to or mail_values.get('email_to'),
'email': recipients_info[res_id]['mail_to'][0] if recipients_info[res_id]['mail_to'] else '',
}
# propagate failed states to trace when still-born
if mail_values.get('state') == 'cancel':
trace_vals['trace_status'] = 'cancel'
elif mail_values.get('state') == 'exception':
trace_vals['trace_status'] = 'error'
if failure_type:
trace_vals['failure_type'] = failure_type
if mail_values.get('failure_type'):
trace_vals['failure_type'] = mail_values['failure_type']
mail_values.update({
'mailing_id': mass_mailing.id,