[FIX] tools, base, mail: add a fallback when parsing wrongly-formatted emails

With input 'name email@domain.com' (missing chevrons allowing to clearly spot
the email part) 'getaddresses' returns ('', 'name email@domain.com) i.e. the
whole input is considered as being the email.

To improve the heuristic we can add a fallback by recalling 'getadresses'
on the input with spaces replaced by commas when it found only an email and
no name. The new email will be split into sub pairs allowing to find the real
email and various name parts, allowing to make a new name / email pair.

Emails should not contain spaces thus this is coherent with email formation.
This fallback actually comes from a specific code done in '_parse_partner_name'
of Partner model. Supporting it directly at tools level make the behavior
coherent for all models.

Task-2612945 (Mail: Defensive email formatting)

X-original-commit: odoo/odoo@18c71edf59
Part-of: odoo/odoo#134934
This commit is contained in:
Thibault Delavallée
2023-09-11 15:22:09 +00:00
parent e0207d1551
commit 64aae8bce1
4 changed files with 42 additions and 27 deletions
+2 -2
View File
@@ -372,8 +372,8 @@ class TestPartner(MailCommon):
# multi email support
(False, "Another Customer", "test.different.1@test.dupe.example.com"),
(False, "Multi Email", "other.customer.2@test.dupe.example.com"),
(False, "Invalid, Multi Format", "other.customer.😊@test.dupe.example.com"),
(False, "Invalid, Multi Format", "other.customer.😊@test.dupe.example.com"),
(False, "Multi Format", "other.customer.😊@test.dupe.example.com"),
(False, "Multi Format", "other.customer.😊@test.dupe.example.com"),
]
with self.mockPartnerCalls():
new_partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
@@ -243,7 +243,7 @@ class TestMassMailing(TestMassMailCommon):
'partner': customer_weird,
'trace_status': 'sent'}, # concatenates everything after domain
{'email': 'Weird Format2 test.customer.weird.2@example.com',
'email_to_recipients': [[f'"{customer_weird_2.name}" <weird format2 test.customer.weird.2@example.com>']],
'email_to_recipients': [[f'"{customer_weird_2.name}" <test.customer.weird.2@example.com>']],
'failure_type': False,
'partner': customer_weird_2,
'trace_status': 'sent'},
@@ -273,8 +273,8 @@ class TestMassMailing(TestMassMailCommon):
'failure_type': False,
'trace_status': 'sent'},
{'email': 'test.record.weird.2@example.com',
'email_to_mail': 'weird format2 test.record.weird.2@example.com',
'email_to_recipients': [['weird format2 test.record.weird.2@example.com']],
'email_to_mail': 'test.record.weird.2@example.com',
'email_to_recipients': [['test.record.weird.2@example.com']],
'failure_type': False,
'trace_status': 'sent'},
],
+10 -10
View File
@@ -517,7 +517,7 @@ class TestEmailTools(BaseCase):
]
expected_list = [
'deboulonneur@example.com',
'Déboulonneur deboulonneur@example.com',
'deboulonneur@example.com',
'deboulonneur@example.comdéboulonneur',
False,
'@example.com', # funny
@@ -527,7 +527,7 @@ class TestEmailTools(BaseCase):
]
expected_fmt_utf8_list = [
f'"{format_name}" <deboulonneur@example.com>',
f'"{format_name}" <Déboulonneur deboulonneur@example.com>',
f'"{format_name}" <deboulonneur@example.com>',
f'"{format_name}" <deboulonneur@example.comdéboulonneur>',
f'"{format_name}" <@>',
f'"{format_name}" <@example.com>',
@@ -537,7 +537,7 @@ class TestEmailTools(BaseCase):
]
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.com>',
f'{format_name_ascii} <deboulonneur@example.xn--comdboulonneur-ekb>',
f'{format_name_ascii} <@>',
f'{format_name_ascii} <@example.com>',
@@ -624,7 +624,7 @@ class TestEmailTools(BaseCase):
['"Super Déboulonneur" <deboulonneur@example.com>'],
# wrong formatting
['"Déboulonneur" <deboulonneur@example.com>'],
['Déboulonneur deboulonneur@example.com'], # returned as it, a bit strange but hey
['"Déboulonneur" <deboulonneur@example.com>'], # extra part correctly considered as a name
['deboulonneur@example.comDéboulonneur'], # concatenated, not sure why
# multi
['deboulonneur@example.com'],
@@ -657,14 +657,14 @@ class TestEmailTools(BaseCase):
[('Fredo The Great', 'alfred.astaire@test.example.com'), ('Evelyne The Goat', 'evelyne.gargouillis@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
[('Fredo The Great', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
# text containing email -> probably not designed for that
[('', 'Hello alfred.astaire@test.example.comhowareyou?')],
[('', 'Hello alfred.astaire@test.example.com')],
# text containing emails -> probably not designed for that
# text containing email -> fallback on parsing to extract text from email
[('Hello', 'alfred.astaire@test.example.comhowareyou?')],
[('Hello', 'alfred.astaire@test.example.com')],
[('Hello Fredo', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')],
[('Hello Fredo', 'alfred.astaire@test.example.com'), ('', 'and evelyne.gargouillis@test.example.com')],
[('Hello Fredo', 'alfred.astaire@test.example.com'), ('and', 'evelyne.gargouillis@test.example.com')],
# falsy -> probably not designed for that
[], [('', "j'adore écrire des@gmail.comou"), ('', '@gmail.com')], [],
[],
[('j\'adore écrire', "des@gmail.comou"), ('', '@gmail.com')], [],
]
for src, exp in zip(self.sources, expected):
+27 -12
View File
@@ -526,14 +526,35 @@ def email_split_tuples(text):
""" Return a list of (name, email) address tuples found in ``text`` . Note
that text should be an email header or a stringified email list as it may
give broader results than expected on actual text. """
def _parse_based_on_spaces(pair):
""" With input 'name email@domain.com' (missing quotes for a formatting)
getaddresses returns ('', 'name email@domain.com). This when having no
name and an email a fallback to enhance parsing is to redo a getaddresses
by replacing spaces by commas. The new email will be split into sub pairs
allowing to find the email and name parts, allowing to make a new name /
email pair. Emails should not contain spaces thus this is coherent with
email formation. """
name, email = pair
if not name and email and ' ' in email:
inside_pairs = getaddresses([email.replace(' ', ',')])
name_parts, found_email = [], False
for pair in inside_pairs:
if pair[1] and '@' not in pair[1]:
name_parts.append(pair[1])
if pair[1] and '@' in pair[1]:
found_email = pair[1]
name, email = (' '.join(name_parts), found_email) if found_email else (name, email)
return (name, email)
if not text:
return []
return [(addr[0], addr[1]) for addr in getaddresses([text])
# getaddresses() returns '' when email parsing fails, and
# sometimes returns emails without at least '@'. The '@'
# is strictly required in RFC2822's `addr-spec`.
if addr[1]
if '@' in addr[1]]
return list(map(_parse_based_on_spaces, [
(addr[0], addr[1]) for addr in getaddresses([text])
# getaddresses() returns '' when email parsing fails, and
# sometimes returns emails without at least '@'. The '@'
# is strictly required in RFC2822's `addr-spec`.
if addr[1] and '@' in addr[1]
]))
def email_split(text):
""" Return a list of the email addresses found in ``text`` """
@@ -736,12 +757,6 @@ def parse_contact_from_email(text):
split_results = email_split_tuples(text)
name, email = split_results[0] if split_results else ('', '')
if email and not name:
fallback_emails = email_split(text.replace(' ', ','))
if fallback_emails:
email = fallback_emails[0]
name = text[:text.index(email)].replace('"', '').replace('<', '').strip()
if email:
email_normalized = email_normalize(email, strict=False) or email
else: