[MERGE] mail, various: support multi domains aliases

RATIONALE

Currently only one alias domain is possible when using Odoo. Even if several
outgoing and/or incoming email servers can be used, all "reply-to" email
addresses belong to the same global email domain. Moreover incoming emails
cannot be cleanly limited or checked against a company as we accept all
incoming emails sent to aliases to ease notably email forwarding.

PURPOSE

Allow alias domains to be multiple, notably to be used in a multi company
environment where each company has its own alias domain.

SPECIFICATIONS

Allow companies sharing a database to handle their mailing flow independently
e.g if a travel agency group uses Odoo to handle its business. Each agency is
a company with their mail domains and servers like 'info@travel.namur.example.com'
'info@travel.liege.example.com'.

ALIAS DOMAINS

Add a new 'mail.alias.domain' model storing information previously stored
as a unique value in 'mail.catchall.domain' configuration parameter. Domains
are not completely linked to companies, allowing to have a mono-domain MC
setup, or mono-company multi-domain setup.

A main email domain is present on companies for all default domain computation
when being in that company while keeping flexibility of having other domains
defined.

Now that a new 'mail.alias.domain' model exists we move bounce and catchall
aliases definition directly on this model. Update related computation on
company model. Default_from (filter) are also moved on this model, allowing
an higher level default from computation for mail servers when having knowledge
of alias domain environment.

Usage of 'mail.catchall.domain' will soon be completely removed to be replaced
by proposed company-based email alias domain usage.

Constraints are added so that each bounce and catchall defined on domains
do not clash with existing aliases. Sanitize of bounce and catchall is also
performed to ensure they make valid emails. This matches previous behavior
of ICP parameters. Domain name is also sanitized like alias names.

ALIASES

Add 'mail.alias.domain' information on alias model. Aliases do not use global
configuration parameters anymore. Instead they are linked to an alias domain
e.g. 'sales' linked to 'mycompany.com' alias domain: 'sales@mycompany.com'.

It is now considered as a different alias compared to 'sales@mycompany.in'
which has the same alias_name but a different alias domain.

Constraints and checks are added, as

  * uniqueness of aliases is now checked inside a given domain;
  * an alias name must not clash with its domain bounce or catchall;
  * a combination of (alias_name, alias_domain_id) must be unique;

Now that alias domains are multiple and can be linked to companies it is
easy to end up with configuration where an alias is using a domain of
CompanyA while the owner and/or target record belongs to CompanyB. We
want to avoid that situation and strengthen company separation.

When changing alias domain of an alias check that it is not used in another
company than the one define on

  * the owner record (using owner fields like the 'project.project' for
    'project.task' creating aliases);
  * the target record (using update fields like the 'mail.group' for group
    aliases that routes emails to a specific group);

If the new alias domain is linked to a company that is different from the
company of any related record, raise an error as it could lead to invalid
multi-company setup and record creation.

If the new alias domain is different from the company domain but is not
used in any company it is a valid configuration. It is just flexibility
offered by multi-domains aliases.

MAIL GATEWAY

Make mail gateway support alias domains instead of relying on configuration
parameters. This implies the following changes

  * destination alias check is now based on full email by default. Previously
    only left-part of aliases were checked. Optionally an allowed list of
    domains could be additionally checked. Default from now on is to check
    the complete email e.g. 'sales@mydomain.com' != 'sales@mydomain.in';
  * detection of direct write to catchall implies checking all domains
    catchall emails;
  * detection of write to bounce implies checking all domains bounce emails;
  * when having to send bounce emails using the bounce alias as mailer-daemon,
    find the bounce email from the relevant company;

However we have to ease transition from the old ICP-based model used since
ages to the new domain-based model. Notably a common usage of mail gateways
is to do mail forwarding e.g. forward mail from domainA to domainB without
rewriting destination. It means that e.g. sales@mail.domainA should be
considered as a valid alias equivalent to sales@mail.domainB. This was
working due to left-part only check of destination aliases. In order to
keep this setup working after migration a flag is added on aliases allowing
to keep the detection of those aliases based only on local parts.

In summary: When searching for aliases, mailgateway now either checks for
exact email, either for matching local parts when the flag is active. This
is not the default behavior, as we want a stricter comparison of emails by
default but it will be the default behavior at **migration time**.

The 'mail.catchall.domain.allowed' configuration parameter is kept. It is
used only for left-part check aliases, allowing to limit the scope of the
match.

EMAIL FLOWS

Store 'company_id' and 'alias_domain_id' on 'mail.message' model. It helps
knowing the environment that produced the message, notably

  * for layouting: it will be used to improve company (and soon alias domain)
    given for the email notification layout;
  * for sending: it will be used to better compute mail-related values like
    default from, return path, ...

When logging, those fields are kept false as anyway no notification is sent.
No need to fetch extra information.

When computing reply-to of message or documents, classify them per company
and use alias domains when computing catchall emails. Reply-to computation
based on alias is also simplified as aliases are now complete and use
alias domains. They do not depend on configuration parameters anymore.

MAIL.MAIL AND OUTGOING EMAILS

Update MailMail to use alias domains. Notably "Return-Path" headers are
now computed based on alias domain when possible, using the recently added
fields on 'mail.message' model for that purpose. Fallback is to use current
company's bounce email when mail_mail creation is done outside of classic
mail flows or without that information.

Update IrMailServer and low-level stack to use alias domains. This has an
impact notably on default values computation for from and bounce emails

  * '_get_default_bounce_address' is called when there is no 'Return-Path'
    given. Most classic mail flows will set it according to current record
    company / alias domain. Fallback when not set is to fallback on current
    company's bounce email, computed based on its alias domain;
  * '_get_default_from_address' is used in two use cases
    * computing a default 'email_from' for outgoing emails when it is not set.
      In most classic mail flows it is set based on current user's email. If
      not set fallback on current company's notification emails is considered
      as a safe bet, replacing the global configuration parameter;
    * overriding the 'email_from' of emails that are considered spoofing the
      mail server, allowing to wrap the sending into a 'notifications@domain'
      generic sender. For those we should try to keep record's information as
      it may be called in classic mail flows;
  * '_get_default_from_filter' is added in base and overridden in mail to
    either use 'mail.default.from_filter' ICP, or use the one defined on
    the alias domain. Supporting both is still an option, as its behavior
    is implemented for basic email sending, without mail being available.

Those methods are updated to try to support multi domains / multi company
setup. However as those defaults are located ar ir.mail_server level it is
not always easy to have complete environment information, hence fallbacking
on current company's parameters when no better information is provided.

When sending emails based on MailMail, we now prepares sending groups based
on MailServer, email_from, but also alias domain to which the mail belongs to.
Information about alias domain (e.g. notifications email based on default_from
and bounce email) is propagated to low-level email preparation methods. It
uses the context as it is the easiest way to propagate information to that
level without hacking too much models or calls.

USAGE: CONFIGURATION

Update settings to configure your company's current alias domain instead
of the global 'alias.catchall.domain' configuration parameter. As domain
configuration is globally done per company, this is now a related on the
company. Advanced configuration and management can still be done manually
in settings.

USAGE: SEARCH

Currently alias search is done only on 'alias_name' field. Now that alias
domains can be multiple we have to be able to search on complete alias
email definition e.g. when checking destination aliases of incoming emails
in mail gateway.

We add a new 'alias_full_name' field that is computed based on alias_name
and alias_domain_id.name. It is stored so that search is possible on it.

Allow to search on 'alias_full_name' from the 'mail.alias.mixin(.optional)'
by making 'alias_email' field searchable, based on 'alias_full_name'.

USAGE: APPS

Update fields used in form views. Use 'alias_domain_id' instead of domain
that is now simply a related on 'alias_domain_id.name'. To ease UI we add
a placeholder on this field as it is now writable e.g. in multi domains
environment. Avoid unwanted configuration change by making it generally
no_open / no_create_edit.

NOTE

Query counters are not at their real value, they were not updated to the lowest
count because of the merge frenzy that might have an impact on those.

LINKS

This merge is done on top of several preliminary work, notably
  * odoo/odoo#130750 (move ICP to mail, prepare mail server code)
  * odoo/odoo#130632 + odoo/enterprise#45118 (alias cleanup)
  * odoo/odoo#130468 + odoo/enterprise#45036 (mail/phone tools cleanup)
  * odoo/odoo#138213 + odoo/enterrpise#48692 (remove 'alias_user_id')
  * odoo/odoo#138202 (email_from check at email send)

Test suite preparation
  * odoo/odoo#130768 + odoo/enterprise#45204 (test suite preparation)
  * odoo/odoo#131492
  * odoo/odoo#135055 + odoo/enterprise#47243
  * odoo/odoo#136102 (mail server test preparation)
  * odoo/odoo#137895 (test tours cleaning)
  * odoo/odoo#138202 (test suite clean inside email_from check)

Task-36879 (Mail: Support Multi Domains Aliases)

closes odoo/odoo#76734

