diff --git a/addons/account/tests/test_account_move_send.py b/addons/account/tests/test_account_move_send.py index 32d489b4140..cafe09c5fe6 100644 --- a/addons/account/tests/test_account_move_send.py +++ b/addons/account/tests/test_account_move_send.py @@ -29,7 +29,7 @@ class TestAccountComposerPerformance(AccountTestInvoicingCommon, MailCommon): 'body_html': '

TemplateBody for

', 'description': 'Sent to customers with their invoices in attachment', 'email_from': "{{ (object.invoice_user_id.email_formatted or user.email_formatted) }}", - 'mail_server_id': cls.mail_server_global.id, + 'mail_server_id': cls.mail_server_default.id, 'model_id': cls.env['ir.model']._get_id('account.move'), 'name': "Invoice: Test Sending", 'partner_to': "{{ object.partner_id.id }}", @@ -124,9 +124,6 @@ class TestAccountComposerPerformance(AccountTestInvoicingCommon, MailCommon): def setUp(self): super().setUp() - # setup mail gateway to simulate complete reply-to computation - self._init_mail_gateway() - # patch registry to simulate a ready environment self.patch(self.env.registry, 'ready', True) self.flush_tracking() @@ -200,7 +197,7 @@ class TestAccountComposerPerformance(AccountTestInvoicingCommon, MailCommon): 'auto_delete': True, 'email_from': self.user_account_other.email_formatted, 'is_notification': True, # should keep logs by default - 'mail_server_id': self.mail_server_global, + 'mail_server_id': self.mail_server_default, 'subject': _exp_subject, 'reply_to': formataddr(( f'{move.company_id.name} {_exp_move_name}', @@ -286,7 +283,7 @@ class TestAccountComposerPerformance(AccountTestInvoicingCommon, MailCommon): 'auto_delete': True, 'email_from': self.user_account_other.email_formatted, 'is_notification': True, # should keep logs by default - 'mail_server_id': self.mail_server_global, + 'mail_server_id': self.mail_server_default, 'subject': f'{self.env.user.company_id.name} Invoice (Ref {test_move.name})', 'reply_to': formataddr(( f'{test_move.company_id.name} {test_move.display_name}', @@ -373,7 +370,7 @@ class TestAccountComposerPerformance(AccountTestInvoicingCommon, MailCommon): 'auto_delete': True, 'email_from': self.user_account_other.email_formatted, 'is_notification': True, # should keep logs by default - 'mail_server_id': self.mail_server_global, + 'mail_server_id': self.mail_server_default, 'subject': f'SpanishSubject for {test_move.name}', # translated version 'reply_to': formataddr(( f'{test_move.company_id.name} {test_move.display_name}', diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 3706a601b20..0d3fb705dda 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -86,6 +86,7 @@ For more specific needs, you may also assign custom-defined actions 'views/mail_shortcode_views.xml', 'views/mail_activity_views.xml', 'views/res_config_settings_views.xml', + 'data/ir_config_parameter_data.xml', 'data/res_partner_data.xml', 'data/mail_message_subtype_data.xml', 'data/mail_templates_chatter.xml', diff --git a/odoo/addons/base/data/ir_config_parameter_data.xml b/addons/mail/data/ir_config_parameter_data.xml similarity index 100% rename from odoo/addons/base/data/ir_config_parameter_data.xml rename to addons/mail/data/ir_config_parameter_data.xml diff --git a/addons/mail/models/ir_mail_server.py b/addons/mail/models/ir_mail_server.py index 316b46ed58e..3c7f52dcba9 100644 --- a/addons/mail/models/ir_mail_server.py +++ b/addons/mail/models/ir_mail_server.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import _, fields, models +from odoo import _, api, fields, models class IrMailServer(models.Model): @@ -21,3 +21,45 @@ class IrMailServer(models.Model): map(lambda t: _('%s (Email Template)', t.display_name), record.mail_template_ids) ) return usages_super + + @api.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}' + return super()._get_default_from_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}" + return super()._get_default_from_address() + + def _get_test_email_from(self): + self.ensure_one() + if from_filter_parts := [part.strip() for part in (self.from_filter or '').split(",") if part.strip()]: + # 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 + # 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 + return super()._get_test_email_from() diff --git a/addons/mail/tests/__init__.py b/addons/mail/tests/__init__.py index 47b456a3f75..43b2f48dd5c 100644 --- a/addons/mail/tests/__init__.py +++ b/addons/mail/tests/__init__.py @@ -1,5 +1,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_ir_mail_server from . import test_link_preview from . import test_mail_activity from . import test_mail_composer diff --git a/addons/mail/tests/common.py b/addons/mail/tests/common.py index 48f01f3cf88..2be12eb3a25 100644 --- a/addons/mail/tests/common.py +++ b/addons/mail/tests/common.py @@ -52,10 +52,15 @@ class MockEmail(common.BaseCase, MockSmtplibCase): self.mail_unlink_sent = mail_unlink_sent self._init_mail_mock() - def _ir_mail_server_build_email(model, *args, **kwargs): - self._mails.append(kwargs) - self._mails_args.append(args) - return build_email_origin(model, *args, **kwargs) + def _ir_mail_server_build_email(model, email_from, email_to, subject, body, **kwargs): + self._mails.append({ + 'email_from': email_from, + 'email_to': email_to, + 'subject': subject, + 'body': body, + **kwargs, + }) + return build_email_origin(model, email_from, email_to, subject, body, **kwargs) def _mail_mail_create(model, *args, **kwargs): res = mail_create_origin(model, *args, **kwargs) @@ -79,40 +84,25 @@ class MockEmail(common.BaseCase, MockSmtplibCase): def _init_mail_mock(self): self._mails = [] - self._mails_args = [] self._new_mails = self.env['mail.mail'].sudo() @classmethod def _init_mail_gateway(cls): + super()._init_mail_gateway() cls.alias_domain = 'test.mycompany.com' cls.alias_catchall = 'catchall.test' cls.alias_bounce = 'bounce.test' - cls.default_from = 'notifications' + 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) # mailer daemon email preformatting cls.mailer_daemon_email = formataddr(('MAILER-DAEMON', '%s@%s' % (cls.alias_bounce, cls.alias_domain))) - @classmethod - def _init_outgoing_gateway(cls): - cls.env['ir.mail_server'].search([]).unlink() - cls.mail_server_domain, cls.mail_server_global = cls.env['ir.mail_server'].create([ - {'from_filter': cls.alias_domain, - 'name': 'Domain Based Server', - 'smtp_encryption': 'none', - 'smtp_host': 'smtp_host', - }, - {'from_filter': False, - 'name': 'No FromFilter Server', - 'smtp_encryption': 'none', - 'smtp_host': 'smtp_host', - } - ]) - cls.mail_servers = cls.mail_server_domain + cls.mail_server_global - # ------------------------------------------------------------ # GATEWAY TOOLS # ------------------------------------------------------------ @@ -1106,7 +1096,7 @@ class MailCommon(common.TransactionCase, MailCase): # give default values for all email aliases and domain cls._init_mail_gateway() - cls._init_outgoing_gateway() + cls._init_mail_servers() # by default avoid rendering restriction complexity cls.env['ir.config_parameter'].set_param('mail.restrict.template.rendering', False) diff --git a/addons/mail/tests/test_ir_mail_server.py b/addons/mail/tests/test_ir_mail_server.py new file mode 100644 index 00000000000..ffc1324eda6 --- /dev/null +++ b/addons/mail/tests/test_ir_mail_server.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch + +from odoo.addons.mail.tests.common import MailCommon +from odoo.tests import tagged, users +from odoo.tools import config, mute_logger + + +@tagged('mail_server') +class TestIrMailServer(MailCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.default_bounce_address = f'{cls.alias_bounce}@{cls.alias_domain}' + cls.default_from_address = f'{cls.default_from}@{cls.alias_domain}' + + def test_assert_base_values(self): + self.assertEqual( + self.env['ir.mail_server']._get_default_bounce_address(), + self.default_bounce_address) + self.assertEqual( + self.env['ir.mail_server']._get_default_from_address(), + self.default_from_address) + + @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( + [ + ('icp', 'test.mycompany.com'), + ('icp', False), + ('icp@another.company.com', 'test.mycompany.com'), + ('icp@another.company.com', False), + (False, 'test.mycompany.com'), + ], [ + "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) + message = self.env["ir.mail_server"].build_email( + False, "recipient@example.com", "Subject", + "The body of an email", + ) + self.assertEqual(message["From"], expected_from) + + @mute_logger('odoo.models.unlink') + @patch.dict(config.options, { + "from_filter": "dummy@example.com, test.mycompany.com, dummy2@example.com", + "smtp_server": "example.com", + }) + def test_mail_server_config_bin(self): + """ Test the configuration provided in the odoo-bin arguments. This config + is used when no mail server exists. Test with and without giving a + pre-configured SMTP session, should not impact results. + + Also check "mail.default.from_filter" parameter usage that should overwrite + odoo-bin argument "--from-filter". + """ + IrMailServer = self.env['ir.mail_server'] + + # Remove all mail server so we will use the odoo-bin arguments + IrMailServer.search([]).unlink() + self.assertFalse(IrMailServer.search([])) + + for mail_from, (expected_smtp_from, expected_msg_from) in zip( + [ + # inside "from_filter" domain + 'specific_user@test.mycompany.com', + '"Formatted Name" ', + '"Formatted Name" ', + '"Formatted Name" ', + # outside "from_filter" domain + 'test@unknown_domain.com', + '"Formatted Name" ', + ], [ + # inside "from_filter" domain: no rewriting + (self.default_bounce_address, 'specific_user@test.mycompany.com'), + (self.default_bounce_address, '"Formatted Name" '), + (self.default_bounce_address, '"Formatted Name" '), + (self.default_bounce_address, '"Formatted Name" '), + # 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" '), + (self.default_bounce_address, '"Formatted Name" '), + ] + ): + for provide_smtp in [False, True]: # providing smtp session should ont impact test + with self.subTest(mail_from=mail_from, provide_smtp=provide_smtp): + with self.mock_smtplib_connection(): + if provide_smtp: + smtp_session = IrMailServer.connect(smtp_from=mail_from) + message = self._build_email(mail_from=mail_from) + IrMailServer.send_email(message, smtp_session=smtp_session) + else: + message = self._build_email(mail_from=mail_from) + IrMailServer.send_email(message) + + self.connect_mocked.assert_called_once() + self.assertEqual(len(self.emails), 1) + self.assertSMTPEmailsSent( + smtp_from=expected_smtp_from, + message_from=expected_msg_from, + from_filter="dummy@example.com, test.mycompany.com, dummy2@example.com", + ) + + # for from_filter in ICP, overwrite the one from odoo-bin + self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', 'icp.example.com') + + # Use an email in the domain of the config parameter "mail.default.from_filter" + with self.mock_smtplib_connection(): + message = self._build_email(mail_from='specific_user@icp.example.com') + IrMailServer.send_email(message) + + self.assertSMTPEmailsSent( + smtp_from='specific_user@icp.example.com', + message_from='specific_user@icp.example.com', + from_filter='icp.example.com', + ) + + @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. """ + test_server = self.env['ir.mail_server'].create({ + 'from_filter': 'example_2.com, example_3.com', + 'name': 'Test Server', + 'smtp_host': 'smtp_host', + 'smtp_encryption': 'none', + }) + + # 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'), + # default relies on "odoo" + (False, 'example.com'), + # fallback on user email if no from_filter + ('example_2.com', ' '), + ('example_2.com', ','), + ('example_2.com', 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', + self.env.user.email, + self.env.user.email, + self.env.user.email, + self.env.user.email, + ], + ): + with self.subTest(default_from=default_from, from_filter=from_filter): + self.env['ir.config_parameter'].set_param('mail.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) + + @mute_logger('odoo.models.unlink') + def test_mail_server_priorities(self): + """ Test if we choose the right mail server to send an email. + Priorities are + 1. Forced mail server (e.g.: in mass mailing) + - If the "from_filter" of the mail server match the notification email + use the notifications email in the "From header" + - Otherwise spoof the "From" (because we force the mail server but we don't + know which email use to send it) + 2. A mail server for which the "from_filter" match the "From" header + 3. A mail server for which the "from_filter" match the domain of the "From" header + 4. The mail server used for notifications + 5. A mail server without "from_filter" (and so spoof the "From" header because we + do not know for which email address it can be used) + """ + # this mail server can now be used for a specific email address and 2 domain names + self.mail_server_user.from_filter = "domain1.com, specific_user@test.mycompany.com, domain2.com" + + for email_from, (expected_mail_server, expected_email_from) in zip( + [ + # matches user-specific server + 'specific_user@test.mycompany.com', + # matches user-specific server (with formatting') -> should extract + # email from full name, must keep the given email_from + '"Name name@strange.name" ', + # case check + 'SPECIFIC_USER@test.mycompany.com', + 'specific_user@test.MYCOMPANY.com', + # matches domain-based server: domain is case insensitive + 'unknown_email@test.mycompany.com', + 'unknown_email@TEST.MYCOMPANY.COM', + '"Unknown" ', + # fallback on notification email + '"Test" ', + # mail_server_user multiple from_filter check: can be used for a + # specific email and 2 domain names -> check other domains in filter + '"Example" ', + '"Example" ', + ], [ + (self.mail_server_user, 'specific_user@test.mycompany.com'), + (self.mail_server_user, '"Name name@strange.name" '), + (self.mail_server_user, 'SPECIFIC_USER@test.mycompany.com'), + (self.mail_server_user, 'specific_user@test.MYCOMPANY.com'), + (self.mail_server_domain, 'unknown_email@test.mycompany.com'), + (self.mail_server_domain, 'unknown_email@TEST.MYCOMPANY.COM'), + (self.mail_server_domain, '"Unknown" '), + (self.mail_server_notification, 'notifications.test@test.mycompany.com'), + # mail_server_user multiple from_filter check + (self.mail_server_user, '"Example" '), + (self.mail_server_user, '"Example" '), + ], + ): + with self.subTest(email_from=email_from): + mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from=email_from) + self.assertEqual(mail_server, expected_mail_server) + self.assertEqual(mail_from, expected_email_from) + + @mute_logger('odoo.models.unlink') + def test_mail_server_send_email(self): + """ Test main 'send_email' usage: check mail_server choice based on from + filters, encapsulation, spoofing. """ + IrMailServer = self.env['ir.mail_server'] + + for mail_from, (expected_smtp_from, expected_msg_from, expected_mail_server) in zip( + [ + 'specific_user@test.mycompany.com', + '"Name" ', + 'test@unknown_domain.com', + '"Name" ' + ], [ + # A mail server is configured for the email + ('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" ', self.mail_server_notification), + # same situation, but the original email has no name part + ('notifications.test@test.mycompany.com', '"test" ', 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" ', self.mail_server_domain), + ], + ): + # test with and without providing an SMTP session, which should not impact test + for provide_smtp in [True, False]: + with self.subTest(mail_from=mail_from): + with self.mock_smtplib_connection(): + if provide_smtp: + smtp_session = IrMailServer.connect(smtp_from=mail_from) + message = self._build_email(mail_from=mail_from) + IrMailServer.send_email(message, smtp_session=smtp_session) + else: + message = self._build_email(mail_from=mail_from) + IrMailServer.send_email(message) + self.connect_mocked.assert_called_once() + self.assertEqual(len(self.emails), 1) + self.assertSMTPEmailsSent( + smtp_from=expected_smtp_from, + message_from=expected_msg_from, + mail_server=expected_mail_server, + ) + + # remove the notification server + # so will use the mail server + # The mail server configured for the notifications email has been removed + # but we can still use the mail server configured for test.mycompany.com + # and so we will be able to use the bounce address + # because we use the mail server for "test.mycompany.com" + self.mail_server_notification.unlink() + for provide_smtp in [False, True]: + with self.mock_smtplib_connection(): + if provide_smtp: + smtp_session = IrMailServer.connect(smtp_from='"Name" ') + message = self._build_email(mail_from='"Name" ') + IrMailServer.send_email(message, smtp_session=smtp_session) + else: + message = self._build_email(mail_from='"Name" ') + IrMailServer.send_email(message) + self.connect_mocked.assert_called_once() + self.assertEqual(len(self.emails), 1) + self.assertSMTPEmailsSent( + smtp_from=self.default_bounce_address, + message_from='"Name" ', + 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" ', + # unknown domain + '"Name" ', + # notification server domain + '"Name" ' + ], [ + ('customer.test@custom_domain.com', '"Custom Server Name" ', custom_server), + (other_default_from, '"Name" ', custom_server), + (self.default_bounce_address, '"Name" ', 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, + ) diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index e2a3917cc94..ee654188e71 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -768,14 +768,14 @@ class TestComposerInternals(TestMailComposer): # 1. check without template + template update composer = self.env['mail.compose.message'].with_context(ctx).create({ 'body': '

