PURPOSE Be defensive when dealing with email fields, notably when having multi-emails or email field containing an already-formatted email. RATIONALE Add tests related to not standard usage of email field. Two main use cases are tested here * formatted emails: `"Full Name" <email@domain.com>` stored into the 'email' field; * multi emails: `email1@domain.com, email2@domain.com` stored into a single 'email' field; Additional tests: tests with unicode / ascii / case / wrong formatting are also added to check the support in normalize and format methods. IMPLICATION Email field is generally managed as "containing a valid email". This means it is sometimes used as it in 'formataddr' as well as to perform searches or identification checks. Example of issue: partner 'Raoul' has a formatted email like "Raoul" <raoul@raoul.fr>. Using 'formataddr' in email_from leads to from: "Raoul" <"Raoul" <raoul@raoul.fr>> -> which is incorrect (but often dynamically corrected by email servers); Email field holding multi-emails are not normalized, as current normalize is done only if the field holds a single email. It means * no easy finding based on 'email_normalized', e.g. various tools like '_mail_find_partner_from_emails' or 'find_or_create' do not find partners based on this email; * no exclusion list management; * issue with formatting, like to: "Raoul" <raoul@raoul.fr,raoul.other@raoul.fr> -> which is incorrect (but often dynamically corrected by email servers); USAGE: OUTGOING EMAILS Those use cases currently generate faulty outgoing emails. This is valid for recipients ('email_cc', 'email_to') as well as author ('email_from'). For formatted emails: `email_to` is formatted again based on name and email which leads to sending emails to `"Full Name" <"Other"<email@domain.com>>`. Note that multi emails without formatting may work as it leads to email_to `"Full name" <email1@domain.com,email2@domain.com>`. Some outgoing email servers correctly send multiple emails. It depends on their fault tolerance. USAGE: FIND BASED ON EMAIL (NORMALIZED) When searching for partners (e.g. using '_mail_find_partner_from_emails' or 'find_or_create') normalized version of input is used. In case of multi emails sanitize is 'False', as normalization expects a single email in the field. Therefore no partner is found. In processes that do a "search or create" (e.g. using a template on a record) this leads to creating a new partner (or several partners in case of multi emails) each time. USAGE: OTHER FLOWS Other flows are build on top of '_mail_find_partner_from_emails' / 'create' of outgoing emails and are impacted by formatted email / multi email usage. Those include notably * mass_mailing: '_message_get_default_recipients' should be defensive to give correct values when creating mailing emails; * mass_mailing: faulty emails is based on normalize and multi-emails are considered as faulty and ignored; * after post hook: '_message_post_after_hook' tries to link messages without author (but email_from) with newly-created partners, when partners are created from chatter. It is therefore impacted by those corner cases; * marketing_automation: built on top of mass_mailing and suffers from the same issues; USAGE: UNICODE Unicode in emails should be supported. 'formataddr' and IrMailServer notably received fixes to support unicode. Some check performed on email addresses fail when unicode is involved, which leads to some emails not being sent while they could. SPECIFICATIONS Add tests related to those corner cases. Also add tests for computation of `email_formatted` field of Partner model. It currently generates wrong email values for the same corner cases (multi emails, formatted emails). Tests are also added for the computation of `email_normalized` field used notably for blacklists. It is not computed currently when being in multi email mode which prevents from any blacklist mechanism as well as make email finding harder. `_mail_find_partner_from_emails` tool method is also tested with multi email as it uses the same heuristic as normalized email field. Tests are also added for mass mailing, when having to mail documents that have a partner with formatted emails / multi-emails, or that have an email field with formatted emails / multi-emails. Also restore a test removed at odoo/odoo@afcb734908 while it should have been updated to state that email addresses containing non-ascii characters are supported. Add some tests for tools methods used in various email processing flows. Unicode tests are also added. In future commits we will try to make email usage a bit more defensive to try to lessen issues with that kind of use cases. Task-2612945 (Mail: Defensive email formatting) X-original-commit: odoo/odoo@fc8442f133 Part-of: odoo/odoo#134934
441 lines
16 KiB
Python
441 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.tools import email_normalize
|
|
|
|
|
|
class MailTestSimple(models.Model):
|
|
""" A very simple model only inheriting from mail.thread when only
|
|
communication history is necessary. """
|
|
_description = 'Simple Chatter Model'
|
|
_name = 'mail.test.simple'
|
|
_inherit = ['mail.thread']
|
|
|
|
name = fields.Char()
|
|
email_from = fields.Char()
|
|
|
|
def _message_compute_subject(self):
|
|
""" To ease mocks """
|
|
_a = super()._message_compute_subject()
|
|
return _a
|
|
|
|
def _notify_by_email_get_final_mail_values(self, *args, **kwargs):
|
|
""" To ease mocks """
|
|
_a = super()._notify_by_email_get_final_mail_values(*args, **kwargs)
|
|
return _a
|
|
|
|
def _notify_by_email_get_headers(self):
|
|
headers = super()._notify_by_email_get_headers()
|
|
headers['X-Custom'] = 'Done'
|
|
return headers
|
|
|
|
|
|
class MailTestSimpleWithMainAttachment(models.Model):
|
|
_description = 'Simple Chatter Model With Main Attachment Management'
|
|
_name = 'mail.test.simple.main.attachment'
|
|
_inherit = ['mail.test.simple', 'mail.thread.main.attachment']
|
|
|
|
|
|
class MailTestSimpleUnfollow(models.Model):
|
|
""" A very simple model only inheriting from mail.thread when only
|
|
communication history is necessary with unfollow link enabled in
|
|
notification emails even for non-internal user. """
|
|
_description = 'Simple Chatter Model'
|
|
_name = 'mail.test.simple.unfollow'
|
|
_inherit = ['mail.thread']
|
|
_partner_unfollow_enabled = True
|
|
|
|
name = fields.Char()
|
|
company_id = fields.Many2one('res.company')
|
|
email_from = fields.Char()
|
|
|
|
|
|
class MailTestAliasOptional(models.Model):
|
|
""" A chatter model inheriting from the alias mixin using optional alias_id
|
|
field, hence no inherits. """
|
|
_description = 'Chatter Model using Optional Alias Mixin'
|
|
_name = 'mail.test.alias.optional'
|
|
_inherit = ['mail.alias.mixin.optional']
|
|
|
|
name = fields.Char()
|
|
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
|
email_from = fields.Char()
|
|
|
|
def _alias_get_creation_values(self):
|
|
""" Updates itself """
|
|
values = super()._alias_get_creation_values()
|
|
values['alias_model_id'] = self.env['ir.model']._get_id('mail.test.alias.optional')
|
|
if self.id:
|
|
values['alias_force_thread_id'] = self.id
|
|
values['alias_defaults'] = {'company_id': self.company_id.id}
|
|
return values
|
|
|
|
|
|
class MailTestGateway(models.Model):
|
|
""" A very simple model only inheriting from mail.thread to test pure mass
|
|
mailing features and base performances. """
|
|
_description = 'Simple Chatter Model for Mail Gateway'
|
|
_name = 'mail.test.gateway'
|
|
_inherit = ['mail.thread.blacklist']
|
|
_primary_email = 'email_from'
|
|
|
|
name = fields.Char()
|
|
email_from = fields.Char()
|
|
custom_field = fields.Char()
|
|
|
|
@api.model
|
|
def message_new(self, msg_dict, custom_values=None):
|
|
""" Check override of 'message_new' allowing to update record values
|
|
base on incoming email. """
|
|
defaults = {
|
|
'email_from': msg_dict.get('from'),
|
|
}
|
|
defaults.update(custom_values or {})
|
|
return super().message_new(msg_dict, custom_values=defaults)
|
|
|
|
|
|
class MailTestGatewayGroups(models.Model):
|
|
""" A model looking like discussion channels / groups (flat thread and
|
|
alias). Used notably for advanced gatewxay tests. """
|
|
_description = 'Channel/Group-like Chatter Model for Mail Gateway'
|
|
_name = 'mail.test.gateway.groups'
|
|
_inherit = ['mail.thread.blacklist', 'mail.alias.mixin']
|
|
_mail_flat_thread = False
|
|
_primary_email = 'email_from'
|
|
|
|
name = fields.Char()
|
|
email_from = fields.Char()
|
|
custom_field = fields.Char()
|
|
customer_id = fields.Many2one('res.partner', 'Customer')
|
|
|
|
def _alias_get_creation_values(self):
|
|
values = super(MailTestGatewayGroups, self)._alias_get_creation_values()
|
|
values['alias_model_id'] = self.env['ir.model']._get('mail.test.gateway.groups').id
|
|
if self.id:
|
|
values['alias_force_thread_id'] = self.id
|
|
values['alias_parent_thread_id'] = self.id
|
|
return values
|
|
|
|
def _mail_get_partner_fields(self, introspect_fields=False):
|
|
return ['customer_id']
|
|
|
|
def _message_get_default_recipients(self):
|
|
return dict(
|
|
(record.id, {
|
|
'email_cc': False,
|
|
'email_to': record.email_from if not record.customer_id.ids else False,
|
|
'partner_ids': record.customer_id.ids,
|
|
})
|
|
for record in self
|
|
)
|
|
|
|
|
|
class MailTestStandard(models.Model):
|
|
""" This model can be used in tests when automatic subscription and simple
|
|
tracking is necessary. Most features are present in a simple way. """
|
|
_description = 'Standard Chatter Model'
|
|
_name = 'mail.test.track'
|
|
_inherit = ['mail.thread']
|
|
|
|
name = fields.Char()
|
|
email_from = fields.Char()
|
|
user_id = fields.Many2one('res.users', 'Responsible', tracking=True)
|
|
container_id = fields.Many2one('mail.test.container', tracking=True)
|
|
company_id = fields.Many2one('res.company')
|
|
|
|
|
|
class MailTestActivity(models.Model):
|
|
""" This model can be used to test activities in addition to simple chatter
|
|
features. """
|
|
_description = 'Activity Model'
|
|
_name = 'mail.test.activity'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
|
|
name = fields.Char()
|
|
date = fields.Date()
|
|
email_from = fields.Char()
|
|
active = fields.Boolean(default=True)
|
|
|
|
def action_start(self, action_summary):
|
|
return self.activity_schedule(
|
|
'test_mail.mail_act_test_todo',
|
|
summary=action_summary
|
|
)
|
|
|
|
def action_close(self, action_feedback, attachment_ids=None):
|
|
self.activity_feedback(['test_mail.mail_act_test_todo'],
|
|
feedback=action_feedback,
|
|
attachment_ids=attachment_ids)
|
|
|
|
|
|
class MailTestTicket(models.Model):
|
|
""" This model can be used in tests when complex chatter features are
|
|
required like modeling tasks or tickets. """
|
|
_description = 'Ticket-like model'
|
|
_name = 'mail.test.ticket'
|
|
_inherit = ['mail.thread']
|
|
_primary_email = 'email_from'
|
|
|
|
name = fields.Char()
|
|
email_from = fields.Char(tracking=True)
|
|
mobile_number = fields.Char()
|
|
phone_number = fields.Char()
|
|
count = fields.Integer(default=1)
|
|
datetime = fields.Datetime(default=fields.Datetime.now)
|
|
mail_template = fields.Many2one('mail.template', 'Template')
|
|
customer_id = fields.Many2one('res.partner', 'Customer', tracking=2)
|
|
user_id = fields.Many2one('res.users', 'Responsible', tracking=1)
|
|
container_id = fields.Many2one('mail.test.container', tracking=True)
|
|
|
|
def _mail_get_partner_fields(self, introspect_fields=False):
|
|
return ['customer_id']
|
|
|
|
def _message_compute_subject(self):
|
|
self.ensure_one()
|
|
return f"Ticket for {self.name} on {self.datetime.strftime('%m/%d/%Y, %H:%M:%S')}"
|
|
|
|
def _message_get_default_recipients(self):
|
|
return dict(
|
|
(record.id, {
|
|
'email_cc': False,
|
|
'email_to': record.email_from if not record.customer_id.ids else False,
|
|
'partner_ids': record.customer_id.ids,
|
|
})
|
|
for record in self
|
|
)
|
|
|
|
def _notify_get_recipients_groups(self, message, model_description, msg_vals=None):
|
|
""" Activate more groups to test query counters notably (and be backward
|
|
compatible for tests). """
|
|
local_msg_vals = dict(msg_vals or {})
|
|
groups = super()._notify_get_recipients_groups(
|
|
message, model_description, msg_vals=msg_vals
|
|
)
|
|
for group_name, _group_method, group_data in groups:
|
|
if group_name == 'portal':
|
|
group_data['active'] = True
|
|
elif group_name == 'customer':
|
|
group_data['active'] = True
|
|
group_data['has_button_access'] = True
|
|
group_data['actions'] = [{
|
|
'url': self._notify_get_action_link(
|
|
'controller',
|
|
controller='/test_mail/do_stuff',
|
|
**local_msg_vals
|
|
),
|
|
'title': _('NotificationButtonTitle')
|
|
}]
|
|
|
|
return groups
|
|
|
|
def _track_template(self, changes):
|
|
res = super(MailTestTicket, self)._track_template(changes)
|
|
record = self[0]
|
|
if 'customer_id' in changes and record.mail_template:
|
|
res['customer_id'] = (
|
|
record.mail_template,
|
|
{
|
|
'composition_mode': 'mass_mail',
|
|
'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'),
|
|
}
|
|
)
|
|
elif 'datetime' in changes:
|
|
res['datetime'] = (
|
|
'test_mail.mail_test_ticket_tracking_view',
|
|
{
|
|
'composition_mode': 'mass_mail',
|
|
'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'),
|
|
}
|
|
)
|
|
return res
|
|
|
|
def _creation_subtype(self):
|
|
if self.container_id:
|
|
return self.env.ref('test_mail.st_mail_test_ticket_container_upd')
|
|
return super(MailTestTicket, self)._creation_subtype()
|
|
|
|
def _track_subtype(self, init_values):
|
|
self.ensure_one()
|
|
if 'container_id' in init_values and self.container_id:
|
|
return self.env.ref('test_mail.st_mail_test_ticket_container_upd')
|
|
return super(MailTestTicket, self)._track_subtype(init_values)
|
|
|
|
def _get_customer_information(self):
|
|
email_normalized_to_values = super()._get_customer_information()
|
|
|
|
for record in self.filtered('email_from'):
|
|
email_from_normalized = email_normalize(record.email_from)
|
|
if not email_from_normalized: # do not fill Falsy with random data
|
|
continue
|
|
values = email_normalized_to_values.setdefault(email_from_normalized, {})
|
|
if not values.get('mobile'):
|
|
values['mobile'] = record.mobile_number
|
|
if not values.get('phone'):
|
|
values['phone'] = record.phone_number
|
|
return email_normalized_to_values
|
|
|
|
def _message_get_suggested_recipients(self):
|
|
recipients = super()._message_get_suggested_recipients()
|
|
for ticket in self:
|
|
if ticket.customer_id:
|
|
ticket.customer_id._message_add_suggested_recipient(
|
|
recipients,
|
|
partner=ticket.customer_id,
|
|
lang=None,
|
|
reason=_('Customer'),
|
|
)
|
|
elif ticket.email_from:
|
|
ticket._message_add_suggested_recipient(
|
|
recipients,
|
|
partner=None,
|
|
email=self.email_from,
|
|
lang=None,
|
|
reason=_('Customer Email'),
|
|
)
|
|
return recipients
|
|
|
|
class MailTestTicketEL(models.Model):
|
|
""" Just mail.test.ticket, but exclusion-list enabled. Kept as different
|
|
model to avoid messing with existing tests, notably performance, and ease
|
|
backward comparison. """
|
|
_description = 'Ticket-like model with exclusion list'
|
|
_name = 'mail.test.ticket.el'
|
|
_inherit = [
|
|
'mail.test.ticket',
|
|
'mail.thread.blacklist',
|
|
]
|
|
_primary_email = 'email_from'
|
|
|
|
email_from = fields.Char(
|
|
'Email',
|
|
compute='_compute_email_from', readonly=False, store=True)
|
|
|
|
@api.depends('customer_id')
|
|
def _compute_email_from(self):
|
|
for ticket in self.filtered(lambda r: r.customer_id and not r.email_from):
|
|
ticket.email_from = ticket.customer_id.email_formatted
|
|
|
|
|
|
class MailTestTicketMC(models.Model):
|
|
""" Just mail.test.ticket, but multi company. Kept as different model to
|
|
avoid messing with existing tests, notably performance, and ease backward
|
|
comparison. """
|
|
_description = 'Ticket-like model'
|
|
_name = 'mail.test.ticket.mc'
|
|
_inherit = ['mail.test.ticket']
|
|
_primary_email = 'email_from'
|
|
|
|
company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company)
|
|
container_id = fields.Many2one('mail.test.container.mc', tracking=True)
|
|
|
|
|
|
class MailTestContainer(models.Model):
|
|
""" This model can be used in tests when container records like projects
|
|
or teams are required. """
|
|
_description = 'Project-like model with alias'
|
|
_name = 'mail.test.container'
|
|
_mail_post_access = 'read'
|
|
_inherit = ['mail.thread', 'mail.alias.mixin']
|
|
|
|
name = fields.Char()
|
|
description = fields.Text()
|
|
customer_id = fields.Many2one('res.partner', 'Customer')
|
|
|
|
def _mail_get_partner_fields(self, introspect_fields=False):
|
|
return ['customer_id']
|
|
|
|
def _message_get_default_recipients(self):
|
|
return dict(
|
|
(record.id, {
|
|
'email_cc': False,
|
|
'email_to': False,
|
|
'partner_ids': record.customer_id.ids,
|
|
})
|
|
for record in self
|
|
)
|
|
|
|
def _notify_get_recipients_groups(self, message, model_description, msg_vals=None):
|
|
""" Activate more groups to test query counters notably (and be backward
|
|
compatible for tests). """
|
|
groups = super()._notify_get_recipients_groups(
|
|
message, model_description, msg_vals=msg_vals
|
|
)
|
|
for group_name, _group_method, group_data in groups:
|
|
if group_name == 'portal':
|
|
group_data['active'] = True
|
|
|
|
return groups
|
|
|
|
def _alias_get_creation_values(self):
|
|
values = super(MailTestContainer, self)._alias_get_creation_values()
|
|
values['alias_model_id'] = self.env['ir.model']._get('mail.test.container').id
|
|
if self.id:
|
|
values['alias_force_thread_id'] = self.id
|
|
values['alias_parent_thread_id'] = self.id
|
|
return values
|
|
|
|
class MailTestContainerMC(models.Model):
|
|
""" Just mail.test.container, but multi company. Kept as different model to
|
|
avoid messing with existing tests, notably performance, and ease backward
|
|
comparison. """
|
|
_description = 'Project-like model with alias (MC)'
|
|
_name = 'mail.test.container.mc'
|
|
_mail_post_access = 'read'
|
|
_inherit = ['mail.test.container']
|
|
|
|
company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company)
|
|
|
|
|
|
class MailTestComposerMixin(models.Model):
|
|
""" A simple invite-like wizard using the composer mixin, rendering on
|
|
composer source test model. Purpose is to have a minimal composer which
|
|
runs on other records and check notably dynamic template support and
|
|
translations. """
|
|
_description = 'Invite-like Wizard'
|
|
_name = 'mail.test.composer.mixin'
|
|
_inherit = ['mail.composer.mixin']
|
|
|
|
name = fields.Char('Name')
|
|
author_id = fields.Many2one('res.partner')
|
|
description = fields.Html('Description', render_engine="qweb", render_options={"post_process": True}, sanitize=False)
|
|
source_ids = fields.Many2many('mail.test.composer.source', string='Invite source')
|
|
|
|
def _compute_render_model(self):
|
|
self.render_model = 'mail.test.composer.source'
|
|
|
|
|
|
class MailTestComposerSource(models.Model):
|
|
""" A simple model on which invites are sent. """
|
|
_description = 'Invite-like Source'
|
|
_name = 'mail.test.composer.source'
|
|
_inherit = ['mail.thread.blacklist']
|
|
_primary_email = 'email_from'
|
|
|
|
name = fields.Char('Name')
|
|
customer_id = fields.Many2one('res.partner', 'Main customer')
|
|
email_from = fields.Char(
|
|
'Email',
|
|
compute='_compute_email_from', readonly=False, store=True)
|
|
|
|
@api.depends('customer_id')
|
|
def _compute_email_from(self):
|
|
for source in self.filtered(lambda r: r.customer_id and not r.email_from):
|
|
source.email_from = source.customer_id.email_formatted
|
|
|
|
def _mail_get_partner_fields(self, introspect_fields=False):
|
|
return ['customer_id']
|
|
|
|
|
|
class MailTestMailTrackingDuration(models.Model):
|
|
_description = 'Fake model to test the mixin mail.tracking.duration.mixin'
|
|
_name = 'mail.test.mail.tracking.duration'
|
|
_track_duration_field = 'customer_id'
|
|
_inherit = ['mail.thread', 'mail.tracking.duration.mixin']
|
|
|
|
name = fields.Char()
|
|
customer_id = fields.Many2one('res.partner', 'Customer', tracking=True)
|
|
|
|
def _mail_get_partner_fields(self, introspect_fields=False):
|
|
return ['customer_id']
|