From 853bb98b81bcc6376c7f468391643306aea25d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= <tde@odoo.com> Date: Fri, 30 Jul 2021 09:42:42 +0200 Subject: [PATCH] [IMP] base: avoid double formatting in partner 'email_formatted' field PURPOSE Be defensive when dealing with email fields, notably when having multi-emails or email field containing an already-formatted email. SPECIFICATIONS Main fix in this commit: fix multiple nested formatting in 'email_formatted' computation for <res.partner>. Other use cases are mainly left untouched as we let users deal with their input. In summary : * double format: if email already holds a formatted email, we should not use it to compute email_formatted, like name: Name / email: 'Format' <email@domain.com> -> before '"Name" <"Name" <email@domain.com>>" -> after '"Name" <email@domain.com>'' * multi emails: sometimes this field is used to hold several addresses like email1@domain.com, email2@domain.com. We currently let this value globally untouched by extracting emails and joining them, as we do not expect email_formatted to be a list of emails. Extractin emails allows to filter out extra text stored in email field, like name: Name / email: text, email1@domain.com, email2@domain.com -> before: "Name" <text, email1@domain.com, email2@domain.com> -> after: "Name" <email1@domain.com,email2@domain.com> * invalid email: if something is wrong, better keep it in email_formatted than harcoding "False". Indeed this eases management and understanding of failures at mail.mail, mail.notification and mailing.trace level. This behavior does not change as it was already implemented like that even if not sure it was intended; Task-2612945 (Mail: Defensive email formatting) Part-of: odoo/odoo#74474 --- .../crm/tests/test_crm_lead_notification.py | 2 +- addons/mail/tests/test_res_partner.py | 4 +-- addons/test_mail/tests/test_mail_composer.py | 32 +++++++++--------- odoo/addons/base/models/res_partner.py | 33 ++++++++++++++++--- odoo/addons/base/tests/test_res_partner.py | 14 ++++---- odoo/tools/mail.py | 14 ++++++++ 6 files changed, 69 insertions(+), 30 deletions(-) diff --git a/addons/crm/tests/test_crm_lead_notification.py b/addons/crm/tests/test_crm_lead_notification.py index ca7df34cc4ff..ec6c4e6039cc 100644 --- a/addons/crm/tests/test_crm_lead_notification.py +++ b/addons/crm/tests/test_crm_lead_notification.py @@ -39,7 +39,7 @@ class NewLeadNotification(TestCrmCommon): [(False, '"New Customer" <new.customer.format@test.example.com>', 'Customer Email'), (False, 'new.customer.multi.1@test.example.com, new.customer.2@test.example.com', 'Customer Email'), (False, 'new.customer.simple@test.example.com', 'Customer Email'), - (self.contact_1.id, '"Philip J Fry" <"Philip, J. Fry" <philip.j.fry@test.example.com>>', 'Customer'), + (self.contact_1.id, '"Philip J Fry" <philip.j.fry@test.example.com>', 'Customer'), ] ): with self.subTest(lead=lead, email_from=lead.email_from): diff --git a/addons/mail/tests/test_res_partner.py b/addons/mail/tests/test_res_partner.py index 56fb86f4db59..588a969ca2b9 100644 --- a/addons/mail/tests/test_res_partner.py +++ b/addons/mail/tests/test_res_partner.py @@ -51,8 +51,8 @@ class TestPartner(MailCommon): self.assertEqual( partners.mapped('email_formatted'), ['"Classic Format" <classic.format@test.example.com>', - '"FindMe Format" <"FindMe Format" <find.me.format@test.example.com>>', - '"FindMe Multi" <find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>>'] + '"FindMe Format" <find.me.format@test.example.com>', + '"FindMe Multi" <find.me.multi.1@test.example.com,find.me.multi.2@test.example.com>'] ) self.assertEqual( partners.mapped('email_normalized'), diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index 107f6abad4bf..99b6664fa6b7 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -637,15 +637,15 @@ class TestComposerResultsComment(TestMailComposer): # ensure values used afterwards for testing self.assertEqual( self.partner_employee.email_formatted, - '"Ernest Employee" <email.from.1@test.example.com, email.from.2@test.example.com>', + '"Ernest Employee" <email.from.1@test.example.com,email.from.2@test.example.com>', 'Formatting: wrong formatting due to multi-email') self.assertEqual( self.partner_1.email_formatted, - '"Valid Lelitre" <"Valid Formatted" <valid.lelitre@agrolait.com>>', - 'Formatting: wrong double encapsulation') + '"Valid Lelitre" <valid.lelitre@agrolait.com>', + 'Formatting: avoid wrong double encapsulation') self.assertEqual( self.partner_2.email_formatted, - '"Valid Poilvache" <valid.other.1@agrolait.com, valid.other.cc@agrolait.com>', + '"Valid Poilvache" <valid.other.1@agrolait.com,valid.other.cc@agrolait.com>', 'Formatting: wrong formatting due to multi-email') # instantiate composer, post message @@ -680,8 +680,8 @@ class TestComposerResultsComment(TestMailComposer): ) self.assertEqual( sorted(new_partners.mapped('email_formatted')), - sorted(['"FindMe Format" <"FindMe Format" <find.me.format@test.example.com>>', - '"FindMe Multi" <find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>>', + sorted(['"FindMe Format" <find.me.format@test.example.com>', + '"FindMe Multi" <find.me.multi.1@test.example.com,find.me.multi.2@test.example.com>', '"find.me.multi.1@test.example.com" <find.me.multi.1@test.example.com>', '"find.me.multi.2@test.example.com" <find.me.multi.2@test.example.com>', '"test.cc.1@example.com" <test.cc.1@example.com>', @@ -722,12 +722,12 @@ class TestComposerResultsComment(TestMailComposer): email_values={ 'body_content': f'TemplateBody {self.test_record.name}', # currently holding multi-email 'from' - 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com, email.from.2@test.example.com')), + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com,email.from.2@test.example.com')), 'subject': f'TemplateSubject {self.test_record.name}', }, fields_values={ # currently holding multi-email 'email_from' - 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com, email.from.2@test.example.com')), + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com,email.from.2@test.example.com')), }, mail_message=self.test_record.message_ids[0], ) @@ -736,12 +736,12 @@ class TestComposerResultsComment(TestMailComposer): author=self.partner_employee, email_values={ 'body_content': f'TemplateBody {self.test_record.name}', - 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com, email.from.2@test.example.com')), + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com,email.from.2@test.example.com')), 'subject': f'TemplateSubject {self.test_record.name}', }, fields_values={ # currently holding multi-email 'email_from' - 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com, email.from.2@test.example.com')), + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com,email.from.2@test.example.com')), }, mail_message=self.test_record.message_ids[0], ) @@ -1080,15 +1080,15 @@ class TestComposerResultsMass(TestMailComposer): # ensure values used afterwards for testing self.assertEqual( self.partner_employee.email_formatted, - '"Ernest Employee" <email.from.1@test.example.com, email.from.2@test.example.com>', + '"Ernest Employee" <email.from.1@test.example.com,email.from.2@test.example.com>', 'Formatting: wrong formatting due to multi-email') self.assertEqual( self.partner_1.email_formatted, - '"Valid Lelitre" <"Valid Formatted" <valid.lelitre@agrolait.com>>', - 'Formatting: wrong double encapsulation') + '"Valid Lelitre" <valid.lelitre@agrolait.com>', + 'Formatting: avoid wrong double encapsulation') self.assertEqual( self.partner_2.email_formatted, - '"Valid Poilvache" <valid.other.1@agrolait.com, valid.other.cc@agrolait.com>', + '"Valid Poilvache" <valid.other.1@agrolait.com,valid.other.cc@agrolait.com>', 'Formatting: wrong formatting due to multi-email') # instantiate composer, send mailing @@ -1123,8 +1123,8 @@ class TestComposerResultsMass(TestMailComposer): ) self.assertEqual( sorted(new_partners.mapped('email_formatted')), - sorted(['"FindMe Format" <"FindMe Format" <find.me.format@test.example.com>>', - '"FindMe Multi" <find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>>', + sorted(['"FindMe Format" <find.me.format@test.example.com>', + '"FindMe Multi" <find.me.multi.1@test.example.com,find.me.multi.2@test.example.com>', '"find.me.multi.1@test.example.com" <find.me.multi.1@test.example.com>', '"find.me.multi.2@test.example.com" <find.me.multi.2@test.example.com>', '"test.cc.1@example.com" <test.cc.1@example.com>', diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index f468951a0a39..2d26e11df9af 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -379,11 +379,36 @@ class Partner(models.Model): @api.depends('name', 'email') def _compute_email_formatted(self): + """ Compute formatted email for partner, using formataddr. Be defensive + in computation, notably + + * double format: if email already holds a formatted email like + 'Name' <email@domain.com> we should not use it as it to compute + email formatted like "Name <'Name' <email@domain.com>>"; + * multi emails: sometimes this field is used to hold several addresses + like email1@domain.com, email2@domain.com. We currently let this value + untouched, but remove any formatting from multi emails; + * invalid email: if something is wrong, keep it in email_formatted as + this eases management and understanding of failures at mail.mail, + mail.notification and mailing.trace level; + * void email: email_formatted is False, as we cannot do anything with + it; + """ + self.email_formatted = False for partner in self: - if partner.email: - partner.email_formatted = tools.formataddr((partner.name or u"False", partner.email or u"False")) - else: - partner.email_formatted = '' + emails_normalized = tools.email_normalize_all(partner.email) + if emails_normalized: + # note: multi-email input leads to invalid email like "Name" <email1, email2> + # but this is current behavior in Odoo 14+ and some servers allow it + partner.email_formatted = tools.formataddr(( + partner.name or u"False", + ','.join(emails_normalized) + )) + elif partner.email: + partner.email_formatted = tools.formataddr(( + partner.name or u"False", + partner.email + )) @api.depends('is_company') def _compute_company_type(self): diff --git a/odoo/addons/base/tests/test_res_partner.py b/odoo/addons/base/tests/test_res_partner.py index 2ec3458de803..3160db404fae 100644 --- a/odoo/addons/base/tests/test_res_partner.py +++ b/odoo/addons/base/tests/test_res_partner.py @@ -61,25 +61,25 @@ class TestPartner(TransactionCase): # encapsulated email ( "Vlad the Impaler <vlad.the.impaler@example.com>", - '"Balázs" <Vlad the Impaler <vlad.the.impaler@example.com>>' + '"Balázs" <vlad.the.impaler@example.com>' ), ( '"Balázs" <balazs@adam.hu>', - '"Balázs" <"Balázs" <balazs@adam.hu>>' + '"Balázs" <balazs@adam.hu>' ), # multi email ( "vlad.the.impaler@example.com, vlad.the.dragon@example.com", - '"Balázs" <vlad.the.impaler@example.com, vlad.the.dragon@example.com>' + '"Balázs" <vlad.the.impaler@example.com,vlad.the.dragon@example.com>' ), ( "vlad.the.impaler.com, vlad.the.dragon@example.com", - '"Balázs" <vlad.the.impaler.com, vlad.the.dragon@example.com>' + '"Balázs" <vlad.the.dragon@example.com>' ), ( 'vlad.the.impaler.com, "Vlad the Dragon" <vlad.the.dragon@example.com>', - '"Balázs" <vlad.the.impaler.com, "Vlad the Dragon" <vlad.the.dragon@example.com>>' + '"Balázs" <vlad.the.dragon@example.com>' ), # falsy emails - (False, ''), - ('', ''), + (False, False), + ('', False), (' ', '"Balázs" <@ >'), ('notanemail', '"Balázs" <@notanemail>'), ]: diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py index 3562b1b726c3..9e8468110781 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -552,6 +552,20 @@ def email_normalize(text): return False return emails[0].lower() +def email_normalize_all(text): + """ Tool method allowing to extract email addresses from a text input and returning + normalized version of all found emails. If no email is found, a void list + is returned. + + e.g. if email is 'tony@e.com, "Tony2" <tony2@e.com' returned result is ['tony@e.com, tony2@e.com'] + + :return list: list of normalized emails found in text + """ + if not text: + return [] + emails = email_split(text) + return list(filter(None, [email_normalize(email) for email in emails])) + def email_escape_char(email_address): """ Escape problematic characters in the given email address string""" return email_address.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') -- GitLab