Test Body />

') - self.assertEqual(composer.mail_server_id, self.mail_server_global) + self.assertEqual(composer.mail_server_id, self.mail_server_default) if composition_mode == 'comment' and not batch: self.assertEqual(composer.record_name, self.test_record.name) else: @@ -804,13 +804,13 @@ class TestComposerInternals(TestMailComposer): # manual values is kept over template composer.write({ 'body': '

Back to my amazing body />

') - self.assertEqual(composer.mail_server_id, self.mail_server_global) + self.assertEqual(composer.mail_server_id, self.mail_server_default) self.assertEqual(composer.record_name, 'Manual update') self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}') self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}') @@ -821,13 +821,13 @@ class TestComposerInternals(TestMailComposer): if composition_mode == 'comment' and not batch: self.assertEqual(composer.body, '

Back to my amazing body />

') - self.assertEqual(composer.mail_server_id, self.mail_server_global) + self.assertEqual(composer.mail_server_id, self.mail_server_default) self.assertEqual(composer.record_name, 'Manual update') self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}') self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}') diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py index f9740306bd2..a7f729f0c15 100644 --- a/addons/test_mail/tests/test_mail_mail.py +++ b/addons/test_mail/tests/test_mail_mail.py @@ -26,7 +26,6 @@ class TestMailMail(MailCommon): @classmethod def setUpClass(cls): super(TestMailMail, cls).setUpClass() - cls._init_mail_servers() cls.test_record = cls.env['mail.test.gateway'].with_context(cls._test_context).create({ 'name': 'Test', @@ -359,9 +358,6 @@ class TestMailMail(MailCommon): managed and stored at mail and notification level. """ mail, notification = self.test_mail, self.test_notification - self.env['ir.config_parameter'].set_param('mail.catchall.domain', self.alias_domain) - self.env['ir.config_parameter'].set_param('mail.default.from', self.default_from) - # MailServer.send_email(): _prepare_email_message: missing To for email_to in self.emails_falsy: self._reset_data() @@ -660,9 +656,8 @@ class TestMailMailServer(MailCommon): @classmethod def setUpClass(cls): super().setUpClass() - cls._init_mail_servers() - cls.server_domain_2 = cls.env['ir.mail_server'].create({ + cls.mail_server_domain_2 = cls.env['ir.mail_server'].create({ 'from_filter': 'test_2.com', 'name': 'Server 2', 'smtp_host': 'test_2.com', @@ -676,7 +671,7 @@ class TestMailMailServer(MailCommon): """ self.assertEqual( self.env['ir.mail_server']._get_default_from_address(), - f'notifications@{self.alias_domain}' + f'{self.default_from}@{self.alias_domain}' ) mail_values = { @@ -696,19 +691,19 @@ class TestMailMailServer(MailCommon): # Should use the test_2 mail server # Once with "user_1@test_2.com" as login # Once with "user_2@test_2.com" as login - mails |= self.env['mail.mail'].create([{ + mails += self.env['mail.mail'].create([{ **mail_values, 'email_from': 'user_1@test_2.com', - } for _ in range(5)]) | self.env['mail.mail'].create([{ + } for _ in range(5)]) + self.env['mail.mail'].create([{ **mail_values, 'email_from': 'user_2@test_2.com', } for _ in range(5)]) # Mail server is forced - mails |= self.env['mail.mail'].create([{ + mails += self.env['mail.mail'].create([{ **mail_values, 'email_from': 'user_1@test_2.com', - 'mail_server_id': self.server_domain.id, + 'mail_server_id': self.mail_server_domain.id, } for _ in range(5)]) with self.mock_smtplib_connection(): @@ -722,21 +717,21 @@ class TestMailMailServer(MailCommon): self.assertEqual(self.connect_mocked.call_count, 4, 'Must be called once per batch which share the same mail server and the same smtp from') self.connect_mocked.assert_has_calls( calls=[ - call(smtp_from=f'notifications@{self.alias_domain}', mail_server_id=self.server_notification.id), - call(smtp_from='user_1@test_2.com', mail_server_id=self.server_domain_2.id), - call(smtp_from='user_2@test_2.com', mail_server_id=self.server_domain_2.id), - call(smtp_from='user_1@test_2.com', mail_server_id=self.server_domain.id), + call(smtp_from=f'{self.default_from}@{self.alias_domain}', mail_server_id=self.mail_server_notification.id), + call(smtp_from='user_1@test_2.com', mail_server_id=self.mail_server_domain_2.id), + call(smtp_from='user_2@test_2.com', mail_server_id=self.mail_server_domain_2.id), + call(smtp_from='user_1@test_2.com', mail_server_id=self.mail_server_domain.id), ], any_order=True, ) - self.assertSMTPEmailsSent(message_from=f'"test" ', - emails_count=5, from_filter=self.server_notification.from_filter) - self.assertSMTPEmailsSent(message_from=f'"test_2" ', - emails_count=5, from_filter=self.server_notification.from_filter) - self.assertSMTPEmailsSent(message_from='user_1@test_2.com', emails_count=5, from_filter=self.server_domain_2.from_filter) - self.assertSMTPEmailsSent(message_from='user_2@test_2.com', emails_count=5, from_filter=self.server_domain_2.from_filter) - self.assertSMTPEmailsSent(message_from='user_1@test_2.com', emails_count=5, from_filter=self.server_domain.from_filter) + self.assertSMTPEmailsSent(message_from=f'"test" <{self.default_from}@{self.alias_domain}>', + emails_count=5, from_filter=self.mail_server_notification.from_filter) + self.assertSMTPEmailsSent(message_from=f'"test_2" <{self.default_from}@{self.alias_domain}>', + emails_count=5, from_filter=self.mail_server_notification.from_filter) + self.assertSMTPEmailsSent(message_from='user_1@test_2.com', emails_count=5, mail_server=self.mail_server_domain_2) + self.assertSMTPEmailsSent(message_from='user_2@test_2.com', emails_count=5, mail_server=self.mail_server_domain_2) + self.assertSMTPEmailsSent(message_from='user_1@test_2.com', emails_count=5, mail_server=self.mail_server_domain) @tagged('mail_mail') diff --git a/addons/test_mass_mailing/tests/test_mailing_server.py b/addons/test_mass_mailing/tests/test_mailing_server.py index b533d26db9e..fda438bf015 100644 --- a/addons/test_mass_mailing/tests/test_mailing_server.py +++ b/addons/test_mass_mailing/tests/test_mailing_server.py @@ -14,7 +14,6 @@ class TestMassMailingServer(TestMassMailCommon): @classmethod def setUpClass(cls): super(TestMassMailingServer, cls).setUpClass() - cls._init_mail_servers() cls.recipients = cls._create_mailing_test_records(model='mailing.test.optout', count=8) def test_mass_mailing_server_archived_usage_protection(self): @@ -98,23 +97,23 @@ class TestMassMailingServer(TestMassMailCommon): 'body_html': 'Body for ', 'email_from': 'unknow_email@unknow_domain.com', 'mailing_model_id': self.env['ir.model']._get_id('mailing.test.optout'), - 'mail_server_id': self.server_notification.id, + 'mail_server_id': self.mail_server_notification.id, }]) with self.mock_smtplib_connection(): mailings.action_send_mail() self.assertEqual(self.find_mail_server_mocked.call_count, 3, 'Must be called only once per mail from except when forced') - for (expected_smtp_from, expected_msg_from, expected_from_filter) in [ - ('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com', self.server_user.from_filter), - (f'{self.alias_bounce}@{self.alias_domain}', 'unknown_name@test.mycompany.com', self.server_domain.from_filter), + for (expected_smtp_from, expected_msg_from, expected_mail_server) in [ + ('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com', self.mail_server_user), + (f'{self.alias_bounce}@{self.alias_domain}', 'unknown_name@test.mycompany.com', self.mail_server_domain), # We do not have a mail server for this address email, so fall back to the "notifications@domain" email. - ('notifications@test.mycompany.com', '"Testing" ', self.server_notification.from_filter), + (f'{self.default_from}@{self.alias_domain}', f'"Testing" <{self.default_from}@{self.alias_domain}>', self.mail_server_notification), # forced sever - ('unknow_email@unknow_domain.com', 'unknow_email@unknow_domain.com', self.server_notification.from_filter), + ('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, - from_filter=expected_from_filter, + mail_server=expected_mail_server, emails_count=8, ) diff --git a/odoo/addons/base/__manifest__.py b/odoo/addons/base/__manifest__.py index d77e0f11e8e..f57cf22966b 100644 --- a/odoo/addons/base/__manifest__.py +++ b/odoo/addons/base/__manifest__.py @@ -21,7 +21,6 @@ The kernel of Odoo, needed for all installation. 'data/report_paperformat_data.xml', 'data/res_country_data.xml', 'data/ir_demo_data.xml', - 'data/ir_config_parameter_data.xml', 'security/base_groups.xml', 'security/base_security.xml', 'views/base_menus.xml', diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py index 72caaf5e17b..1f3238b51eb 100644 --- a/odoo/addons/base/models/ir_mail_server.py +++ b/odoo/addons/base/models/ir_mail_server.py @@ -200,31 +200,25 @@ class IrMailServer(models.Model): """ return dict() - def _get_test_email_addresses(self): + def _get_test_email_from(self): self.ensure_one() - email_to = "noreply@odoo.com" - if self.from_filter: - from_filter_parts = [part.strip() for part in self.from_filter.split(",")] - if mail_from := next((email for email in from_filter_parts if "@" in email), None): - # All emails will be sent from the same address - return mail_from, email_to - # All emails will be sent from any address in the same domain - 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]}", email_to - elif self._match_from_filter(default_from, self.from_filter): - # the mail server is configured for a domain - # that match the default email address - return default_from, email_to - # the from_filter is configured for a domain different that the one - # of the full email configured in mail.default.from - return f"noreply@{from_filter_parts[0]}", email_to - # Fallback to current user email if there's no from filter - email_from = self.env.user.email + email_from = False + if from_filter_parts := [part.strip() for part in (self.from_filter or '').split(",") if part.strip()]: + # find first found complete email in filter parts + email_from = next((email for email in from_filter_parts if "@" in email), False) + # no complete email -> consider noreply + if not email_from: + email_from = f"noreply@{from_filter_parts[0]}" if not email_from: + # Fallback to current user email if there's no from filter + email_from = self.env.user.email + if not email_from or "@" not in email_from: raise UserError(_('Please configure an email on the current user to simulate ' 'sending an email message via this outgoing server')) - return email_from, email_to + return email_from + + def _get_test_email_to(self): + return "noreply@odoo.com" def test_smtp_connection(self): for server in self: @@ -232,7 +226,8 @@ class IrMailServer(models.Model): try: smtp = self.connect(mail_server_id=server.id, allow_archived=True) # simulate sending an email from current user's address - without sending it! - email_from, email_to = server._get_test_email_addresses() + email_from = server._get_test_email_from() + email_to = server._get_test_email_to() # Testing the MAIL FROM step should detect sender filter problems (code, repl) = smtp.mail(email_from) if code != 250: @@ -529,47 +524,20 @@ class IrMailServer(models.Model): @api.model def _get_default_bounce_address(self): - '''Compute the default bounce address. + """ Computes the default bounce address. It is used to set the envelop + address if no envelop address is provided in the message. - The default bounce address is used to set the envelop address if no - envelop address is provided in the message. It is formed by properly - joining the parameters "mail.bounce.alias" and - "mail.catchall.domain". - - If "mail.bounce.alias" is not set it defaults to "postmaster-odoo". - - If "mail.catchall.domain" is not set, return None. - - ''' - 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 '%s@%s' % (bounce_alias, domain) - return + :return str/None: defaults to the ``--email-from`` CLI/config parameter. + """ + return tools.config.get("email_from") @api.model def _get_default_from_address(self): - """Compute the default from address. + """ Computes the default from address. It is used for the "header from" + address when no other has been received. - Used for the "header from" address when no other has been received. - - :return str/None: - If the config parameter ``mail.default.from`` contains - a full email address, return it. - Otherwise, combines config parameters ``mail.default.from`` and - ``mail.catchall.domain`` to generate a default sender address. - - If some of those parameters is not defined, it will default to the - ``--email-from`` CLI/config parameter. + :return str/None: defaults to the ``--email-from`` CLI/config parameter. """ - 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 - domain = get_param("mail.catchall.domain") - if email_from and domain: - return "%s@%s" % (email_from, domain) return tools.config.get("email_from") def _prepare_email_message(self, message, smtp_session): @@ -794,8 +762,7 @@ class IrMailServer(models.Model): normalized_mail_from = email_normalize(email_from) normalized_domain = email_domain_extract(normalized_mail_from) - for email_filter in from_filter.split(','): - email_filter = email_filter.strip() + for email_filter in [part.strip() for part in (from_filter or '').split(',') if part.strip()]: if '@' in email_filter and email_normalize(email_filter) == normalized_mail_from: return True if '@' not in email_filter and email_domain_normalize(email_filter) == normalized_domain: diff --git a/odoo/addons/base/tests/common.py b/odoo/addons/base/tests/common.py index b2411e0eda4..1e20d0416cf 100644 --- a/odoo/addons/base/tests/common.py +++ b/odoo/addons/base/tests/common.py @@ -341,11 +341,11 @@ class MockSmtplibCase: def _build_email(self, mail_from, return_path=None, **kwargs): return self.env['ir.mail_server'].build_email( - body=kwargs.pop('body', 'body'), - email_from=mail_from, - email_to='dest@example-é.com', + mail_from, + kwargs.pop('email_to', 'dest@example-é.com'), + kwargs.pop('subject', 'subject'), + kwargs.pop('body', 'body'), headers={'Return-Path': return_path} if return_path else None, - subject='subject', **kwargs, ) @@ -354,19 +354,26 @@ class MockSmtplibCase: self.env['ir.mail_server'].send_email(msg, smtp_session=smtp_session) return smtp_session.messages.pop() - def assertSMTPEmailsSent(self, smtp_from=None, smtp_to_list=None, message_from=None, from_filter=None, emails_count=1): - """Check that the given email has been sent. - - If one of the parameter is None, it's just ignored and not used to retrieve the email. + def assertSMTPEmailsSent(self, smtp_from=None, smtp_to_list=None, message_from=None, + mail_server=None, from_filter=None, + emails_count=1): + """Check that the given email has been sent. If one of the parameter is + None it is just ignored and not used to retrieve the email. :param smtp_from: FROM used for the authentication to the mail server :param smtp_to_list: List of destination email address :param message_from: FROM used in the SMTP headers - :param from_filter: from_filter of the used to send the email - Can use a lambda to check the value + :arap mail_server: used to compare the 'from_filter' as an alternative + to using the from_filter parameter + :param from_filter: from_filter of the used to send the + email. False means 'match everything';' :param emails_count: the number of emails which should match the condition :return: True if at least one email has been found with those parameters """ + if from_filter is not None and mail_server: + raise ValueError('Invalid usage: use either from_filter either mail_server') + if from_filter is None and mail_server is not None: + from_filter = mail_server.from_filter matching_emails = filter( lambda email: (smtp_from is None or smtp_from == email['smtp_from']) @@ -391,14 +398,8 @@ class MockSmtplibCase: ) @classmethod - def _init_mail_config(cls): - cls.alias_bounce = 'bounce.test' - cls.alias_domain = 'test.mycompany.com' - cls.default_from = 'notifications' + def _init_mail_gateway(cls): cls.default_from_filter = False - cls.env['ir.config_parameter'].sudo().set_param('mail.catchall.domain', cls.alias_domain) - cls.env['ir.config_parameter'].sudo().set_param('mail.default.from', cls.default_from) - cls.env['ir.config_parameter'].sudo().set_param('mail.bounce.alias', cls.alias_bounce) cls.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', cls.default_from_filter) @classmethod @@ -409,12 +410,7 @@ class MockSmtplibCase: 'smtp_host': 'smtp_host', 'smtp_encryption': 'none', } - ( - cls.server_domain, - cls.server_user, - cls.server_notification, - cls.server_default, - ) = cls.env['ir.mail_server'].create([ + cls.mail_servers = cls.env['ir.mail_server'].create([ { 'name': 'Domain based server', 'from_filter': 'test.mycompany.com', @@ -425,7 +421,7 @@ class MockSmtplibCase: ** ir_mail_server_values, }, { 'name': 'Server Notifications', - 'from_filter': 'notifications@test.mycompany.com', + 'from_filter': 'notifications.test@test.mycompany.com', ** ir_mail_server_values, }, { 'name': 'Server No From Filter', @@ -433,3 +429,7 @@ class MockSmtplibCase: ** ir_mail_server_values, }, ]) + ( + cls.mail_server_domain, cls.mail_server_user, + cls.mail_server_notification, cls.mail_server_default + ) = cls.mail_servers diff --git a/odoo/addons/base/tests/test_ir_mail_server.py b/odoo/addons/base/tests/test_ir_mail_server.py index 9d97f6e640b..7557421649a 100644 --- a/odoo/addons/base/tests/test_ir_mail_server.py +++ b/odoo/addons/base/tests/test_ir_mail_server.py @@ -3,14 +3,13 @@ import email.message import email.policy -import itertools from unittest.mock import patch from odoo import tools from odoo.addons.base.tests import test_mail_examples from odoo.addons.base.tests.common import MockSmtplibCase -from odoo.tests import tagged +from odoo.tests import tagged, users from odoo.tests.common import TransactionCase from odoo.tools import mute_logger from odoo.tools import config @@ -37,28 +36,13 @@ class _FakeSMTP: class EmailConfigCase(TransactionCase): @patch.dict(config.options, {"email_from": "settings@example.com"}) - def test_default_email_from(self, *args): - """Email from setting is respected.""" - ICP = self.env["ir.config_parameter"].sudo() - for (icp_from, icp_domain), expected_from in zip( - [ - ('icp', 'test.mycompany.com'), - ('icp@another.company.com', 'test.mycompany.com'), - (False, 'test.mycompany.com'), - ], [ - "icp@test.mycompany.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) - message = self.env["ir.mail_server"].build_email( - False, "recipient@example.com", "Subject", - "The body of an email", - ) - self.assertEqual(message["From"], expected_from) + def test_default_email_from(self): + """ Email from setting is respected and comes from configuration. """ + message = self.env["ir.mail_server"].build_email( + False, "recipient@example.com", "Subject", + "The body of an email", + ) + self.assertEqual(message["From"], "settings@example.com") @tagged('mail_server') @@ -67,19 +51,16 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase): @classmethod def setUpClass(cls): super().setUpClass() - cls._init_mail_config() + # 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._init_mail_servers() - - cls.default_bounce_address = f'{cls.alias_bounce}@{cls.alias_domain}' - cls.default_from_address = f'{cls.default_from}@{cls.alias_domain}' + cls.default_bounce_address = 'CACA' + cls.default_from_address = 'PROUT' def test_assert_base_values(self): - self.assertEqual( - self.env['ir.mail_server']._get_default_bounce_address(), - self.default_bounce_address) - self.assertEqual( - self.env['ir.mail_server']._get_default_from_address(), - self.default_from_address) + self.assertFalse(self.env['ir.mail_server']._get_default_bounce_address()) + self.assertFalse(self.env['ir.mail_server']._get_default_from_address()) def test_bpo_34424_35805(self): """Ensure all email sent are bpo-34424 and bpo-35805 free""" @@ -124,35 +105,7 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase): "There should be 3 headers MIME-Version: one on the enveloppe, " "one on the html part, one on the text part") - def test_mail_server_match_from_filter(self): - """ Test the from_filter field on the "ir.mail_server". """ - # Should match - tests = [ - ('admin@mail.example.com', 'mail.example.com'), - ('admin@mail.example.com', 'mail.EXAMPLE.com'), - ('admin@mail.example.com', 'admin@mail.example.com'), - ('admin@mail.example.com', False), - ('"fake@test.mycompany.com" ', 'mail.example.com'), - ('"fake@test.mycompany.com" ', 'mail.example.com'), - ('"fake@test.mycompany.com" ', 'test.mycompany.com, mail.example.com, test2.com'), - ] - for email, from_filter in tests: - self.assertTrue(self.env['ir.mail_server']._match_from_filter(email, from_filter)) - - # Should not match - tests = [ - ('admin@mail.example.com', 'test@mail.example.com'), - ('admin@mail.example.com', 'test.mycompany.com'), - ('admin@mail.example.com', 'mail.éxample.com'), - ('admin@mmail.example.com', 'mail.example.com'), - ('admin@mail.example.com', 'mmail.example.com'), - ('"admin@mail.example.com" ', 'mail.example.com'), - ('"fake@test.mycompany.com" ', 'test.mycompany.com, wrong.mail.example.com, test3.com'), - ] - for email, from_filter in tests: - self.assertFalse(self.env['ir.mail_server']._match_from_filter(email, from_filter)) - - def test_mail_body(self): + def test_content_mail_body(self): bodies = [ 'content', '

content

', @@ -187,61 +140,80 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase): body_alternative = body_alternative.strip('\n') self.assertEqual(body_alternative, expected) + @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 / default fallback value. """ + test_server = self.env['ir.mail_server'].create({ + 'from_filter': 'example_2.com, example_3.com', + 'name': 'Test Server', + 'smtp_host': 'smtp_host', + 'smtp_encryption': 'none', + }) + for from_filter, expected_test_email in zip( + [ + 'example_2.com, example_3.com', + 'dummy.com, full_email@example_2.com, dummy2.com', + # fallback on user's email + ' ', + ',', + False, + ], [ + 'noreply@example_2.com', + 'full_email@example_2.com', + self.env.user.email, + self.env.user.email, + self.env.user.email, + ], + ): + with self.subTest(from_filter=from_filter): + test_server.from_filter = from_filter + email_from = test_server._get_test_email_from() + self.assertEqual(email_from, expected_test_email) + + def test_mail_server_match_from_filter(self): + """ Test the from_filter field on the "ir.mail_server". """ + # Should match + tests = [ + ('admin@mail.example.com', 'mail.example.com'), + ('admin@mail.example.com', 'mail.EXAMPLE.com'), + ('admin@mail.example.com', 'admin@mail.example.com'), + ('admin@mail.example.com', False), + ('"fake@test.mycompany.com" ', 'mail.example.com'), + ('"fake@test.mycompany.com" ', 'mail.example.com'), + ('"fake@test.mycompany.com" ', 'test.mycompany.com, mail.example.com, test2.com'), + ] + for email, from_filter in tests: + self.assertTrue(self.env['ir.mail_server']._match_from_filter(email, from_filter)) + + # Should not match + tests = [ + ('admin@mail.example.com', 'test@mail.example.com'), + ('admin@mail.example.com', 'test.mycompany.com'), + ('admin@mail.example.com', 'mail.éxample.com'), + ('admin@mmail.example.com', 'mail.example.com'), + ('admin@mail.example.com', 'mmail.example.com'), + ('"admin@mail.example.com" ', 'mail.example.com'), + ('"fake@test.mycompany.com" ', 'test.mycompany.com, wrong.mail.example.com, test3.com'), + ] + for email, from_filter in tests: + self.assertFalse(self.env['ir.mail_server']._match_from_filter(email, from_filter)) + @mute_logger('odoo.models.unlink') def test_mail_server_priorities(self): - """ Test if we choose the right mail server to send an email. - - Priorities are - 1. Forced mail server (e.g.: in mass mailing) - - If the "from_filter" of the mail server match the notification email - use the notifications email in the "From header" - - Otherwise spoof the "From" (because we force the mail server but we don't - know which email use to send it) - 2. A mail server for which the "from_filter" match the "From" header - 3. A mail server for which the "from_filter" match the domain of the "From" header - 4. The mail server used for notifications - 5. A mail server without "from_filter" (and so spoof the "From" header because we - do not know for which email address it can be used) - """ - # sanity checks - self.assertTrue(self.env['ir.mail_server']._get_default_from_address(), 'Notifications email must be set for testing') - self.assertTrue(self.env['ir.mail_server']._get_default_bounce_address(), 'Bounce email must be set for testing') - # this mail server can not be used for a specific email address and 2 domain names - self.server_user.from_filter = "domain1.com, specific_user@test.mycompany.com, domain2.com" - + """ Test if we choose the right mail server to send an email. Simulates + simple Odoo DB so we have to spoof the FROM otherwise we cannot send + any email. """ for email_from, (expected_mail_server, expected_email_from) in zip( [ - 'specific_user@test.mycompany.com', - '"Name name@strange.name" ', 'specific_user@test.mycompany.com', 'unknown_email@test.mycompany.com', - 'unknown_email@TEST.MYCOMPANY.COM', + # no notification set, must be forced to spoof the FROM '"Test" ', - # server_user multiple from filter check - '"Test" ', - '"Example" ', - '"Example" ', - '"Test" ', ], [ - (self.server_user, 'specific_user@test.mycompany.com'), - # must extract email from full name, must keep the given email from - (self.server_user, '"Name name@strange.name" '), - # should not be case sensitive - (self.server_user, 'specific_user@test.mycompany.com'), - (self.server_domain, 'unknown_email@test.mycompany.com'), - # cover a different condition that the "email case insensitive" test: domain is case insensitive - (self.server_domain, 'unknown_email@TEST.MYCOMPANY.COM'), - # should take the notification email - (self.server_notification, 'notifications@test.mycompany.com'), - # server_user multipl from_filter check: can be used for a sepcific emial and 2 domain names - # 1. entire email matches, server should be used - (self.server_user, '"Test" '), - # 2. domain matches, server should be used - (self.server_user, '"Example" '), - # 3. other domain matches, server shold be used - (self.server_user, '"Example" '), - # check that we take the domain server and not the "user specific" server - (self.server_domain, '"Test" '), + (self.mail_server_user, 'specific_user@test.mycompany.com'), + (self.mail_server_domain, 'unknown_email@test.mycompany.com'), + (self.mail_server_default, '"Test" '), ], ): with self.subTest(email_from=email_from): @@ -249,77 +221,58 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase): self.assertEqual(mail_server, expected_mail_server) self.assertEqual(mail_from, expected_email_from) - @mute_logger('odoo.models.unlink') - def test_mail_server_priorities_base_config(self): - """ Test if we choose the right mail server to send an email, without - ICP configuration. Simulates unconfigured Odoo DB so we have to spoof - the FROM otherwise we cannot send any email. """ - self.env['ir.config_parameter'].sudo().set_param('mail.catchall.domain', False) - self.env['ir.config_parameter'].sudo().set_param('mail.default.from', False) - for email_from, (expected_mail_server, expected_from_filter, expected_email_from) in zip( - [ - 'test@unknown_domain.com', - ], [ - (self.server_default, False, 'test@unknown_domain.com'), - ], - ): - with self.subTest(email_from=email_from): - mail_server, mail_from = self.env['ir.mail_server']._find_mail_server(email_from='test@unknown_domain.com') - self.assertEqual(mail_server.from_filter, expected_from_filter, 'No notifications email set, must be forced to spoof the FROM') - self.assertEqual(mail_server, expected_mail_server) - self.assertEqual(mail_from, expected_email_from) - @mute_logger('odoo.models.unlink') def test_mail_server_send_email(self): + """ Test main 'send_email' usage: check mail_server choice based on from + filters, encapsulation, spoofing. """ IrMailServer = self.env['ir.mail_server'] - for (mail_from, (expected_smtp_from, expected_msg_from, expected_from_filter)), provide_smtp in itertools.product( - zip( - [ - 'specific_user@test.mycompany.com', - '"Name" ', - 'test@unknown_domain.com', - 'unknown_name@test.mycompany.com' - ], [ - # A mail server is configured for the email - ('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com', 'specific_user@test.mycompany.com'), - # No mail server are configured for the email address, so it will use the - # notifications email instead and encapsulate the old email - ('notifications@test.mycompany.com', '"Name" ', 'notifications@test.mycompany.com'), - # same situation, but the original email has no name part - ('notifications@test.mycompany.com', '"test" ', 'notifications@test.mycompany.com'), - # 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, 'unknown_name@test.mycompany.com', 'test.mycompany.com'), - ], - ), - [False, True], # provide SMTP session or not: should not impact sending + for mail_from, (expected_smtp_from, expected_msg_from, expected_mail_server) in zip( + [ + 'specific_user@test.mycompany.com', + '"Name" ', + 'test@unknown_domain.com', + '"Name" ' + ], [ + # A mail server is configured for the email + ('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 + ('test@unknown_domain.com', '"Name" ', self.mail_server_default), + # same situation, but the original email has no name part + ('test@unknown_domain.com', 'test@unknown_domain.com', self.mail_server_default), + # A mail server is configured for the entire domain name, so we can use the bounce + # email address because the mail server supports it + ('unknown_name@test.mycompany.com', '"Name" ', self.mail_server_domain), + ] ): - with self.subTest(mail_from=mail_from): - with self.mock_smtplib_connection(): - if provide_smtp: - smtp_session = IrMailServer.connect(smtp_from=mail_from) - message = self._build_email(mail_from=mail_from) - IrMailServer.send_email(message, smtp_session=smtp_session) - else: - message = self._build_email(mail_from=mail_from) - IrMailServer.send_email(message) + # test with and without providing an SMTP session, which should not impact test + for provide_smtp in [False, True]: + with self.subTest(mail_from=mail_from, provide_smtp=provide_smtp): + with self.mock_smtplib_connection(): + if provide_smtp: + smtp_session = IrMailServer.connect(smtp_from=mail_from) + message = self._build_email(mail_from=mail_from) + IrMailServer.send_email(message, smtp_session=smtp_session) + else: + message = self._build_email(mail_from=mail_from) + IrMailServer.send_email(message) - self.connect_mocked.assert_called_once() - self.assertEqual(len(self.emails), 1) - self.assertSMTPEmailsSent( - smtp_from=expected_smtp_from, - message_from=expected_msg_from, - from_filter=expected_from_filter, - ) + self.connect_mocked.assert_called_once() + self.assertEqual(len(self.emails), 1) + self.assertSMTPEmailsSent( + smtp_from=expected_smtp_from, + message_from=expected_msg_from, + mail_server=expected_mail_server, + ) # remove the notification server - # so will use the mail server + # so will use the mail server # The mail server configured for the notifications email has been removed # but we can still use the mail server configured for test.mycompany.com # and so we will be able to use the bounce address # because we use the mail server for "test.mycompany.com" - self.server_notification.unlink() + self.mail_server_notification.unlink() for provide_smtp in [False, True]: with self.mock_smtplib_connection(): if provide_smtp: @@ -333,14 +286,14 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase): self.connect_mocked.assert_called_once() self.assertEqual(len(self.emails), 1) self.assertSMTPEmailsSent( - smtp_from=self.default_bounce_address, - message_from='"Name" ', - from_filter='test.mycompany.com', + smtp_from='test@unknown_domain.com', + message_from='"Name" ', + from_filter=False, ) @mute_logger('odoo.models.unlink') def test_mail_server_send_email_IDNA(self): - # Test that the mail from / recipient envelop are encoded using IDNA + """ 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') @@ -348,194 +301,98 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase): self.assertEqual(len(self.emails), 1) self.assertSMTPEmailsSent( - smtp_from=f'{self.alias_bounce}@xn--9caaaaaaa.com', + smtp_from='test@xn--9caaaaaaa.com', smtp_to_list=['dest@xn--example--i1a.com'], message_from='test@=?utf-8?b?w6nDqcOpw6nDqcOpw6k=?=.com', from_filter=False, ) - @mute_logger('odoo.models.unlink') - 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 - self.env['ir.config_parameter'].sudo().set_param('mail.default.from', 'test@custom_domain.com') - _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_from_filter) in zip( - [ - '"Name" ', - ], [ - ('test@custom_domain.com', '"Name" ', 'custom_domain.com'), - ], - ): - 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, - from_filter=expected_from_filter, - ) - - @mute_logger('odoo.models.unlink') - @patch.dict(config.options, {"from_filter": "test.mycompany.com", "smtp_server": "example.com"}) - def test_mail_server_config_bin(self): - """Test the configuration provided in the odoo-bin arguments. - - This config is used when no mail server exists. - """ - IrMailServer = self.env['ir.mail_server'] - - # Remove all mail server so we will use the odoo-bin arguments - self.env['ir.mail_server'].search([]).unlink() - self.assertFalse(self.env['ir.mail_server'].search([])) - - for email_from, (expected_smtp_from, expected_msg_from) in zip( - [ - # inside "from_filter" domain - 'specific_user@test.mycompany.com', - # outside "from_filter" domain - 'test@unknown_domain.com', - ], [ - (self.default_bounce_address, 'specific_user@test.mycompany.com'), - # 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" '), - ] - ): - with self.subTest(email_from=email_from): - with self.mock_smtplib_connection(): - message = self._build_email(mail_from=email_from) - IrMailServer.send_email(message) - - self.connect_mocked.assert_called_once() - self.assertSMTPEmailsSent( - smtp_from=expected_smtp_from, - message_from=expected_msg_from, - from_filter='test.mycompany.com', - ) - - @mute_logger('odoo.models.unlink') - @patch.dict(config.options, {'from_filter': 'test.mycompany.com', 'smtp_server': 'example.com'}) - def test_mail_server_config_bin_default_from_filter(self): - """Test that the config parameter "mail.default.from_filter" overwrites - the odoo-bin argument "--from-filter" """ - self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', 'example.com') - - IrMailServer = self.env['ir.mail_server'] - - # Remove all mail server so we will use the odoo-bin arguments - IrMailServer.search([]).unlink() - self.assertFalse(IrMailServer.search([])) - - # Use an email in the domain of the config parameter "mail.default.from_filter" - with self.mock_smtplib_connection(): - message = self._build_email(mail_from='specific_user@example.com') - IrMailServer.send_email(message) - - self.assertSMTPEmailsSent( - smtp_from='specific_user@example.com', - message_from='specific_user@example.com', - from_filter='example.com', - ) - @mute_logger('odoo.models.unlink') @patch.dict(config.options, { "from_filter": "dummy@example.com, test.mycompany.com, dummy2@example.com", "smtp_server": "example.com", }) - def test_mail_server_config_bin_smtp_session(self): - """Test the configuration provided in the odoo-bin arguments. + def test_mail_server_config_bin(self): + """ Test the configuration provided in the odoo-bin arguments. This config + is used when no mail server exists. Test with and without giving a + pre-configured SMTP session, should not impact results. - This config is used when no mail server exists. - Use a pre-configured SMTP session. + Also check "mail.default.from_filter" parameter usage that should overwrite + odoo-bin argument "--from-filter". """ IrMailServer = self.env['ir.mail_server'] # Remove all mail server so we will use the odoo-bin arguments - self.env['ir.mail_server'].search([]).unlink() - self.assertFalse(self.env['ir.mail_server'].search([])) + IrMailServer.search([]).unlink() + self.assertFalse(IrMailServer.search([])) - for email_from, (expected_smtp_from, expected_msg_from) in zip( + for mail_from, (expected_smtp_from, expected_msg_from) in zip( [ - # use an email in the domain of the "from_filter" + # inside "from_filter" domain 'specific_user@test.mycompany.com', - # Use an email outside of the domain of the "from_filter" + '"Formatted Name" ', + '"Formatted Name" ', + '"Formatted Name" ', + # outside "from_filter" domain 'test@unknown_domain.com', + '"Formatted Name" ', ], [ - (self.default_bounce_address, 'specific_user@test.mycompany.com'), - # Use an email outside of the domain of the "from_filter" - # So we will use the notifications email in the headers and the bounce address - # in the envelop because the "from_filter" allows to use the entire domain - (self.default_bounce_address, '"test" ') + # inside "from_filter" domain: no rewriting + ('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com'), + ('specific_user@test.mycompany.com', '"Formatted Name" '), + ('specific_user@test.MYCOMPANY.com', '"Formatted Name" '), + ('SPECIFIC_USER@test.mycompany.com', '"Formatted Name" '), + # outside "from_filter" domain: spoofing, as fallback email can be found + ('test@unknown_domain.com', 'test@unknown_domain.com'), + ('test@unknown_domain.com', '"Formatted Name" '), ] ): - with self.subTest(email_from=email_from): - with self.mock_smtplib_connection(): - smtp_session = IrMailServer.connect(smtp_from=email_from) - message = self._build_email(mail_from=email_from) - IrMailServer.send_email(message, smtp_session=smtp_session) + for provide_smtp in [False, True]: # providing smtp session should ont impact test + with self.subTest(mail_from=mail_from, provide_smtp=provide_smtp): + with self.mock_smtplib_connection(): + if provide_smtp: + smtp_session = IrMailServer.connect(smtp_from=mail_from) + message = self._build_email(mail_from=mail_from) + IrMailServer.send_email(message, smtp_session=smtp_session) + else: + message = self._build_email(mail_from=mail_from) + IrMailServer.send_email(message) - self.connect_mocked.assert_called_once() - self.assertSMTPEmailsSent( - smtp_from=expected_smtp_from, - message_from=expected_msg_from, - from_filter='dummy@example.com, test.mycompany.com, dummy2@example.com', - ) + self.connect_mocked.assert_called_once() + self.assertEqual(len(self.emails), 1) + self.assertSMTPEmailsSent( + smtp_from=expected_smtp_from, + message_from=expected_msg_from, + from_filter="dummy@example.com, test.mycompany.com, dummy2@example.com", + ) - def test_mail_server_get_test_email_addresses(self): - """Test the email used to test the mail server connection.""" - test_server = self.env['ir.mail_server'].create({ - 'from_filter': 'example_2.com, example_3.com', - 'name': 'Test Server', - 'smtp_host': 'smtp_host', - 'smtp_encryption': 'none', - }) + # for from_filter in ICP, overwrite the one from odoo-bin + self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', 'icp.example.com') - # 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'), - ], [ - 'noreply@example_2.com', - 'notifications@example_2.com', - 'full_email@example_2.com', - 'full_email@example_2.com', - 'notifications@example.com', - ], - ): - with self.subTest(default_from=default_from, from_filter=from_filter): - self.env['ir.config_parameter'].set_param('mail.default.from', default_from) - test_server.from_filter = from_filter - email_from = test_server._get_test_email_addresses()[0] - self.assertEqual(email_from, expected_test_email) + # Use an email in the domain of the config parameter "mail.default.from_filter" + with self.mock_smtplib_connection(): + message = self._build_email(mail_from='specific_user@icp.example.com') + IrMailServer.send_email(message) + + self.assertSMTPEmailsSent( + smtp_from='specific_user@icp.example.com', + message_from='specific_user@icp.example.com', + from_filter='icp.example.com', + ) @mute_logger('odoo.models.unlink') @patch.dict(config.options, {'from_filter': 'fake.com', 'smtp_server': 'cli_example.com'}) def test_mail_server_config_cli(self): - """Check the mail server when the "smtp_authentication" is "cli". - - Should take the configuration from the odoo-bin argument. - The "from_filter" of the mail server should overwrite the one set - in the CLI arguments. + """ Test the mail server configuration when the "smtp_authentication" is + "cli". It should take the configuration from the odoo-bin argument. The + "from_filter" of the mail server should overwrite the one set in the CLI + arguments. """ + IrMailServer = self.env['ir.mail_server'] # should be ignored by the mail server self.env['ir.config_parameter'].sudo().set_param('mail.default.from_filter', 'fake.com') - self.env['ir.mail_server'].create([{ + server_other = IrMailServer.create([{ 'name': 'Server No From Filter', 'smtp_host': 'smtp_host', 'smtp_encryption': 'none', @@ -543,27 +400,25 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase): 'from_filter': 'dummy@example.com, cli_example.com, dummy2@example.com', }]) - IrMailServer = self.env['ir.mail_server'] + for mail_from, (expected_smtp_from, expected_msg_from, expected_mail_server) in zip( + [ + # check that the CLI server take the configuration in the odoo-bin argument + # except the from_filter which is taken on the mail server + 'test@cli_example.com', + # other mail servers still work + 'specific_user@test.mycompany.com', + ], [ + ('test@cli_example.com', 'test@cli_example.com', server_other), + ('specific_user@test.mycompany.com', 'specific_user@test.mycompany.com', self.mail_server_user), + ], + ): + with self.subTest(mail_from=mail_from): + with self.mock_smtplib_connection(): + message = self._build_email(mail_from=mail_from) + IrMailServer.send_email(message) - # check that the CLI server take the configuration in the odoo-bin argument - # except the from_filter which is taken on the mail server - with self.mock_smtplib_connection(): - message = self._build_email(mail_from='test@cli_example.com') - IrMailServer.send_email(message) - - self.assertSMTPEmailsSent( - smtp_from='test@cli_example.com', - message_from='test@cli_example.com', - from_filter='dummy@example.com, cli_example.com, dummy2@example.com', - ) - - # other mail server still work - with self.mock_smtplib_connection(): - message = self._build_email(mail_from='specific_user@test.mycompany.com') - IrMailServer.send_email(message) - - self.assertSMTPEmailsSent( - smtp_from='specific_user@test.mycompany.com', - message_from='specific_user@test.mycompany.com', - from_filter='specific_user@test.mycompany.com', - ) + self.assertSMTPEmailsSent( + smtp_from=expected_smtp_from, + message_from=expected_msg_from, + mail_server=expected_mail_server, + )