Related: odoo/enterprise#20983
Related: odoo/upgrade#2846
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2023-10-24 19:24:51 +00:00
committed by Odoo Online
65 changed files with 2296 additions and 769 deletions
@@ -12,8 +12,6 @@ class TestAccountIncomingSupplierInvoice(AccountTestInvoicingCommon):
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.env['ir.config_parameter'].sudo().set_param('mail.catchall.domain', 'test.mycompany.com')
cls.internal_user = cls.env['res.users'].create({
'name': 'Internal User',
'login': 'internal.user@test.odoo.com',
@@ -12,7 +12,7 @@
<field name="kanban_dashboard"/>
<field name="activity_ids"/>
<field name="activity_state"/>
<field name="alias_domain"/>
<field name="alias_domain_id"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="d-flex flex-column flex-fill">
@@ -45,7 +45,7 @@
<span groups="base.group_multi_company" class="small">- <field name="company_id"/></span>
</t>
</div>
<div class="o_secondary" t-att-title="dashboard.title" t-if="journal_type == 'purchase' &amp;&amp; record.alias_domain.raw_value">
<div class="o_secondary" t-att-title="dashboard.title" t-if="journal_type == 'purchase' &amp;&amp; record.alias_domain_id.raw_value">
<field name="alias_id"/>
</div>
</div>
+12 -5
View File
@@ -143,18 +143,25 @@
<field name="restrict_mode_hash_table" groups="account.group_account_readonly" invisible="type in ['bank', 'cash']"/>
</group>
<!-- email alias -->
<group class="oe_read_only" name="group_alias_ro" string="Create Invoices upon Emails" invisible="type not in ('sale', 'purchase') or not alias_domain">
<group class="oe_read_only" name="group_alias_ro"
string="Create Invoices upon Emails" invisible="type not in ('sale', 'purchase')">
<field name="alias_id"/>
</group>
<group name="group_alias_no_domain" string="Create Invoices upon Emails" invisible="type not in ('sale', 'purchase') or alias_domain">
<group name="group_alias_no_domain" string="Create Invoices upon Emails"
invisible="type not in ('sale', 'purchase') or alias_domain_id">
<div class="content-group" colspan="2">
<a type='action' name='%(action_open_settings)d' class="btn btn-link" role="button"><i class="oi oi-fw o_button_icon oi-arrow-right"/> Configure Email Servers</a>
<a type='action' name='%(action_open_settings)d' class="btn btn-link" role="button">
<i class="oi oi-fw o_button_icon oi-arrow-right"/> Choose or Configure Email Servers
</a>
</div>
</group>
<group class="oe_edit_only" name="group_alias_edit" string="Create Invoices upon Emails" invisible="type not in ('sale', 'purchase') or not alias_domain">
<group class="oe_edit_only" name="group_alias_edit" string="Create Invoices upon Emails"
invisible="type not in ('sale', 'purchase') or not alias_domain_id">
<label string="Email Alias" for="alias_name"/>
<div class="oe_inline" name="edit_alias" style="display: inline;" >
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
<field name="alias_name" class="oe_inline"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</div>
</group>
<!-- edi -->
@@ -15,9 +15,6 @@ class ResConfigSettings(models.TransientModel):
user_default_rights = fields.Boolean(
"Default Access Rights",
config_parameter='base_setup.default_user_rights')
external_email_server_default = fields.Boolean(
"Use Custom Email Servers",
config_parameter='base_setup.default_external_email_server')
module_base_import = fields.Boolean("Allow users to import data from CSV/XLS/XLSX/ODS files")
module_google_calendar = fields.Boolean(
string='Allow the users to synchronize their calendar with Google Calendar')
+12
View File
@@ -265,6 +265,18 @@ class TestCrmCommon(TestSalesCommon, MailCase):
'email': 'company.2@test.example.com',
'name': 'New Test Company',
})
cls.alias_bounce_c2 = 'bounce.c2'
cls.alias_catchall_c2 = 'catchall.c2'
cls.alias_default_from_c2 = 'notifications.c2'
cls.alias_domain_c2_name = 'test.mycompany2.com'
cls.mail_alias_domain_c2 = cls.env['mail.alias.domain'].create({
'bounce_alias': cls.alias_bounce_c2,
'catchall_alias': cls.alias_catchall_c2,
'company_ids': [(4, cls.company_2.id)],
'default_from': cls.alias_default_from_c2,
'name': cls.alias_domain_c2_name,
'sequence': 2,
})
cls.user_sales_manager_mc = mail_new_test_user(
cls.env,
@@ -42,7 +42,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon):
with self.assertQueryCount(user_sales_manager=0):
test_leads = self.env['crm.lead'].browse(test_leads.ids)
with self.assertQueryCount(user_sales_manager=581): # crm 579 / com 581 / ent 581
with self.assertQueryCount(user_sales_manager=583): # crm 543 / com 545 / ent 583
test_leads._handle_salesmen_assignment(user_ids=user_ids, team_id=team_id)
self.assertEqual(test_leads.team_id, self.sales_team_convert)
@@ -167,7 +167,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon):
user_ids = self.assign_users.ids
# randomness: at least 1 query
with self.assertQueryCount(user_sales_manager=1800): # crm 1503 / com 1790 / ent 1798
with self.assertQueryCount(user_sales_manager=1800): # crm 1503 / com 1790 / ent 1800
mass_convert = self.env['crm.lead2opportunity.partner.mass'].with_context({
'active_model': 'crm.lead',
'active_ids': test_leads.ids,
+15 -11
View File
@@ -14,6 +14,8 @@ class NewLeadNotification(TestCrmCommon):
def setUpClass(cls):
""" Activate some langs to test lang propagation in various mail flows """
super(NewLeadNotification, cls).setUpClass()
cls._activate_multi_company()
cls.test_email = '"Test Email" <test.email@example.com>'
model_lang = cls.env['res.lang'].sudo().with_context(active_test=False)
@@ -221,14 +223,14 @@ class NewLeadNotification(TestCrmCommon):
@mute_logger('odoo.addons.mail.models.mail_thread')
def test_new_lead_from_email_multicompany(self):
company0 = self.env.company
company1 = self.env['res.company'].create({'name': 'new_company'})
company1 = self.company_2
self.env.user.write({
'company_ids': [(4, company0.id, False), (4, company1.id, False)],
})
crm_team_model = self.env['ir.model'].search([('model', '=', 'crm.team')])
crm_lead_model = self.env['ir.model'].search([('model', '=', 'crm.lead')])
crm_team_model_id = self.env['ir.model']._get_id('crm.team')
crm_lead_model_id = self.env['ir.model']._get_id('crm.lead')
crm_team0 = self.env['crm.team'].create({
'name': 'crm team 0',
@@ -240,16 +242,18 @@ class NewLeadNotification(TestCrmCommon):
})
mail_alias0 = self.env['mail.alias'].create({
'alias_domain_id': company0.alias_domain_id.id,
'alias_name': 'sale_team_0',
'alias_model_id': crm_lead_model.id,
'alias_parent_model_id': crm_team_model.id,
'alias_model_id': crm_lead_model_id,
'alias_parent_model_id': crm_team_model_id,
'alias_parent_thread_id': crm_team0.id,
'alias_defaults': "{'type': 'opportunity', 'team_id': %s}" % crm_team0.id,
})
mail_alias1 = self.env['mail.alias'].create({
'alias_domain_id': company1.alias_domain_id.id,
'alias_name': 'sale_team_1',
'alias_model_id': crm_lead_model.id,
'alias_parent_model_id': crm_team_model.id,
'alias_model_id': crm_lead_model_id,
'alias_parent_model_id': crm_team_model_id,
'alias_parent_thread_id': crm_team1.id,
'alias_defaults': "{'type': 'opportunity', 'team_id': %s}" % crm_team1.id,
})
@@ -257,12 +261,12 @@ class NewLeadNotification(TestCrmCommon):
crm_team0.write({'alias_id': mail_alias0.id})
crm_team1.write({'alias_id': mail_alias1.id})
new_message0 = """MIME-Version: 1.0
new_message0 = f"""MIME-Version: 1.0
Date: Thu, 27 Dec 2018 16:27:45 +0100
Message-ID: <blablabla0>
Subject: sale team 0 in company 0
From: A client <client_a@someprovider.com>
To: sale_team_0@test.mycompany.com
To: {mail_alias0.display_name}
Content-Type: multipart/alternative; boundary="000000000000a47519057e029630"
--000000000000a47519057e029630
@@ -278,12 +282,12 @@ Content-Transfer-Encoding: quoted-printable
--000000000000a47519057e029630--
"""
new_message1 = """MIME-Version: 1.0
new_message1 = f"""MIME-Version: 1.0
Date: Thu, 27 Dec 2018 16:27:45 +0100
Message-ID: <blablabla1>
Subject: sale team 1 in company 1
From: B client <client_b@someprovider.com>
To: sale_team_1@test.mycompany.com
To: {mail_alias1.display_name}
Content-Type: multipart/alternative; boundary="000000000000a47519057e029630"
--000000000000a47519057e029630
+1 -1
View File
@@ -178,7 +178,7 @@ class TestLeadAssignPerf(TestLeadAssignCommon):
# randomness: add 2 queries
with self.with_user('user_sales_manager'):
with self.assertQueryCount(user_sales_manager=6066): # crm 6048 / com 6052 / ent 6054
with self.assertQueryCount(user_sales_manager=6066): # crm 6048 / com 6052 / ent 6055
self.env['crm.team'].browse(sales_teams.ids)._action_assign_leads(work_days=30)
# teams assign
+7 -12
View File
@@ -159,18 +159,13 @@
</xpath>
<xpath expr="//field[@name='user_id']" position="after">
<label for="alias_name" string="Email Alias"
invisible="not use_leads and not use_opportunities"/>
<div class="oe_inline" name="alias_def"
invisible="not use_leads and not use_opportunities">
<field name="alias_id" class="oe_read_only oe_inline"
string="Email Alias"
required="0"
invisible="not alias_domain"/>
<div class="oe_inline" name="edit_alias" style="display: inline;" >
<div class="oe_edit_only" invisible="not alias_domain">
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
</div>
<button icon="oi-arrow-right" type="action" name="%(base_setup.action_general_configuration)d" string="Configure a custom domain" class="p-0 btn-link" invisible="alias_domain"/>
invisible="not use_leads and not use_opportunities"/>
<div name="alias_def" invisible="not use_leads and not use_opportunities">
<field name="alias_id" string="Email Alias" class="oe_read_only" required="0"/>
<div class="oe_edit_only" name="edit_alias">
<field name="alias_name" class="oe_inline"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</div>
</div>
<field name="alias_contact"
@@ -1345,7 +1345,6 @@ class TestSyncGoogle2Odoo(TestSyncGoogle):
@patch_api
def test_alias_email_sync_recurrence(self):
self.env['ir.config_parameter'].sudo().set_param('mail.catchall.domain', 'test.mycompany.com')
alias_model = self.env['ir.model'].search([('model', '=', 'calendar.event')])
mail_alias = self.env['mail.alias'].create({'alias_name': 'sale', 'alias_model_id': alias_model.id})
@@ -40,6 +40,7 @@ class ResConfigSettings(models.TransientModel):
# create data again
alias = self.env['mail.alias'].sudo().create({
'alias_contact': 'employees',
'alias_domain_id': self.env.company.alias_domain_id.id,
'alias_model_id': self.env['ir.model']._get_id('hr.expense'),
'alias_name': self.hr_expense_alias_prefix,
})
@@ -14,18 +14,19 @@
help="Create expenses from incoming emails"
title="Send an email to this email alias with the receipt in attachment to create an expense in one click. If the first word of the mail subject contains the category's internal reference or the category name, the corresponding category will automatically be set. Type the expense amount in the mail subject to set it on the expense too.">
<field name="hr_expense_use_mailgateway"/>
<div class="content-group" invisible="not hr_expense_use_mailgateway or alias_domain in ['localhost', '', False]">
<div class="mt16">
<div class="content-group" invisible="not hr_expense_use_mailgateway or not alias_domain_id">
<div class="mt16 d-flex align-items-start">
<label for="hr_expense_alias_prefix" string="Alias" class="o_light_label"/>
<field name="hr_expense_alias_prefix" class="oe_inline ps-2"/>
<span>@</span>
<field name="alias_domain" class="oe_inline" readonly="1" force_save="1"/>
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</div>
</div>
<div class="content-group" invisible="not hr_expense_use_mailgateway or alias_domain not in ['localhost', '', False]">
<div class="content-group" invisible="not hr_expense_use_mailgateway or alias_domain_id">
<div class="mt16">
<button type="action" name="base_setup.action_general_configuration"
icon="oi-arrow-right" string="Setup your domain alias" class="btn-link"/>
icon="oi-arrow-right" string="Setup your alias domain" class="btn-link"/>
</div>
</div>
</setting>
@@ -22,18 +22,19 @@ class RecruitmentSource(models.Model):
def create_alias(self):
campaign = self.env.ref('hr_recruitment.utm_campaign_job')
medium = self.env.ref('utm.utm_medium_email')
for source in self:
for source in self.filtered(lambda s: not s.alias_id):
vals = {
'alias_parent_thread_id': source.job_id.id,
'alias_model_id': self.env['ir.model']._get('hr.applicant').id,
'alias_parent_model_id': self.env['ir.model']._get('hr.job').id,
'alias_name': "%s+%s" % (source.job_id.alias_name or source.job_id.name, source.name),
'alias_defaults': {
'job_id': source.job_id.id,
'campaign_id': campaign.id,
'medium_id': medium.id,
'source_id': source.source_id.id,
},
'alias_domain_id': source.job_id.company_id.alias_domain_id.id or self.env.company.alias_domain_id.id,
'alias_model_id': self.env['ir.model']._get_id('hr.applicant'),
'alias_name': f"{source.job_id.alias_name or source.job_id.name}+{source.name}",
'alias_parent_thread_id': source.job_id.id,
'alias_parent_model_id': self.env['ir.model']._get_id('hr.job'),
}
source.alias_id = self.env['mail.alias'].create(vals)
+15 -10
View File
@@ -14,8 +14,7 @@
<kanban class="o_kanban_dashboard o_hr_recruitment_kanban" on_create="hr_recruitment.create_job_simple" sample="1" limit="40" action="%(action_hr_job_applications)d" type="action">
<field name="active"/>
<field name="name"/>
<field name="alias_name"/>
<field name="alias_domain"/>
<field name="alias_email"/>
<field name="is_favorite"/>
<field name="department_id"/>
<field name="no_of_recruitment"/>
@@ -86,7 +85,7 @@
<div class="ps-3 o_secondary" groups="base.group_multi_company">
<small><i class="fa fa-building-o" role="img" aria-label="Company" title="Company"></i> <field name="company_id"/></small>
</div>
<div t-if="record.alias_name.value and record.alias_domain.value" class="ps-3 o_secondary o_job_alias">
<div t-if="record.alias_email.value" class="ps-3 o_secondary o_job_alias">
<small><i class="fa fa-envelope-o" role="img" aria-label="Alias" title="Alias"></i> <field name="alias_id"/></small>
</div>
</div>
@@ -146,13 +145,16 @@
<form string="Create a Job Position" class="o_hr_job_simple_form" >
<group>
<field name="name" class="o_job_name oe_inline" placeholder="e.g. Sales Manager"/>
<label for="alias_name" string="Application email" invisible="not alias_domain" help="Define a specific contact address for this job position. If you keep it empty, the default email address will be used which is in human resources settings"/>
<div name="alias_def" invisible="not alias_domain">
<label for="alias_name" string="Application email"
help="Define a specific contact address for this job position. If you keep it empty, the default email address will be used which is in human resources settings"/>
<div name="alias_def">
<field name="alias_id" class="oe_read_only" string="Email Alias" required="0"/>
<div class="oe_edit_only" name="edit_alias">
<field name="alias_name" class="oe_inline o_job_alias" placeholder="e.g. sales-manager"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
<field name="alias_name" class="oe_inline o_job_alias" placeholder="e.g. sales-manager"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</div>
<div class="text-muted" invisible="not alias_domain">Applicants can send resume to this email address,<br/>it will create an application automatically</div>
<div class="text-muted">Applicants can send resume to this email address,<br/>it will create an application automatically</div>
</div>
</group>
<footer>
@@ -187,11 +189,14 @@
<span invisible="address_id" class="oe_read_only">Remote</span>
<field name="address_id" context="{'show_address': 1}" placeholder="Remote"/>
</div>
<label for="alias_name" string="Email Alias" invisible="not alias_domain" help="Define a specific contact address for this job position. If you keep it empty, the default email address will be used which is in human resources settings"/>
<div name="alias_def" invisible="not alias_domain">
<label for="alias_name" string="Email Alias"
help="Define a specific contact address for this job position. If you keep it empty, the default email address will be used which is in human resources settings"/>
<div name="alias_def">
<field name="alias_id" class="oe_read_only" string="Email Alias" required="0"/>
<div class="oe_edit_only" name="edit_alias">
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
<field name="alias_name" class="oe_inline"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</div>
</div>
</xpath>
@@ -32,7 +32,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase):
self.richard_emp.generate_work_entries(date(2018, 1, 1), date(2018, 1, 2))
leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0))
with self.assertQueryCount(__system__=100, admin=103): # com 96/97
with self.assertQueryCount(__system__=101, admin=103): # com 96/97
leave.action_validate()
leave.action_refuse()
@@ -48,7 +48,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase):
@users('__system__', 'admin')
@warmup
def test_performance_leave_create(self):
with self.assertQueryCount(__system__=59, admin=59):
with self.assertQueryCount(__system__=60, admin=60):
leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0))
leave.action_refuse()
@@ -57,7 +57,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase):
def test_performance_leave_confirm(self):
leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0))
leave.action_draft()
with self.assertQueryCount(__system__=42, admin=41):
with self.assertQueryCount(__system__=43, admin=42):
leave.action_confirm()
leave.state = 'refuse'
+2 -1
View File
@@ -99,10 +99,10 @@ For more specific needs, you may also assign custom-defined actions
'data/mail_activity_data.xml',
'data/security_notifications_templates.xml',
'data/ir_cron_data.xml',
'data/ir_config_parameter.xml',
'security/mail_security.xml',
'security/ir.model.access.csv',
'views/discuss_public_templates.xml',
'views/mail_alias_domain_views.xml',
'views/mail_alias_views.xml',
'views/mail_gateway_allowed_views.xml',
'views/mail_guest_views.xml',
@@ -116,6 +116,7 @@ For more specific needs, you may also assign custom-defined actions
'views/res_partner_views.xml',
'views/mail_blacklist_views.xml',
'views/mail_menus.xml',
'views/res_company_views.xml',
],
'demo': [
'data/discuss_channel_demo.xml',
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<record id="gc_delete_overdue_activities_year_threshold" model="ir.config_parameter">
<field name="key">mail.activity.gc.delete_overdue_years</field>
<field name="value">3</field>
</record>
</odoo>
+3 -17
View File
@@ -1,23 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Catchall Email Alias -->
<record id="icp_mail_catchall_alias" model="ir.config_parameter">
<field name="key">mail.catchall.alias</field>
<field name="value">catchall</field>
<record id="gc_delete_overdue_activities_year_threshold" model="ir.config_parameter">
<field name="key">mail.activity.gc.delete_overdue_years</field>
<field name="value">3</field>
</record>
<!-- Bounce Email Alias -->
<record id="icp_mail_bounce_alias" model="ir.config_parameter">
<field name="key">mail.bounce.alias</field>
<field name="value">bounce</field>
</record>
<!-- Notifications -->
<record id="icp_mail_default_from" model="ir.config_parameter">
<field name="key">mail.default.from</field>
<field name="value">notifications</field>
</record>
</data>
</odoo>
+1
View File
@@ -3,6 +3,7 @@
# core models (required for mixins)
from . import mail_alias
from . import mail_alias_domain
from . import models
# mixin
+1 -7
View File
@@ -20,14 +20,8 @@ class IrConfigParameter(models.Model):
# remove existing users, including inactive template user
# admin will regain the right via implied_ids on group_system
group_user._remove_group(group_mail_template_editor)
# sanitize, normalize and check uniqueness of catchall / bounce aliases
# note that False = unlink ICP
elif key in {'mail.bounce.alias', 'mail.catchall.alias'} and value:
value = self.env['mail.alias']._sanitize_alias_name(value)
if value:
self.env['mail.alias']._check_unique([value], skip_icp_keys={key})
# sanitize and normalize allowed catchall domains
elif key == 'mail.catchall.domain.allowed' and value:
value = self.env['mail.alias']._clean_and_check_mail_catchall_allowed_domains(value)
value = self.env['mail.alias']._sanitize_allowed_domains(value)
return super().set_param(key, value)
+17 -22
View File
@@ -26,25 +26,16 @@ class IrMailServer(models.Model):
def _get_default_bounce_address(self):
""" Compute the default bounce address. Try to use mail-defined config
parameter bounce alias if set. """
ICP = self.env['ir.config_parameter'].sudo()
bounce_alias = ICP.get_param('mail.bounce.alias')
domain = ICP.get_param('mail.catchall.domain')
if bounce_alias and domain:
return f'{bounce_alias}@{domain}'
if self.env.company.bounce_email:
return self.env.company.bounce_email
return super()._get_default_bounce_address()
@api.model
def _get_default_from_address(self):
""" Default from: try to use ``mail.default.from`` ICP either as a
full email address, either completed with ``mail.catchall.domain``
to form a alias-based default from. """
get_param = self.env['ir.config_parameter'].sudo().get_param
email_from = get_param("mail.default.from")
if email_from and "@" in email_from:
return email_from
if email_from:
if domain := get_param("mail.catchall.domain"):
return f"{email_from}@{domain}"
""" Default from: try to use default_from defined on company's alias
domain. """
if default_from := self.env.company.default_from_email:
return default_from
return super()._get_default_from_address()
def _get_test_email_from(self):
@@ -53,13 +44,17 @@ class IrMailServer(models.Model):
# find first found complete email in filter parts
if mail_from := next((email for email in from_filter_parts if "@" in email), None):
return mail_from
# try to complete with default.from in same domain of first found filter parts
default_from = self.env["ir.config_parameter"].sudo().get_param("mail.default.from", "odoo")
if "@" not in default_from:
return f"{default_from}@{from_filter_parts[0]}"
# the mail server is configured for a domain that matches the default email address
if self._match_from_filter(default_from, self.from_filter):
return default_from
alias_domains = self.env['mail.alias.domain'].sudo().search([])
matching = next(
(alias_domain for alias_domain in alias_domains
if self._match_from_filter(alias_domain.default_from_email, self.from_filter)
), False
)
if matching:
return matching.default_from_email
# fake default_from "odoo@domain"
return f"odoo@{from_filter_parts[0]}"
# no from_filter or from_filter is configured for a domain different that
# the one of the full email configured in mail.default.from -> fallback
# the default_from of company's alias_domain -> fallback
return super()._get_test_email_from()
+214 -98
View File
@@ -3,11 +3,12 @@
import ast
import re
from collections import defaultdict
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError, UserError
from odoo.osv import expression
from odoo.tools import is_html_empty, remove_accents
# see rfc5322 section 3.2.3
@@ -37,7 +38,12 @@ class Alias(models.Model):
alias_name = fields.Char(
'Alias Name', copy=False,
help="The name of the email alias, e.g. 'jobs' if you want to catch emails for <jobs@example.odoo.com>")
alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain')
alias_full_name = fields.Char('Alias Email', compute='_compute_alias_full_name', store=True)
display_name = fields.Char(string='Display Name', compute='_compute_display_name')
alias_domain_id = fields.Many2one(
'mail.alias.domain', string='Alias Domain', ondelete='restrict',
default=lambda self: self.env.company.alias_domain_id)
alias_domain = fields.Char('Alias domain name', related='alias_domain_id.name')
# target: create / update
alias_model_id = fields.Many2one('ir.model', 'Aliased Model', required=True, ondelete="cascade",
help="The model (Odoo Document Kind) to which this alias "
@@ -75,6 +81,7 @@ class Alias(models.Model):
"- everyone: everyone can post\n"
"- partners: only authenticated partners\n"
"- followers: only followers of the related document or members of following channels\n")
alias_incoming_local = fields.Boolean('Local-part based incoming detection', default=False)
alias_bounced_content = fields.Html(
"Custom Bounced Message", translate=True,
help="If set, this content will automatically be sent out to unauthorized users instead of the default message.")
@@ -86,9 +93,87 @@ class Alias(models.Model):
], compute='_compute_alias_status', store=True,
help='Alias status assessed on the last message received.')
_sql_constraints = [
('alias_unique', 'UNIQUE(alias_name)', 'Unfortunately this email alias is already used, please choose a unique one')
]
def init(self):
"""Make sure there aren't multiple records for the same name and alias
domain. Not in _sql_constraint because COALESCE is not supported for
PostgreSQL constraint. """
self.env.cr.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS mail_alias_name_domain_unique
ON mail_alias (alias_name, COALESCE(alias_domain_id, 0))
""")
@api.constrains('alias_domain_id', 'alias_force_thread_id', 'alias_parent_model_id',
'alias_parent_thread_id', 'alias_model_id')
def _check_alias_domain_id_mc(self):
""" Check for invalid alias domains based on company configuration.
When having a parent record and/or updating an existing record alias
domain should match the one used on the related record. """
tocheck = self.filtered(lambda domain: domain.alias_domain_id.company_ids)
if not tocheck:
return
# helpers to find owner / target models
def _owner_model(alias):
return alias.alias_parent_model_id.model
def _owner_env(alias):
return self.env[_owner_model(alias)]
def _target_model(alias):
return alias.alias_model_id.model
def _target_env(alias):
return self.env[_target_model(alias)]
# fetch impacted records, classify by model
recs_by_model = defaultdict(list)
for alias in tocheck:
# owner record (like 'project.project' for aliases creating new 'project.task')
if alias.alias_parent_model_id and alias.alias_parent_thread_id:
if _owner_env(alias)._mail_get_company_field():
recs_by_model[_owner_model(alias)].append(alias.alias_parent_thread_id)
# target record (like 'mail.group' updating a given group)
if alias.alias_model_id and alias.alias_force_thread_id:
if _target_env(alias)._mail_get_company_field():
recs_by_model[_target_model(alias)].append(alias.alias_force_thread_id)
# helpers to fetch owner / target with prefetching
def _fetch_owner(alias):
if alias.alias_parent_thread_id in recs_by_model[alias.alias_parent_model_id.model]:
return _owner_env(alias).with_prefetch(
recs_by_model[_owner_model(alias)]
).browse(alias.alias_parent_thread_id)
return None
def _fetch_target(alias):
if alias.alias_force_thread_id in recs_by_model[alias.alias_model_id.model]:
return _target_env(alias).with_prefetch(
recs_by_model[_target_model(alias)]
).browse(alias.alias_force_thread_id)
return None
# check company domains are compatible
for alias in tocheck:
if owner := _fetch_owner(alias):
company = owner[owner._mail_get_company_field()]
if company and company.alias_domain_id != alias.alias_domain_id and alias.alias_domain_id.company_ids:
raise ValidationError(_(
"We could not create alias %(alias_name)s because domain " +
"%(alias_domain_name)s belongs to company %(alias_company_names)s " +
"while the owner document belongs to company %(company_name)s.",
alias_company_names=','.join(alias.alias_domain_id.company_ids.mapped('name')),
alias_domain_name=alias.alias_domain_id.name,
alias_name=alias.display_name,
company_name=company.name,
))
if target := _fetch_target(alias):
company = target[target._mail_get_company_field()]
if company and company.alias_domain_id != alias.alias_domain_id and alias.alias_domain_id.company_ids:
raise ValidationError(_(
"We could not create alias %(alias_name)s because domain " +
"%(alias_domain_name)s belongs to company %(alias_company_names)s " +
"while the target document belongs to company %(company_name)s.",
alias_company_names=','.join(alias.alias_domain_id.company_ids.mapped('name')),
alias_domain_name=alias.alias_domain_id.name,
alias_name=alias.display_name,
company_name=company.name,
))
@api.constrains('alias_name')
def _check_alias_is_ascii(self):
@@ -115,12 +200,36 @@ class Alias(models.Model):
_('Invalid expression, it must be a literal python dictionary definition e.g. "{\'field\': \'value\'}"')
) from e
@api.constrains('alias_name', 'alias_domain_id')
def _check_alias_domain_clash(self):
""" Within a given alias domain, aliases should not conflict with bounce
or catchall email addresses, as emails should be unique for the gateway. """
failing = self.filtered(lambda alias: alias.alias_name and alias.alias_name in [
alias.alias_domain_id.bounce_alias, alias.alias_domain_id.catchall_alias
])
if failing:
raise ValidationError(
_('Aliases %(alias_names)s is already used as bounce or catchall address. Please choose another alias.',
alias_names=', '.join(failing.mapped('display_name')))
)
@api.depends('alias_domain_id.name', 'alias_name')
def _compute_alias_full_name(self):
""" A bit like display_name, but without the 'inactive alias' UI display.
Moreover it is stored, allowing to search on it. """
for record in self:
if record.alias_domain_id and record.alias_name:
record.alias_full_name = f"{record.alias_name}@{record.alias_domain_id.name}"
elif record.alias_name:
record.alias_full_name = record.alias_name
else:
record.alias_full_name = False
@api.depends('alias_domain', 'alias_name')
def _compute_display_name(self):
"""Return the mail alias display alias_name, including the implicit
mail catchall domain if exists from config otherwise "New Alias".
e.g. `jobs@mail.odoo.com` or `jobs` or 'New Alias'
"""
""" Return the mail alias display alias_name, including the catchall
domain if found otherwise "Inactive Alias". e.g.`jobs@mail.odoo.com`
or `jobs` or 'Inactive Alias' """
for record in self:
if record.alias_name and record.alias_domain:
record.display_name = f"{record.alias_name}@{record.alias_domain}"
@@ -129,10 +238,6 @@ class Alias(models.Model):
else:
record.display_name = _("Inactive Alias")
@api.depends('alias_name')
def _compute_alias_domain(self):
self.alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
@api.depends('alias_contact', 'alias_defaults', 'alias_model_id')
def _compute_alias_status(self):
"""Reset alias_status to "not_tested" when fields, that can be the source of an error, are modified."""
@@ -140,111 +245,122 @@ class Alias(models.Model):
@api.model_create_multi
def create(self, vals_list):
""" Creates email.alias records according to the values provided in
``vals`` with 1 alteration:
""" Creates mail.alias records according to the values provided in
``vals`` but sanitize 'alias_name' by replacing certain unsafe
characters; set default alias domain if not given.
* ``alias_name`` value may be cleaned by replacing certain unsafe
characters;
:raise UserError: if given alias_name is already assigned or there are
duplicates in given vals_list;
:raise UserError: if given (alias_name, alias_domain_id) already exists
or if there are duplicates in given vals_list;
"""
alias_names = [self._sanitize_alias_name(vals.get('alias_name')) for vals in vals_list]
self._check_unique(alias_names)
for vals, alias_name in zip(vals_list, alias_names):
vals['alias_name'] = alias_name
alias_names, alias_domains = [], []
for vals in vals_list:
vals['alias_name'] = self._sanitize_alias_name(vals.get('alias_name'))
alias_names.append(vals['alias_name'])
vals['alias_domain_id'] = vals.get('alias_domain_id', self.env.company.alias_domain_id.id)
alias_domains.append(self.env['mail.alias.domain'].browse(vals['alias_domain_id']))
self._check_unique(alias_names, alias_domains)
return super().create(vals_list)
def write(self, vals):
""" Raise UserError with a meaningfull message instead of letting the
unicity constraint give its error. """
""" Raise UserError with a meaningful message instead of letting the
uniqueness constraint raise an SQL error. To check uniqueness we have
to rebuild pairs of names / domains to validate, taking into account
that a void alias_domain_id is acceptable (but also raises for
uniqueness).
"""
alias_names, alias_domains = [], []
if 'alias_name' in vals:
vals['alias_name'] = self._sanitize_alias_name(vals['alias_name'])
if vals.get('alias_name') and self.ids:
self._check_unique([vals['alias_name']])
if len(self) > 1:
raise UserError(
_('Email alias %(alias_name)s cannot be used on %(count)d records at the same time. Please update records one by one.',
alias_name=vals['alias_name'], count=len(self))
)
alias_names = [vals['alias_name']] * len(self)
elif 'alias_name' not in vals and 'alias_domain_id' in vals:
# avoid checking when writing the same value
if [vals['alias_domain_id']] != self.alias_domain_id.ids:
alias_names = self.filtered('alias_name').mapped('alias_name')
if alias_names:
tocheck_records = self if vals.get('alias_name') else self.filtered('alias_name')
if 'alias_domain_id' in vals:
alias_domains = [self.env['mail.alias.domain'].browse(vals['alias_domain_id'])] * len(tocheck_records)
else:
alias_domains = [record.alias_domain_id for record in tocheck_records]
self._check_unique(alias_names, alias_domains)
return super().write(vals)
def _clean_and_check_mail_catchall_allowed_domains(self, value):
""" The purpose of this system parameter is to avoid the creation
of records from incoming emails with a domain != alias_domain
but that have a pattern matching an internal mail.alias . """
value = [domain.strip().lower() for domain in value.split(',') if domain.strip()]
if not value:
raise ValidationError(
_("Value for `mail.catchall.domain.allowed` cannot be validated.\n"
"It should be a comma separated list of domains e.g. example.com,example.org.")
)
return ",".join(value)
def _check_unique(self, sanitized_names, skip_icp_keys=None):
def _check_unique(self, alias_names, alias_domains):
""" Check unicity constraint won't be raised, otherwise raise a UserError
with a complete error message. Also check unicity against alias config
parameters.
:param list sanitized_names: a list of names (considered as sanitized
:param list alias_names: a list of names (considered as sanitized
and ready to be sent to DB);
:param list alias_domains: list of alias_domain records under which
the check is performed, as uniqueness is performed for given pair
(name, alias_domain);
"""
valid_names = list(filter(None, sanitized_names))
if len(alias_names) != len(alias_domains):
msg = (f"Invalid call to '_check_unique': names and domains should make coherent lists, "
f"received {', '.join(alias_names)} and {', '.join(alias_domains.mapped('name'))}")
raise ValueError(msg)
# list itself should be unique obviously
seen = set()
dupes = [name for name in valid_names if name in seen or seen.add(name)]
if dupes:
raise UserError(
_('Email aliases %(alias_name)s cannot be used on several records at the same time. Please update records one by one.',
alias_name=', '.join(dupes))
)
alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
icp_to_check = dict(
(icp_key, self.env["ir.config_parameter"].sudo().get_param(icp_key))
for icp_key in {'mail.bounce.alias', 'mail.catchall.alias'}
if icp_key not in (skip_icp_keys or ())
)
icp_label_by_key = {
'mail.bounce.alias': _('bounce'),
'mail.catchall.alias': _('catchall'),
}
# matches catchall or bounce alias
for sanitized_name in valid_names:
for icp_key, icp_value in icp_to_check.items():
if icp_value and sanitized_name == icp_value:
matching_alias_name = f'{sanitized_name}@{alias_domain}' if alias_domain else sanitized_name
raise UserError(
_('The e-mail alias %(matching_alias_name)s is already used as %(alias_duplicate)s alias. Please choose another alias.',
matching_alias_name=matching_alias_name,
alias_duplicate=icp_label_by_key[icp_key])
)
# reorder per alias domain, keep only not void alias names (void domain also checks uniqueness)
domain_to_names = defaultdict(list)
for alias_name, alias_domain in zip(alias_names, alias_domains):
if alias_name and alias_name in domain_to_names[alias_domain]:
raise UserError(
_('Email aliases %(alias_name)s cannot be used on several records at the same time. Please update records one by one.',
alias_name=alias_name)
)
if alias_name:
domain_to_names[alias_domain].append(alias_name)
# matches existing alias
matching_alias = self.env['mail.alias']
if valid_names:
domain = [('alias_name', 'in', valid_names)]
if self:
domain += [('id', 'not in', self.ids)]
matching_alias = self.search(domain, limit=1)
if not matching_alias:
domain = expression.OR([
['&', ('alias_name', 'in', alias_names), ('alias_domain_id', '=', alias_domain.id)]
for alias_domain, alias_names in domain_to_names.items()
])
if domain and self:
domain = expression.AND([domain, [('id', 'not in', self.ids)]])
existing = self.search(domain, limit=1) if domain else self.env['mail.alias']
if not existing:
return
if matching_alias.alias_parent_model_id and matching_alias.alias_parent_thread_id:
# If parent model and parent thread ID both are set, display document name also in the warning
document_name = self.env[matching_alias.alias_parent_model_id.model].sudo().browse(matching_alias.alias_parent_thread_id).display_name
raise UserError(
_('The e-mail alias %(matching_alias_name)s is already used by the %(document_name)s %(model_name)s. Choose another alias or change it on the other document.',
matching_alias_name=matching_alias.display_name,
document_name=document_name,
model_name=matching_alias.alias_parent_model_id.name)
)
raise UserError(
_('The e-mail alias %(matching_alias_name)s is already linked with %(alias_model_name)s. Choose another alias or change it on the linked model.',
matching_alias_name=matching_alias.display_name,
alias_model_name=matching_alias.alias_model_id.name)
)
if existing.alias_parent_model_id and existing.alias_parent_thread_id:
parent_name = self.env[existing.alias_parent_model_id.model].sudo().browse(existing.alias_parent_thread_id).display_name
msg_begin = _(
'Alias %(matching_name)s (%(current_id)s) is already linked with %(alias_model_name)s (%(matching_id)s) and used by the %(parent_name)s %(parent_model_name)s.',
alias_model_name=existing.alias_model_id.name,
current_id=self.ids if self else _('your alias'),
matching_id=existing.id,
matching_name=existing.display_name,
parent_name=parent_name,
parent_model_name=existing.alias_parent_model_id.name
)
else:
msg_begin = _(
'Alias %(matching_name)s (%(current_id)s) is already linked with %(alias_model_name)s (%(matching_id)s).',
alias_model_name=existing.alias_model_id.name,
current_id=self.ids if self else _('new'),
matching_id=existing.id,
matching_name=existing.display_name,
)
msg_end = _('Choose another value or change it on the other document.')
raise UserError(f'{msg_begin} {msg_end}')
@api.model
def _sanitize_allowed_domains(self, allowed_domains):
""" When having aliases checked on email left-part only we may define
an allowed list for right-part filtering, allowing more fine-grain than
either alias domain, either everything. This method sanitized its value. """
value = [domain.strip().lower() for domain in allowed_domains.split(',') if domain.strip()]
if not value:
raise ValidationError(_(
"Value %(allowed_domains)s for `mail.catchall.domain.allowed` cannot be validated.\n"
"It should be a comma separated list of domains e.g. example.com,example.org.",
allowed_domains=allowed_domains
))
return ",".join(value)
@api.model
def _sanitize_alias_name(self, name):
+154
View File
@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, exceptions, fields, models, _
from odoo.addons.mail.models.mail_alias import dot_atom_text
class AliasDomain(models.Model):
""" Model alias domains, now company-specific. Alias domains are email
domains used to receive emails through catchall and bounce aliases, as
well as using mail.alias records to redirect email replies.
This replaces ``mail.alias.domain`` configuration parameter use until v16.
"""
_name = 'mail.alias.domain'
_description = "Email Domain"
_order = 'sequence ASC, id ASC'
name = fields.Char('Name', required=True)
company_ids = fields.One2many(
'res.company', 'alias_domain_id', string='Companies')
sequence = fields.Integer(default=10)
bounce_alias = fields.Char('Bounce Alias', default='bounce', required=True)
bounce_email = fields.Char('Bounce Email', compute='_compute_bounce_email')
catchall_alias = fields.Char('Catchall Alias', default='catchall', required=True)
catchall_email = fields.Char('Catchall Email', compute='_compute_catchall_email')
default_from = fields.Char('Default From Alias', default='notifications')
default_from_email = fields.Char('Default From', compute='_compute_default_from_email')
_sql_constraints = [
(
'bounce_email_uniques',
'UNIQUE(bounce_alias, name)',
'Bounce emails should be unique'
),
(
'catchall_email_uniques',
'UNIQUE(catchall_alias, name)',
'Catchall emails should be unique'
),
]
@api.depends('bounce_alias', 'name')
def _compute_bounce_email(self):
self.bounce_email = ''
for domain in self.filtered('bounce_alias'):
domain.bounce_email = f'{domain.bounce_alias}@{domain.name}'
@api.depends('catchall_alias', 'name')
def _compute_catchall_email(self):
self.catchall_email = ''
for domain in self.filtered('catchall_alias'):
domain.catchall_email = f'{domain.catchall_alias}@{domain.name}'
@api.depends('default_from', 'name')
def _compute_default_from_email(self):
self.default_from_email = ''
for domain in self.filtered('default_from'):
domain.default_from_email = f'{domain.default_from}@{domain.name}'
@api.constrains('bounce_alias', 'catchall_alias')
def _check_bounce_catchall_uniqueness(self):
names = self.filtered('bounce_alias').mapped('bounce_alias') + self.filtered('catchall_alias').mapped('catchall_alias')
if not names:
return
similar_domains = self.env['mail.alias.domain'].search([('name', 'in', self.mapped('name'))])
for tocheck in self:
if any(similar.bounce_alias == tocheck.bounce_alias
for similar in similar_domains if similar != tocheck and similar.name == tocheck.name):
raise exceptions.ValidationError(
_('Bounce alias %(bounce)s is already used for another domain with same name. '
'Use another bounce or simply use the other alias domain.',
bounce=tocheck.bounce_email)
)
if any(similar.catchall_alias == tocheck.catchall_alias
for similar in similar_domains if similar != tocheck and similar.name == tocheck.name):
raise exceptions.ValidationError(
_('Catchall alias %(catchall)s is already used for another domain with same name. '
'Use another catchall or simply use the other alias domain.',
catchall=tocheck.catchall_email)
)
# search on left-part only to speedup, then filter on right part
potential_aliases = self.env['mail.alias'].search([
('alias_name', 'in', list(set(names))),
('alias_domain_id', '!=', False)
])
existing = next(
(alias for alias in potential_aliases
if alias.display_name in (self.mapped('bounce_email') + self.mapped('catchall_email'))),
self.env['mail.alias']
)
if existing:
document_name = False
# If owner or target: display document name also in the warning
if existing.alias_parent_model_id and existing.alias_parent_thread_id:
document_name = self.env[existing.alias_parent_model_id.model].sudo().browse(existing.alias_parent_thread_id).display_name
elif existing.alias_model_id and existing.alias_force_thread_id:
document_name = self.env[existing.alias_model_id.model].sudo().browse(existing.alias_force_thread_id).display_name
if document_name:
raise exceptions.ValidationError(
_("Bounce/Catchall '%(matching_alias_name)s' is already used by %(document_name)s. Choose another alias or change it on the other document.",
matching_alias_name=existing.display_name,
document_name=document_name)
)
raise exceptions.ValidationError(
_("Bounce/Catchall '%(matching_alias_name)s' is already used. Choose another alias or change it on the linked model.",
matching_alias_name=existing.display_name)
)
@api.constrains('name')
def _check_name(self):
""" Should match a sanitized version of itself, otherwise raise to warn
user (do not dynamically change it, would be confusing). """
for domain in self:
if not dot_atom_text.match(domain.name):
raise exceptions.ValidationError(
_("You cannot use anything else than unaccented latin characters in the domain name %(domain_name)s.",
domain_name=domain.name)
)
@api.model_create_multi
def create(self, vals_list):
""" Sanitize bounce_alias / catchall_alias """
for vals in vals_list:
if vals.get('bounce_alias'):
vals['bounce_alias'] = self.env['mail.alias']._sanitize_alias_name(vals['bounce_alias'])
if vals.get('catchall_alias'):
vals['catchall_alias'] = self.env['mail.alias']._sanitize_alias_name(vals['catchall_alias'])
if vals.get('default_from'):
vals['default_from'] = self.env['mail.alias']._sanitize_alias_name(vals['default_from'])
alias_domains = super().create(vals_list)
# alias domain init: populate companies and aliases at first creation
if alias_domains and self.search_count([]) == len(alias_domains):
self.env['res.company'].search(
[('alias_domain_id', '=', False)]
).alias_domain_id = alias_domains[0].id
self.env['mail.alias'].sudo().search(
[('alias_domain_id', '=', False)]
).alias_domain_id = alias_domains[0].id
return alias_domains
def write(self, vals):
""" Sanitize bounce_alias / catchall_alias """
if vals.get('bounce_alias'):
vals['bounce_alias'] = self.env['mail.alias']._sanitize_alias_name(vals['bounce_alias'])
if vals.get('catchall_alias'):
vals['catchall_alias'] = self.env['mail.alias']._sanitize_alias_name(vals['catchall_alias'])
if vals.get('default_from'):
vals['default_from'] = self.env['mail.alias']._sanitize_alias_name(vals['default_from'])
return super().write(vals)
+62 -13
View File
@@ -16,17 +16,16 @@ class AliasMixinOptional(models.AbstractModel):
"""
_name = 'mail.alias.mixin.optional'
_description = 'Email Aliases Mixin (light)'
ALIAS_WRITEABLE_FIELDS = ['alias_name', 'alias_contact', 'alias_defaults', 'alias_bounced_content']
ALIAS_WRITEABLE_FIELDS = ['alias_domain_id', 'alias_name', 'alias_contact', 'alias_defaults', 'alias_bounced_content']
alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=False)
alias_name = fields.Char(related='alias_id.alias_name', readonly=False)
alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain')
alias_domain_id = fields.Many2one(
'mail.alias.domain', string='Alias Domain',
related='alias_id.alias_domain_id', readonly=False)
alias_domain = fields.Char('Alias Domain Name', related='alias_id.alias_domain')
alias_defaults = fields.Text(related='alias_id.alias_defaults')
alias_email = fields.Char('Email Alias', compute='_compute_alias_email')
@api.depends('alias_name')
def _compute_alias_domain(self):
self.alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
alias_email = fields.Char('Email Alias', compute='_compute_alias_email', search='_search_alias_email')
@api.depends('alias_domain', 'alias_name')
def _compute_alias_email(self):
@@ -37,6 +36,9 @@ class AliasMixinOptional(models.AbstractModel):
for record in self.filtered(lambda rec: rec.alias_name and rec.alias_domain):
record.alias_email = f"{record.alias_name}@{record.alias_domain}"
def _search_alias_email(self, operator, operand):
return [('alias_id.alias_full_name', operator, operand)]
# --------------------------------------------------
# CRUD
# --------------------------------------------------
@@ -45,15 +47,29 @@ class AliasMixinOptional(models.AbstractModel):
def create(self, vals_list):
""" Create aliases using sudo if an alias is required, notably if its
name is given. """
# prefetch company information, used for alias domain
company_fname = self._mail_get_company_field()
if company_fname:
company_id_default = self.default_get([company_fname]).get(company_fname) or self.env.company.id
company_prefetch_ids = {vals[company_fname] for vals in vals_list if vals.get(company_fname)}
company_prefetch_ids.add(company_id_default)
else:
company_id_default = self.env.company.id
company_prefetch_ids = {company_id_default}
# prepare all alias values
alias_vals_list, record_vals_list = [], []
for vals in vals_list:
if vals.get('alias_name'):
vals['alias_name'] = self.env['mail.alias']._sanitize_alias_name(vals['alias_name'])
if self._require_new_alias(vals):
company_id = vals.get(company_fname) or company_id_default
company = self.env['res.company'].with_prefetch(company_prefetch_ids).browse(company_id)
alias_vals, record_vals = self._alias_filter_fields(vals)
# generate record-agnostic base alias values
alias_vals.update(self.env[self._name]._alias_get_creation_values())
alias_vals.update(self.env[self._name].with_context(
default_alias_domain_id=company.alias_domain_id.id,
)._alias_get_creation_values())
alias_vals_list.append(alias_vals)
record_vals_list.append(record_vals)
@@ -75,15 +91,20 @@ class AliasMixinOptional(models.AbstractModel):
records = super().create(valid_vals_list)
for record in records.filtered('alias_id'):
record.alias_id.sudo().write(record._alias_get_creation_values())
# update alias values with values coming from record, post-create to have
# access to all its values (notably its ID)
records_walias = records.filtered('alias_id')
for record in records_walias:
alias_values = record._alias_get_creation_values()
record.alias_id.sudo().write(alias_values)
return records
def write(self, vals):
""" Split writable fields of mail.alias and other fields alias fields will
write with sudo and the other normally. If alias does not exist and we
try to set a name, create the alias automatically. """
write with sudo and the other normally. Also handle alias_domain_id
update. If alias does not exist and we try to set a name, create the
alias automatically. """
# create missing aliases
if vals.get('alias_name'):
alias_create_values = [
@@ -101,6 +122,14 @@ class AliasMixinOptional(models.AbstractModel):
alias_vals, record_vals = self._alias_filter_fields(vals, filters=self.ALIAS_WRITEABLE_FIELDS)
if record_vals:
super().write(record_vals)
# synchronize alias domain if company environment changed
company_fname = self._mail_get_company_field()
if company_fname in vals:
alias_domain_values = self.filtered('alias_id')._alias_get_alias_domain_id()
for record, alias_domain_id in alias_domain_values.items():
record.sudo().alias_domain_id = alias_domain_id.id
if alias_vals and (record_vals or self.check_access_rights('write', raise_exception=False)):
self.mapped('alias_id').sudo().write(alias_vals)
@@ -131,14 +160,34 @@ class AliasMixinOptional(models.AbstractModel):
# MIXIN TOOL OVERRIDE METHODS
# --------------------------------------------------
def _alias_get_alias_domain_id(self):
""" Return alias domain value to synchronize with owner's company.
Implementing it with a compute is complicated, as its 'alias_domain_id'
is a field on 'mail.alias' model, coming from 'alias_id' field and due
to current implementation of the mixin, notably the create / write
overrides, compute is not called in all cases. We therefore use a tool
method to call in the mixin. """
alias_domain_values = {}
record_companies = self._mail_get_companies()
for record in self:
record_company = record_companies[record.id]
alias_domain_values[record] = (
record_company.alias_domain_id
or record.alias_domain_id or self.env.company.alias_domain_id
)
return alias_domain_values
def _alias_get_creation_values(self):
""" Return values to create an alias, or to write on the alias after its
creation.
"""
return {
values = {
'alias_parent_thread_id': self.id if self.id else False,
'alias_parent_model_id': self.env['ir.model']._get_id(self._name),
}
if self.env.context.get('default_alias_domain_id'):
values['alias_domain_id'] = self.env.context['default_alias_domain_id']
return values
def _alias_filter_fields(self, values, filters=False):
""" Split the vals dict into two dictionnary of vals, one for alias
+39 -22
View File
@@ -362,14 +362,9 @@ class MailMail(models.Model):
# headers
headers = {}
ICP = self.env['ir.config_parameter'].sudo()
bounce_alias = ICP.get_param("mail.bounce.alias")
catchall_domain = ICP.get_param("mail.catchall.domain")
if bounce_alias and catchall_domain:
headers['Return-Path'] = f'{bounce_alias}@{catchall_domain}'
if self.headers:
try:
headers.update(ast.literal_eval(self.headers))
headers = ast.literal_eval(self.headers)
except (ValueError, TypeError) as e:
_logger.warning(
'Evaluation error when evaluating mail headers (received %r): %s',
@@ -382,6 +377,7 @@ class MailMail(models.Model):
'Unknown error when evaluating mail headers (received %r): %s',
self.headers, e,
)
headers.setdefault('Return-Path', self.record_alias_domain_id.bounce_email or self.env.company.bounce_email)
# prepare recipients: use email_to if defined then check recipient_ids
# that receive a specific email, notably due to link shortening / redirect
@@ -471,10 +467,11 @@ class MailMail(models.Model):
return results
def _split_by_mail_configuration(self):
"""Group the <mail.mail> based on their "email_from" and their "mail_server_id".
"""Group the <mail.mail> based on their "email_from", their "alias domain"
and their "mail_server_id".
The <mail.mail> will have the "same sending configuration" if they have the same
mail server or the same mail from. For performance purpose, we can use an SMTP
mail server, alias domain and mail from. For performance purpose, we can use an SMTP
session in batch and therefore we need to group them by the parameter that will
influence the mail server used.
@@ -484,33 +481,42 @@ class MailMail(models.Model):
Return iterators over
mail_server_id, email_from, Records<mail.mail>.ids
"""
mail_values = self.read(['id', 'email_from', 'mail_server_id'])
mail_values = self.read(['id', 'email_from', 'mail_server_id', 'record_alias_domain_id'])
# First group the <mail.mail> per mail_server_id and per email_from
# First group the <mail.mail> per mail_server_id, per alias_domain (if no server) and per email_from
group_per_email_from = defaultdict(list)
for values in mail_values:
mail_server_id = values['mail_server_id'][0] if values['mail_server_id'] else False
group_per_email_from[(mail_server_id, values['email_from'])].append(values['id'])
alias_domain_id = values['record_alias_domain_id'][0] if values['record_alias_domain_id'] else False
key = (mail_server_id, alias_domain_id, values['email_from'])
group_per_email_from[key].append(values['id'])
# Then find the mail server for each email_from and group the <mail.mail>
# per mail_server_id and smtp_from
mail_servers = self.env['ir.mail_server'].sudo().search([], order='sequence')
mail_servers = self.env['ir.mail_server'].sudo().search([], order='sequence, id')
group_per_smtp_from = defaultdict(list)
for (mail_server_id, email_from), mail_ids in group_per_email_from.items():
for (mail_server_id, alias_domain_id, email_from), mail_ids in group_per_email_from.items():
if not mail_server_id:
mail_server, smtp_from = self.env['ir.mail_server']._find_mail_server(email_from, mail_servers)
mail_server = self.env['ir.mail_server']
if alias_domain_id:
alias_domain = self.env['mail.alias.domain'].sudo().browse(alias_domain_id)
mail_server = mail_server.with_context(
domain_notifications_email=alias_domain.default_from_email,
domain_bounce_address=alias_domain.bounce_email,
)
mail_server, smtp_from = mail_server._find_mail_server(email_from, mail_servers)
mail_server_id = mail_server.id if mail_server else False
else:
smtp_from = email_from
group_per_smtp_from[(mail_server_id, smtp_from)].extend(mail_ids)
group_per_smtp_from[(mail_server_id, alias_domain_id, smtp_from)].extend(mail_ids)
sys_params = self.env['ir.config_parameter'].sudo()
batch_size = int(sys_params.get_param('mail.session.batch.size', 1000))
for (mail_server_id, smtp_from), record_ids in group_per_smtp_from.items():
for (mail_server_id, alias_domain_id, smtp_from), record_ids in group_per_smtp_from.items():
for batch_ids in tools.split_every(batch_size, record_ids):
yield mail_server_id, smtp_from, batch_ids
yield mail_server_id, alias_domain_id, smtp_from, batch_ids
def send(self, auto_commit=False, raise_exception=False):
""" Sends the selected emails immediately, ignoring their current
@@ -527,7 +533,7 @@ class MailMail(models.Model):
email sending process has failed
:return: True
"""
for mail_server_id, smtp_from, batch_ids in self._split_by_mail_configuration():
for mail_server_id, alias_domain_id, smtp_from, batch_ids in self._split_by_mail_configuration():
smtp_session = None
try:
smtp_session = self.env['ir.mail_server'].connect(mail_server_id=mail_server_id, smtp_from=smtp_from)
@@ -544,7 +550,9 @@ class MailMail(models.Model):
self.browse(batch_ids)._send(
auto_commit=auto_commit,
raise_exception=raise_exception,
smtp_session=smtp_session)
smtp_session=smtp_session,
alias_domain_id=alias_domain_id,
)
_logger.info(
'Sent batch %s emails via mail server ID #%s',
len(batch_ids), mail_server_id)
@@ -552,7 +560,7 @@ class MailMail(models.Model):
if smtp_session:
smtp_session.quit()
def _send(self, auto_commit=False, raise_exception=False, smtp_session=None):
def _send(self, auto_commit=False, raise_exception=False, smtp_session=None, alias_domain_id=False):
IrMailServer = self.env['ir.mail_server']
# Only retrieve recipient followers of the mails if needed
mails_with_unfollow_link = self.filtered(lambda m: m.body_html and '/mail/unfollow' in m.body_html)
@@ -610,7 +618,16 @@ class MailMail(models.Model):
# send each sub-email
for email in email_list:
msg = IrMailServer.build_email(
# if given, contextualize sending using alias domains
if alias_domain_id:
alias_domain = self.env['mail.alias.domain'].sudo().browse(alias_domain_id)
SendIrMailServer = IrMailServer.with_context(
domain_notifications_email=alias_domain.default_from_email,
domain_bounce_address=email['headers'].get('Return-Path') or alias_domain.bounce_email,
)
else:
SendIrMailServer = IrMailServer
msg = SendIrMailServer.build_email(
email_from=email_from,
email_to=email['email_to'],
subject=email['subject'],
@@ -628,7 +645,7 @@ class MailMail(models.Model):
)
processing_pid = email.pop("partner_id", None)
try:
res = IrMailServer.send_email(
res = SendIrMailServer.send_email(
msg, mail_server_id=mail.mail_server_id.id, smtp_session=smtp_session)
if processing_pid:
success_pids.append(processing_pid)
+9 -3
View File
@@ -93,6 +93,12 @@ class Message(models.Model):
preview = fields.Char(
'Preview', compute='_compute_preview',
help='The text-only beginning of the body used as email preview.')
link_preview_ids = fields.One2many(
'mail.link.preview', 'message_id', string='Link Previews',
groups="base.group_erp_manager")
reaction_ids = fields.One2many(
'mail.message.reaction', 'message_id', string="Reactions",
groups="base.group_system")
# Attachments are linked to a document through model / res_id and to the message through this field.
attachment_ids = fields.Many2many(
'ir.attachment', 'message_attachment_rel',
@@ -105,7 +111,8 @@ class Message(models.Model):
model = fields.Char('Related Document Model')
res_id = fields.Many2oneReference('Related Document ID', model_field='model')
record_name = fields.Char('Message Record Name') # display_name of the related document
link_preview_ids = fields.One2many('mail.link.preview', 'message_id', string='Link Previews', groups="base.group_erp_manager")
record_alias_domain_id = fields.Many2one('mail.alias.domain', 'Alias Domain', ondelete='set null')
record_company_id = fields.Many2one('res.company', 'Company', ondelete='set null')
# characteristics
message_type = fields.Selection([
('email', 'Email'),
@@ -167,7 +174,7 @@ class Message(models.Model):
message_id = fields.Char('Message-Id', help='Message unique identifier', index='btree', readonly=True, copy=False)
reply_to = fields.Char('Reply-To', help='Reply email address. Setting the reply_to bypasses the automatic thread creation.')
mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server')
# keep notification layout informations to be able to generate mail again
# send notification information (for resend / reschedule)
email_layout_xmlid = fields.Char('Layout', copy=False) # xml id of layout
email_add_signature = fields.Boolean(default=True)
# `test_adv_activity`, `test_adv_activity_full`, `test_message_assignation_inbox`,...
@@ -178,7 +185,6 @@ class Message(models.Model):
# as the cache value for this inverse one2many is up-to-date.
# Besides for new messages, and messages never sending emails, there was no mail, and it was searching for nothing.
mail_ids = fields.One2many('mail.mail', 'mail_message_id', string='Mails', groups="base.group_system")
reaction_ids = fields.One2many('mail.message.reaction', 'message_id', string="Reactions", groups="base.group_system")
@api.depends('body', 'subject')
def _compute_description(self):
+178 -113
View File
@@ -345,22 +345,23 @@ class MailThread(models.AbstractModel):
that adds alias information. """
model = self._context.get('empty_list_help_model')
res_id = self._context.get('empty_list_help_id')
catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
document_name = self._context.get('empty_list_help_document_name', _('document'))
nothing_here = is_html_empty(help_message)
alias = None
if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
# specific res_id -> find its alias (i.e. section_id specified)
if model and res_id:
record = self.env[model].sudo().browse(res_id)
# check that the alias effectively creates new records
if record.alias_id and record.alias_id.alias_name and \
record.alias_id.alias_model_id and \
record.alias_id.alias_model_id.model == self._name and \
record.alias_id.alias_force_thread_id == 0:
if ('alias_id' in record and record.alias_id and
record.alias_id.alias_name and record.alias_id.alias_domain and
record.alias_id.alias_model_id.model == self._name and
record.alias_id.alias_force_thread_id == 0):
alias = record.alias_id
if not alias and catchall_domain and model: # no res_id or res_id not linked to an alias -> generic help message, take a generic alias of the model
Alias = self.env['mail.alias']
aliases = Alias.search([
# no res_id or res_id not linked to an alias -> generic help message, take a generic alias of the model
if not alias and model and self.env.company.alias_domain_id:
aliases = self.env['mail.alias'].search([
("alias_domain_id", "=", self.env.company.alias_domain_id.id),
("alias_parent_model_id.model", "=", model),
("alias_name", "!=", False),
('alias_force_thread_id', '=', False),
@@ -695,13 +696,19 @@ class MailThread(models.AbstractModel):
'email_to': bounce_to,
'auto_delete': True,
}
bounce_from = tools.email_normalize(self.env['ir.mail_server']._get_default_bounce_address() or '')
if bounce_from:
bounce_mail_values['email_from'] = tools.formataddr(('MAILER-DAEMON', bounce_from))
elif self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias") not in message['To']:
bounce_mail_values['email_from'] = tools.decode_message_header(message, 'To')
else:
bounce_mail_values['email_from'] = tools.formataddr(('MAILER-DAEMON', self.env.user.email_normalized))
# find an email_from for the bounce email
email_from = False
if bounce_from := self.env.company.bounce_email:
email_from = tools.formataddr(('MAILER-DAEMON', bounce_from))
if not email_from:
catchall_aliases = self.env['mail.alias.domain'].search([]).mapped('catchall_email')
if not any(catchall_email in message['To'] for catchall_email in catchall_aliases):
email_from = tools.decode_message_header(message, 'To')
if not email_from:
email_from = tools.formataddr(('MAILER-DAEMON', self.env.user.email_normalized))
bounce_mail_values['email_from'] = email_from
bounce_mail_values.update(mail_values)
self.env['mail.mail'].sudo().create(bounce_mail_values).send()
@@ -874,6 +881,39 @@ class MailThread(models.AbstractModel):
for model in [bl_model for bl_model in bl_models if bl_model.model in self.env]: # transient test mode
self.env[model.model].sudo().search([('message_bounce', '>', 0), ('email_normalized', '=', valid_email)])._message_reset_bounce(valid_email)
@api.model
def _detect_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_aliases = self.env['mail.alias.domain'].search([]).mapped('bounce_email')
email_to_list = [
tools.email_normalize(e) or e
for e in (tools.email_split(message_dict['to']) or [''])
]
if bounce_aliases and any(email in bounce_aliases for email in email_to_list):
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 _detect_loop_sender_domain(self, email_from_normalized):
"""Return the domain to be used to detect duplicated records created by alias.
@@ -900,7 +940,8 @@ class MailThread(models.AbstractModel):
email_from_normalized = tools.email_normalize(email_from)
if self.env['mail.gateway.allowed'].sudo().search_count(
[('email_normalized', '=', email_from_normalized)]):
[('email_normalized', '=', email_from_normalized)]
):
return False
# Detect the email address sent to many emails
@@ -963,6 +1004,20 @@ class MailThread(models.AbstractModel):
return False
@api.model
def _detect_write_to_catchall(self, msg_dict):
"""Return True if directly contacts catchall."""
catchall_aliases = self.env['mail.alias.domain'].search([]).mapped('catchall_email')
email_to_list = [
tools.email_normalize(e) or e
for e in (tools.email_split(msg_dict['to']) or [''])
]
# check it does not directly contact catchall
return (
catchall_aliases and email_to_list and
all(email_to in catchall_aliases for email_to in email_to_list)
)
@api.model
def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None):
""" Attempt to figure out the correct target model, thread_id,
@@ -1000,13 +1055,28 @@ 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")
catchall_domain_lowered = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain", "").strip().lower()
catchall_domains_allowed = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain.allowed")
if catchall_domain_lowered and catchall_domains_allowed:
catchall_domains_allowed = catchall_domains_allowed.split(',') + [catchall_domain_lowered]
catchall_domains_allowed = list(filter(None, (self.env["ir.config_parameter"].sudo().get_param(
"mail.catchall.domain.allowed") or '').split(',')))
if catchall_domains_allowed:
catchall_domains_allowed += self.env['mail.alias.domain'].search([]).mapped('name')
def _filter_excluded_local_part(email):
left, _at, domain = email.partition('@')
if not domain:
return False
if catchall_domains_allowed and domain not in catchall_domains_allowed:
return False
return left
fallback_model = model
# 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)
# get email.message.Message variables for future processing
message_id = message_dict['message_id']
@@ -1017,31 +1087,20 @@ class MailThread(models.AbstractModel):
for ref in tools.mail_header_msgid_re.findall(thread_references)
if 'reply_to' not in ref
]
mail_messages = self.env['mail.message'].sudo().search([('message_id', 'in', msg_references)], limit=1, order='id desc, message_id')
is_a_reply = bool(mail_messages)
reply_model, reply_thread_id = mail_messages.model, mail_messages.res_id
replying_to_msg = self.env['mail.message'].sudo().search(
[('message_id', 'in', msg_references)], limit=1, order='id desc, message_id'
) if msg_references else self.env['mail.message']
is_a_reply, reply_model, reply_thread_id = bool(replying_to_msg), replying_to_msg.model, replying_to_msg.res_id
# author and recipients
email_from = message_dict['email_from']
email_to = message_dict['to']
email_to_localparts = [
e.split('@', 1)[0].lower()
for e in (tools.email_split(email_to) or [''])
]
email_to_list = [e.lower() for e in (tools.email_split(message_dict['to']) or [''])]
email_to_localparts = list(filter(None, (_filter_excluded_local_part(email_to) for email_to in email_to_list)))
# Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
# for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
rcpt_tos_localparts = []
for recipient in tools.email_split(message_dict['recipients']):
to_local, to_domain = recipient.split('@', maxsplit=1)
if not catchall_domains_allowed or to_domain.lower() in catchall_domains_allowed:
rcpt_tos_localparts.append(to_local.lower())
rcpt_tos_valid_localparts = [to for to in rcpt_tos_localparts]
# 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)
rcpt_tos_list = [e.lower() for e in (tools.email_split(message_dict['recipients']) or [''])]
rcpt_tos_localparts = list(filter(None, (_filter_excluded_local_part(email_to) for email_to in rcpt_tos_list)))
rcpt_tos_valid_list = list(rcpt_tos_list)
# 1. Handle reply
# if destination = alias with different model -> consider it is a forward and not a reply
@@ -1049,20 +1108,32 @@ class MailThread(models.AbstractModel):
if reply_model and reply_thread_id:
reply_model_id = self.env['ir.model']._get_id(reply_model)
other_model_aliases = self.env['mail.alias'].search([
'&', '&',
('alias_name', '!=', False),
('alias_name', 'in', email_to_localparts),
'&',
('alias_model_id', '!=', reply_model_id),
'|',
('alias_full_name', 'in', email_to_list),
'&', ('alias_name', 'in', email_to_localparts), ('alias_incoming_local', '=', True),
])
if other_model_aliases:
is_a_reply = False
rcpt_tos_valid_localparts = [to for to in rcpt_tos_valid_localparts if to in other_model_aliases.mapped('alias_name')]
is_a_reply, reply_model, reply_thread_id = False, False, False
rcpt_tos_valid_list = [
to
for to in rcpt_tos_valid_list
if (
to in other_model_aliases.mapped('alias_full_name')
or to.split('@', 1)[0] in other_model_aliases.filtered('alias_incoming_local').mapped('alias_name')
)
]
rcpt_tos_valid_localparts = list(filter(None, (_filter_excluded_local_part(email_to) for email_to in rcpt_tos_valid_list)))
if is_a_reply and reply_model:
reply_model_id = self.env['ir.model']._get_id(reply_model)
dest_aliases = self.env['mail.alias'].search([
('alias_name', 'in', rcpt_tos_localparts),
('alias_model_id', '=', reply_model_id)
'&',
('alias_model_id', '=', reply_model_id),
'|',
('alias_full_name', 'in', rcpt_tos_list),
'&', ('alias_name', 'in', rcpt_tos_localparts), ('alias_incoming_local', '=', True),
], limit=1)
user_id = self._mail_find_user_for_gateway(email_from, alias=dest_aliases).id or self._uid
@@ -1073,26 +1144,31 @@ class MailThread(models.AbstractModel):
if route:
_logger.info(
'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
email_from, email_to, message_id, reply_model, reply_thread_id, custom_values, self._uid)
email_from, message_dict['to'], message_id, reply_model, reply_thread_id, custom_values, self._uid)
return [route]
elif route is False:
if route is False:
return []
# 2. Handle new incoming email by checking aliases and applying their settings
if rcpt_tos_localparts:
if rcpt_tos_list:
# no route found for a matching reference (or reply), so parent is invalid
message_dict.pop('parent_id', None)
# check it does not directly contact catchall
if catchall_alias and email_to_localparts and all(email_localpart == catchall_alias for email_localpart in email_to_localparts):
_logger.info('Routing mail from %s to %s with Message-Id %s: direct write to catchall, bounce', email_from, email_to, message_id)
if self._detect_write_to_catchall(message_dict):
_logger.info('Routing mail from %s to %s with Message-Id %s: direct write to catchall, bounce',
email_from, message_dict['to'], message_id)
body = self.env['ir.qweb']._render('mail.mail_bounce_catchall', {
'message': message,
})
self._routing_create_bounce_email(email_from, body, message, references=message_id, reply_to=self.env.company.email)
return []
dest_aliases = self.env['mail.alias'].search([('alias_name', 'in', rcpt_tos_valid_localparts)])
dest_aliases = self.env['mail.alias'].search([
'|',
('alias_full_name', 'in', rcpt_tos_valid_list),
'&', ('alias_name', 'in', rcpt_tos_valid_localparts), ('alias_incoming_local', '=', True),
])
if dest_aliases:
routes = []
for alias in dest_aliases:
@@ -1102,7 +1178,7 @@ class MailThread(models.AbstractModel):
if route:
_logger.info(
'Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
email_from, email_to, message_id, route)
email_from, message_dict['to'], message_id, route)
routes.append(route)
return routes
@@ -1118,14 +1194,14 @@ class MailThread(models.AbstractModel):
if route:
_logger.info(
'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
email_from, email_to, message_id, fallback_model, thread_id, custom_values, user_id)
email_from, message_dict['to'], message_id, fallback_model, thread_id, custom_values, user_id)
return [route]
# ValueError if no routes found and if no bounce occurred
raise ValueError(
'No possible route found for incoming message from %s to %s (Message-Id %s:). '
'Create an appropriate mail.alias or force the destination model.' %
(email_from, email_to, message_id)
(email_from, message_dict['to'], message_id)
)
@api.model
@@ -1344,7 +1420,10 @@ class MailThread(models.AbstractModel):
extracted from the email. Note that this processing is specific to the
mail module, and should not contain security or generic html cleaning.
Indeed those aspects should be covered by the html_sanitize method
located in tools. """
located in tools.
:param string message: an email.message instance
"""
body, attachments = payload_dict['body'], payload_dict['attachments']
if not body.strip():
return {'body': body, 'attachments': attachments}
@@ -1376,7 +1455,10 @@ class MailThread(models.AbstractModel):
return {'body': body, 'attachments': attachments}
def _message_parse_extract_payload(self, message, message_dict, save_original=False):
"""Extract body as HTML and attachments from the mail message"""
"""Extract body as HTML and attachments from the mail message
:param string message: an email.message instance
"""
attachments = []
body = ''
if save_original:
@@ -1467,6 +1549,7 @@ class MailThread(models.AbstractModel):
:return dict: bounce-related values will be added, containing
* is_bounce: whether the email is recognized as a bounce email;
* bounced_email: email that bounced (normalized);
* bounce_partner: res.partner recordset whose email_normalized =
bounced_email;
@@ -1477,6 +1560,10 @@ class MailThread(models.AbstractModel):
if not isinstance(email_message, EmailMessage):
raise TypeError('message must be an email.message.EmailMessage at this point')
is_bounce = self._detect_is_bounce(email_message, message_dict)
if not is_bounce:
return {'is_bounce': False}
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"
@@ -1520,41 +1607,9 @@ class MailThread(models.AbstractModel):
'bounced_partner': bounced_partner,
'bounced_msg_ids': bounced_msg_ids,
'bounced_message': bounced_message,
'is_bounce': True,
}
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
@@ -1651,10 +1706,8 @@ class MailThread(models.AbstractModel):
msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
msg_dict.update(self._message_parse_extract_from_parent(self._get_parent_message(msg_dict)))
msg_dict['is_bounce'] = self._message_parse_is_bounce(message, msg_dict)
msg_dict.update(self._message_parse_extract_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 _message_parse_extract_from_parent(self, parent_message):
@@ -1789,7 +1842,7 @@ class MailThread(models.AbstractModel):
domain = expression.AND([domain, extra_domain])
return self.env['res.partner'].search(domain)
def _mail_find_user_for_gateway(self, email, alias=None):
def _mail_find_user_for_gateway(self, email_value, alias=None):
""" Utility method to find user from email address that can create documents
in the target model. Purpose is to link document creation to users whenever
possible, for example when creating document through mailgateway.
@@ -1802,24 +1855,19 @@ class MailThread(models.AbstractModel):
Note that standard search order is applied.
:param str email: will be sanitized and parsed to find email;
:param str email_value: will be sanitized and parsed to find email;
:param mail.alias alias: optional alias. Used to fetch owner followers
or fallback user (alias owner);
:param fallback_model: if not alias, related model to check access rights;
:return res.user user: user matching email or void recordset if none found
"""
# find normalized emails and exclude aliases (to avoid subscribing alias emails to records)
normalized_email = tools.email_normalize(email)
normalized_email = tools.email_normalize(email_value)
if not normalized_email:
return self.env['res.users']
catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
if catchall_domain:
left_part = normalized_email.split('@')[0] if normalized_email.split('@')[1] == catchall_domain.lower() else False
if left_part:
if self.env['mail.alias'].sudo().search_count([('alias_name', '=', left_part)]):
return self.env['res.users']
if self.env['mail.alias'].sudo().search_count([('alias_full_name', '=', email_value)]):
return self.env['res.users']
if alias and alias.alias_parent_model_id and alias.alias_parent_thread_id:
followers = self.env['mail.followers'].search([
@@ -1867,7 +1915,6 @@ class MailThread(models.AbstractModel):
followers = records.mapped('message_partner_ids')
else:
followers = self.env['res.partner']
catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
# first, build a normalized email list and remove those linked to aliases
# to avoid adding aliases as partners. In case of multi-email input, use
@@ -1876,11 +1923,9 @@ class MailThread(models.AbstractModel):
for email_normalized in (tools.email_normalize(contact, strict=False) for contact in emails)
if email_normalized
]
if catchall_domain:
domain_left_parts = [email.split('@')[0] for email in normalized_emails if email and email.split('@')[1] == catchall_domain.lower()]
if domain_left_parts:
found_alias_names = self.env['mail.alias'].sudo().search([('alias_name', 'in', domain_left_parts)]).mapped('alias_name')
normalized_emails = [email for email in normalized_emails if email.split('@')[0] not in found_alias_names]
matching_aliases = self.env['mail.alias'].sudo().search([('alias_full_name', 'in', normalized_emails)])
if matching_aliases:
normalized_emails = [email for email in normalized_emails if email not in matching_aliases.mapped('alias_full_name')]
done_partners = [follower for follower in followers if follower.email_normalized in normalized_emails]
remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
@@ -2077,6 +2122,13 @@ class MailThread(models.AbstractModel):
# recipients
'partner_ids': partner_ids,
})
# add default-like values afterwards, to avoid useless queries
if 'record_alias_domain_id' not in msg_values:
msg_values['record_alias_domain_id'] = self.sudo()._mail_get_alias_domains(default_company=self.env.company)[self.id].id
if 'record_company_id' not in msg_values:
msg_values['record_company_id'] = self._mail_get_companies(default=self.env.company)[self.id].id
if 'reply_to' not in msg_values:
msg_values['reply_to'] = self._notify_get_reply_to(default=email_from)[self.id]
msg_values.update(
self._process_attachments_for_post(attachments, attachment_ids, msg_values)
@@ -2549,6 +2601,11 @@ class MailThread(models.AbstractModel):
}
msg_values.update(msg_kwargs)
# add default-like values afterwards, to avoid useless queries
if self:
if 'record_alias_domain_id' not in msg_values:
msg_values['record_alias_domain_id'] = self._mail_get_alias_domains(default_company=self.env.company)[self.id].id
if 'record_company_id' not in msg_values:
msg_values['record_company_id'] = self._mail_get_companies(default=self.env.company)[self.id].id
if 'reply_to' not in msg_values:
msg_values['reply_to'] = self._notify_get_reply_to(default=email_from)[self.id if self else False]
@@ -2641,6 +2698,8 @@ class MailThread(models.AbstractModel):
'email_from': email_from,
# document
'model': self._name,
'record_alias_domain_id': False,
'record_company_id': False,
'record_name': False,
# content
'attachment_ids': attachment_ids,
@@ -2778,6 +2837,8 @@ class MailThread(models.AbstractModel):
'model',
'parent_id',
'partner_ids',
'record_alias_domain_id',
'record_company_id',
'record_name',
'reply_to',
'reply_to_force_new',
@@ -3444,7 +3505,11 @@ class MailThread(models.AbstractModel):
if additional_values:
base_mail_values.update(additional_values)
headers = self._notify_by_email_get_headers()
# prepare headers (as sudo as accessing mail.alias.domain, restricted)
headers = {}
if message_sudo.record_alias_domain_id.bounce_email:
headers['Return-Path'] = message_sudo.record_alias_domain_id.bounce_email
headers = self._notify_by_email_get_headers(headers=headers)
if headers:
base_mail_values['headers'] = repr(headers)
return base_mail_values
+111 -48
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from lxml.builder import E
from markupsafe import Markup
@@ -22,6 +23,51 @@ class BaseModel(models.AbstractModel):
# FIELDS HELPERS
# ------------------------------------------------------------
def _mail_get_alias_domains(self, default_company=False):
""" Return alias domain linked to each record in self. It is based
on the company (record's company, environment company) and fallback
on the first found alias domain if configuration is not correct.
:param <res.company> default_company: default company in case records
have no company (or no company field); defaults to env.company;
:return: for each record ID in self, found <mail.alias.domain>
"""
record_companies = self._mail_get_companies(default=(default_company or self.env.company))
# prepare default alias domain, fetch only if necessary
default_domain = (default_company or self.env.company).alias_domain_id
all_companies = self.env['res.company'].browse({comp.id for comp in record_companies.values()})
# early optimization: search only if necessary
if not default_domain and any(not comp.alias_domain_id for comp in all_companies):
default_domain = self.env['mail.alias.domain'].search([], limit=1)
return {
record.id: (
record_companies[record.id].alias_domain_id or default_domain
)
for record in self
}
@api.model
def _mail_get_company_field(self):
return 'company_id' if 'company_id' in self else False
def _mail_get_companies(self, default=False):
""" Return company linked to each record in self.
:param <res.company> default: default value if no company field is found
or if it holds a void value. Defaults to a void recordset;
:return: for each record ID in self, found <res.company>
"""
default_company = default or self.env['res.company']
company_fname = self._mail_get_company_field()
return {
record.id: (record[company_fname] or default_company) if company_fname else default_company
for record in self
}
@api.model
def _mail_get_partner_fields(self, introspect_fields=False):
""" This method returns the fields to use to find the contact to link
@@ -33,7 +79,7 @@ class BaseModel(models.AbstractModel):
res.partner model. This is used notably when partners are
mandatory like in voip;
:return list: list of valid field names that can be used to retrieve
:return: list of valid field names that can be used to retrieve
a partner (customer) on the record;
"""
partner_fnames = [fname for fname in ('partner_id', 'partner_ids') if fname in self]
@@ -49,7 +95,7 @@ class BaseModel(models.AbstractModel):
:param bool introspect_fields: see '_mail_get_partner_fields';
:return dict: for each record ID, a res.partner recordsets being default
:return: for each record ID, a res.partner recordsets being default
customers to contact;
"""
partner_fields = self._mail_get_partner_fields(introspect_fields=introspect_fields)
@@ -193,45 +239,52 @@ class BaseModel(models.AbstractModel):
model = _records._name if _records and _records._name != 'mail.thread' else False
res_ids = _records.ids if _records and model else []
_res_ids = res_ids or [False] # always have a default value located in False
_records_sudo = _records.sudo()
doc_names = {rec.id: rec.display_name for rec in _records_sudo} if res_ids else {}
alias_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
result = dict.fromkeys(_res_ids, False)
result_email = dict()
doc_names = dict()
# group ids per company
if res_ids:
company_to_res_ids = defaultdict(list)
record_ids_to_company = _records_sudo._mail_get_companies(default=self.env.company)
for record_id, company in record_ids_to_company.items():
company_to_res_ids[company].append(record_id)
else:
company_to_res_ids = {self.env.company: _res_ids}
record_ids_to_company = {_res_id: self.env.company for _res_id in _res_ids}
if alias_domain:
if model and res_ids:
if not doc_names:
doc_names = dict((rec.id, rec.display_name) for rec in _records)
# begin with aliases (independent from company, alias_domain_id on alias wins)
reply_to_email = {}
if model and res_ids:
mail_aliases = self.env['mail.alias'].sudo().search([
('alias_domain_id', '!=', False),
('alias_parent_model_id.model', '=', model),
('alias_parent_thread_id', 'in', res_ids),
('alias_name', '!=', False)
])
# take only first found alias for each thread_id, to match order (1 found -> limit=1 for each res_id)
for alias in mail_aliases:
reply_to_email.setdefault(alias.alias_parent_thread_id, alias.alias_full_name)
mail_aliases = self.env['mail.alias'].sudo().search([
('alias_parent_model_id.model', '=', model),
('alias_parent_thread_id', 'in', res_ids),
('alias_name', '!=', False)])
# take only first found alias for each thread_id, to match order (1 found -> limit=1 for each res_id)
for alias in mail_aliases:
result_email.setdefault(alias.alias_parent_thread_id, '%s@%s' % (alias.alias_name, alias_domain))
# left ids: use catchall
left_ids = set(_res_ids) - set(result_email)
if left_ids:
catchall = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias")
if catchall:
result_email.update(dict((rid, '%s@%s' % (catchall, alias_domain)) for rid in left_ids))
for res_id in result_email:
result[res_id] = self._notify_get_reply_to_formatted_email(
result_email[res_id],
doc_names.get(res_id) or '',
)
left_ids = set(_res_ids) - set(result_email)
# continue with company alias
left_ids = set(_res_ids) - set(reply_to_email)
if left_ids:
result.update(dict((res_id, default) for res_id in left_ids))
for company, record_ids in company_to_res_ids.items():
# left ids: use catchall defined on company alias domain
if company.catchall_email:
left_ids = set(record_ids) - set(reply_to_email)
if left_ids:
reply_to_email.update({rec_id: company.catchall_email for rec_id in left_ids})
return result
# compute name of reply-to ("Company Document" <alias@domain>)
reply_to_formatted = dict.fromkeys(_res_ids, default)
for res_id, record_reply_to in reply_to_email.items():
reply_to_formatted[res_id] = self._notify_get_reply_to_formatted_email(
record_reply_to, doc_names.get(res_id) or '', company=record_ids_to_company[res_id],
)
def _notify_get_reply_to_formatted_email(self, record_email, record_name):
return reply_to_formatted
def _notify_get_reply_to_formatted_email(self, record_email, record_name, company=False):
""" Compute formatted email for reply_to and try to avoid refold issue
with python that splits the reply-to over multiple lines. It is due to
a bad management of quotes (missing quotes after refold). This appears
@@ -243,22 +296,27 @@ class BaseModel(models.AbstractModel):
possible we return only the email and skip the formataddr which causes
the issue in python. We do not use hacks like crop the name part as
encoding and quoting would be error prone.
:param <res.company> company: if given, setup the company used to
complete name in formataddr. Otherwise fallback on 'company_id'
of self or environment company;
"""
# address itself is too long for 78 chars limit: return only email
if len(record_email) >= 78:
return record_email
if 'company_id' in self and len(self.company_id) == 1:
company_name = self.sudo().company_id.name
else:
company_name = self.env.company.name
if not company:
if len(self) == 1:
company = self.sudo()._mail_get_companies(default=self.env.company)
else:
company = self.env.company
# try company_name + record_name, or record_name alone (or company_name alone)
name = f"{company_name} {record_name}" if record_name else company_name
# try company.name + record_name, or record_name alone (or company.name alone)
name = f"{company.name} {record_name}" if record_name else company.name
formatted_email = tools.formataddr((name, record_email))
if len(formatted_email) > 78:
formatted_email = tools.formataddr((record_name or company_name, record_email))
formatted_email = tools.formataddr((record_name or company.name, record_email))
if len(formatted_email) > 78:
formatted_email = record_email
return formatted_email
@@ -316,14 +374,19 @@ class BaseModel(models.AbstractModel):
# GATEWAY: NOTIFICATION
# ------------------------------------------------------------
def _notify_by_email_get_headers(self):
""" Generate the email headers based on record """
def _notify_by_email_get_headers(self, headers=None):
""" Generate the email headers based on record. Each header not already
present in 'headers' will be added in it. """
headers = headers or {}
if not self:
return {}
return headers
self.ensure_one()
return {
'X-Odoo-Objects': "%s-%s" % (self._name, self.id),
}
headers['X-Odoo-Objects'] = f"{self._name}-{self.id}"
if 'Return-Path' not in headers:
company = self._mail_get_companies(default=self.env.company)[self.id]
if company.bounce_email:
headers['Return-Path'] = company.bounce_email
return headers
# ------------------------------------------------------------
# TOOLS
+30 -12
View File
@@ -8,8 +8,20 @@ class Company(models.Model):
_name = 'res.company'
_inherit = 'res.company'
def _default_alias_domain_id(self):
return self.env['mail.alias.domain'].search([], limit=1)
alias_domain_id = fields.Many2one(
'mail.alias.domain', string='Email Domain',
default=lambda self: self._default_alias_domain_id())
alias_domain_name = fields.Char('Alias Domain Name', related='alias_domain_id.name', readonly=True, store=True)
bounce_email = fields.Char(string="Bounce Email", compute="_compute_bounce")
bounce_formatted = fields.Char(string="Bounce", compute="_compute_bounce")
catchall_email = fields.Char(string="Catchall Email", compute="_compute_catchall")
catchall_formatted = fields.Char(string="Catchall", compute="_compute_catchall")
default_from_email = fields.Char(
string="Default From", related="alias_domain_id.default_from_email",
readonly=True)
# the compute method is sudo'ed because it needs to access res.partner records
# portal users cannot access those (but they should be able to read the company email address)
email_formatted = fields.Char(
@@ -22,19 +34,25 @@ class Company(models.Model):
"Email Button Color", compute="_compute_email_secondary_color",
readonly=False, store=True)
@api.depends('name')
@api.depends('alias_domain_id', 'name')
def _compute_bounce(self):
self.bounce_email = ''
self.bounce_formatted = ''
for company in self.filtered('alias_domain_id'):
bounce_email = company.alias_domain_id.bounce_email
company.bounce_email = bounce_email
company.bounce_formatted = tools.formataddr((company.name, bounce_email))
@api.depends('alias_domain_id', 'name')
def _compute_catchall(self):
ConfigParameter = self.env['ir.config_parameter'].sudo()
alias = ConfigParameter.get_param('mail.catchall.alias')
domain = ConfigParameter.get_param('mail.catchall.domain')
if alias and domain:
for company in self:
company.catchall_email = '%s@%s' % (alias, domain)
company.catchall_formatted = tools.formataddr((company.name, company.catchall_email))
else:
for company in self:
company.catchall_email = ''
company.catchall_formatted = ''
self.catchall_email = ''
self.catchall_formatted = ''
for company in self.filtered('alias_domain_id'):
catchall_email = company.alias_domain_id.catchall_email
company.catchall_email = catchall_email
company.catchall_formatted = tools.formataddr((company.name, catchall_email))
@api.depends('partner_id', 'catchall_formatted')
def _compute_email_formatted(self):
+6 -2
View File
@@ -12,9 +12,13 @@ class ResConfigSettings(models.TransientModel):
the alias domain. """
_inherit = 'res.config.settings'
external_email_server_default = fields.Boolean(
"Use Custom Email Servers",
config_parameter='base_setup.default_external_email_server')
fail_counter = fields.Integer('Fail Mail', compute="_compute_fail_counter")
alias_domain = fields.Char(
'Alias Domain', config_parameter='mail.catchall.domain',
alias_domain_id = fields.Many2one(
'mail.alias.domain', 'Alias Domain',
readonly=False, related='company_id.alias_domain_id',
help="If you have setup a catch-all email domain redirected to the Odoo server, enter the domain name here.")
module_google_gmail = fields.Boolean('Support Gmail Authentication')
module_microsoft_outlook = fields.Boolean('Support Outlook Authentication')
+2
View File
@@ -20,6 +20,8 @@ access_discuss_channel_member_user,discuss.channel.member.user,model_discuss_cha
access_discuss_channel_rtc_session_system,discuss.channel.rtc.session.system,model_discuss_channel_rtc_session,base.group_system,1,1,1,1
access_mail_alias_user,mail.alias.user,model_mail_alias,base.group_user,1,0,0,0
access_mail_alias_system,mail.alias.system,model_mail_alias,base.group_system,1,1,1,1
access_mail_alias_domain_user,mail.alias.domain.user,model_mail_alias_domain,base.group_user,1,0,0,0
access_mail_alias_domain_system,mail.alias.domain.system,model_mail_alias_domain,base.group_erp_manager,1,1,1,1
access_mail_gateway_allowed_system,mail.gateway.allowed.system,model_mail_gateway_allowed,base.group_system,1,1,1,1
access_mail_message_reaction_system,mail.message.reaction.system,model_mail_message_reaction,base.group_system,1,1,1,1
access_mail_message_subtype_public,mail.message.subtype.all,model_mail_message_subtype,base.group_public,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
20 access_discuss_channel_rtc_session_system discuss.channel.rtc.session.system model_discuss_channel_rtc_session base.group_system 1 1 1 1
21 access_mail_alias_user mail.alias.user model_mail_alias base.group_user 1 0 0 0
22 access_mail_alias_system mail.alias.system model_mail_alias base.group_system 1 1 1 1
23 access_mail_alias_domain_user mail.alias.domain.user model_mail_alias_domain base.group_user 1 0 0 0
24 access_mail_alias_domain_system mail.alias.domain.system model_mail_alias_domain base.group_erp_manager 1 1 1 1
25 access_mail_gateway_allowed_system mail.gateway.allowed.system model_mail_gateway_allowed base.group_system 1 1 1 1
26 access_mail_message_reaction_system mail.message.reaction.system model_mail_message_reaction base.group_system 1 1 1 1
27 access_mail_message_subtype_public mail.message.subtype.all model_mail_message_subtype base.group_public 1 0 0 0
+60 -10
View File
@@ -95,20 +95,66 @@ class MockEmail(common.BaseCase, MockSmtplibCase):
@classmethod
def _init_mail_gateway(cls):
super()._init_mail_gateway()
# main company alias parameters
cls.alias_domain = 'test.mycompany.com'
cls.alias_catchall = 'catchall.test'
cls.alias_bounce = 'bounce.test'
cls.default_from = 'notifications.test'
cls.default_from_filter = False
cls.env['ir.config_parameter'].set_param('mail.bounce.alias', cls.alias_bounce)
cls.env['ir.config_parameter'].set_param('mail.catchall.domain', cls.alias_domain)
cls.env['ir.config_parameter'].set_param('mail.catchall.alias', cls.alias_catchall)
cls.env['ir.config_parameter'].set_param('mail.default.from', cls.default_from)
cls.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', cls.default_from_filter)
cls.env['ir.config_parameter'].set_param('mail.default.from_filter', cls.default_from_filter)
# ensure global alias domain for tests: to ease tests, search or create
# the default test domains
cls.env['mail.alias.domain'].search([]).write({'sequence': 9999})
cls.mail_alias_domain = cls._init_alias_domain(cls.alias_domain, {
'bounce_alias': cls.alias_bounce,
'catchall_alias': cls.alias_catchall,
'company_ids': [(4, cls.env.ref('base.user_admin').company_id.id)],
'default_from': cls.default_from,
'name': cls.alias_domain,
'sequence': 1,
})
if cls._mc_enabled:
# alias domain specific to new company
cls.alias_bounce_c2 = 'bounce.c2'
cls.alias_catchall_c2 = 'catchall.c2'
cls.alias_default_from_c2 = 'notifications.c2'
cls.alias_domain_c2_name = 'test.mycompany2.com'
cls.mail_alias_domain_c2 = cls._init_alias_domain(cls.alias_domain_c2_name, {
'bounce_alias': cls.alias_bounce_c2,
'catchall_alias': cls.alias_catchall_c2,
'company_ids': [(4, cls.company_2.id)],
'default_from': cls.alias_default_from_c2,
'name': cls.alias_domain_c2_name,
'sequence': 2,
})
# alias domain specific to third company
cls.alias_bounce_c3 = 'bounce.c3'
cls.alias_catchall_c3 = 'catchall.c3'
cls.alias_default_from_c3 = 'notifications.c3'
cls.alias_domain_c3_name = 'test.mycompany3.com'
cls.mail_alias_domain_c3 = cls._init_alias_domain(cls.alias_domain_c3_name, {
'bounce_alias': cls.alias_bounce_c3,
'catchall_alias': cls.alias_catchall_c3,
'company_ids': [(4, cls.company_3.id)],
'default_from': cls.alias_default_from_c3,
'name': cls.alias_domain_c3_name,
'sequence': 3,
})
# mailer daemon email preformatting
cls.mailer_daemon_email = formataddr(('MAILER-DAEMON', f'{cls.alias_bounce}@{cls.alias_domain}'))
@classmethod
def _init_alias_domain(cls, name, values):
alias_domain = cls.env['mail.alias.domain'].search([('name', '=', name)])
if alias_domain:
alias_domain.write(values)
else:
alias_domain = cls.env['mail.alias.domain'].create(values)
return alias_domain
# ------------------------------------------------------------
# GATEWAY TOOLS
# ------------------------------------------------------------
@@ -1269,20 +1315,24 @@ class MailCommon(common.TransactionCase, MailCase):
different companies. """
cls._mc_enabled = True
# new company
# new companies
cls.company_2 = cls.env['res.company'].create({
'currency_id': cls.env.ref('base.CAD').id,
'email': 'company_2@test.example.com',
'name': 'Company 2',
})
cls.company_3 = cls.env['res.company'].create({
'name': 'ELIT',
})
cls.user_admin.write({'company_ids': [(4, cls.company_2.id)]})
cls.company_3 = cls.env['res.company'].create({
'country_id': cls.env.ref('base.be').id,
'currency_id': cls.env.ref('base.EUR').id,
'email': 'company_3@test.example.com',
'name': 'Company 3',
})
cls.user_admin.write({
'company_ids': [
(4, cls.company_2.id),
(4, cls.company_3.id),
],
})
# employee specific to second company
cls.user_employee_c2 = mail_new_test_user(
+27 -71
View File
@@ -27,26 +27,25 @@ class TestIrMailServer(MailCommon):
@patch.dict(config.options, {"email_from": "settings@example.com"})
def test_default_email_from(self, *args):
""" Check that 'mail.default.from' setting is respected. """
ICP = self.env["ir.config_parameter"].sudo()
for (icp_from, icp_domain), expected_from in zip(
""" Check that default_from parameter of alias domain respected. """
for (default_from, domain_name), expected_from in zip(
[
('icp', 'test.mycompany.com'),
('icp', False),
('icp@another.company.com', 'test.mycompany.com'),
('icp@another.company.com', False),
(False, 'test.mycompany.com'),
(False, False),
], [
"icp@test.mycompany.com",
"settings@example.com",
"icp@another.company.com",
"icp@another.company.com",
"settings@example.com",
],
):
with self.subTest(icp_from=icp_from, icp_domain=icp_domain):
ICP.set_param("mail.default.from", icp_from)
ICP.set_param("mail.catchall.domain", icp_domain)
with self.subTest(default_from=default_from, domain_name=domain_name):
if domain_name:
self.mail_alias_domain.name = domain_name
self.mail_alias_domain.default_from = default_from
self.env.company.alias_domain_id = self.mail_alias_domain
else:
self.env.company.alias_domain_id = False
message = self.env["ir.mail_server"].build_email(
False, "recipient@example.com", "Subject",
"The body of an email",
@@ -91,8 +90,8 @@ class TestIrMailServer(MailCommon):
# outside "from_filter" domain: we will use notifications emails in the
# headers, and bounce address in the envelope because the "from_filter"
# allows to use the entire domain
(self.default_bounce_address, '"test" <notifications.test@test.mycompany.com>'),
(self.default_bounce_address, '"Formatted Name" <notifications.test@test.mycompany.com>'),
(self.default_bounce_address, f'"test" <{self.default_from}@{self.alias_domain}>'),
(self.default_bounce_address, f'"Formatted Name" <{self.default_from}@{self.alias_domain}>'),
]
):
for provide_smtp in [False, True]: # providing smtp session should ont impact test
@@ -131,7 +130,7 @@ class TestIrMailServer(MailCommon):
@users('admin')
def test_mail_server_get_test_email_from(self):
""" Test the email used to test the mail server connection. Check
from_filter parsing / mail.default.from support. """
from_filter parsing / alias_domain.default_from support. """
test_server = self.env['ir.mail_server'].create({
'from_filter': 'example_2.com, example_3.com',
'name': 'Test Server',
@@ -142,25 +141,21 @@ class TestIrMailServer(MailCommon):
# check default.from / filter matching
for (default_from, from_filter), expected_test_email in zip(
[
('notifications@example.com', 'example_2.com, example_3.com'),
('notifications', 'example_2.com, example_3.com'),
('notifications@example.com', 'dummy.com, full_email@example_2.com, dummy2.com'),
('notifications', 'dummy.com, full_email@example_2.com, dummy2.com'),
('notifications@example.com', 'example.com'),
('notifications', self.mail_alias_domain.name),
('notifications', f'{self.mail_alias_domain.name}, example_2.com'),
# default relies on "odoo"
(False, 'example.com'),
(False, self.mail_alias_domain.name),
# fallback on user email if no from_filter
('example_2.com', ' '),
('example_2.com', ','),
('example_2.com', False),
('notifications', ' '),
('notifications', ','),
('notifications', False),
(False, False),
], [
'noreply@example_2.com',
'notifications@example_2.com',
'full_email@example_2.com',
'full_email@example_2.com',
'notifications@example.com',
'odoo@example.com',
f'notifications@{self.mail_alias_domain.name}',
f'notifications@{self.mail_alias_domain.name}',
f'odoo@{self.mail_alias_domain.name}',
self.env.user.email,
self.env.user.email,
self.env.user.email,
@@ -168,7 +163,7 @@ class TestIrMailServer(MailCommon):
],
):
with self.subTest(default_from=default_from, from_filter=from_filter):
self.env['ir.config_parameter'].set_param('mail.default.from', default_from)
self.mail_alias_domain.default_from = default_from
test_server.from_filter = from_filter
email_from = test_server._get_test_email_from()
self.assertEqual(email_from, expected_test_email)
@@ -219,7 +214,7 @@ class TestIrMailServer(MailCommon):
(self.mail_server_domain, 'unknown_email@test.mycompany.com'),
(self.mail_server_domain, 'unknown_email@TEST.MYCOMPANY.COM'),
(self.mail_server_domain, '"Unknown" <unknown_email@test.mycompany.com>'),
(self.mail_server_notification, 'notifications.test@test.mycompany.com'),
(self.mail_server_notification, f'{self.default_from}@test.mycompany.com'),
# mail_server_user multiple from_filter check
(self.mail_server_user, '"Example" <test@domain2.com>'),
(self.mail_server_user, '"Example" <test@domain1.com>'),
@@ -247,9 +242,9 @@ class TestIrMailServer(MailCommon):
('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com', self.mail_server_user),
# No mail server are configured for the email address, so it will use the
# notifications email instead and encapsulate the old email
('notifications.test@test.mycompany.com', '"Name" <notifications.test@test.mycompany.com>', self.mail_server_notification),
(f'{self.default_from}@{self.alias_domain}', f'"Name" <{self.default_from}@{self.alias_domain}>', self.mail_server_notification),
# same situation, but the original email has no name part
('notifications.test@test.mycompany.com', '"test" <notifications.test@test.mycompany.com>', self.mail_server_notification),
(f'{self.default_from}@{self.alias_domain}', f'"test" <{self.default_from}@{self.alias_domain}>', self.mail_server_notification),
# A mail server is configured for the entire domain name, so we can use the bounce
# email address because the mail server supports it
(self.default_bounce_address, '"Name" <unknown_name@test.mycompany.com>', self.mail_server_domain),
@@ -294,45 +289,6 @@ class TestIrMailServer(MailCommon):
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
smtp_from=self.default_bounce_address,
message_from='"Name" <notifications.test@test.mycompany.com>',
message_from=f'"Name" <{self.default_from}@{self.alias_domain}>',
mail_server=self.mail_server_domain,
)
def test_mail_server_send_email_default_from(self):
""" Test the case when the "mail.default.from" contains a full email address
and not just the local part the domain of this default email address can be
different than the catchall domain """
other_default_from = 'notifications.other.test@custom_domain.com'
self.env['ir.config_parameter'].sudo().set_param('mail.default.from', other_default_from)
custom_server = self.env['ir.mail_server'].create({
'from_filter': 'custom_domain.com',
'name': 'Custom Domain Server',
'smtp_host': 'smtp_host',
'smtp_encryption': 'none',
})
for mail_from, (expected_smtp_from, expected_msg_from, expected_mail_server) in zip(
[
# custom server domain
'"Custom Server Name" <customer.test@custom_domain.com>',
# unknown domain
'"Name" <customer.test@unknown_domain.com>',
# notification server domain
'"Name" <unknown_name@test.mycompany.com>'
], [
('customer.test@custom_domain.com', '"Custom Server Name" <customer.test@custom_domain.com>', custom_server),
(other_default_from, '"Name" <notifications.other.test@custom_domain.com>', custom_server),
(self.default_bounce_address, '"Name" <unknown_name@test.mycompany.com>', self.mail_server_domain),
],
):
with self.subTest(mail_from=mail_from):
with self.mock_smtplib_connection():
message = self._build_email(mail_from=mail_from)
self.env['ir.mail_server'].send_email(message)
self.assertSMTPEmailsSent(
smtp_from=expected_smtp_from,
smtp_to_list=['dest@xn--example--i1a.com'],
message_from=expected_msg_from,
mail_server=expected_mail_server,
)
@@ -0,0 +1,63 @@
<?xml version="1.0"?>
<odoo>
<record id="mail_alias_domain_view_form" model="ir.ui.view">
<field name="name">mail.alias.domain.view.form</field>
<field name="model">mail.alias.domain</field>
<field name="arch" type="xml">
<form string="Alias Domain" duplicate="false">
<sheet>
<group>
<field name="name"/>
<field name="bounce_alias"/>
<field name="catchall_alias"/>
<field name="default_from"/>
<field name="company_ids" widget="many2many_tags"
readonly="True"
groups="base.group_multi_company"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="mail_alias_domain_view_tree" model="ir.ui.view">
<field name="name">mail.alias.domain.view.tree</field>
<field name="model">mail.alias.domain</field>
<field name="arch" type="xml">
<tree string="Alias Domains" duplicate="false">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="bounce_alias"/>
<field name="catchall_alias"/>
<field name="default_from"/>
<field name="company_ids" optional="hide" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="mail_alias_domain_view_search" model="ir.ui.view">
<field name="name">mail.alias.domain.view.search</field>
<field name="model">mail.alias.domain</field>
<field name="arch" type="xml">
<search string="Alias Domains">
<field name="name"/>
<field name="bounce_alias"/>
<field name="catchall_alias"/>
<field name="company_ids" groups="base.group_multi_company"/>
<group expand="0" string="Group By">
<filter string="Company" name="group_by_company_ids"
domain="[]" context="{'group_by': 'company_ids'}"
groups="base.group_multi_company"/>
</group>
</search>
</field>
</record>
<record id="mail_alias_domain_action" model="ir.actions.act_window">
<field name="name">Alias Domains</field>
<field name="res_model">mail.alias.domain</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>
+11 -1
View File
@@ -27,7 +27,11 @@
</button>
</div>
<div class="d-flex">
<h2 class="flex-grow-1"><field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline"/></h2>
<h2 class="flex-grow-1">
<field name="alias_name" class="oe_inline"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</h2>
<field name="alias_status" widget="badge"
decoration-success="alias_status == 'valid'"
decoration-warning="alias_status == 'not_tested'"
@@ -38,6 +42,7 @@
<field name="alias_force_thread_id"/>
<field name="alias_defaults"/>
<field name="alias_contact"/>
<field name="alias_incoming_local"/>
<field name="alias_parent_model_id"/>
<field name="alias_parent_thread_id"/>
</group>
@@ -55,12 +60,14 @@
<field name="arch" type="xml">
<tree string="Alias">
<field name="alias_name"/>
<field name="alias_domain_id"/>
<field name="alias_model_id"/>
<field name="alias_force_thread_id" optional="hide"/>
<field name="alias_parent_model_id"/>
<field name="alias_parent_thread_id" optional="hide"/>
<field name="alias_defaults" optional="hide"/>
<field name="alias_contact"/>
<field name="alias_incoming_local" optional="hide"/>
<field name="alias_status" widget="badge"
decoration-success="alias_status == 'valid'"
decoration-warning="alias_status == 'not_tested'"
@@ -82,6 +89,7 @@
<field name="arch" type="xml">
<search string="Search Alias">
<field name="alias_name"/>
<field name="alias_domain_id"/>
<field name="alias_model_id"/>
<field name="create_uid"/>
<field name="alias_force_thread_id"/>
@@ -92,6 +100,8 @@
<group expand="0" string="Group By">
<filter string="Creator" name="groupby_create_uid"
context="{'group_by': 'create_uid'}"/>
<filter string="Alias Domain" name="groupby_alias_domain_id"
context="{'group_by': 'alias_domain_id'}"/>
<filter string="Document Model" name="groupby_alias_model_id"
context="{'group_by': 'alias_model_id'}"/>
<filter string="Container Model" name="groupby_alias_model_id"
+10 -5
View File
@@ -18,6 +18,16 @@
parent="base.menu_email"
action="action_view_mail_mail"
sequence="1"/>
<menuitem id="mail_alias_menu"
parent="base.menu_email"
action="mail_alias_action"
sequence="11"
groups="base.group_no_one"/>
<menuitem id="mail_alias_domain_menu"
parent="base.menu_email"
action="mail_alias_domain_action"
sequence="12"
groups="base.group_no_one"/>
<menuitem name="Incoming Mail Servers"
id="menu_action_fetchmail_server_tree"
parent="base.menu_email"
@@ -28,11 +38,6 @@
parent="base.menu_email"
action="action_email_template_tree_all"
sequence="10"/>
<menuitem id="mail_alias_menu"
parent="base.menu_email"
action="mail_alias_action"
sequence="11"
groups="base.group_no_one"/>
<menuitem id="mail.discuss_channel_menu_settings"
name="Channels"
parent="base.menu_email"
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="res_company_view_form" model="ir.ui.view">
<field name="name">res.company.view.form.inherit.mail</field>
<field name="model">res.company</field>
<field name="inherit_id" ref="base.view_company_form"/>
<field name="arch" type="xml">
<field name="sequence" position="after">
<field name="alias_domain_id"/>
<field name="bounce_formatted" groups="base.group_no_one"/>
<field name="catchall_formatted" groups="base.group_no_one"/>
<field name="default_from_email" groups="base.group_no_one"/>
</field>
</field>
</record>
</odoo>
@@ -33,7 +33,18 @@
</div>
</div>
</setting>
<setting string="Use a Gmail Server" help="Send and receive emails through your Gmail account." documentation="https://console.developers.google.com/" invisible="not external_email_server_default">
<setting id="email-alias-setting" string="Alias Domain"
help="Use different domains for your mail aliases"
company_dependent="1"
documentation="/applications/general/email_communication/email_domain.html#be-spf-compliant">
<span class="me-1">@</span>
<field name="alias_domain_id" placeholder="e.g. domain.com"
options="{'no_create_edit': True}"/>
</setting>
<setting string="Use a Gmail Server"
help="Send and receive emails through your Gmail account."
documentation="https://console.developers.google.com/"
invisible="not external_email_server_default">
<field name="module_google_gmail"/>
<div class="content-group" invisible="not module_google_gmail" id="msg_module_google_gmail">
<div class="mt16 text-warning"><strong>Save</strong> this page and come back here to set up the feature.</div>
@@ -51,9 +62,6 @@
</div>
</div>
</setting>
<setting id="email-alias-setting" string="Alias Domain" help="Use a different domain for your mail aliases" documentation="/applications/general/email_communication/email_domain.html#be-spf-compliant">
<span>@<field name="alias_domain" placeholder='e.g. "mycompany.com"'/></span>
</setting>
<setting help="Add your twilio credentials for ICE servers">
<field name="use_twilio_rtc_servers"/>
<div class="content-group" invisible="not use_twilio_rtc_servers">
+48 -2
View File
@@ -121,6 +121,12 @@ class MailComposer(models.TransientModel):
res_domain_user_id = fields.Many2one(
'res.users', string='Responsible',
help='Used as context used to evaluate composer domain')
record_alias_domain_id = fields.Many2one(
'mail.alias.domain', 'Alias Domain',
compute='_compute_record_environment', readonly=False, store=True) # useful only in monorecord comment mode
record_company_id = fields.Many2one(
'res.company', 'Company',
compute='_compute_record_environment', readonly=False, store=True) # useful only in monorecord comment mode
record_name = fields.Char(
'Record Name',
compute='_compute_record_name', readonly=False, store=True) # useful only in monorecord comment mode
@@ -387,6 +393,34 @@ class MailComposer(models.TransientModel):
elif not active_res_ids and self.env.context.get('active_id'):
composer.res_ids = f"{[self.env.context['active_id']]}"
@api.depends('composition_mode', 'model', 'res_domain', 'res_ids')
def _compute_record_environment(self):
""" In monorecord mode, fetch record company and the linked alias domain,
easing future processing notably at post and notification sending time.
In batch mode it makes no sense to compute a single company, it will be
dynamically generated. """
toreset = self.filtered(
lambda comp: (comp.record_company_id or comp.record_alias_domain_id) and comp.composition_batch
)
if toreset:
toreset.record_alias_domain_id = False
toreset.record_company_id = False
toupdate = self.filtered(
lambda comp: not comp.composition_batch
)
for composer in toupdate:
res_ids = composer._evaluate_res_ids()
if composer.model and len(res_ids) == 1:
record = self.env[composer.model].browse(res_ids)
composer.record_company_id = record._mail_get_companies(
default=self.env.company
)[record.id]
composer.record_alias_domain_id = record._mail_get_alias_domains(
default_company=self.env.company
)[record.id]
@api.depends('composition_mode', 'model', 'parent_id', 'res_domain', 'res_ids')
def _compute_record_name(self):
""" Computation is coming either from parent message, either from the
@@ -781,6 +815,8 @@ class MailComposer(models.TransientModel):
STA - 'email_add_signature',
STA - 'email_layout_xmlid',
DYN - 'force_email_lang', # notify parameter
STA - 'record_alias_domain_id', # monorecord only
STA - 'record_company_id', # monorecord only
BOTH
DYN - 'attachment_ids',
@@ -879,6 +915,8 @@ class MailComposer(models.TransientModel):
force_send=self.force_send,
mail_auto_delete=self.auto_delete,
model_description=model_description,
record_alias_domain_id=self.record_alias_domain_id.id,
record_company_id=self.record_company_id.id,
)
return values
@@ -899,6 +937,10 @@ class MailComposer(models.TransientModel):
RecordsModel = self.env[self.model].with_prefetch(res_ids)
email_mode = self.composition_mode == 'mass_mail'
# records values
companies = RecordsModel.browse(res_ids)._mail_get_companies(default=self.env.company)
alias_domains = RecordsModel.browse(res_ids)._mail_get_alias_domains(default_company=self.env.company)
# langs, used currently only to propagate in comment mode for notification
# layout translation
langs = self._render_field('lang', res_ids)
@@ -916,13 +958,17 @@ class MailComposer(models.TransientModel):
'email_from': emails_from[res_id],
'scheduled_date': False,
'subject': subjects[res_id],
# some fields are specific to mail or message
# record-specific environment values (company, alias_domain)
'record_alias_domain_id': alias_domains[res_id].id,
'record_company_id': companies[res_id].id,
# some fields are specific to mail
**(
{
'body_html': bodies[res_id],
'res_id': res_id,
} if email_mode else {}
),
# some fields are specific to message
**(
{
# notify parameter to force layout lang
@@ -952,7 +998,7 @@ class MailComposer(models.TransientModel):
# Handle recipients. Without template, if no partner_ids is given, update
# recipients using default recipients to be sure to notify someone
if not self.template_id and not self.partner_ids:
if not self.template_id and not self.partner_ids and email_mode:
default_recipients = RecordsModel.browse(res_ids)._message_get_default_recipients()
for res_id in res_ids:
mail_values_all[res_id].update(
@@ -21,6 +21,8 @@
<field name="model" invisible="1"/>
<field name="model_is_thread" invisible="1"/>
<field name="parent_id" invisible="1"/>
<field name="record_alias_domain_id" invisible="1"/>
<field name="record_company_id" invisible="1"/>
<field name="record_name" invisible="1"/>
<field name="render_model" invisible="1"/>
<field name="res_domain" invisible="1"/>
+10 -9
View File
@@ -109,15 +109,16 @@
<field name="active" invisible="1"/>
<label for="alias_name" string="Email Alias"/>
<div class="oe_inline" name="alias_def">
<field name="alias_id" class="oe_read_only oe_inline"
string="Email Alias"
required="0"
invisible="not alias_domain"/>
<div class="oe_inline" name="edit_alias" style="display: inline;" >
<div class="oe_edit_only" invisible="not alias_domain">
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
<field name="alias_id" class="oe_read_only oe_inline" string="Email Alias" required="0"/>
<div class="oe_inline" name="edit_alias" style="display: inline;">
<div class="oe_edit_only">
<field name="alias_name" class="oe_inline"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</div>
<button icon="oi-arrow-right" type="action" name="%(base_setup.action_general_configuration)d" string="Configure a custom domain" class="p-0 btn-link" invisible="alias_domain"/>
<button icon="oi-arrow-right" type="action" name="%(base_setup.action_general_configuration)d"
string="Choose or configure a custom domain" class="p-0 btn-link"
invisible="alias_domain_id"/>
</div>
</div>
<field name="description"/>
@@ -163,7 +164,7 @@
<field name="arch" type="xml">
<search string="Search Mail group">
<field name="name"/>
<field name="alias_name"/>
<field name="alias_email"/>
<separator/>
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
<filter string="Moderated" name="moderation" domain="[('moderation', '=', True)]"/>
@@ -667,12 +667,14 @@
<field name="technician_user_id" class="oe_inline" domain="[('share', '=', False)]"/>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}" class="oe_inline"/>
</group>
<group name="group_alias" invisible="not alias_domain" groups="base.group_no_one">
<group name="group_alias">
<label for="alias_name" string="Email Alias"/>
<div name="alias_def">
<field name="alias_id" class="oe_read_only oe_inline" string="Email Alias" required="0"/>
<div class="oe_edit_only oe_inline" name="edit_alias" style="display: inline;">
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
<field name="alias_name" class="oe_inline"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</div>
</div>
</group>
@@ -27,7 +27,7 @@ class TestMailingRetry(MassMailCommon, CronMixinCase):
mailing.action_launch()
# force email sending to fail to test our retry mechanism
def patched_mail_mail_send(mail_records, auto_commit=False, raise_exception=False, smtp_session=None):
def patched_mail_mail_send(mail_records, auto_commit=False, raise_exception=False, smtp_session=None, alias_domain_id=False):
mail_records.write({'state': 'exception', 'failure_reason': 'forced_failure'})
with patch('odoo.addons.mail.models.mail_mail.MailMail._send', patched_mail_mail_send):
+1 -1
View File
@@ -28,7 +28,7 @@
<field name="group_id" ref="project.group_project_user"/>
<field name="tip_description" type="html">
<div>
<t t-set="project_record" t-value="object.env['project.project'].search([('alias_name', '!=', False)], limit=1, order='sequence asc')"/>
<t t-set="project_record" t-value="object.env['project.project'].search([('alias_name', '!=', False), ('alias_domain_id', '!=', False)], limit=1, order='sequence asc')"/>
<p class="tip_title">Tip: Create tasks from incoming emails</p>
<t t-if="project_record.alias_email">
<p class="tip_content">Emails sent to <a t-attf-href="mailto:{{project_record.alias_email}}" target="_blank" style="color: #875a7b; text-decoration: none;"><t t-out="project_record.alias_email" /></a> will generate tasks in your <t t-out="project_record.name"></t> project.</p>
-10
View File
@@ -136,7 +136,6 @@ class Project(models.Model):
domain=[('state', 'not in', list(CLOSED_STATES))])
color = fields.Integer(string='Color Index')
user_id = fields.Many2one('res.users', string='Project Manager', default=lambda self: self.env.user, tracking=True)
alias_enabled = fields.Boolean(string='Use Email Alias', compute='_compute_alias_enabled', readonly=False)
alias_id = fields.Many2one(help="Internal email associated with this project. Incoming emails are automatically synchronized "
"with Tasks (or optionally Issues if the Issue Tracker module is installed).")
privacy_visibility = fields.Selection([
@@ -215,11 +214,6 @@ class Project(models.Model):
('project_date_greater', 'check(date >= date_start)', "The project's start date must be before its end date.")
]
@api.onchange('alias_enabled')
def _onchange_alias_name(self):
if not self.alias_enabled:
self.alias_name = False
@api.onchange('company_id')
def _onchange_company_id(self):
if (self.env.user.has_group('project.group_project_stages') and self.stage_id.company_id
@@ -230,10 +224,6 @@ class Project(models.Model):
limit=1,
).id
def _compute_alias_enabled(self):
for project in self:
project.alias_enabled = bool(project.alias_email)
def _compute_access_url(self):
super(Project, self)._compute_access_url()
for project in self:
+2 -2
View File
@@ -1425,8 +1425,8 @@ class Task(models.Model):
task._message_add_suggested_recipient(recipients, partner=task.partner_id, reason=reason)
return recipients
def _notify_by_email_get_headers(self):
headers = super(Task, self)._notify_by_email_get_headers()
def _notify_by_email_get_headers(self, headers=None):
headers = super(Task, self)._notify_by_email_get_headers(headers=headers)
if self.project_id:
current_objects = [h for h in headers.get('X-Odoo-Objects', '').split(',') if h]
current_objects.insert(0, 'project.project-%s, ' % self.project_id.id)
+4 -4
View File
@@ -45,7 +45,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
def test_task_process_without_stage(self):
# Do: incoming mail from an unknown partner on an alias creates a new task 'Frogs'
task = self.format_and_process(
EMAIL_TPL, to='project+pigs@mydomain.com, valid.lelitre@agrolait.com', cc='valid.other@gmail.com',
EMAIL_TPL, to=f'project+pigs@{self.alias_domain}, valid.lelitre@agrolait.com', cc='valid.other@gmail.com',
email_from='%s' % self.user_projectuser.email,
subject='Frogs', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>',
target_model='project.task')
@@ -72,7 +72,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
def test_task_process_with_stages(self):
# Do: incoming mail from an unknown partner on an alias creates a new task 'Cats'
task = self.format_and_process(
EMAIL_TPL, to='project+goats@mydomain.com, valid.lelitre@agrolait.com', cc='valid.other@gmail.com',
EMAIL_TPL, to=f'project+goats@{self.alias_domain}, valid.lelitre@agrolait.com', cc='valid.other@gmail.com',
email_from='%s' % self.user_projectuser.email,
subject='Cats', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>',
target_model='project.task')
@@ -99,7 +99,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
def test_task_from_email_alias(self):
# Do: incoming mail from a known partner email on an alias creates a new task 'Super Frog'
task = self.format_and_process(
EMAIL_TPL, to='project+goats@mydomain.com, valid.lelitre@agrolait.com', cc='valid.other@gmail.com',
EMAIL_TPL, to=f'project+goats@{self.alias_domain}, valid.lelitre@agrolait.com', cc='valid.other@gmail.com',
email_from='%s' % self.user_portal.email,
subject='Super Frog', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>',
target_model='project.task')
@@ -131,7 +131,7 @@ class TestProjectFlow(TestProjectCommon, MailCommon):
self.assertFalse(new_partner)
task = self.format_and_process(
EMAIL_TPL, to='project+pigs@mydomain.com, valid.lelitre@agrolait.com',
EMAIL_TPL, to=f'project+pigs@{self.alias_domain}, valid.lelitre@agrolait.com',
cc='valid.other@gmail.com',
email_from=email,
subject='subject',
@@ -108,19 +108,20 @@
</span>
</group>
<group>
<div name="alias_def" colspan="2" class="pb-2" invisible="not alias_domain">
<div name="alias_def" colspan="2" class="pb-2">
<!-- Always display the whole alias in edit mode. It depends in read only -->
<field name="alias_enabled" invisible="1"/>
<label for="alias_name" class="fw-bold o_form_label" string="Create tasks by sending an email to"/>
<field name="alias_email" class="oe_read_only d-inline" widget="email" readonly="1" invisible="not alias_name" />
<span class="oe_edit_only o_row">
<field name="alias_name" class="oe_inline"/>@<field name="alias_domain" class="oe_inline" readonly="1"/>
<field name="alias_name" class="oe_inline"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</span>
</div>
<!-- the alias contact must appear when the user start typing and it must disappear
when the string is deleted. -->
<field name="alias_contact" class="oe_inline" string="Accept Emails From"
invisible="alias_name == '' or not alias_name"/>
invisible="not alias_email"/>
</group>
<group name="extra_settings">
</group>
@@ -335,11 +336,12 @@
</div>
<field name="user_id" invisible="1"/>
<div class="row o_settings_container"/>
<div name="alias_def" class="mt-4" colspan="2" invisible="not alias_domain">
<div name="alias_def" class="mt-4" colspan="2">
<label for="alias_name" string="Create tasks by sending an email to"/>
<field name="alias_enabled" invisible="1"/>
<span>
<field name="alias_name" placeholder="e.g. office-party"/>@<field name="alias_domain"/>
<field name="alias_name" placeholder="e.g. office-party"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. domain.com"
options="{'no_create': True, 'no_open': True}"/>
</span>
</div>
</form>
@@ -392,8 +394,6 @@
<field name="allow_milestones"/>
<field name="label_tasks"/>
<field name="alias_email"/>
<field name="alias_name"/>
<field name="alias_domain"/>
<field name="is_favorite"/>
<field name="rating_count" />
<field name="rating_avg"/>
+12 -2
View File
@@ -25,8 +25,8 @@ class MailTestSimple(models.Model):
_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()
def _notify_by_email_get_headers(self, headers=None):
headers = super()._notify_by_email_get_headers(headers=headers)
headers['X-Custom'] = 'Done'
return headers
@@ -95,6 +95,16 @@ class MailTestGateway(models.Model):
return super().message_new(msg_dict, custom_values=defaults)
class MailTestGatewayCompany(models.Model):
""" A very simple model only inheriting from mail.thread to test pure mass
mailing features and base performances, with a company field. """
_description = 'Simple Chatter Model for Mail Gateway with company'
_name = 'mail.test.gateway.company'
_inherit = ['mail.test.gateway']
company_id = fields.Many2one('res.company', 'Company')
class MailTestGatewayGroups(models.Model):
""" A model looking like discussion channels / groups (flat thread and
alias). Used notably for advanced gatewxay tests. """
@@ -11,6 +11,7 @@ access_mail_test_simple_main_attachment_portal,mail.test.simple.main.attachment.
access_mail_test_simple_main_attachment_user,mail.test.simple.main.attachment.user,model_mail_test_simple_main_attachment,base.group_user,1,1,1,1
access_mail_test_gateway_portal,mail.test.gateway.portal,model_mail_test_gateway,base.group_portal,1,0,0,0
access_mail_test_gateway_user,mail.test.gateway.user,model_mail_test_gateway,base.group_user,1,1,1,1
access_mail_test_gateway_company_user,mail.test.gateway.company.user,model_mail_test_gateway_company,base.group_user,1,1,1,1
access_mail_test_gateway_groups_portal,mail.test.gateway.groups.portal,model_mail_test_gateway_groups,base.group_portal,1,0,0,0
access_mail_test_gateway_groups_user,mail.test.gateway.groups.user,model_mail_test_gateway_groups,base.group_user,1,1,1,1
access_mail_test_track_portal,mail.test.track.portal,model_mail_test_track,base.group_portal,0,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_mail_test_simple_main_attachment_user mail.test.simple.main.attachment.user model_mail_test_simple_main_attachment base.group_user 1 1 1 1
12 access_mail_test_gateway_portal mail.test.gateway.portal model_mail_test_gateway base.group_portal 1 0 0 0
13 access_mail_test_gateway_user mail.test.gateway.user model_mail_test_gateway base.group_user 1 1 1 1
14 access_mail_test_gateway_company_user mail.test.gateway.company.user model_mail_test_gateway_company base.group_user 1 1 1 1
15 access_mail_test_gateway_groups_portal mail.test.gateway.groups.portal model_mail_test_gateway_groups base.group_portal 1 0 0 0
16 access_mail_test_gateway_groups_user mail.test.gateway.groups.user model_mail_test_gateway_groups base.group_user 1 1 1 1
17 access_mail_test_track_portal mail.test.track.portal model_mail_test_track base.group_portal 0 0 0 0
+512 -79
View File
@@ -19,6 +19,7 @@ class TestMailAliasCommon(MailCommon):
super().setUpClass()
cls.test_alias_mc = cls.env['mail.alias'].create({
'alias_domain_id': cls.mail_alias_domain.id,
'alias_model_id': cls.env['ir.model']._get('mail.test.container.mc').id,
'alias_name': 'test.alias',
})
@@ -32,8 +33,7 @@ class TestMailAlias(TestMailAliasCommon):
def test_alias_domain_allowed_validation(self):
""" Check the validation of `mail.catchall.domain.allowed` system parameter"""
for value in [',', ',,', ', ,']:
with self.assertRaises(exceptions.ValidationError,
msg="The value '%s' should not be allowed" % value):
with self.assertRaises(exceptions.ValidationError):
self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', value)
for value, expected in [
@@ -46,56 +46,72 @@ class TestMailAlias(TestMailAliasCommon):
self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', value)
self.assertEqual(self.env['ir.config_parameter'].get_param('mail.catchall.domain.allowed'), expected)
@users('admin')
@mute_logger('odoo.models.unlink')
def test_alias_domain_parameters(self):
""" Check the validation of ``mail.bounce.alias`` and ``mail.catchall.alias``
parameters. """
ICP = self.env['ir.config_parameter']
# sanitization
for (bounce_value, catchall_value), (expected_bounce, expected_catchall) in zip(
[
('bounce+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~', 'catchall+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~'),
('bounce+😊', 'catchall+😊'),
('Bouncâïde 😊', 'Catchôïee 😊'),
('ぁ', 'ぁぁ'),
],
[
('bounce+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~', 'catchall+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~'),
('bounce+-', 'catchall+-'),
('bouncaide-', 'catchoiee-'),
('?', '??'),
]
):
with self.subTest(bounce_value=bounce_value):
ICP.set_param('mail.bounce.alias', bounce_value)
self.assertEqual(ICP.get_param('mail.bounce.alias'), expected_bounce)
with self.subTest(catchall_value=catchall_value):
ICP.set_param('mail.catchall.alias', catchall_value)
self.assertEqual(ICP.get_param('mail.catchall.alias'), expected_catchall)
@users('erp_manager')
def test_alias_domain_company_check(self):
""" Check constraint trying to avoid ill-defined company setup aka
having an alias domain on parent record / record to update that does
not match the alias domain. """
misc_alias_domain = self.env['mail.alias.domain'].create({'name': 'misc.com'})
record_mc_c1, record_mc_c2 = self.env['mail.test.container.mc'].create([
{
'alias_name': 'Test1',
'company_id': self.company_admin.id,
}, {
'alias_name': 'Test2',
'company_id': self.company_2.id,
}
])
alias_mc_c1, alias_mc_c2 = record_mc_c1.alias_id, record_mc_c2.alias_id
self.assertEqual(
(alias_mc_c1 + alias_mc_c2).alias_parent_model_id,
self.env['ir.model']._get('mail.test.container.mc'))
self.assertEqual(
(alias_mc_c1 + alias_mc_c2).mapped('alias_parent_thread_id'),
(record_mc_c1 + record_mc_c2).ids)
self.assertEqual(alias_mc_c1.alias_domain_id, self.mail_alias_domain)
self.assertEqual(alias_mc_c2.alias_domain_id, self.mail_alias_domain_c2)
# falsy values
for config_value in [False, None, '', ' ']:
with self.subTest(config_value=config_value):
ICP.set_param('mail.bounce.alias', config_value)
self.assertFalse(ICP.get_param('mail.bounce.alias'))
ICP.set_param('mail.catchall.alias', config_value)
self.assertFalse(ICP.get_param('mail.catchall.alias'))
# mail_alias_domain_c2 is linked to a conflicting company
with self.assertRaises(exceptions.ValidationError):
record_mc_c1.alias_domain_id = self.mail_alias_domain_c2
with self.assertRaises(exceptions.ValidationError):
alias_mc_c1.sudo().alias_domain_id = self.mail_alias_domain_c2
# misc_alias_domain is not linked to any company, therefore ok
record_mc_c1.alias_domain_id = misc_alias_domain
# check successive param set, should not raise for unicity against itself
for _ in range(2):
ICP.set_param('mail.bounce.alias', 'bounce+double.test')
ICP.set_param('mail.catchall.alias', 'catchall+double.test')
self.assertEqual(ICP.get_param('mail.bounce.alias'), 'bounce+double.test')
self.assertEqual(ICP.get_param('mail.catchall.alias'), 'catchall+double.test')
# alias updating records
record_upd_c1, record_upd_c2 = self.env['mail.test.alias.optional'].sudo().create([
{
'alias_name': 'Update C1',
'company_id': self.company_admin.id,
}, {
'alias_name': 'Update C2',
'company_id': self.company_2.id,
}
])
alias_update_c1, alias_update_c2 = record_upd_c1.alias_id, record_upd_c2.alias_id
self.assertEqual(
(alias_update_c1 + alias_update_c2).mapped('alias_force_thread_id'),
(record_upd_c1 + record_upd_c2).ids)
self.assertEqual(alias_update_c1.alias_domain_id, self.mail_alias_domain)
self.assertEqual(alias_update_c2.alias_domain_id, self.mail_alias_domain_c2)
# mail_alias_domain_c2 is linked to a conflicting company
with self.assertRaises(exceptions.ValidationError):
record_upd_c1.alias_domain_id = self.mail_alias_domain_c2
with self.assertRaises(exceptions.ValidationError):
alias_update_c1.sudo().alias_domain_id = self.mail_alias_domain_c2
# misc_alias_domain is not linked to any company, therefore ok
record_upd_c1.alias_domain_id = misc_alias_domain
@users('admin')
def test_alias_name_unique(self):
""" Check uniqueness constraint on alias names, at create and update.
Also check conflict management with bounce / catchall aliases. """
Also check conflict management with bounce / catchall defined on
alias domains. """
mail_alias_domain = self.mail_alias_domain.with_env(self.env)
mail_alias_domain_c2 = self.mail_alias_domain_c2.with_env(self.env)
alias_model_id = self.env['ir.model']._get('mail.test.gateway').id
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')
new_mail_alias = self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
@@ -105,18 +121,40 @@ class TestMailAlias(TestMailAliasCommon):
'alias_model_id': alias_model_id,
'alias_name': 'other.test.alias',
})
self.assertEqual((new_mail_alias + other_alias).alias_domain_id, mail_alias_domain)
# test you cannot create or update aliases matching bounce / catchall
# test you cannot create or update aliases matching bounce / catchall of same alias domain
with self.assertRaises(exceptions.ValidationError), self.cr.savepoint():
self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
'alias_name': mail_alias_domain.catchall_alias,
})
with self.assertRaises(exceptions.ValidationError), self.cr.savepoint():
self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
'alias_name': mail_alias_domain.bounce_alias,
})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create({'alias_model_id': alias_model_id, 'alias_name': catchall_alias})
new_mail_alias.write({'alias_name': mail_alias_domain.catchall_alias})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create({'alias_model_id': alias_model_id, 'alias_name': bounce_alias})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
new_mail_alias.write({'alias_name': catchall_alias})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
new_mail_alias.write({'alias_name': bounce_alias})
new_mail_alias.write({'alias_name': mail_alias_domain.bounce_alias})
# test that alias names should be unique
# other domains bounce / catchall do not prevent
new_aliases = self.env['mail.alias'].create([
{'alias_model_id': alias_model_id, 'alias_name': self.alias_catchall_c2},
{'alias_model_id': alias_model_id, 'alias_name': self.alias_bounce_c2},
])
self.assertEqual(new_aliases.alias_domain_id, mail_alias_domain)
new_aliases.unlink()
# bounce/catchall of another domain is ok
new_mail_alias.write({'alias_name': mail_alias_domain_c2.bounce_alias})
other_alias.write({'alias_name': mail_alias_domain_c2.catchall_alias})
# changing domain would clash with existing catchall
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
new_mail_alias.write({'alias_domain_id': mail_alias_domain_c2.id,})
new_mail_alias.write({'alias_name': 'unused.test.alias'})
# test that alias {name, alias_domain_id} should be unique
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
@@ -133,15 +171,48 @@ class TestMailAlias(TestMailAliasCommon):
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
other_alias.write({'alias_name': 'unused.test.alias'})
# cannot set catchall / bounce to used alias
# also valid for void domain
nodom_alias = self.env['mail.alias'].create({
'alias_domain_id': False,
'alias_model_id': alias_model_id,
'alias_name': 'no.domain',
})
self.assertFalse(nodom_alias.alias_domain_id)
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['ir.config_parameter'].sudo().set_param('mail.catchall.alias', new_mail_alias.alias_name)
self.env['mail.alias'].create({
'alias_domain_id': False,
'alias_model_id': alias_model_id,
'alias_name': 'no.domain',
})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
self.env['ir.config_parameter'].sudo().set_param('mail.bounce.alias', new_mail_alias.alias_name)
self.env['mail.alias'].create([
{
'alias_domain_id': False,
'alias_model_id': alias_model_id,
'alias_name': 'dupes.wo.domain',
} for _x in range(2)
])
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
other_alias.write({
'alias_domain_id': False,
'alias_name': 'no.domain',
})
# test that alias name can be duplicated in case of different alias domains
other_domain_alias = self.env['mail.alias'].create({
'alias_domain_id': mail_alias_domain_c2.id,
'alias_model_id': alias_model_id,
'alias_name': 'unused.test.alias'
})
self.assertEqual(other_domain_alias.alias_domain_id, mail_alias_domain_c2)
# changing domain would violate uniqueness
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
other_domain_alias.write({'alias_domain_id': mail_alias_domain.id})
@users('admin')
def test_alias_name_unique_copy(self):
""" Check uniqueness constraint check when copying aliases """
mail_alias_domain = self.mail_alias_domain.with_env(self.env)
alias_model_id = self.env['ir.model']._get('mail.test.gateway').id
new_mail_alias = self.env['mail.alias'].create({
'alias_model_id': alias_model_id,
@@ -154,9 +225,11 @@ class TestMailAlias(TestMailAliasCommon):
# test that duplicating an alias should have blank name
copy_1 = new_mail_alias.copy()
self.assertFalse(copy_1.alias_name)
self.assertEqual(copy_1.alias_domain_id, mail_alias_domain)
# test sanitize of copy with new name
copy_2 = new_mail_alias.copy({'alias_name': 'test.alias.2.éè#'})
self.assertEqual(copy_2.alias_name, 'test.alias.2.ee#')
self.assertEqual(copy_2.alias_domain_id, mail_alias_domain)
# cannot batch update, would create duplicates
with self.assertRaises(exceptions.UserError):
@@ -231,6 +304,69 @@ class TestMailAlias(TestMailAliasCommon):
for alias in aliases:
self.assertEqual(alias.alias_name, False)
@users('admin')
def test_search(self):
""" Test search on aliases, notably searching on display_name which should
be split on searching on alias_name and alias_domain_id. """
# ensure existing aliases to ease future asserts
existing = self.env['mail.alias'].search([('alias_domain_id', '!=', False)])
self.assertEqual(existing.alias_domain_id, self.mail_alias_domain)
existing.write({'alias_name': False}) # don't be annoyed by existing aliases
mail_alias_domain = self.mail_alias_domain.with_env(self.env)
mail_alias_domain_c2 = self.mail_alias_domain_c2.with_env(self.env)
self.assertEqual(mail_alias_domain.name, 'test.mycompany.com')
self.assertEqual(mail_alias_domain_c2.name, 'test.mycompany2.com')
aliases = self.env['mail.alias'].create([
{
'alias_model_id': self.env['ir.model']._get('mail.test.container.mc').id,
'alias_name': f'test.search.{idx}',
'alias_domain_id': domain.id,
}
for idx in range(5)
for domain in (mail_alias_domain + mail_alias_domain_c2)
])
aliases_d1 = aliases.filtered(lambda a: a.alias_domain_id == mail_alias_domain)
aliases_d2 = aliases.filtered(lambda a: a.alias_domain_id == mail_alias_domain_c2)
# search on alias_name: classic search
self.assertEqual(
self.env['mail.alias'].search([('alias_name', 'ilike', 'test.search')]),
aliases
)
# search on alias_fullname: search on aggregated of {name}@{domain}
for search_term, expected, msg in [
('mycompany', aliases,
'Match all aliases on both domains as "mycompany" is contained in those two'),
(mail_alias_domain.name, aliases_d1,
'Exact match on domain 1: should find all aliases in that domain'),
(mail_alias_domain_c2.name, aliases_d2,
'Exact match on domain 2: should find all aliases in that domain'),
('search.0@test.mycompany', aliases.filtered(lambda a: a.alias_name == 'test.search.0'),
'Match in both domains'),
('search.0@test.mycompany.com', aliases.filtered(lambda a: a.alias_name == 'test.search.0' and a.alias_domain_id == mail_alias_domain),
'Match only in domain 1'),
('search@test.mycompany.com', self.env['mail.alias'],
'Does not match even as ilike'),
]:
with self.subTest(search_term=search_term):
self.assertEqual(
self.env['mail.alias'].search([('alias_full_name', 'ilike', search_term)]),
expected, msg
)
# search using IN operator
for search_term, expected, msg in [
(['mycompany'], self.env['mail.alias'], 'mycompany is too vague: does not match a left- and right- part (!= ilike)'),
([mail_alias_domain.name], self.env['mail.alias'], 'Match only right-part of aliases emails'),
]:
with self.subTest(search_term=search_term):
self.assertEqual(self.env['mail.alias'].search([('alias_full_name', 'in', search_term)]),
expected, msg
)
@users('admin')
def test_alias_setup(self):
""" Test various constraints / configuration of alias model"""
@@ -250,26 +386,103 @@ class TestMailAlias(TestMailAliasCommon):
class TestAliasCompany(TestMailAliasCommon):
""" Test company / alias domain and configuration synchronization """
@mute_logger('odoo.models.unlink')
@users('erp_manager')
def test_alias_domain_setup(self):
""" Test synchronization of alias domain with companies when adding /
updating / removing alias domains """
mail_alias_domain = self.mail_alias_domain.with_env(self.env)
mail_alias_domain_c2 = self.mail_alias_domain_c2.with_env(self.env)
self.assertEqual(self.company_admin.alias_domain_id, mail_alias_domain)
self.assertEqual(self.company_2.alias_domain_id, mail_alias_domain_c2)
# cannot unlink alias domain as there are aliases linked to it
with self.assertRaises(psycopg2.errors.ForeignKeyViolation), self.cr.savepoint(), mute_logger('odoo.sql_db'):
mail_alias_domain.unlink()
# eject linked aliases then remove alias domain of first company; should
# not impact second company
self.env['mail.alias'].sudo().search([]).write({'alias_domain_id': False})
mail_alias_domain.unlink()
self.assertFalse(self.company_admin.alias_domain_id)
self.assertEqual(self.company_2.alias_domain_id, mail_alias_domain_c2)
self.assertFalse(self.test_alias_mc.alias_domain_id)
# remove all alias domains
self.env['mail.alias.domain'].search([]).unlink()
self.assertFalse(self.company_2.alias_domain_id)
self.assertEqual(self.company_2.bounce_email, '')
self.assertEqual(self.company_2.bounce_formatted, '')
self.assertEqual(self.company_2.catchall_email, '')
self.assertEqual(self.company_2.catchall_formatted, '')
self.assertFalse(self.company_2.default_from_email, '')
self.assertFalse(self.company_3.alias_domain_id)
# create a new alias domain -> consider as re-init, populate all companies
alias_domain_new = self.env['mail.alias.domain'].create({
'bounce_alias': 'bounce.new',
'catchall_alias': 'catchall.new',
'name': 'test.global.bitnurk.com',
})
self.assertEqual(self.company_admin.alias_domain_id, alias_domain_new,
'MC Alias: first domain should populate void companies')
self.assertEqual(self.company_2.alias_domain_id, alias_domain_new,
'MC Alias: should take alias domain with lower sequence')
self.assertEqual(self.company_3.alias_domain_id, alias_domain_new,
'MC Alias: should take alias domain with lower sequence')
self.assertEqual(self.test_alias_mc.alias_domain_id, alias_domain_new,
'MC Alias: first domain should populate void aliases')
# manual update
self.company_2.alias_domain_id = alias_domain_new.id
self.assertEqual(self.company_2.alias_domain_id, alias_domain_new)
self.assertEqual(self.company_2.bounce_email, 'bounce.new@test.global.bitnurk.com')
self.assertEqual(self.company_2.catchall_email, 'catchall.new@test.global.bitnurk.com')
def test_assert_initial_values(self):
""" Test initial setup values: currently all companies share the same
alias configuration as it is unique. """
self.assertEqual(self.test_alias_mc.alias_domain_id, self.mail_alias_domain)
self.assertEqual(self.company_admin.alias_domain_id, self.mail_alias_domain)
self.assertEqual(self.company_admin.bounce_email, f'{self.alias_bounce}@{self.alias_domain}')
self.assertEqual(
self.company_admin.bounce_formatted,
formataddr((self.company_admin.name, f'{self.alias_bounce}@{self.alias_domain}'))
)
self.assertEqual(self.company_admin.catchall_email, f'{self.alias_catchall}@{self.alias_domain}')
self.assertEqual(
self.company_admin.catchall_formatted,
formataddr((self.company_admin.name, f'{self.alias_catchall}@{self.alias_domain}'))
)
self.assertEqual(self.company_admin.default_from_email, f'{self.default_from}@{self.alias_domain}')
self.assertEqual(self.company_2.catchall_email, f'{self.alias_catchall}@{self.alias_domain}')
self.assertEqual(self.company_2.alias_domain_id, self.mail_alias_domain_c2)
self.assertEqual(self.company_2.bounce_email, f'{self.alias_bounce_c2}@{self.alias_domain_c2_name}')
self.assertEqual(
self.company_2.bounce_formatted,
formataddr((self.company_2.name, f'{self.alias_bounce_c2}@{self.alias_domain_c2_name}'))
)
self.assertEqual(self.company_2.catchall_email, f'{self.alias_catchall_c2}@{self.alias_domain_c2_name}')
self.assertEqual(
self.company_2.catchall_formatted,
formataddr((self.company_2.name, f'{self.alias_catchall}@{self.alias_domain}'))
formataddr((self.company_2.name, f'{self.alias_catchall_c2}@{self.alias_domain_c2_name}'))
)
self.assertEqual(self.company_2.default_from_email, f'{self.alias_default_from_c2}@{self.alias_domain_c2_name}')
self.assertEqual(self.company_3.catchall_email, f'{self.alias_catchall}@{self.alias_domain}')
self.assertEqual(self.company_3.alias_domain_id, self.mail_alias_domain_c3)
self.assertEqual(self.company_3.bounce_email, f'{self.alias_bounce_c3}@{self.alias_domain_c3_name}')
self.assertEqual(
self.company_3.bounce_formatted,
formataddr((self.company_3.name, f'{self.alias_bounce_c3}@{self.alias_domain_c3_name}'))
)
self.assertEqual(self.company_3.catchall_email, f'{self.alias_catchall_c3}@{self.alias_domain_c3_name}')
self.assertEqual(
self.company_3.catchall_formatted,
formataddr((self.company_3.name, f'{self.alias_catchall}@{self.alias_domain}'))
formataddr((self.company_3.name, f'{self.alias_catchall_c3}@{self.alias_domain_c3_name}'))
)
self.assertEqual(self.company_3.default_from_email, f'{self.alias_default_from_c3}@{self.alias_domain_c3_name}')
@users('erp_manager')
def test_res_company_creation_alias_domain(self):
@@ -280,25 +493,182 @@ class TestAliasCompany(TestMailAliasCommon):
})
company.flush_recordset()
self.assertEqual(
company.catchall_formatted,
formataddr((company.name, f'{self.alias_catchall}@{self.alias_domain}'))
)
company.alias_domain_id, self.mail_alias_domain,
'Default alias domain: sequence based')
# respect forced value
company = self.env['res.company'].create({
'alias_domain_id': self.mail_alias_domain_c2.id,
'email': '"Yet Another Company" <yet.another.company@test.embed.mycompany.com>',
'name': 'Yet Another Company',
})
self.assertEqual(company.alias_domain_id, self.mail_alias_domain_c2)
@tagged('mail_gateway', 'mail_alias', 'multi_company')
class TestMailAliasDomain(TestMailAliasCommon):
@users('admin')
def test_alias_domain_config_alias_clash(self):
""" Domain names are not unique e.g. owning multiple gmail.com accounts.
However bounce / catchall should not clash with aliases. """
alias_domain = self.mail_alias_domain.with_env(self.env)
for domain_config in {'bounce_alias', 'catchall_alias'}:
with self.subTest(domain_config=domain_config):
with self.assertRaises(exceptions.ValidationError):
self.env['mail.alias.domain'].create({
domain_config: self.test_alias_mc.alias_name,
'name': self.test_alias_mc.alias_domain_id.name,
})
# left-part should not clech
self.env['mail.alias.domain'].create({
domain_config: self.test_alias_mc.alias_name,
'name': 'another.domain.name.com',
})
# should not clash with existing aliases, to avoid valid aliases be
# considered as bounce / catchall
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
alias_domain.write({'bounce_alias': self.test_alias_mc.alias_name})
with self.assertRaises(exceptions.UserError), self.cr.savepoint():
alias_domain.write({'catchall_alias': self.test_alias_mc.alias_name})
@users('admin')
def test_alias_domain_config_unique(self):
""" Domain names are not unique e.g. owning multiple gmail.com accounts.
However bounce / catchall should be unique. """
alias_domain = self.mail_alias_domain.with_env(self.env)
# copying directly would duplicate bounce / catchall emails
with mute_logger('odoo.sql_db'), self.assertRaises(psycopg2.errors.UniqueViolation), self.cr.savepoint():
new_alias_domain = alias_domain.copy()
# same domain name is authorized if bounce and catchall are different
new_alias_domain = alias_domain.copy({
'bounce_alias': 'new.bounce',
'catchall_alias': 'new.catchall',
})
self.assertEqual(new_alias_domain.bounce_email, f'new.bounce@{alias_domain.name}')
self.assertEqual(new_alias_domain.catchall_email, f'new.catchall@{alias_domain.name}')
self.assertEqual(new_alias_domain.name, alias_domain.name)
# check bounce / catchall are unique at create
self.env['mail.alias.domain'].create({
'bounce_alias': 'unique.bounce',
'catchall_alias': 'unique.catchall',
'name': alias_domain.name,
})
# any not unique should raise UniqueViolation (SQL constraint fired after check)
with mute_logger('odoo.sql_db'), self.assertRaises(psycopg2.errors.UniqueViolation), self.cr.savepoint():
self.env['mail.alias.domain'].create({
'bounce_alias': alias_domain.bounce_alias,
'name': alias_domain.name,
})
with mute_logger('odoo.sql_db'), self.assertRaises(psycopg2.errors.UniqueViolation), self.cr.savepoint():
self.env['mail.alias.domain'].create({
'catchall_alias': alias_domain.catchall_alias,
'name': alias_domain.name,
})
# also check write operation
with self.assertRaises(exceptions.ValidationError):
new_alias_domain.write({'bounce_alias': alias_domain.bounce_alias})
with self.assertRaises(exceptions.ValidationError):
new_alias_domain.write({'catchall_alias': alias_domain.catchall_alias})
@users('admin')
def test_alias_domain_parameters_validation(self):
""" Test validation of bounce and catchall fields of alias domain as
well as sanitization. """
alias_domain = self.mail_alias_domain.with_env(self.env)
# sanitization of name (both create and write)
for failing_name in [
'outlook.fr, gmail.com',
# accents
'provaïder',
'provaïder.cöm',
# fail
'', ' ',
]:
with self.subTest(failing_name=failing_name):
with self.assertRaises(exceptions.ValidationError):
_new_domain = self.env['mail.alias.domain'].create({'name': failing_name})
with self.assertRaises(exceptions.ValidationError):
alias_domain.write({'name': failing_name})
# sanitization of bounce / catchall
for (bounce_alias, catchall_alias, default_from), (exp_bounce, exp_catchall, exp_default_from) in zip(
[
(
'bounce+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~',
'catchall+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~',
'notifications+b4r=*R3wl_#_-$€{}[]()~|\\/!?&%^\'"`~',
),
('bounce+😊', 'catchall+😊', 'notifications+😊'),
('Bouncâïde 😊', 'Catchôïee 😊', 'Notificâtïons 😊'),
('ぁ', 'ぁぁ', 'ぁぁぁ'),
],
[
(
'bounce+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~',
'catchall+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~',
'notifications+b4r=*r3wl_#_-$-{}-~|-/!?&%^\'-`~',
),
('bounce+-', 'catchall+-', 'notifications+-'),
('bouncaide-', 'catchoiee-', 'notifications-'),
('?', '??', '???'),
]
):
with self.subTest(bounce_alias=bounce_alias):
alias_domain.write({'bounce_alias': bounce_alias})
self.assertEqual(alias_domain.bounce_alias, exp_bounce)
with self.subTest(catchall_alias=catchall_alias):
alias_domain.write({'catchall_alias': catchall_alias})
self.assertEqual(alias_domain.catchall_alias, exp_catchall)
with self.subTest(default_from=default_from):
alias_domain.write({'default_from': default_from})
self.assertEqual(alias_domain.default_from, exp_default_from)
# falsy values
for config_value in [False, None, '', ' ']:
with self.subTest(config_value=config_value):
alias_domain.write({'bounce_alias': config_value})
self.assertFalse(alias_domain.bounce_alias)
alias_domain.write({'catchall_alias': config_value})
self.assertFalse(alias_domain.catchall_alias)
alias_domain.write({'default_from': config_value})
self.assertFalse(alias_domain.default_from)
# check successive param set, should not raise for unicity against itself
for _ in range(2):
alias_domain.write({
'bounce_alias': 'bounce+double.test',
'catchall_alias': 'catchall+double.test',
})
self.assertEqual(alias_domain.bounce_alias, 'bounce+double.test')
self.assertEqual(alias_domain.catchall_alias, 'catchall+double.test')
@tagged('mail_gateway', 'mail_alias', 'mail_alias_mixin', 'multi_company')
class TestMailAliasMixin(TestMailAliasCommon):
""" Test alias mixin implementation, synchornization of alias records
""" Test alias mixin implementation, synchronization of alias records
based on owner records. """
@users('employee')
@mute_logger('odoo.addons.base.models.ir_model')
def test_alias_mixin(self):
""" Various base checks on alias mixin behavior """
self.assertEqual(self.env.company.alias_domain_id, self.mail_alias_domain)
record = self.env['mail.test.container'].create({
'name': 'Test Record',
'alias_name': 'alias.test',
'alias_contact': 'followers',
})
self.assertEqual(record.alias_id.alias_domain_id, self.mail_alias_domain)
self.assertEqual(record.alias_id.alias_model_id, self.env['ir.model']._get('mail.test.container'))
self.assertEqual(record.alias_id.alias_force_thread_id, record.id)
self.assertEqual(record.alias_id.alias_parent_model_id, self.env['ir.model']._get('mail.test.container'))
@@ -307,9 +677,11 @@ class TestMailAliasMixin(TestMailAliasCommon):
self.assertEqual(record.alias_id.alias_contact, 'followers')
record.write({
'alias_domain_id': self.mail_alias_domain_c2.id,
'alias_name': 'better.alias.test',
'alias_defaults': "{'default_name': 'defaults'}"
})
self.assertEqual(record.alias_domain, self.mail_alias_domain_c2.name)
self.assertEqual(record.alias_id.alias_name, 'better.alias.test')
self.assertEqual(record.alias_id.alias_defaults, "{'default_name': 'defaults'}")
@@ -326,6 +698,32 @@ class TestMailAliasMixin(TestMailAliasCommon):
with self.assertRaises(exceptions.ValidationError):
record.write({'alias_defaults': "{'custom_field': brokendict"})
@users('erp_manager')
def test_alias_mixin_alias_email(self):
""" Test 'alias_email' mixin field computation and search capability """
Model = self.env['mail.test.container.mc']
records = Model.create([
{
'alias_name': f'alias.email.{idx}', # will be present in all companies
'company_id': company.id,
'name': f'Test {company.id} {idx}',
}
for company in (self.company_admin, self.company_2)
for idx in range(5)
])
self.assertEqual(
Model.search([('alias_email', 'ilike', 'alias.email')]), records,
'Search: partial search: any domain, matching all left parts')
self.assertEqual(
Model.search([('alias_email', 'ilike', 'alias.email.0')]), records[0] + records[5],
'Search: partial search: any domain, matching some left parts')
self.assertEqual(
Model.search([('alias_email', '=', self.mail_alias_domain.name)]), Model,
'Search: partial search: does not match any complete email')
self.assertEqual(
Model.search([('alias_email', '=', f'alias.email.1@{self.mail_alias_domain.name}')]), records[1],
'Search: both part search: search on name + domain')
@users('employee')
@mute_logger('odoo.addons.base.models.ir_model')
def test_alias_mixin_alias_id_management(self):
@@ -411,20 +809,55 @@ class TestMailAliasMixin(TestMailAliasCommon):
@users('erp_manager')
def test_multi_company_setup(self):
""" Test company change does not impact anything at alias domain level """
record = self.env['mail.test.container.mc'].create({
'name': 'Test Record',
'alias_name': 'alias.test',
'alias_contact': 'followers',
'company_id': self.env.user.company_id.id,
})
self.assertEqual(record.alias_domain, self.alias_domain)
self.assertEqual(record.company_id, self.company_2)
""" Test company impact on alias domains when creating or updating
records in a MC environment. """
counter = 0
for create_cid, exp_company, exp_alias_domain in [
(None, self.company_2, self.mail_alias_domain_c2),
(False, self.env['res.company'], self.mail_alias_domain_c2),
(self.env.user.company_id.id, self.company_2, self.mail_alias_domain_c2),
(self.company_admin.id, self.company_admin, self.mail_alias_domain),
]:
with self.subTest(create_cid=create_cid, exp_company=exp_company, exp_alias_domain=exp_alias_domain):
counter += 1
base_values = {
'name': f'Test Record {counter}',
'alias_name': f'alias.test.{counter}',
'alias_contact': 'followers',
}
if create_cid is not None:
base_values['company_id'] = create_cid
record = self.env['mail.test.container.mc'].create(base_values)
self.assertEqual(record.alias_domain_id, exp_alias_domain)
self.assertEqual(record.company_id, exp_company)
record.write({'company_id': self.company_admin.id})
self.assertEqual(record.alias_domain, self.alias_domain)
self.assertEqual(record.company_id, self.company_admin)
# copy: keep company
record_copy = record.copy(
default={
'alias_name': f'alias.copy.{counter}',
'name': f'Copy of {record.name}',
}
)
self.assertEqual(record_copy.alias_domain_id, exp_alias_domain)
self.assertEqual(record_copy.company_id, record.company_id)
record.write({'company_id': False})
self.assertEqual(record.alias_domain, self.alias_domain)
self.assertFalse(record.company_id)
# copy: force company
record_copy_2 = record.copy(
default={
'alias_name': f'alias.copy.{counter}.2',
'company_id': self.company_admin.id,
'name': f'Copy 2 of {record.name}',
}
)
self.assertEqual(record_copy_2.alias_domain_id, self.mail_alias_domain)
self.assertEqual(record_copy_2.company_id, self.company_admin)
# updating company: force same alias domain
record.write({'company_id': self.company_admin.id})
self.assertEqual(record.alias_domain_id, self.mail_alias_domain)
self.assertEqual(record.company_id, self.company_admin)
# reset company: should not impact alias_domain if set
record.write({'company_id': False})
self.assertEqual(record.alias_domain_id, self.mail_alias_domain)
self.assertFalse(record.company_id)
+359 -13
View File
@@ -56,32 +56,32 @@ class TestMailComposer(MailCommon, TestRecipients):
)
cls.env.ref('mail.group_mail_template_editor').users -= cls.user_rendering_restricted
cls.test_record = cls.env['mail.test.ticket'].with_context(cls._test_context).create({
cls.test_record = cls.env['mail.test.ticket.mc'].with_context(cls._test_context).create({
'name': 'TestRecord',
'customer_id': cls.partner_1.id,
'user_id': cls.user_employee_2.id,
})
cls.test_records, cls.test_partners = cls._create_records_for_batch(
'mail.test.ticket', 2,
'mail.test.ticket.mc', 2,
additional_values={'user_id': cls.user_employee_2.id},
)
cls.test_report, cls.test_report_2, cls.test_report_3 = cls.env['ir.actions.report'].create([
{
'name': 'Test Report on Mail Test Ticket',
'model': 'mail.test.ticket',
'model': 'mail.test.ticket.mc',
'print_report_name': "'TestReport for %s' % object.name",
'report_type': 'qweb-pdf',
'report_name': 'test_mail.mail_test_ticket_test_template',
}, {
'name': 'Test Report 2 on Mail Test Ticket',
'model': 'mail.test.ticket',
'model': 'mail.test.ticket.mc',
'print_report_name': "'TestReport2 for %s' % object.name",
'report_type': 'qweb-pdf',
'report_name': 'test_mail.mail_test_ticket_test_template_2',
}, {
'name': 'Test Report 3 with variable data on Mail Test Ticket',
'model': 'mail.test.ticket',
'model': 'mail.test.ticket.mc',
'print_report_name': "'TestReport3 for %s' % object.name",
'report_type': 'qweb-pdf',
'report_name': 'test_mail.mail_test_ticket_test_variable_template',
@@ -100,7 +100,7 @@ class TestMailComposer(MailCommon, TestRecipients):
'email_from': '{{ (object.user_id.email_formatted or user.email_formatted) }}',
'lang': '{{ object.customer_id.lang }}',
'mail_server_id': cls.mail_server_domain.id,
'model_id': cls.env['ir.model']._get('mail.test.ticket').id,
'model_id': cls.env['ir.model']._get('mail.test.ticket.mc').id,
'reply_to': '{{ ctx.get("custom_reply_to") or "info@test.example.com" }}',
'scheduled_date': '{{ (object.create_date or datetime.datetime(2022, 12, 26, 18, 0, 0)) + datetime.timedelta(days=2) }}',
})
@@ -171,6 +171,8 @@ class TestComposerForm(TestMailComposer):
self.assertFalse(composer_form.mail_server_id)
self.assertEqual(composer_form.model, self.test_record._name)
self.assertFalse(composer_form.partner_ids)
self.assertEqual(composer_form.record_alias_domain_id, self.mail_alias_domain)
self.assertEqual(composer_form.record_company_id, self.env.company)
self.assertEqual(composer_form.record_name, self.test_record.name, 'MailComposer: comment mode should compute record name')
self.assertFalse(composer_form.reply_to)
self.assertFalse(composer_form.reply_to_force_new)
@@ -182,6 +184,32 @@ class TestComposerForm(TestMailComposer):
self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment'))
self.assertFalse(composer_form.subtype_is_log)
@users('employee')
def test_mail_composer_comment_mc(self):
""" Test values specific to multi-company setup, in comment mode, using
the Form tool. """
# add access to second company to avoid MC rules on ticket model
self.env.user.company_ids = [(4, self.company_2.id)]
test_record = self.test_record.with_env(self.env)
source_company = [
self.company_admin,
self.company_2,
self.env['res.company'],
]
expected = [
(self.company_admin, self.mail_alias_domain),
(self.company_2, self.mail_alias_domain_c2),
(self.company_admin, self.mail_alias_domain), # env company
]
for company, (exp_company, exp_alias_domain) in zip(source_company, expected):
with self.subTest(cmopany=company):
test_record.write({'company_id': company.id})
composer_form = Form(self.env['mail.compose.message'].with_context(
self._get_web_context(self.test_record, add_web=True)
))
self.assertEqual(composer_form.record_alias_domain_id, exp_alias_domain)
self.assertEqual(composer_form.record_company_id, exp_company)
@users('employee')
def test_mail_composer_comment_attachments(self):
"""Tests that all attachments are added to the composer, static attachments
@@ -251,6 +279,8 @@ class TestComposerForm(TestMailComposer):
self.assertEqual(composer_form.mail_server_id, self.mail_server_domain)
self.assertEqual(composer_form.model, self.test_record._name)
self.assertEqual(composer_form.partner_ids[:], self.partner_1)
self.assertEqual(composer_form.record_alias_domain_id, self.mail_alias_domain)
self.assertEqual(composer_form.record_company_id, self.env.company)
self.assertEqual(composer_form.record_name, self.test_record.name, 'MailComposer: comment mode should compute record name')
self.assertEqual(composer_form.reply_to, 'info@test.example.com')
self.assertFalse(composer_form.reply_to_force_new)
@@ -283,6 +313,8 @@ class TestComposerForm(TestMailComposer):
self.assertFalse(composer_form.force_send, 'MailComposer: batch record post use email queue for notifications')
self.assertEqual(composer_form.mail_server_id, self.mail_server_domain)
self.assertEqual(composer_form.model, self.test_record._name)
self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: comment in batch mode should have void alias domain')
self.assertFalse(composer_form.record_company_id, 'MailComposer: comment in batch mode should have void company')
self.assertFalse(composer_form.record_name, 'MailComposer: comment in batch mode should have void record name')
self.assertEqual(composer_form.reply_to, self.template.reply_to)
self.assertFalse(composer_form.reply_to_force_new)
@@ -315,6 +347,8 @@ class TestComposerForm(TestMailComposer):
self.assertFalse(composer_form.force_send, 'MailComposer: batch record post use email queue for notifications')
self.assertEqual(composer_form.mail_server_id, self.mail_server_domain)
self.assertEqual(composer_form.model, self.test_record._name)
self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: comment in batch mode should have void alias domain')
self.assertFalse(composer_form.record_company_id, 'MailComposer: comment in batch mode should have void company')
self.assertFalse(composer_form.record_name, 'MailComposer: comment in batch mode should have void record name')
self.assertEqual(composer_form.reply_to, self.template.reply_to)
self.assertFalse(composer_form.reply_to_force_new)
@@ -332,7 +366,7 @@ class TestComposerForm(TestMailComposer):
when nothing is given as context. """
composer_form = Form(self.env['mail.compose.message'].with_context(
default_composition_mode='comment',
default_model='mail.test.ticket',
default_model='mail.test.ticket.mc',
default_template_id=self.template.id,
))
self.assertTrue(composer_form.auto_delete, 'Should take composer value')
@@ -347,6 +381,8 @@ class TestComposerForm(TestMailComposer):
self.assertEqual(composer_form.mail_server_id, self.mail_server_domain)
self.assertEqual(composer_form.model, self.test_record._name)
self.assertFalse(composer_form.partner_ids[:])
self.assertFalse(composer_form.record_alias_domain_id)
self.assertFalse(composer_form.record_company_id)
self.assertFalse(composer_form.record_name)
self.assertEqual(composer_form.reply_to, 'info@test.example.com')
self.assertFalse(composer_form.reply_to_force_new)
@@ -375,6 +411,8 @@ class TestComposerForm(TestMailComposer):
self.assertTrue(composer_form.force_send, 'MailComposer: mass mode sends emails right away')
self.assertFalse(composer_form.mail_server_id)
self.assertEqual(composer_form.model, self.test_records._name)
self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: mass mode should have void alias domain')
self.assertFalse(composer_form.record_company_id, 'MailComposer: mass mode should have void company')
self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name')
self.assertFalse(composer_form.reply_to)
self.assertFalse(composer_form.reply_to_force_new)
@@ -404,6 +442,8 @@ class TestComposerForm(TestMailComposer):
self.assertTrue(composer_form.force_send, 'MailComposer: mass mode sends emails right away')
self.assertEqual(composer_form.mail_server_id, self.mail_server_domain)
self.assertEqual(composer_form.model, self.test_records._name)
self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: mass mode should have void alias domain')
self.assertFalse(composer_form.record_company_id, 'MailComposer: mass mode should have void company')
self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name')
self.assertEqual(composer_form.reply_to, self.template.reply_to)
self.assertFalse(composer_form.reply_to_force_new)
@@ -437,6 +477,8 @@ class TestComposerForm(TestMailComposer):
self.assertTrue(composer_form.force_send, 'MailComposer: mass mode sends emails right away')
self.assertEqual(composer_form.mail_server_id, self.mail_server_domain)
self.assertEqual(composer_form.model, self.test_records._name)
self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: mass mode should have void alias domain')
self.assertFalse(composer_form.record_company_id, 'MailComposer: mass mode should have void company')
self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name')
self.assertEqual(composer_form.reply_to, self.template.reply_to)
self.assertFalse(composer_form.reply_to_force_new)
@@ -454,7 +496,7 @@ class TestComposerForm(TestMailComposer):
when nothing is given as context. """
composer_form = Form(self.env['mail.compose.message'].with_context(
default_composition_mode='mass_mail',
default_model='mail.test.ticket',
default_model='mail.test.ticket.mc',
default_template_id=self.template.id,
))
self.assertTrue(composer_form.auto_delete, 'Should take composer value')
@@ -470,6 +512,8 @@ class TestComposerForm(TestMailComposer):
self.assertTrue(composer_form.force_send, 'MailComposer: mass mode sends emails right away')
self.assertEqual(composer_form.mail_server_id, self.mail_server_domain)
self.assertEqual(composer_form.model, self.test_records._name)
self.assertFalse(composer_form.record_alias_domain_id, 'MailComposer: mass mode should have void alias domain')
self.assertFalse(composer_form.record_company_id, 'MailComposer: mass mode should have void company')
self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name')
self.assertEqual(composer_form.reply_to, self.template.reply_to)
self.assertFalse(composer_form.reply_to_force_new)
@@ -1253,7 +1297,7 @@ class TestComposerInternals(TestMailComposer):
new_partners.unlink()
@tagged('mail_composer', 'multi_lang')
@tagged('mail_composer', 'multi_lang', 'multi_company')
class TestComposerResultsComment(TestMailComposer, CronMixinCase):
""" Test global output of composer used in comment mode. Test notably
notification and emails generated during this process. """
@@ -1262,11 +1306,18 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase):
""" Ensure class initial data to ease understanding """
self.assertTrue(self.template.auto_delete)
self.assertEqual(len(self.test_record), 1)
self.assertEqual(self.test_record.user_id, self.user_employee_2)
self.assertEqual(self.test_record.message_partner_ids, self.partner_employee_2)
self.assertEqual(self.test_record[0].customer_id.lang, 'en_US')
self.assertEqual(self.test_record.company_id, self.company_admin)
self.assertEqual(len(self.test_records), 2)
self.assertEqual(self.test_records.user_id, self.user_employee_2)
self.assertEqual(self.test_records.message_partner_ids, self.partner_employee_2)
self.assertEqual(self.test_records[0].customer_id.lang, 'en_US')
self.assertEqual(self.test_records[1].customer_id.lang, 'en_US')
self.assertEqual(self.test_records.company_id, self.company_admin)
self.assertEqual(len(self.test_partners), 2)
@@ -1426,6 +1477,89 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase):
self.assertEqual(message.subtype_id, self.env.ref('mail.mt_comment'))
self.assertEqual(message.partner_ids, self.partner_1 | self.partner_2)
@users('employee')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mail_composer_server_config(self):
""" Test various configuration to check behavior of outgoing mail
servers, notifications, .... """
# add access to second company to avoid MC rules on ticket model
self.env.user.company_ids = [(4, self.company_2.id)]
# initial data
self.assertEqual(self.env.company, self.company_admin)
self.assertEqual(self.user_admin.company_id, self.company_admin)
# update test configuration
test_records = self.test_records
test_companies = self.company_admin + self.company_2
self.company_2.alias_domain_id = self.mail_alias_domain
for company, record in zip(test_companies, test_records):
record.company_id = company.id
# various from / servers configuration
self.template.write({'mail_server_id': False}) # allow server archive
server_other = self.env['ir.mail_server'].sudo().create({
'name': 'Server Other',
'from_filter': 'test.othercompany.com',
'sequence': 4,
'smtp_encryption': 'none',
'smtp_host': 'smtp_host',
})
servers_all = self.mail_servers + server_other
for (emails_from, servers_active, mail_config), (exp_smtp_from_lst, exp_msg_from_lst, exp_from_filter) in zip(
[
(
[f'user.from@{self.alias_domain}', 'user@other.domain.com'],
self.env['ir.mail_server'],
{'default_from': f'notifications@{self.alias_domain}', 'from_filter': self.alias_domain}
), # odoo-style configuration
], [
(
[f'{self.alias_bounce}@{self.alias_domain}', f'{self.alias_bounce}@{self.alias_domain}'],
[f'"{self.env.user.name}" <user.from@{self.alias_domain}>', f'"{self.env.user.name}" <notifications@{self.alias_domain}>'],
self.alias_domain
), # no spoof
],
):
with self.subTest(emails_from=emails_from,
servers_active=servers_active):
# update servers
servers_all.active = False
if servers_active:
servers_active.active = True
# update mail config
default_from = mail_config.get('default_from', self.default_from)
from_filter = mail_config.get('from_filter', self.default_from_filter)
self.mail_alias_domain.default_from = default_from
self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', from_filter)
for email_from, exp_smtp_from, exp_msg_from in zip(emails_from, exp_smtp_from_lst, exp_msg_from_lst):
self.env.user.email = email_from
# open a composer and run it in comment mode
composer = Form(self.env['mail.compose.message'].with_context(
default_composition_mode='comment',
default_force_send=True, # force sending emails directly to check SMTP
default_model=test_records._name,
default_res_ids=test_records.ids,
# avoid successive tests issues with followers
mail_create_nosubscribe=True,
))
composer.body = 'Hello {{ object.name }}'
composer.subject = 'My Subject'
composer = composer.save()
with self.mock_mail_gateway(mail_unlink_sent=False), \
self.mock_mail_app():
composer._action_send_mail()
self.assertSMTPEmailsSent(
smtp_from=exp_smtp_from,
smtp_to_list=[self.partner_employee_2.email_normalized],
emails_count=2, # same on both records
message_from=exp_msg_from,
from_filter=exp_from_filter,
)
@users('employee')
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_message_schedule')
def test_mail_composer_wtpl_complete(self):
@@ -1761,6 +1895,110 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase):
self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(test_record.ids))
self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments')
@users('employee')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mail_composer_wtpl_mc(self):
""" Test specific to multi-company environment, notably company propagation
or aliases. """
# add access to second company to avoid MC rules on ticket model
self.env.user.company_ids = [(4, self.company_2.id)]
# initial data
self.assertEqual(self.env.company, self.company_admin)
self.assertEqual(self.user_admin.company_id, self.company_admin)
attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id)
email_to_1 = 'test.to.1@test.example.com'
self.template.write({
'auto_delete': False, # keep sent emails to check content
'attachment_ids': [(0, 0, a) for a in attachment_data],
'email_from': False, # use current user as author
'email_layout_xmlid': 'mail.test_layout',
'email_to': email_to_1,
'mail_server_id': False, # let it find a server
'partner_to': '%s, {{ object.customer_id.id if object.customer_id else "" }}' % self.partner_admin.id,
})
attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])])
self.assertEqual(len(attachs), 2)
for batch, companies, expected_companies, expected_alias_domains in [
(False, self.company_admin, self.company_admin, self.mail_alias_domain),
(
True, self.company_admin + self.company_2, self.company_admin + self.company_2,
self.mail_alias_domain + self.mail_alias_domain_c2,
),
]:
with self.subTest(batch=batch,
companies=companies):
# update test configuration
test_records = self.test_records if batch else self.test_record
for company, record in zip(companies, test_records):
record.company_id = company.id
# open a composer and run it in comment mode
composer = Form(self.env['mail.compose.message'].with_context(
default_composition_mode='comment',
default_force_send=True, # force sending emails directly to check SMTP
default_model=test_records._name,
default_res_ids=test_records.ids,
default_template_id=self.template.id,
# avoid successive tests issues with followers
mail_create_nosubscribe=True,
)).save()
with self.mock_mail_gateway(mail_unlink_sent=False), \
self.mock_mail_app():
composer._action_send_mail()
new_partner = self.env['res.partner'].search([('email_normalized', '=', 'test.to.1@test.example.com')])
self.assertEqual(len(new_partner), 1)
# check output, company-specific values mainly for this test
for record, exp_company, exp_alias_domain in zip(
test_records, expected_companies, expected_alias_domains
):
message = record.message_ids[0]
for recipient in [self.partner_employee_2, new_partner, record.customer_id]:
self.assertMailMail(
recipient,
'sent',
author=self.partner_employee,
mail_message=message,
email_values={
'headers': {
'Return-Path': f'{exp_alias_domain.bounce_email}',
'X-Odoo-Objects': f'{record._name}-{record.id}',
},
'subject': f'TemplateSubject {record.name}',
},
fields_values={
'headers': {
'Return-Path': f'{exp_alias_domain.bounce_email}',
'X-Odoo-Objects': f'{record._name}-{record.id}',
},
'mail_server_id': self.env['ir.mail_server'],
'record_alias_domain_id': exp_alias_domain,
'record_company_id': exp_company,
'subject': f'TemplateSubject {record.name}',
},
)
# to check behavior of extract_rfc2822_addresses
if recipient == new_partner:
smtp_to_list = ['"test.to.1@test.example.com"', 'test.to.1@test.example.com']
else:
smtp_to_list = [recipient.email_normalized]
if exp_alias_domain == self.mail_alias_domain:
self.assertSMTPEmailsSent(
smtp_from=f'{self.default_from}@{self.alias_domain}',
smtp_to_list=smtp_to_list,
mail_server=self.mail_server_notification,
emails_count=1,
)
else:
self.assertSMTPEmailsSent(
smtp_from=exp_alias_domain.bounce_email,
smtp_to_list=smtp_to_list,
emails_count=1,
)
@users('employee')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mail_composer_wtpl_recipients_email_fields(self):
@@ -2462,6 +2700,111 @@ class TestComposerResultsMass(TestMailComposer):
# self.assertIn(exp_button_es, sent_mail['body'])
self.assertIn(exp_button_en, sent_mail['body'])
@users('employee')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mail_composer_wtpl_mc(self):
""" Test specific to multi-company environment, notably company propagation
or aliases. """
# add access to second company to avoid MC rules on ticket model
self.env.user.company_ids = [(4, self.company_2.id)]
# initial data
self.assertEqual(self.env.company, self.company_admin)
self.assertEqual(self.user_admin.company_id, self.company_admin)
attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id)
email_to_1 = 'test.to.1@test.example.com'
self.template.write({
'auto_delete': False, # keep sent emails to check content
'attachment_ids': [(0, 0, a) for a in attachment_data],
'email_from': False, # use current user as author
'email_layout_xmlid': 'mail.test_layout',
'email_to': email_to_1,
'mail_server_id': False, # let it find a server
'partner_to': '%s, {{ object.customer_id.id if object.customer_id else "" }}' % self.partner_admin.id,
})
attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])])
self.assertEqual(len(attachs), 2)
for companies, expected_companies, expected_alias_domains in [
(
self.company_admin + self.company_2,
self.company_admin + self.company_2,
self.mail_alias_domain + self.mail_alias_domain_c2,
),
]:
with self.subTest(companies=companies):
# update test configuration
test_records = self.test_records
for company, record in zip(companies, test_records):
record.company_id = company.id
# open a composer and run it in comment mode
composer = Form(self.env['mail.compose.message'].with_context(
default_composition_mode='mass_mail',
default_force_send=True, # force sending emails directly to check SMTP
default_model=test_records._name,
default_res_ids=test_records.ids,
default_template_id=self.template.id,
# avoid successive tests issues with followers
mail_create_nosubscribe=True,
)).save()
with self.mock_mail_gateway(mail_unlink_sent=False), \
self.mock_mail_app():
composer._action_send_mail()
new_partner = self.env['res.partner'].search([('email_normalized', '=', 'test.to.1@test.example.com')])
self.assertEqual(len(new_partner), 1)
# check output, company-specific values mainly for this test
for record, exp_company, exp_alias_domain in zip(
test_records, expected_companies, expected_alias_domains
):
# message copy is kept
message = record.message_ids[0]
recipients = record.customer_id + new_partner + self.partner_admin
self.assertMailMail(
record.customer_id + new_partner + self.partner_admin,
'sent',
author=self.partner_employee,
mail_message=message,
email_values={
'headers': {
'Return-Path': f'{exp_alias_domain.bounce_email}',
'X-Odoo-Objects': f'{record._name}-{record.id}',
},
'subject': f'TemplateSubject {record.name}',
},
fields_values={
'headers': {
'Return-Path': f'{exp_alias_domain.bounce_email}',
'X-Odoo-Objects': f'{record._name}-{record.id}',
},
'mail_server_id': self.env['ir.mail_server'],
'record_alias_domain_id': exp_alias_domain,
'record_company_id': exp_company,
'subject': f'TemplateSubject {record.name}',
},
)
for recipient in recipients:
# to check behavior of extract_rfc2822_addresses
if recipient == new_partner:
smtp_to_list = ['"test.to.1@test.example.com"', 'test.to.1@test.example.com']
else:
smtp_to_list = [recipient.email_normalized]
if exp_alias_domain == self.mail_alias_domain:
self.assertSMTPEmailsSent(
smtp_from=f'{self.default_from}@{self.alias_domain}',
smtp_to_list=smtp_to_list,
mail_server=self.mail_server_notification,
emails_count=1,
)
else:
self.assertSMTPEmailsSent(
smtp_from=exp_alias_domain.bounce_email,
smtp_to_list=smtp_to_list,
emails_count=1,
)
@users('employee')
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
def test_mail_composer_wtpl_recipients(self):
@@ -2581,7 +2924,7 @@ class TestComposerResultsMass(TestMailComposer):
# 6: void is void: raise in comment mode, just don't send anything in mass mail mode
composer_form = Form(self.env['mail.compose.message'].with_context(
default_model='mail.test.ticket',
default_model='mail.test.ticket.mc',
default_template_id=self.template.id
))
composer = composer_form.save()
@@ -2592,7 +2935,7 @@ class TestComposerResultsMass(TestMailComposer):
composer_form = Form(self.env['mail.compose.message'].with_context(
default_composition_mode='mass_mail',
default_model='mail.test.ticket',
default_model='mail.test.ticket.mc',
default_template_id=self.template.id
))
composer = composer_form.save()
@@ -2780,9 +3123,12 @@ class TestComposerResultsMass(TestMailComposer):
default_template_id=self.template.id)
))
composer = composer_form.save()
# remove alias so that _notify_get_reply_to will return the default value instead of alias
self.company_admin.write({
'alias_domain_id': False,
})
with self.mock_mail_gateway(mail_unlink_sent=False):
# remove alias so that _notify_get_reply_to will return the default value instead of alias
self.env['ir.config_parameter'].sudo().set_param("mail.catchall.domain", None)
composer.action_send_mail()
for record in self.test_records:
+62 -11
View File
@@ -128,9 +128,7 @@ class TestEmailParsing(MailCommon):
email_to='bounce@xxx.odoo.com',
delivered_to='bounce@xxx.odoo.com'
)
msg_dict = {}
msg = self.env['mail.thread']._message_parse_extract_bounce(self.from_string(incoming_bounce), msg_dict)
msg = self.env['mail.thread'].message_parse(self.from_string(incoming_bounce))
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_ids'][0], message.message_id, "The sender message-id should correctly parsed")
@@ -155,6 +153,7 @@ class TestMailgateway(MailCommon):
super(TestMailgateway, cls).setUpClass()
cls.mail_test_gateway_model = cls.env['ir.model']._get('mail.test.gateway')
cls.mail_test_gateway_company_model = cls.env['ir.model']._get('mail.test.gateway.company')
cls.email_from = '"Sylvie Lelitre" <test.sylvie.lelitre@agrolait.com>'
cls.test_record = cls.env['mail.test.gateway'].with_context(cls._test_context).create({
@@ -166,12 +165,23 @@ class TestMailgateway(MailCommon):
'name': 'Valid Lelitre',
'email': 'valid.lelitre@agrolait.com',
})
# groups@.. will cause the creation of new mail.test.gateway
# groups@test.mycompany.com will cause the creation of new mail.test.gateway
cls.alias = cls.env['mail.alias'].create({
'alias_domain_id': cls.mail_alias_domain.id,
'alias_contact': 'everyone',
'alias_model_id': cls.mail_test_gateway_model.id,
'alias_name': 'groups',
})
# groups@test.mycompany2.com will cause the creation of new mail.test.gateway.company
cls.alias_c2 = cls.env['mail.alias'].create({
'alias_defaults': {
'company_id': cls.company_2.id,
},
'alias_domain_id': cls.mail_alias_domain_c2.id,
'alias_contact': 'everyone',
'alias_model_id': cls.mail_test_gateway_company_model.id,
'alias_name': 'groups',
})
# Set a first message on public group to test update and hierarchy
cls.fake_email = cls._create_gateway_message(cls.test_record, '123456')
@@ -382,9 +392,10 @@ class TestMailgateway(MailCommon):
""" Do not set alias as author to avoid including aliases in discussions """
from_1 = self.env['res.partner'].create({
'name': 'Brice Denisse',
'email': f'from.test@{self.alias_domain}',
'email': f'from.test@{self.mail_alias_domain.name}',
})
self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'from.test',
'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id
})
@@ -522,6 +533,7 @@ class TestMailgateway(MailCommon):
test_model_track = self.env['ir.model']._get('mail.test.track')
container_custom = self.env['mail.test.container'].create({})
alias_valid = self.env['mail.alias'].with_user(self.user_admin).create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'valid',
'alias_model_id': test_model_track.id,
'alias_contact': 'everyone',
@@ -794,13 +806,36 @@ class TestMailgateway(MailCommon):
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models')
def test_message_route_alias_no_domain(self):
""" Incoming email: write to alias even if no domain set: considered as valid alias """
self.env['ir.config_parameter'].set_param('mail.catchall.domain', '')
""" Incoming email: write to alias with no domain set: not recognized as
a valid alias even when local-part only is checked. """
self.alias.alias_domain_id = False
new_record = self.format_and_process(MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@another.domain.com', subject='Test Subject')
# Test: one group created
for incoming_ok in [True, False]:
with self.subTest(incoming_ok=incoming_ok):
with self.assertRaises(ValueError):
_new_record = self.format_and_process(
MAIL_TEMPLATE, self.partner_1.email_formatted, f'groups@{self.alias_domain}',
subject='Test Subject'
)
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models')
def test_message_route_alias_alias_incoming_local(self):
""" Incoming email: write to alias using local part only: depends on
alias accepting local only flag. """
self.alias.alias_incoming_local = True
new_record = self.format_and_process(
MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@another.domain.com',
subject='Test Subject Global'
)
self.assertEqual(len(new_record), 1, 'message_process: a new mail.test.simple should have been created')
self.alias.alias_incoming_local = False
with self.assertRaises(ValueError):
_new_record = self.format_and_process(
MAIL_TEMPLATE, self.partner_1.email_formatted, 'groups@another.domain.com',
subject='Test Subject Local'
)
@mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models')
def test_message_route_alias_forward_bypass_reply_first(self):
""" Incoming email: write to two "new thread" alias, one as a reply, one being another model -> consider as a forward """
@@ -808,6 +843,7 @@ class TestMailgateway(MailCommon):
# test@.. will cause the creation of new mail.test
new_alias_2 = self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'test',
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_contact': 'everyone',
@@ -834,6 +870,7 @@ class TestMailgateway(MailCommon):
# test@.. will cause the creation of new mail.test
new_alias_2 = self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'test',
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_contact': 'everyone',
@@ -863,6 +900,7 @@ class TestMailgateway(MailCommon):
# test@.. will cause the creation of new mail.test
new_alias_2 = self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'test',
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_contact': 'everyone',
@@ -888,6 +926,7 @@ class TestMailgateway(MailCommon):
""" Incoming email: write to two aliases creating records: both should be activated """
# test@.. will cause the creation of new mail.test
new_alias_2 = self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'test',
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_contact': 'everyone',
@@ -909,9 +948,11 @@ class TestMailgateway(MailCommon):
""" Incoming email: check that if domains are set in the optional system
parameter `mail.catchall.domain.allowed` only incoming emails from these
domains will generate records."""
# test@.. will cause the creation of new mail.test
# test@.. will cause the creation of new mail.test.container
new_alias_2 = self.env['mail.alias'].create({
'alias_contact': 'everyone',
'alias_domain_id': self.mail_alias_domain_c2.id,
'alias_incoming_local': True,
'alias_model_id': self.env['ir.model']._get_id('mail.test.container.mc'),
'alias_name': 'test',
})
@@ -919,6 +960,8 @@ class TestMailgateway(MailCommon):
test_domain = 'hello.com'
for (alias_right_part, allowed_domain), container_created in zip(
[
# Test a valid alias domain, standard case
(self.mail_alias_domain_c2.name, ""),
# Test with 'mail.catchall.domain.allowed' not set in system parameters
# and with a domain not allowed
('bonjour.com', ""),
@@ -928,7 +971,7 @@ class TestMailgateway(MailCommon):
# Test with 'mail.catchall.domain.allowed' set in system parameters
# and with a domain allowed
(test_domain, test_domain),
], [True, False, True]):
], [True, True, False, True]):
with self.subTest(alias_right_part=alias_right_part, allowed_domain=allowed_domain):
self.env['ir.config_parameter'].set_param('mail.catchall.domain.allowed', allowed_domain)
@@ -1439,6 +1482,7 @@ class TestMailgateway(MailCommon):
def test_message_process_references_forward(self):
""" Incoming email using references but with alias forward should not go into references destination """
self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'test.alias',
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_contact': 'everyone',
@@ -1459,6 +1503,7 @@ class TestMailgateway(MailCommon):
def test_message_process_references_forward_same_model(self):
""" Incoming email using references but with alias forward on same model should be considered as a reply """
self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'test.alias',
'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id,
'alias_contact': 'everyone',
@@ -1478,6 +1523,7 @@ class TestMailgateway(MailCommon):
def test_message_process_references_forward_cc(self):
""" Incoming email using references but with alias forward in CC should be considered as a repy (To > Cc) """
self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'test.alias',
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_contact': 'everyone',
@@ -1551,6 +1597,7 @@ class TestMailgateway(MailCommon):
"""New record with mail that contains base64 inline image."""
target_model = "mail.test.field.type"
alias = self.env["mail.alias"].create({
'alias_domain_id': self.mail_alias_domain.id,
"alias_name": "base64-lover",
"alias_model_id": self.env["ir.model"]._get(target_model).id,
"alias_defaults": "{}",
@@ -1574,6 +1621,7 @@ class TestMailgateway(MailCommon):
coming from alias."""
target_model = "mail.test.field.type"
alias = self.env["mail.alias"].create({
'alias_domain_id': self.mail_alias_domain.id,
"alias_name": "base64-lover",
"alias_model_id": self.env["ir.model"]._get(target_model).id,
"alias_defaults": "{'type': 'second'}",
@@ -1700,6 +1748,7 @@ class TestMailgateway(MailCommon):
])
alias = self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'test',
'alias_model_id': self.env['ir.model']._get('mail.test.container').id,
'alias_contact': 'everyone',
@@ -1830,6 +1879,7 @@ class TestMailgateway(MailCommon):
})
self.env['mail.alias'].create({
'alias_domain_id': self.mail_alias_domain.id,
'alias_name': 'test',
'alias_model_id': self.env['ir.model']._get('mail.test.gateway').id,
})
@@ -1854,6 +1904,7 @@ class TestMailThreadCC(MailCommon):
cls.email_from = 'Sylvie Lelitre <test.sylvie.lelitre@agrolait.com>'
cls.alias = cls.env['mail.alias'].create({
'alias_contact': 'everyone',
'alias_domain_id': cls.mail_alias_domain.id,
'alias_model_id': cls.env['ir.model']._get('mail.test.cc').id,
'alias_name': 'cc_record',
})
+4 -17
View File
@@ -294,7 +294,7 @@ class TestMailMail(MailCommon):
# MailServer.build_email(): invalid from (missing)
for default_from in [False, '']:
self.env['ir.config_parameter'].set_param('mail.default.from', default_from)
self.mail_alias_domain.default_from = default_from
self._reset_data()
with self.mock_mail_gateway(), mute_logger('odoo.addons.mail.models.mail_mail'):
mail.send(raise_exception=False)
@@ -311,8 +311,9 @@ class TestMailMail(MailCommon):
self.assertEqual(notification.notification_status, 'exception')
# MailServer.send_email(): _prepare_email_message: unexpected ASCII / Malformed 'Return-Path' or 'From' address
# Force catchall domain to void otherwise bounce is set to postmaster-odoo@domain
self.env['ir.config_parameter'].set_param('mail.catchall.domain', '')
# Force bounce alias to void, will force usage of email_from
self.mail_alias_domain.bounce_alias = False
self.env.company.invalidate_recordset(fnames={'bounce_email', 'bounce_formatted'})
for email_from in ['strange@example¢¡.com', 'robert']:
self._reset_data()
mail.write({'email_from': email_from})
@@ -326,20 +327,6 @@ class TestMailMail(MailCommon):
self.assertEqual(notification.failure_type, 'mail_from_invalid')
self.assertEqual(notification.notification_status, 'exception')
# MailServer.send_email(): _prepare_email_message: unexpected ASCII based on catchall domain in bounce
self.env['ir.config_parameter'].set_param('mail.catchall.domain', 'domain¢¡.com')
self._reset_data()
mail.write({'email_from': 'test.user@example.com'})
with self.mock_mail_gateway():
mail.send(raise_exception=False)
self.assertEqual(self._mails[0]['email_from'], 'test.user@example.com')
self.assertIn("Malformed 'Return-Path' or 'From' address: bounce.test@domain¢¡.com", mail.failure_reason)
self.assertEqual(mail.failure_type, 'mail_from_invalid')
self.assertEqual(mail.state, 'exception')
self.assertEqual(notification.failure_reason, "Malformed 'Return-Path' or 'From' address: bounce.test@domain¢¡.com - It should contain one valid plain ASCII email")
self.assertEqual(notification.failure_type, 'mail_from_invalid')
self.assertEqual(notification.notification_status, 'exception')
@mute_logger('odoo.addons.mail.models.mail_mail')
def test_mail_mail_send_exceptions_recipients_emails(self):
""" Test various use case with exceptions and errors and see how they are
+8 -16
View File
@@ -221,16 +221,8 @@ class TestMessageValues(MailCommon):
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
# no alias domain -> author
self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.domain')]).unlink()
msg = self.Message.create({})
self.assertIn('-private', msg.message_id.split('@')[0], 'mail_message: message_id for a void message should be a "private" one')
self.assertEqual(msg.reply_to, formataddr((self.user_employee.name, self.user_employee.email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
# no alias catchall, no alias -> author
self.env['ir.config_parameter'].set_param('mail.catchall.domain', self.alias_domain)
self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.alias')]).unlink()
self.env.company.alias_domain_id = False
self.assertFalse(self.env.company.catchall_email)
msg = self.Message.create({})
self.assertIn('-private', msg.message_id.split('@')[0], 'mail_message: message_id for a void message should be a "private" one')
@@ -249,8 +241,10 @@ class TestMessageValues(MailCommon):
self.assertEqual(msg.reply_to, formataddr((reply_to_name, reply_to_email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
# no alias domain -> author
self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.domain')]).unlink()
# no alias domain, no company catchall -> author
self.alias_record.alias_domain_id = False
self.env.company.alias_domain_id = False
self.assertFalse(self.env.company.catchall_email)
msg = self.Message.create({
'model': 'mail.test.container',
@@ -260,9 +254,8 @@ class TestMessageValues(MailCommon):
self.assertEqual(msg.reply_to, formataddr((self.user_employee.name, self.user_employee.email)))
self.assertEqual(msg.email_from, formataddr((self.user_employee.name, self.user_employee.email)))
# no catchall -> don't care, alias
self.env['ir.config_parameter'].set_param('mail.catchall.domain', self.alias_domain)
self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.alias')]).unlink()
# alias wins over company, hence no catchall is not an issue
self.alias_record.alias_domain_id = self.mail_alias_domain
msg = self.Message.create({
'model': 'mail.test.container',
@@ -467,7 +460,6 @@ class TestMessageAccess(MailCommon):
def test_mail_message_access_create_reply(self):
# TDE FIXME: should it really work ? not sure - catchall makes crash (aka, post will crash also)
self.env['ir.config_parameter'].set_param('mail.catchall.domain', False)
self.message.write({'partner_ids': [(4, self.user_employee.partner_id.id)]})
self.env['mail.message'].with_user(self.user_employee).create({'model': 'discuss.channel', 'res_id': self.private_group.id, 'body': 'Test', 'parent_id': self.message.id})
+22 -9
View File
@@ -290,7 +290,7 @@ class TestMailNotifyAPI(TestMessagePostCommon):
test_record._notify_get_reply_to()[test_record.id],
formataddr((
f"{self.user_employee_c2.company_id.name} {test_record.name}",
f"{self.alias_catchall}@{self.alias_domain}"))
f"{self.alias_catchall_c2}@{self.alias_domain_c2_name}"))
)
test_record_c1 = test_record.with_user(self.user_employee)
self.assertEqual(
@@ -310,11 +310,17 @@ class TestMailNotifyAPI(TestMessagePostCommon):
])
res = test_records._notify_get_reply_to()
for test_record in test_records:
company = test_record.company_id
if company == self.company_2:
alias_domain = self.alias_domain_c2_name
alias_catchall = self.alias_catchall_c2
else:
alias_domain = self.alias_domain
alias_catchall = self.alias_catchall
self.assertEqual(
res[test_record.id],
formataddr((
f"{self.user_employee_c2.company_id.name} {test_record.name}",
f"{self.alias_catchall}@{self.alias_domain}"))
formataddr((f"{company.name} {test_record.name}", f"{alias_catchall}@{alias_domain}"))
)
# Test3: get company from record (company_id field)
@@ -331,7 +337,7 @@ class TestMailNotifyAPI(TestMessagePostCommon):
res[test_record.id],
formataddr((
f"{self.company_3.name} {test_record.name}",
f"{self.alias_catchall}@{self.alias_domain}"))
f"{self.alias_catchall_c3}@{self.alias_domain_c3_name}"))
)
@@ -956,7 +962,10 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase):
},
])
expected_companies = [self.company_2, self.company_admin, self.company_2]
for record, expected_company in zip(records, expected_companies):
expected_alias_domains = [self.mail_alias_domain_c2, self.mail_alias_domain, self.mail_alias_domain_c2]
for record, expected_company, expected_alias_domain in zip(
records, expected_companies, expected_alias_domains
):
with self.subTest(record=record):
with self.assertSinglePostNotifications(
[{'partner': self.partner_employee_2, 'type': 'email'}],
@@ -964,11 +973,12 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase):
'content': 'Body',
'email_values': {
'headers': {
'Return-Path': f'{self.alias_bounce}@{self.alias_domain}',
'Return-Path': f'{expected_alias_domain.bounce_alias}@{expected_alias_domain.name}',
},
},
'mail_mail_values': {
'headers': {
'Return-Path': f'{expected_alias_domain.bounce_alias}@{expected_alias_domain.name}',
'X-Odoo-Objects': f'{record._name}-{record.id}',
},
},
@@ -978,7 +988,10 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase):
'is_internal': False,
'notified_partner_ids': self.partner_employee_2,
'reply_to': formataddr(
(f'{expected_company.name} {record.name}', f'{self.alias_catchall}@{self.alias_domain}')
(
f'{expected_company.name} {record.name}',
f'{expected_alias_domain.catchall_alias}@{expected_alias_domain.name}'
)
),
},
}
@@ -1289,7 +1302,7 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase):
)
self.assertEqual(msg.subject, '1st line 2nd line')
@mute_logger('odoo.addons.mail.models.mail_mail')
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.mail.models.mail_mail')
def test_portal_acls(self):
self.test_record.message_subscribe((self.partner_1 | self.user_employee.partner_id).ids)
+22 -22
View File
@@ -224,19 +224,19 @@ class TestBaseMailPerformance(BaseMailPerformance):
@warmup
def test_create_mail_with_tracking(self):
""" Create records inheriting from 'mail.thread' (with field tracking). """
with self.assertQueryCount(admin=8, demo=8):
with self.assertQueryCount(admin=9, demo=9):
self.env['mail.performance.thread'].create({'name': 'X'})
@users('admin', 'employee')
@warmup
def test_create_mail_simple(self):
with self.assertQueryCount(admin=7, employee=7):
with self.assertQueryCount(admin=8, employee=8):
self.env['mail.test.simple'].create({'name': 'Test'})
@users('admin', 'employee')
@warmup
def test_create_mail_simple_multi(self):
with self.assertQueryCount(admin=7, employee=7):
with self.assertQueryCount(admin=8, employee=8):
self.env['mail.test.simple'].create([{'name': 'Test'}] * 5)
@users('admin', 'employee')
@@ -264,7 +264,7 @@ class TestBaseAPIPerformance(BaseMailPerformance):
def test_adv_activity(self):
model = self.env['mail.test.activity']
with self.assertQueryCount(admin=7, employee=7):
with self.assertQueryCount(admin=8, employee=8):
model.create({'name': 'Test'})
@users('admin', 'employee')
@@ -400,7 +400,7 @@ class TestBaseAPIPerformance(BaseMailPerformance):
composer_form.attachment_ids.add(attachment)
composer = composer_form.save()
with self.assertQueryCount(admin=54, employee=54): # tm+com 53/53
with self.assertQueryCount(admin=55, employee=55): # tm+com 53/53
composer._action_send_mail()
# notifications
@@ -454,7 +454,7 @@ class TestBaseAPIPerformance(BaseMailPerformance):
test_record, test_template = self._create_test_records()
test_template.write({'attachment_ids': [(5, 0)]})
with self.assertQueryCount(admin=26, employee=26): # tm 15/15 / com 25/25
with self.assertQueryCount(admin=27, employee=27): # tm 15/15 / com 25/25
composer = self.env['mail.compose.message'].with_context({
'default_composition_mode': 'comment',
'default_model': test_record._name,
@@ -478,7 +478,7 @@ class TestBaseAPIPerformance(BaseMailPerformance):
def test_mail_composer_w_template_attachments(self):
test_record, test_template = self._create_test_records()
with self.assertQueryCount(admin=27, employee=27): # tm 16/16 / com 26/26
with self.assertQueryCount(admin=28, employee=28): # tm 16/16 / com 26/26
composer = self.env['mail.compose.message'].with_context({
'default_composition_mode': 'comment',
'default_model': test_record._name,
@@ -507,7 +507,7 @@ class TestBaseAPIPerformance(BaseMailPerformance):
test_template.write({'attachment_ids': [(5, 0)]})
customer = self.env['res.partner'].browse(self.customer.ids)
with self.assertQueryCount(admin=33, employee=33): # tm 22/22 / com 32/32
with self.assertQueryCount(admin=34, employee=34): # tm 22/22 / com 32/32
composer_form = Form(
self.env['mail.compose.message'].with_context({
'default_composition_mode': 'comment',
@@ -518,7 +518,7 @@ class TestBaseAPIPerformance(BaseMailPerformance):
)
composer = composer_form.save()
with self.assertQueryCount(admin=50, employee=50): # com 44/44
with self.assertQueryCount(admin=51, employee=51): # com 45/45
composer._action_send_mail()
# notifications
@@ -537,7 +537,7 @@ class TestBaseAPIPerformance(BaseMailPerformance):
test_record, test_template = self._create_test_records()
customer = self.env['res.partner'].browse(self.customer.ids)
with self.assertQueryCount(admin=33, employee=33): # tm 22/22 / com 32/32
with self.assertQueryCount(admin=34, employee=34): # tm 22/22 / com 32/32
composer_form = Form(
self.env['mail.compose.message'].with_context({
'default_composition_mode': 'comment',
@@ -548,7 +548,7 @@ class TestBaseAPIPerformance(BaseMailPerformance):
)
composer = composer_form.save()
with self.assertQueryCount(admin=69, employee=69): # com 61/61
with self.assertQueryCount(admin=70, employee=70): # com 62/62
composer._action_send_mail()
# notifications
@@ -575,7 +575,7 @@ class TestBaseAPIPerformance(BaseMailPerformance):
# use another user already pre-defined with the email notification type,
# so the ormcache is preserved.
record = self.env['mail.test.track'].create({'name': 'Test'})
with self.assertQueryCount(admin=41, employee=41):
with self.assertQueryCount(admin=42, employee=42):
record.write({
'user_id': self.user_test_email.id,
})
@@ -801,7 +801,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
'mail_message_id': message.id,
'recipient_ids': [(4, pid) for pid in self.partners.ids],
})
with self.assertQueryCount(admin=7, employee=7):
with self.assertQueryCount(admin=8, employee=8):
self.env['mail.mail'].sudo().browse(mail.ids).send()
@mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
@@ -840,7 +840,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
unlinked_mails |= set(records.ids)
unlinked_mails = set()
with self.assertQueryCount(admin=30, employee=30), \
with self.assertQueryCount(admin=31, employee=31), \
patch.object(type(self.env['mail.mail']), 'unlink', _patched_unlink):
self.env['mail.mail'].sudo().browse(mails.ids).send()
@@ -860,7 +860,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
record = self.container.with_user(self.env.user)
# about 20 (19?) queries per additional customer group
with self.assertQueryCount(admin=58, employee=57): # com 55/54
with self.assertQueryCount(admin=61, employee=60): # com 55/54
record.message_post(
body=Markup('<p>Test Post Performances</p>'),
message_type='comment',
@@ -878,7 +878,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
template = self.env.ref('test_mail.mail_test_container_tpl')
# about 20 (19 ?) queries per additional customer group
with self.assertQueryCount(admin=67, employee=66): # com 62/61
with self.assertQueryCount(admin=70, employee=69): # com 62/61
record.message_post_with_source(
template,
message_type='comment',
@@ -968,7 +968,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
})
rec1 = rec.with_context(active_test=False) # to see inactive records
self.assertEqual(rec1.message_partner_ids, self.partners | self.env.user.partner_id)
with self.assertQueryCount(admin=41, employee=41):
with self.assertQueryCount(admin=43, employee=43):
rec.write({'user_id': self.user_portal.id})
self.assertEqual(rec1.message_partner_ids, self.partners | self.env.user.partner_id | self.user_portal.partner_id)
# write tracking message
@@ -988,7 +988,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
customer_id = self.customer.id
user_id = self.user_portal.id
with self.assertQueryCount(admin=98, employee=98):
with self.assertQueryCount(admin=102, employee=102):
rec = self.env['mail.test.ticket'].create({
'name': 'Test',
'container_id': container_id,
@@ -1017,7 +1017,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
rec1 = rec.with_context(active_test=False) # to see inactive records
self.assertEqual(rec1.message_partner_ids, self.user_portal.partner_id | self.env.user.partner_id)
self.assertEqual(len(rec1.message_ids), 1)
with self.assertQueryCount(admin=61, employee=61):
with self.assertQueryCount(admin=63, employee=63):
rec.write({
'name': 'Test2',
'container_id': self.container.id,
@@ -1054,7 +1054,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
rec1 = rec.with_context(active_test=False) # to see inactive records
self.assertEqual(rec1.message_partner_ids, self.user_portal.partner_id | self.env.user.partner_id)
with self.assertQueryCount(admin=67, employee=67):
with self.assertQueryCount(admin=70, employee=70):
rec.write({
'name': 'Test2',
'container_id': container_id,
@@ -1087,7 +1087,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
rec1 = rec.with_context(active_test=False) # to see inactive records
self.assertEqual(rec1.message_partner_ids, self.partners | self.env.user.partner_id | self.user_portal.partner_id)
with self.assertQueryCount(admin=33, employee=33):
with self.assertQueryCount(admin=34, employee=34):
rec.write({
'name': 'Test2',
'customer_id': customer_id,
@@ -1364,7 +1364,7 @@ class TestPerformance(BaseMailPerformance):
attachments = self.env['ir.attachment'].with_user(self.env.user).create(self.test_attachments_vals)
# enable_logging = self.cr._enable_logging() if self.warm else nullcontext()
# with self.assertQueryCount(employee=63), enable_logging:
with self.assertQueryCount(employee=66): # com 60
with self.assertQueryCount(employee=69): # com 63
record_container.with_context({}).message_post(
body=Markup('<p>Test body <img src="cid:cid1"> <img src="cid:cid2"></p>'),
subject='Test Subject',
@@ -91,7 +91,7 @@ class TestMailPerformance(FullBaseMailPerformance):
record_ticket = self.env['mail.test.ticket.mc'].browse(self.record_ticket.ids)
attachments = self.env['ir.attachment'].create(self.test_attachments_vals)
with self.assertQueryCount(employee=96): # test_mail_full: 95
with self.assertQueryCount(employee=101): # test_mail_full: 95
new_message = record_ticket.message_post(
attachment_ids=attachments.ids,
body=Markup('<p>Test Content</p>'),
+2 -2
View File
@@ -172,7 +172,7 @@ class TestRatingPerformance(TestRatingCommon):
@users('employee')
@warmup
def test_rating_last_value_perfs(self):
with self.assertQueryCount(employee=1616): # tmf 1313 / com 1313
with self.assertQueryCount(employee=1617): # tmf 1313 / com 1313
self.create_ratings('mail.test.rating.thread')
with self.assertQueryCount(employee=2101): # tmf 1901
@@ -184,7 +184,7 @@ class TestRatingPerformance(TestRatingCommon):
@users('employee')
@warmup
def test_rating_last_value_perfs_with_rating_mixin(self):
with self.assertQueryCount(employee=1723): # tmf 1419 / com 1419
with self.assertQueryCount(employee=1724): # tmf 1419 / com 1419
self.create_ratings('mail.test.rating')
with self.assertQueryCount(employee=2304): # tmf 2104
@@ -35,7 +35,7 @@ class TestSMSPerformance(BaseMailPerformance, sms_common.SMSCase):
def test_message_sms_record_1_partner(self):
record = self.test_record.with_user(self.env.user)
pids = self.customer.ids
with self.subTest("QueryCount"), self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=23):
with self.subTest("QueryCount"), self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=27):
messages = record._message_sms(
body='Performance Test',
partner_ids=pids,
@@ -50,7 +50,7 @@ class TestSMSPerformance(BaseMailPerformance, sms_common.SMSCase):
def test_message_sms_record_10_partners(self):
record = self.test_record.with_user(self.env.user)
pids = self.partners.ids
with self.subTest("QueryCount"), self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=23):
with self.subTest("QueryCount"), self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=27):
messages = record._message_sms(
body='Performance Test',
partner_ids=pids,
@@ -64,7 +64,7 @@ class TestSMSPerformance(BaseMailPerformance, sms_common.SMSCase):
@warmup
def test_message_sms_record_default(self):
record = self.test_record.with_user(self.env.user)
with self.subTest("QueryCount"), self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=25):
with self.subTest("QueryCount"), self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=29):
messages = record._message_sms(
body='Performance Test',
)
@@ -133,5 +133,5 @@ class TestSMSMassPerformance(BaseMailPerformance, sms_common.MockSMS):
'mass_keep_log': True,
})
with self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=57):
with self.mockSMSGateway(sms_allow_unlink=True), self.assertQueryCount(employee=58):
composer.action_send_sms()
@@ -112,8 +112,8 @@ class TestMassMailingServer(TestMassMailCommon):
('unknow_email@unknow_domain.com', 'unknow_email@unknow_domain.com', self.mail_server_notification),
]:
self.assertSMTPEmailsSent(
smtp_from=expected_smtp_from,
message_from=expected_msg_from,
mail_server=expected_mail_server,
emails_count=8,
)
smtp_from=expected_smtp_from,
message_from=expected_msg_from,
mail_server=expected_mail_server,
emails_count=8,
)
@@ -47,7 +47,7 @@ class TestMassMailPerformance(TestMassMailPerformanceBase):
})
# runbot needs +51 compared to local
with self.assertQueryCount(__system__=1523, marketing=1524):
with self.assertQueryCount(__system__=1622, marketing=1623):
mailing.action_send_mail()
self.assertEqual(mailing.sent, 50)
@@ -90,7 +90,7 @@ class TestMassMailBlPerformance(TestMassMailPerformanceBase):
})
# runbot needs +51 compared to local
with self.assertQueryCount(__system__=1595, marketing=1596):
with self.assertQueryCount(__system__=1694, marketing=1695):
mailing.action_send_mail()
self.assertEqual(mailing.sent, 50)
+26 -11
View File
@@ -87,7 +87,7 @@ class IrMailServer(models.Model):
"""Represents an SMTP server, able to send outgoing emails, with SSL and TLS capabilities."""
_name = "ir.mail_server"
_description = 'Mail Server'
_order = 'sequence'
_order = 'sequence, id'
NO_VALID_RECIPIENT = ("At least one valid recipient address should be "
"specified for outgoing emails (To/Cc/Bcc)")
@@ -363,8 +363,7 @@ class IrMailServer(models.Model):
if mail_server:
from_filter = mail_server.from_filter
else:
from_filter = self.env['ir.config_parameter'].sudo().get_param(
'mail.default.from_filter', tools.config.get('from_filter'))
from_filter = self.env['ir.mail_server']._get_default_from_filter()
smtp_encryption = encryption
if smtp_encryption is None and tools.config.get('smtp_ssl'):
@@ -474,7 +473,7 @@ class IrMailServer(models.Model):
:rtype: email.message.EmailMessage
:return: the new RFC2822 email message
"""
email_from = email_from or self._get_default_from_address()
email_from = email_from or self.env.context.get('domain_notifications_email') or self._get_default_from_address()
assert email_from, self.NO_FOUND_FROM
headers = headers or {} # need valid dict later
@@ -540,6 +539,19 @@ class IrMailServer(models.Model):
"""
return tools.config.get("email_from")
@api.model
def _get_default_from_filter(self):
""" Computes the default from_filter. It is used when no specific
ir.mail_server is used when sending emails, hence having no value for
from_filter.
:return str/None: defaults to 'mail.default.from_filter', then
``--from-filter`` CLI/config parameter.
"""
return self.env['ir.config_parameter'].sudo().get_param(
'mail.default.from_filter', tools.config.get('from_filter')
)
def _prepare_email_message(self, message, smtp_session):
"""Prepare the SMTP information (from, to, message) before sending.
@@ -554,7 +566,9 @@ class IrMailServer(models.Model):
# Use the default bounce address **only if** no Return-Path was
# provided by caller. Caller may be using Variable Envelope Return
# Path (VERP) to detect no-longer valid email addresses.
bounce_address = message['Return-Path'] or self._get_default_bounce_address() or message['From']
# context may force a value, e.g. mail.alias.domain usage
bounce_address = self.env.context.get('domain_bounce_address') or message['Return-Path'] or self._get_default_bounce_address() or message['From']
smtp_from = message['From'] or bounce_address
assert smtp_from, self.NO_FOUND_SMTP_FROM
@@ -579,11 +593,13 @@ class IrMailServer(models.Model):
del message['To'] # avoid multiple To: headers!
message['To'] = x_forge_to
# Try to not spoof the mail from headers
# Try to not spoof the mail from headers; fetch session-based or contextualized
# values for encapsulation computation
from_filter = getattr(smtp_session, 'from_filter', False)
smtp_from = getattr(smtp_session, 'smtp_from', False) or smtp_from
notifications_email = email_normalize(self._get_default_from_address())
notifications_email = email_normalize(
self.env.context.get('domain_notifications_email') or self._get_default_from_address()
)
if notifications_email and smtp_from == notifications_email and message['From'] != notifications_email:
smtp_from = encapsulate_email(message['From'], notifications_email)
@@ -699,7 +715,7 @@ class IrMailServer(models.Model):
"""
email_from_normalized = email_normalize(email_from)
email_from_domain = email_domain_extract(email_from_normalized)
notifications_email = email_normalize(self._get_default_from_address())
notifications_email = self.env.context.get('domain_notifications_email') or email_normalize(self._get_default_from_address())
notifications_domain = email_domain_extract(notifications_email)
if mail_servers is None:
@@ -741,8 +757,7 @@ class IrMailServer(models.Model):
return mail_servers[0], email_from
# 5: SMTP config in odoo-bin arguments
from_filter = self.env['ir.config_parameter'].sudo().get_param(
'mail.default.from_filter', tools.config.get('from_filter'))
from_filter = self.env['ir.mail_server']._get_default_from_filter()
if self._match_from_filter(email_from, from_filter):
return None, email_from
+32 -11
View File
@@ -4,7 +4,7 @@
import threading
from contextlib import contextmanager
from unittest.mock import patch
from unittest.mock import patch, Mock
from odoo.tests.common import TransactionCase, HttpCase
from odoo import Command
@@ -326,16 +326,27 @@ class MockSmtplibCase:
self.testing_smtp_session = TestingSMTPSession()
IrMailServer = self.env['ir.mail_server']
connect_origin = IrMailServer.connect
find_mail_server_origin = IrMailServer._find_mail_server
connect_origin = type(IrMailServer).connect
find_mail_server_origin = type(IrMailServer)._find_mail_server
# custom mock to avoid losing context
def mock_function(func):
mock = Mock()
def _call(*args, **kwargs):
mock(*args[1:], **kwargs)
return func(*args, **kwargs)
_call.mock = mock
return _call
with patch('smtplib.SMTP_SSL', side_effect=lambda *args, **kwargs: self.testing_smtp_session), \
patch('smtplib.SMTP', side_effect=lambda *args, **kwargs: self.testing_smtp_session), \
patch.object(type(IrMailServer), '_is_test_mode', lambda self: False), \
patch.object(type(IrMailServer), 'connect', wraps=IrMailServer, side_effect=connect_origin) as connect_mocked, \
patch.object(type(IrMailServer), '_find_mail_server', side_effect=find_mail_server_origin) as find_mail_server_mocked:
self.connect_mocked = connect_mocked
self.find_mail_server_mocked = find_mail_server_mocked
patch.object(type(IrMailServer), 'connect', mock_function(connect_origin)) as connect_mocked, \
patch.object(type(IrMailServer), '_find_mail_server', mock_function(find_mail_server_origin)) as find_mail_server_mocked:
self.connect_mocked = connect_mocked.mock
self.find_mail_server_mocked = find_mail_server_mocked.mock
yield
def _build_email(self, mail_from, return_path=None, **kwargs):
@@ -385,15 +396,21 @@ class MockSmtplibCase:
debug_info = ''
matching_emails_count = len(list(matching_emails))
if matching_emails_count != emails_count:
emails_from = []
for email in self.emails:
from_found = next((
line.split('From:')[1].strip() for line in email['message'].splitlines()
if line.startswith('From:')), '')
emails_from.append(from_found)
debug_info = '\n'.join(
f"SMTP-From: {email['smtp_from']}, SMTP-To: ({email['smtp_to_list']}) - From_filter {email['from_filter']})"
for email in self.emails
f"SMTP-From: {email['smtp_from']}, SMTP-To: {email['smtp_to_list']}, Msg-From: {email_msg_from}, From_filter: {email['from_filter']})"
for email, email_msg_from in zip(self.emails, emails_from)
)
self.assertEqual(
matching_emails_count, emails_count,
msg=f'Incorrect emails sent: {matching_emails_count} found, {emails_count} expected'
f'\nConditions: SMTP-From {smtp_from}, SMTP-To {smtp_to_list}, Msg-From {message_from}, From_filter {from_filter}'
f'\n{debug_info}'
f'\nConditions\nSMTP-From: {smtp_from}, SMTP-To: {smtp_to_list}, Msg-From: {message_from}, From_filter: {from_filter}'
f'\nNot found in\n{debug_info}'
)
@classmethod
@@ -413,18 +430,22 @@ class MockSmtplibCase:
{
'name': 'Domain based server',
'from_filter': 'test.mycompany.com',
'sequence': 0,
** ir_mail_server_values,
}, {
'name': 'User specific server',
'from_filter': 'specific_user@test.mycompany.com',
'sequence': 1,
** ir_mail_server_values,
}, {
'name': 'Server Notifications',
'from_filter': 'notifications.test@test.mycompany.com',
'sequence': 2,
** ir_mail_server_values,
}, {
'name': 'Server No From Filter',
'from_filter': False,
'sequence': 3,
** ir_mail_server_values,
},
])
+31 -4
View File
@@ -51,9 +51,7 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# TEMPORARY
for icp in {'mail.default.from', 'mail.catchall.alias', 'mail.bounce.alias', 'mail.catchall.domain', 'mail.default.from_filter'}:
cls.env['ir.config_parameter'].sudo().set_param(icp, False)
cls.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', False)
cls._init_mail_servers()
cls.default_bounce_address = 'CACA'
cls.default_from_address = 'PROUT'
@@ -291,10 +289,39 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase):
from_filter=False,
)
@mute_logger('odoo.models.unlink')
def test_mail_server_send_email_context_force(self):
""" Allow to force notifications_email / bounce_address from context
to allow higher-level apps to send values until end of mail stack
without hacking too much models. """
# custom notification / bounce email from context
context_server = self.env['ir.mail_server'].create({
'from_filter': 'context.example.com',
'name': 'context',
'smtp_host': 'test',
})
IrMailServer = self.env["ir.mail_server"].with_context(
domain_notifications_email="notification@context.example.com",
domain_bounce_address="bounce@context.example.com",
)
with self.mock_smtplib_connection():
mail_server, smtp_from = IrMailServer._find_mail_server(email_from='"Name" <test@unknown_domain.com>')
self.assertEqual(mail_server, context_server)
self.assertEqual(smtp_from, "notification@context.example.com")
smtp_session = IrMailServer.connect(smtp_from=smtp_from)
message = self._build_email(mail_from='"Name" <test@unknown_domain.com>')
IrMailServer.send_email(message, smtp_session=smtp_session)
self.assertEqual(len(self.emails), 1)
self.assertSMTPEmailsSent(
smtp_from="bounce@context.example.com",
message_from='"Name" <notification@context.example.com>',
from_filter=context_server.from_filter,
)
@mute_logger('odoo.models.unlink')
def test_mail_server_send_email_IDNA(self):
""" Test that the mail from / recipient envelop are encoded using IDNA """
self.env['ir.config_parameter'].sudo().set_param('mail.catchall.domain', 'ééééééé.com')
with self.mock_smtplib_connection():
message = self._build_email(mail_from='test@ééééééé.com')
self.env['ir.mail_server'].send_email(message)