diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 7b47d4d031f02ecc9db3b8dd6583d48bbef5a168..47a799e993ce84f34ae6d831572952f3449fd8c4 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -8,7 +8,6 @@ import re from odoo import _, api, fields, models, tools, Command from odoo.exceptions import UserError from odoo.osv import expression -from odoo.tools import email_re def _reopen(self, res_id, model, context=None): @@ -466,12 +465,11 @@ class MailComposer(models.TransientModel): recipients_info = {} for record_id, mail_values in mail_values_dict.items(): - mail_to = [] - if mail_values.get('email_to'): - mail_to += email_re.findall(mail_values['email_to']) - # if unrecognized email in email_to -> keep it as used for further processing - if not mail_to: - mail_to.append(mail_values['email_to']) + # add email from email_to; if unrecognized email in email_to keep + # it as used for further processing + mail_to = tools.email_split_and_format(mail_values.get('email_to')) + if not mail_to and mail_values.get('email_to'): + mail_to.append(mail_values['email_to']) # add email from recipients (res.partner) mail_to += [ recipient_emails[recipient_command[1]] @@ -528,7 +526,7 @@ class MailComposer(models.TransientModel): elif not mail_to: mail_values['state'] = 'cancel' mail_values['failure_type'] = 'mail_email_missing' - elif not mail_to_normalized or not email_re.findall(mail_to): + elif not mail_to_normalized: mail_values['state'] = 'cancel' mail_values['failure_type'] = 'mail_email_invalid' elif done_emails is not None and not mailing_document_based: diff --git a/addons/test_mass_mailing/tests/test_mailing.py b/addons/test_mass_mailing/tests/test_mailing.py index 66e55fe496be6d755fea0ed386aea796fe258c14..1e7d3ae84686c2787bf0fffccab8ed4ac99009b2 100644 --- a/addons/test_mass_mailing/tests/test_mailing.py +++ b/addons/test_mass_mailing/tests/test_mailing.py @@ -221,11 +221,11 @@ class TestMassMailing(TestMassMailCommon): 'partner': customer_fmt, 'trace_status': 'sent'}, {'email': '"Unicode Customer" <test.customer.😊@example.com>', - # double encapsulation, not good - 'email_to_recipients': [[f'"{customer_unic.name}" <"Unicode Customer" <test.customer.format@example.com>>']], - 'failure_type': 'mail_email_invalid', + # mail to avoids double encapsulation + 'email_to_recipients': [[f'"{customer_unic.name}" <test.customer.😊@example.com>']], + 'failure_type': False, 'partner': customer_unic, - 'trace_status': 'cancel'}, # email_re usage forbids mailing to unicode + 'trace_status': 'sent'}, {'email': 'TEST.CUSTOMER.CASE@EXAMPLE.COM', 'email_to_recipients': [[f'"{customer_case.name}" <test.customer.case@example.com>']], 'failure_type': False, @@ -254,8 +254,8 @@ class TestMassMailing(TestMassMailCommon): {'email': 'record.😊@example.com', 'email_to_mail': 'record.😊@example.com', 'email_to_recipients': [['record.😊@example.com']], - 'failure_type': 'mail_email_invalid', - 'trace_status': 'cancel'}, # email_re usage forbids mailing to unicode + 'failure_type': False, + 'trace_status': 'sent'}, {'email': 'test.record.case@example.com', 'email_to_mail': 'test.record.case@example.com', 'email_to_recipients': [['test.record.case@example.com']], diff --git a/odoo/addons/base/tests/test_mail.py b/odoo/addons/base/tests/test_mail.py index a2bd023d7e6a83c344bfc429aae3ec130179621a..0465b563748041b7bdde6ce481f35eced27dce6f 100644 --- a/odoo/addons/base/tests/test_mail.py +++ b/odoo/addons/base/tests/test_mail.py @@ -418,6 +418,8 @@ class TestEmailTools(BaseCase): """ Test 'email_normalize'. Note that it is built on 'email_split' so some use cases are already managed in 'test_email_split(_and_format)' hence having more specific test cases here about normalization itself. """ + format_name = 'My Super Prénom' + format_name_ascii = '=?utf-8?b?TXkgU3VwZXIgUHLDqW5vbQ==?=' sources = [ '"Super Déboulonneur" <deboulonneur@example.com>', # formatted 'Déboulonneur deboulonneur@example.com', # wrong formatting @@ -430,17 +432,42 @@ class TestEmailTools(BaseCase): ] expected_list = [ 'deboulonneur@example.com', - 'déboulonneur deboulonneur@example.com', + 'Déboulonneur deboulonneur@example.com', 'deboulonneur@example.comdéboulonneur', False, '@example.com', # funny 'deboulonneur.😊@example.com', 'déboulonneur@examplé.com', - 'déboulonneur@examplé.com', + 'DéBoulonneur@examplé.com', ] - for source, expected in zip(sources, expected_list): + expected_fmt_utf8_list = [ + f'"{format_name}" <deboulonneur@example.com>', + f'"{format_name}" <Déboulonneur deboulonneur@example.com>', + f'"{format_name}" <deboulonneur@example.comdéboulonneur>', + f'"{format_name}" <@>', + f'"{format_name}" <@example.com>', + f'"{format_name}" <deboulonneur.😊@example.com>', + f'"{format_name}" <déboulonneur@examplé.com>', + f'"{format_name}" <DéBoulonneur@examplé.com>', + ] + expected_fmt_ascii_list = [ + f'{format_name_ascii} <deboulonneur@example.com>', + f'{format_name_ascii} <Déboulonneur deboulonneur@example.com>', + f'{format_name_ascii} <deboulonneur@example.xn--comdboulonneur-ekb>', + f'{format_name_ascii} <@>', + f'{format_name_ascii} <@example.com>', + f'{format_name_ascii} <deboulonneur.😊@example.com>', + f'{format_name_ascii} <déboulonneur@xn--exampl-gva.com>', + f'{format_name_ascii} <DéBoulonneur@xn--exampl-gva.com>', + ] + for source, expected, expected_utf8_fmt, expected_ascii_fmt in zip(sources, expected_list, expected_fmt_utf8_list, expected_fmt_ascii_list): with self.subTest(source=source): self.assertEqual(email_normalize(source, strict=True), expected) + # standard usage of formataddr + self.assertEqual(formataddr((format_name, (expected or '')), charset='utf-8'), expected_utf8_fmt) + # check using INDA at format time, using ascii charset as done when + # sending emails (see extract_rfc2822_addresses) + self.assertEqual(formataddr((format_name, (expected or '')), charset='ascii'), expected_ascii_fmt) def test_email_split(self): """ Test 'email_split' """ diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py index 720344aecf6ff848b18016b0fde59093797f6203..312578b418c38d394ea0050a46b29688f556e7b6 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -510,7 +510,6 @@ mail_header_msgid_re = re.compile('<[^<>]+>') email_addr_escapes_re = re.compile(r'[\\"]') - def generate_tracking_message_id(res_id): """Returns a string that can be used in the Message-ID RFC822 header field @@ -551,14 +550,26 @@ def email_split_and_format(text): return [formataddr((name, email)) for (name, email) in email_split_tuples(text)] def email_normalize(text, strict=True): - """ Sanitize and standardize email address entries. - A normalized email is considered as : - - having a left part + @ + a right part (the domain can be without '.something') - - being lower case - - having no name before the address. Typically, having no 'Name <>' - Ex: - - Possible Input Email : 'Name <NaMe@DoMaIn.CoM>' - - Normalized Output Email : 'name@domain.com' + """ Sanitize and standardize email address entries. As of rfc5322 section + 3.4.1 local-part is case-sensitive. However most main providers do consider + the local-part as case insensitive. With the introduction of smtp-utf8 + within odoo, this assumption is certain to fall short for international + emails. We now consider that + + * if local part is ascii: normalize still 'lower' ; + * else: use as it, SMTP-UF8 is made for non-ascii local parts; + + Concerning domain part of the address, as of v14 international domain (IDNA) + are handled fine. The domain is always lowercase, lowering it is fine as it + is probably an error. With the introduction of IDNA, there is an encoding + that allow non-ascii characters to be encoded to ascii ones, using 'idna.encode'. + + A normalized email is considered as : + - having a left part + @ + a right part (the domain can be without '.something') + - having no name before the address. Typically, having no 'Name <>' + Ex: + - Possible Input Email : 'Name <NaMe@DoMaIn.CoM>' + - Normalized Output Email : 'name@domain.com' :param boolean strict: if True, text should contain a single email (default behavior in stable 14+). If more than one email is found no @@ -572,7 +583,16 @@ def email_normalize(text, strict=True): emails = email_split(text) if not emails or (strict and len(emails) != 1): return False - return emails[0].lower() + + local_part, at, domain = emails[0].rpartition('@') + try: + local_part.encode('ascii') + except UnicodeEncodeError: + pass + else: + local_part = local_part.lower() + + return local_part + at + domain.lower() def email_normalize_all(text): """ Tool method allowing to extract email addresses from a text input and returning