diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 7138f1cd6933ab74823800fa8d8fa19098fa373b..2ba9cd39316c3f32b8df51486683b265a596590d 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -15,7 +15,7 @@ from odoo.addons.phone_validation.tools import phone_validation from odoo.exceptions import UserError, AccessError from odoo.osv import expression from odoo.tools.translate import _ -from odoo.tools import date_utils, email_re, email_split, is_html_empty +from odoo.tools import date_utils, email_split, is_html_empty from . import crm_stage @@ -1789,11 +1789,13 @@ class Lead(models.Model): return res def _message_get_default_recipients(self): - return {r.id: { - 'partner_ids': [], - 'email_to': r.email_normalized, - 'email_cc': False} - for r in self} + return { + r.id: { + 'partner_ids': [], + 'email_to': ','.join(tools.email_normalize_all(r.email_from)) or r.email_from, + 'email_cc': False, + } for r in self + } def _message_get_suggested_recipients(self): recipients = super(Lead, self)._message_get_suggested_recipients() @@ -1838,23 +1840,41 @@ class Lead(models.Model): # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email_from or (self.email_normalized and partner.email_normalized == self.email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email_normalized', '=', new_partner[0].email_normalized) + else: + email_domain = ('email_from', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email_from', '=', new_partner.email), - ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False) + ]).write({'partner_id': new_partner[0].id}) return super(Lead, self)._message_post_after_hook(message, msg_vals) def _message_partner_info_from_emails(self, emails, link_mail=False): + """ Try to propose a better recipient when having only an email by populating + it with the partner_name / contact_name field of the lead e.g. if lead + contact_name is "Raoul" and email is "raoul@raoul.fr", suggest + "Raoul" <raoul@raoul.fr> as recipient. """ result = super(Lead, self)._message_partner_info_from_emails(emails, link_mail=link_mail) - for partner_info in result: - if not partner_info.get('partner_id') and (self.partner_name or self.contact_name): - emails = email_re.findall(partner_info['full_name'] or '') - email = emails and emails[0] or '' - if email and self.email_from and email.lower() == self.email_from.lower(): - partner_info['full_name'] = tools.formataddr((self.contact_name or self.partner_name, email)) - break + for email, partner_info in zip(emails, result): + if partner_info.get('partner_id') or not email or not (self.partner_name or self.contact_name): + continue + # reformat email if no name information + name_emails = tools.email_split_tuples(email) + name_from_email = name_emails[0][0] if name_emails else False + if name_from_email: + continue # already containing name + email + name_from_email = self.partner_name or self.contact_name + emails_normalized = tools.email_normalize_all(email) + email_normalized = emails_normalized[0] if emails_normalized else False + if email.lower() == self.email_from.lower() or (email_normalized and self.email_normalized == email_normalized): + partner_info['full_name'] = tools.formataddr(( + name_from_email, + ','.join(emails_normalized) if emails_normalized else email)) + break return result def _phone_get_number_fields(self): diff --git a/addons/crm/tests/test_crm_lead_notification.py b/addons/crm/tests/test_crm_lead_notification.py index 7cc3143d7b6e5749a68fb6769af196f8f1c2c817..af1c77305c26e70a27386a1f66bddc1fd75e0d2a 100644 --- a/addons/crm/tests/test_crm_lead_notification.py +++ b/addons/crm/tests/test_crm_lead_notification.py @@ -1,11 +1,52 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from .common import TestCrmCommon +from odoo.addons.crm.tests.common import TestCrmCommon +from odoo.tests import tagged, users +from odoo.tools import mute_logger +@tagged('mail_thread', 'mail_gateway') class NewLeadNotification(TestCrmCommon): + @users('user_sales_manager') + def test_lead_message_get_suggested_recipient(self): + """ Test '_message_get_suggested_recipients' and its override in lead. """ + lead_format, lead_multi, lead_from, lead_partner = self.env['crm.lead'].create([ + { + 'email_from': '"New Customer" <new.customer.format@test.example.com>', + 'name': 'Test Suggestion (email_from with format)', + 'partner_name': 'Format Name', + 'user_id': self.user_sales_leads.id, + }, { + 'email_from': 'new.customer.multi.1@test.example.com, new.customer.2@test.example.com', + 'name': 'Test Suggestion (email_from multi)', + 'partner_name': 'Multi Name', + 'user_id': self.user_sales_leads.id, + }, { + 'email_from': 'new.customer.simple@test.example.com', + 'name': 'Test Suggestion (email_from)', + 'partner_name': 'Std Name', + 'user_id': self.user_sales_leads.id, + }, { + 'name': 'Test Suggestion (partner_id)', + 'partner_id': self.contact_1.id, + 'user_id': self.user_sales_leads.id, + } + ]) + for lead, expected_suggested in zip( + lead_format + lead_multi + lead_from + lead_partner, + [(False, '"New Customer" <new.customer.format@test.example.com>', 'Customer Email'), + (False, '"Multi Name" <new.customer.multi.1@test.example.com,new.customer.2@test.example.com>', 'Customer Email'), + (False, '"Std Name" <new.customer.simple@test.example.com>', 'Customer Email'), + (self.contact_1.id, '"Philip J Fry" <philip.j.fry@test.example.com>', 'Customer'), + ] + ): + with self.subTest(lead=lead, email_from=lead.email_from): + res = lead._message_get_suggested_recipients()[lead.id] + self.assertEqual(len(res), 1) + self.assertEqual(res[0], expected_suggested) + def test_new_lead_notification(self): """ Test newly create leads like from the website. People and channels subscribed to the Sales Team shoud be notified. """ @@ -39,6 +80,7 @@ class NewLeadNotification(TestCrmCommon): lead_user = lead.with_user(self.user_sales_manager) self.assertTrue(lead_user.message_needaction) + @mute_logger('odoo.addons.mail.models.mail_thread') def test_new_lead_from_email_multicompany(self): company0 = self.env.company company1 = self.env['res.company'].create({'name': 'new_company'}) diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py index 3ecb8ea39d514d88998d3a8f4b06eb4a5fed83ee..ef1c284ac8880b7407e6f82863446450ad0ab9a9 100644 --- a/addons/event/models/event_registration.py +++ b/addons/event/models/event_registration.py @@ -4,7 +4,7 @@ from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models, SUPERUSER_ID -from odoo.tools import format_datetime +from odoo.tools import format_datetime, email_normalize, email_normalize_all from odoo.exceptions import AccessError, ValidationError @@ -294,24 +294,31 @@ class EventRegistration(models.Model): def _message_get_default_recipients(self): # Prioritize registration email over partner_id, which may be shared when a single # partner booked multiple seats - return {r.id: { - 'partner_ids': [], - 'email_to': r.email, - 'email_cc': False} - for r in self} + return {r.id: + { + 'partner_ids': [], + 'email_to': ','.join(email_normalize_all(r.email)) or r.email, + 'email_cc': False, + } for r in self + } def _message_post_after_hook(self, message, msg_vals): if self.email and not self.partner_id: # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email) + email_normalized = email_normalize(self.email) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email or (email_normalized and partner.email_normalized == email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('email', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email', '=', new_partner.email), - ('state', 'not in', ['cancel']), - ]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('state', 'not in', ['cancel']), + ]).write({'partner_id': new_partner[0].id}) return super(EventRegistration, self)._message_post_after_hook(message, msg_vals) # ------------------------------------------------------------ diff --git a/addons/hr_recruitment/models/hr_recruitment.py b/addons/hr_recruitment/models/hr_recruitment.py index 1e405136ff480a1f37ac8ec1a83b6f27395f9826..73544832831c14ddf5330b56a9c2f16c1d20e619 100644 --- a/addons/hr_recruitment/models/hr_recruitment.py +++ b/addons/hr_recruitment/models/hr_recruitment.py @@ -430,7 +430,7 @@ class Applicant(models.Model): if applicant.partner_id: applicant._message_add_suggested_recipient(recipients, partner=applicant.partner_id.sudo(), reason=_('Contact')) elif applicant.email_from: - email_from = applicant.email_from + email_from = tools.email_normalize(applicant.email_from) if applicant.partner_name: email_from = tools.formataddr((applicant.partner_name, email_from)) applicant._message_add_suggested_recipient(recipients, email=email_from, reason=_('Contact Email')) @@ -470,18 +470,24 @@ class Applicant(models.Model): # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from) + email_normalized = tools.email_normalize(self.email_from) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email_from or (email_normalized and partner.email_normalized == email_normalized) + ) if new_partner: - if new_partner.create_date.date() == fields.Date.today(): - new_partner.write({ + if new_partner[0].create_date.date() == fields.Date.today(): + new_partner[0].write({ 'type': 'private', 'phone': self.partner_phone, 'mobile': self.partner_mobile, }) + if new_partner[0].email_normalized: + email_domain = ('email_from', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('email_from', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email_from', '=', new_partner.email), - ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False) + ]).write({'partner_id': new_partner[0].id}) return super(Applicant, self)._message_post_after_hook(message, msg_vals) def create_employee_from_applicant(self): diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 1a3fa6e55c58761150b8afe3453d2b0ee81cac4d..bc784270e1e30ac541bce545b2bc460c6b034a3f 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -272,7 +272,14 @@ class MailMail(models.Model): body = self._send_prepare_body() body_alternative = tools.html2plaintext(body) if partner: - email_to = [tools.formataddr((partner.name or 'False', partner.email or 'False'))] + emails_normalized = tools.email_normalize_all(partner.email) + if emails_normalized: + email_to = [ + tools.formataddr((partner.name or "False", email or "False")) + for email in emails_normalized + ] + else: + email_to = [tools.formataddr((partner.name or "False", partner.email or "False"))] else: email_to = tools.email_split_and_format(self.email_to) res = { @@ -439,13 +446,17 @@ class MailMail(models.Model): # see rev. 56596e5240ef920df14d99087451ce6f06ac6d36 notifs.flush(fnames=['notification_status', 'failure_type', 'failure_reason'], records=notifs) + # protect against ill-formatted email_from when formataddr was used on an already formatted email + emails_from = tools.email_split_and_format(mail.email_from) + email_from = emails_from[0] if emails_from else mail.email_from + # build an RFC2822 email.message.Message object and send it without queuing res = None # TDE note: could be great to pre-detect missing to/cc and skip sending it # to go directly to failed state update for email in email_list: msg = IrMailServer.build_email( - email_from=mail.email_from, + email_from=email_from, email_to=email.get('email_to'), subject=mail.subject, body=email.get('body'), diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index a98c2d99df87f5b5bffe5db2ae538c0c4bb47115..d4c7edee93110ac20b8a8f9a0da26cc307641a10 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -176,7 +176,7 @@ class MailTemplate(models.Model): partner_to = values.pop('partner_to', '') if partner_to: # placeholders could generate '', 3, 2 due to some empty field values - tpl_partner_ids = [int(pid) for pid in partner_to.split(',') if pid] + tpl_partner_ids = [int(pid.strip()) for pid in partner_to.split(',') if (pid and pid.strip().isdigit())] partner_ids += self.env['res.partner'].sudo().browse(tpl_partner_ids).exists().ids results[res_id]['partner_ids'] = partner_ids return results diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 2da0eefd9e9e0735c2a3bdf407c039df9680f201..c2cebb1b1f4a5baaafd57b4109e3930c359e9cc3 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -660,7 +660,7 @@ class MailThread(models.AbstractModel): 'email_to': bounce_to, 'auto_delete': True, } - bounce_from = self.env['ir.mail_server']._get_default_bounce_address() + bounce_from = tools.email_normalize(self.env['ir.mail_server']._get_default_bounce_address() or '') if bounce_from: bounce_mail_values['email_from'] = tools.formataddr(('MAILER-DAEMON', bounce_from)) elif self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias") not in message['To']: @@ -1487,6 +1487,7 @@ class MailThread(models.AbstractModel): recipient in the result dictionary. The form is : partner_id, partner_name<partner_email> or partner_name, reason """ self.ensure_one() + partner_info = {} if email and not partner: # get partner info from email partner_info = self._message_partner_info_from_emails([email])[0] @@ -1501,9 +1502,9 @@ class MailThread(models.AbstractModel): if partner and partner.email: # complete profile: id, name <email> result[self.ids[0]].append((partner.id, partner.email_formatted, reason)) elif partner: # incomplete profile: id, name - result[self.ids[0]].append((partner.id, '%s' % (partner.name), reason)) + result[self.ids[0]].append((partner.id, partner.name, reason)) else: # unknown partner, we are probably managing an email address - result[self.ids[0]].append((False, email, reason)) + result[self.ids[0]].append((False, partner_info.get('full_name') or email, reason)) return result def _message_get_suggested_recipients(self): @@ -1524,7 +1525,7 @@ class MailThread(models.AbstractModel): domain = [('email_normalized', 'in', normalized_emails)] if extra_domain: domain = expression.AND([domain, extra_domain]) - partners = self.env['res.users'].sudo().search(domain, order='name ASC').mapped('partner_id') + partners = self.env['res.users'].sudo().search(domain).mapped('partner_id') # return a search on partner to filter results current user should not see (multi company for example) return self.env['res.partner'].search([('id', 'in', partners.ids)]) @@ -1582,7 +1583,7 @@ class MailThread(models.AbstractModel): return matching_user if not matching_user: - std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1, order='name ASC') + std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1) matching_user = std_users[0] if std_users else self.env['res.users'] if matching_user: return matching_user @@ -1599,6 +1600,9 @@ class MailThread(models.AbstractModel): """ Utility method to find partners from email addresses. If no partner is found, create new partners if force_create is enabled. Search heuristics + * 0: clean incoming email list to use only normalized emails. Exclude + those used in aliases to avoid setting partner emails to emails + used as aliases; * 1: check in records (record set) followers if records is mail.thread enabled and if check_followers parameter is enabled; * 2: search for partners with user; @@ -1618,8 +1622,13 @@ class MailThread(models.AbstractModel): followers = self.env['res.partner'] catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain") - # first, build a normalized email list and remove those linked to aliases to avoid adding aliases as partners - normalized_emails = [tools.email_normalize(contact) for contact in emails if tools.email_normalize(contact)] + # first, build a normalized email list and remove those linked to aliases + # to avoid adding aliases as partners. In case of multi-email input, use + # the first found valid one to be tolerant against multi emails encoding + normalized_emails = [email_normalized + for email_normalized in (tools.email_normalize(contact, force_single=False) for contact in emails) + if email_normalized + ] if catchall_domain: domain_left_parts = [email.split('@')[0] for email in normalized_emails if email and email.split('@')[1] == catchall_domain.lower()] if domain_left_parts: @@ -1640,7 +1649,7 @@ class MailThread(models.AbstractModel): # iterate and keep ordering partners = [] for contact in emails: - normalized_email = tools.email_normalize(contact) + normalized_email = tools.email_normalize(contact, force_single=False) partner = next((partner for partner in done_partners if partner.email_normalized == normalized_email), self.env['res.partner']) if not partner and force_create and normalized_email in normalized_emails: partner = self.env['res.partner'].browse(self.env['res.partner'].name_create(contact)[0]) diff --git a/addons/mail/models/mail_thread_blacklist.py b/addons/mail/models/mail_thread_blacklist.py index 48b9e734ab80b3d859cd35049bd13fe3aae3a094..171a4b1562a435c5faaf4b3db8e90882b4407f73 100644 --- a/addons/mail/models/mail_thread_blacklist.py +++ b/addons/mail/models/mail_thread_blacklist.py @@ -47,7 +47,7 @@ class MailBlackListMixin(models.AbstractModel): def _compute_email_normalized(self): self._assert_primary_email() for record in self: - record.email_normalized = tools.email_normalize(record[self._primary_email]) + record.email_normalized = tools.email_normalize(record[self._primary_email], force_single=False) @api.model def _search_is_blacklisted(self, operator, value): diff --git a/addons/mail/models/models.py b/addons/mail/models/models.py index c2381c0b118785868a047dd38f266e3dee431079..c1b1b3185fdf235e9f2e01af7d4bdb995b8e9bd6 100644 --- a/addons/mail/models/models.py +++ b/addons/mail/models/models.py @@ -73,14 +73,20 @@ class BaseModel(models.AbstractModel): recipient_ids, email_to, email_cc = [], False, False if 'partner_id' in record and record.partner_id: recipient_ids.append(record.partner_id.id) - elif 'email_normalized' in record and record.email_normalized: - email_to = record.email_normalized - elif 'email_from' in record and record.email_from: - email_to = record.email_from - elif 'partner_email' in record and record.partner_email: - email_to = record.partner_email - elif 'email' in record and record.email: - email_to = record.email + else: + found_email = False + if 'email_from' in record and record.email_from: + found_email = record.email_from + elif 'partner_email' in record and record.partner_email: + found_email = record.partner_email + elif 'email' in record and record.email: + found_email = record.email + elif 'email_normalized' in record and record.email_normalized: + found_email = record.email_normalized + if found_email: + email_to = ','.join(tools.email_normalize_all(found_email)) + if not email_to: # keep value to ease debug / trace update + email_to = found_email res[record.id] = {'partner_ids': recipient_ids, 'email_to': email_to, 'email_cc': email_cc} return res diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index d9d43e73c4b7904b782a605d4a5959590d4329df..30629b24329f74a117b9082e441e1d5d9184d495 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -154,7 +154,8 @@ function parseEmail(text) { if (text){ var result = text.match(/(.*)<(.*@.*)>/); if (result) { - return [_.str.trim(result[1]), _.str.trim(result[2])]; + name = _.str.trim(result[1]).replace(/(^"|"$)/g, '') + return [name, _.str.trim(result[2])]; } result = text.match(/(.*@.*)/); if (result) { diff --git a/addons/mail/tests/test_mail_tools.py b/addons/mail/tests/test_mail_tools.py index f66a586a93e9d4f29d1643a84014e8749bd1a2d9..88c68f6f566bddea4632103ca3c80d0b6dd72ade 100644 --- a/addons/mail/tests/test_mail_tools.py +++ b/addons/mail/tests/test_mail_tools.py @@ -47,65 +47,128 @@ class TestMailTools(MailCommon): ] @users('employee') - def test_find_partner_from_emails(self): + def test_mail_find_partner_from_emails(self): Partner = self.env['res.partner'] test_partner = Partner.browse(self.test_partner.ids) self.assertEqual(test_partner.email, self._test_email) - # test direct match - found = Partner._mail_find_partner_from_emails([self._test_email]) - self.assertEqual(found, [test_partner]) - - # test encapsulated email - found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email]) - self.assertEqual(found, [test_partner]) + sources = [ + self._test_email, # test direct match + f'"Norbert Poiluchette" <{self._test_email}>', # encapsulated + 'fredoastaire@test.example.com', # partial email -> should not match ! + ] + expected_partners = [ + test_partner, + test_partner, + self.env['res.partner'], + ] + for source, expected_partner in zip(sources, expected_partners): + with self.subTest(source=source): + found = Partner._mail_find_partner_from_emails([source]) + self.assertEqual(found, [expected_partner]) # test with wildcard "_" found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com']) self.assertEqual(found, [self.env['res.partner']]) - # sub-check: this search does not consider _ as a wildcard found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com']) self.assertEqual(found, self.env['res.partner']) # test partners with encapsulated emails # ------------------------------------------------------------ - test_partner.sudo().write({'email': '"Alfred Mighty Power Astaire" <%s>' % self._test_email}) + test_partner.sudo().write({'email': f'"Alfred Mighty Power Astaire" <{self._test_email}>'}) - # test direct match - found = Partner._mail_find_partner_from_emails([self._test_email]) - self.assertEqual(found, [test_partner]) - - # test encapsulated email - found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email]) - self.assertEqual(found, [test_partner]) + sources = [ + self._test_email, # test direct match + f'"Norbert Poiluchette" <{self._test_email}>', # encapsulated + ] + expected_partners = [ + test_partner, + test_partner, + ] + for source, expected_partner in zip(sources, expected_partners): + with self.subTest(source=source): + found = Partner._mail_find_partner_from_emails([source]) + self.assertEqual(found, [expected_partner]) # test with wildcard "_" found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com']) self.assertEqual(found, [self.env['res.partner']]) - # sub-check: this search does not consider _ as a wildcard found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com']) self.assertEqual(found, self.env['res.partner']) - # test partners with look-alike emails - # ------------------------------------------------------------ - for email_lookalike in [ - 'alfred.astaire@test.example.com', - 'alfredoastaire@example.com', - 'aalfredoastaire@test.example.com', - 'alfredoastaire@test.example.comm']: - test_partner.sudo().write({'email': '"Alfred Astaire" <%s>' % email_lookalike}) - - # test direct match - found = Partner._mail_find_partner_from_emails([self._test_email]) - self.assertEqual(found, [self.env['res.partner']]) - # test encapsulated email - found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email]) - self.assertEqual(found, [self.env['res.partner']]) - # test with wildcard "_" - found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com']) - self.assertEqual(found, [self.env['res.partner']]) + @users('employee') + def test_mail_find_partner_from_emails_followers(self): + """ Test '_mail_find_partner_from_emails' when dealing with records on + which followers have to be found based on email. Check multi email + and encapsulated email support. """ + # create partner just for the follow mechanism + linked_record = self.env['res.partner'].sudo().create({'name': 'Record for followers'}) + follower_partner = self.env['res.partner'].sudo().create({ + 'email': self._test_email, + 'name': 'Duplicated, follower of record', + }) + linked_record.message_subscribe(partner_ids=follower_partner.ids) + test_partner = self.test_partner.with_env(self.env) + + # standard test, no multi-email, to assert base behavior + sources = [(self._test_email, True), (self._test_email, False),] + expected = [follower_partner, test_partner] + for (source, follower_check), expected in zip(sources, expected): + with self.subTest(source=source, follower_check=follower_check): + partner = self.env['res.partner']._mail_find_partner_from_emails( + [source], records=linked_record if follower_check else None + )[0] + self.assertEqual(partner, expected) + + # formatted email + encapsulated_test_email = f'"Robert Astaire" <{self._test_email}>' + (follower_partner + test_partner).sudo().write({'email': encapsulated_test_email}) + sources = [ + (self._test_email, True), # normalized + (self._test_email, False), # normalized + (encapsulated_test_email, True), # encapsulated, same + (encapsulated_test_email, False), # encapsulated, same + (f'"AnotherName" <{self._test_email}', True), # same normalized, other name + (f'"AnotherName" <{self._test_email}', False), # same normalized, other name + ] + expected = [follower_partner, test_partner, + follower_partner, test_partner, + follower_partner, test_partner, + follower_partner, test_partner] + for (source, follower_check), expected in zip(sources, expected): + with self.subTest(source=source, follower_check=follower_check): + partner = self.env['res.partner']._mail_find_partner_from_emails( + [source], records=linked_record if follower_check else None + )[0] + self.assertEqual(partner, expected, + 'Mail: formatted email is recognized through usage of normalized email') + + # multi-email + _test_email_2 = '"Robert Astaire" <not.alfredoastaire@test.example.com>' + (follower_partner + test_partner).sudo().write({'email': f'{self._test_email}, {_test_email_2}'}) + sources = [ + (self._test_email, True), # first email + (self._test_email, False), # first email + (_test_email_2, True), # second email + (_test_email_2, False), # second email + ('not.alfredoastaire@test.example.com', True), # normalized second email in field + ('not.alfredoastaire@test.example.com', False), # normalized second email in field + (f'{self._test_email}, {_test_email_2}', True), # multi-email, both matching, depends on comparison + (f'{self._test_email}, {_test_email_2}', False) # multi-email, both matching, depends on comparison + ] + expected = [follower_partner, test_partner, + self.env['res.partner'], self.env['res.partner'], + self.env['res.partner'], self.env['res.partner'], + follower_partner, test_partner] + for (source, follower_check), expected in zip(sources, expected): + with self.subTest(source=source, follower_check=follower_check): + partner = self.env['res.partner']._mail_find_partner_from_emails( + [source], records=linked_record if follower_check else None + )[0] + self.assertEqual(partner, expected, + 'Mail (FIXME): partial recognition of multi email through email_normalize') @users('employee') def test_tools_email_re(self): @@ -152,14 +215,14 @@ class TestMailTools(MailCommon): [('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): diff --git a/addons/mail/tests/test_res_partner.py b/addons/mail/tests/test_res_partner.py index 4a2f557653438a62d1acaab8ec20b31b09b58ae7..ca855d1343c9366cc5a62689fa46b0722d3591e3 100644 --- a/addons/mail/tests/test_res_partner.py +++ b/addons/mail/tests/test_res_partner.py @@ -5,6 +5,7 @@ from uuid import uuid4 from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user from odoo.tests.common import Form, users +from odoo.tests import tagged # samples use effective TLDs from the Mozilla public suffix @@ -22,6 +23,7 @@ SAMPLES = [ ] +@tagged('res_partner') class TestPartner(MailCommon): def _check_find_or_create(self, test_string, expected_name, expected_email, expected_email_normalized=False, check_partner=False, should_create=False): @@ -142,6 +144,82 @@ class TestPartner(MailCommon): self.assertEqual(list(map(lambda p: p['is_internal_user'], partners_format)), [True, True, False, False, False], "should return internal users in priority") self.assertEqual(list(map(lambda p: bool(p['user_id']), partners_format)), [True, True, True, True, False], "should return partners without users last") + @users('admin') + def test_res_partner_find_or_create_email(self): + """ Test 'find_or_create' tool used in mail, notably when linking emails + found in recipients to partners when sending emails using the mail + composer. """ + partners = self.env['res.partner'].create([ + { + 'email': 'classic.format@test.example.com', + 'name': 'Classic Format', + }, + { + 'email': '"FindMe Format" <find.me.format@test.example.com>', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>', + 'name': 'FindMe Multi', + }, + ]) + # check data used for finding / searching + self.assertEqual( + partners.mapped('email_formatted'), + ['"Classic Format" <classic.format@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>'] + ) + # when having multi emails, first found one is taken as normalized email + self.assertEqual( + partners.mapped('email_normalized'), + ['classic.format@test.example.com', 'find.me.format@test.example.com', + 'find.me.multi.1@test.example.com'] + ) + + # classic find or create: use normalized email to compare records + for email in ('CLASSIC.FORMAT@TEST.EXAMPLE.COM', '"Another Name" <classic.format@test.example.com>'): + with self.subTest(email=email): + self.assertEqual(self.env['res.partner'].find_or_create(email), partners[0]) + # find on encapsulated email: comparison of normalized should work + for email in ('FIND.ME.FORMAT@TEST.EXAMPLE.COM', '"Different Format" <find.me.format@test.example.com>'): + with self.subTest(email=email): + self.assertEqual(self.env['res.partner'].find_or_create(email), partners[1]) + # multi-emails -> no normalized email -> fails each time, create new partner (FIXME) + for email_input, match_partner in [ + ('find.me.multi.1@test.example.com', partners[2]), + ('find.me.multi.2@test.example.com', self.env['res.partner']), + ]: + with self.subTest(email_input=email_input): + partner = self.env['res.partner'].find_or_create(email_input) + # either matching existing, either new partner + if match_partner: + self.assertEqual(partner, match_partner) + else: + self.assertNotIn(partner, partners) + self.assertEqual(partner.email, email_input) + partner.unlink() # do not mess with subsequent tests + + # now input is multi email -> '_parse_partner_name' used in 'find_or_create' + # before trying to normalize is quite tolerant, allowing positive checks + for email_input, match_partner, exp_email_partner in [ + ('classic.format@test.example.com,another.email@test.example.com', + partners[0], 'classic.format@test.example.com'), # first found email matches existing + ('another.email@test.example.com,classic.format@test.example.com', + self.env['res.partner'], 'another.email@test.example.com'), # first found email does not match + ('find.me.multi.1@test.example.com,find.me.multi.2@test.example.com', + self.env['res.partner'], 'find.me.multi.1@test.example.com'), + ]: + with self.subTest(email_input=email_input): + partner = self.env['res.partner'].find_or_create(email_input) + # either matching existing, either new partner + if match_partner: + self.assertEqual(partner, match_partner) + else: + self.assertNotIn(partner, partners) + self.assertEqual(partner.email, exp_email_partner) + if partner not in partners: + partner.unlink() # do not mess with subsequent tests + @users('admin') def test_res_partner_merge_wizards(self): Partner = self.env['res.partner'] diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index a576bfc4b7e3f2d28947f57ddfb8fc225c6d8ee7..3b0e1ffd452832de706bc82b7808716a240fb361 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): @@ -456,25 +455,27 @@ 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]] for recipient_command in mail_values.get('recipient_ids') or [] if recipient_command[1] ] - mail_to = list(set(mail_to)) + # uniquify, keep ordering + seen = set() + mail_to = [email for email in mail_to if email not in seen and not seen.add(email)] + recipients_info[record_id] = { 'mail_to': mail_to, 'mail_to_normalized': [ - tools.email_normalize(mail) + tools.email_normalize(mail, force_single=False) for mail in mail_to - if tools.email_normalize(mail) + if tools.email_normalize(mail, force_single=False) ] } return recipients_info @@ -515,7 +516,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/mass_mailing/models/mailing_contact.py b/addons/mass_mailing/models/mailing_contact.py index 025bf7b2d7ef2d7bff376db5cb60d833672dd5a6..30d5a6cd4e8c14a6628ed00ff69951440f2310b9 100644 --- a/addons/mass_mailing/models/mailing_contact.py +++ b/addons/mass_mailing/models/mailing_contact.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, tools from odoo.osv import expression @@ -169,11 +169,12 @@ class MassMailingContact(models.Model): return contact.name_get()[0] def _message_get_default_recipients(self): - return {r.id: { - 'partner_ids': [], - 'email_to': r.email_normalized, - 'email_cc': False} - for r in self + return { + r.id: { + 'partner_ids': [], + 'email_to': ','.join(tools.email_normalize_all(r.email)) or r.email, + 'email_cc': False, + } for r in self } def action_add_to_mailing_list(self): diff --git a/addons/mass_mailing/tests/common.py b/addons/mass_mailing/tests/common.py index 651428ed4f4033a335995ef924083d33ed0c45c3..51ef32b401d49a8ec2121c03336246ea70c12c0f 100644 --- a/addons/mass_mailing/tests/common.py +++ b/addons/mass_mailing/tests/common.py @@ -52,6 +52,8 @@ class MassMailCase(MailCase, MockLinkTracker): 'record: linked record, # MAIL.MAIL 'content': optional content that should be present in mail.mail body_html; + 'email_to_mail': optional email used for the mail, when different from the + one stored on the trace itself; 'email_to_recipients': optional, see '_assertMailMail'; 'failure_type': optional failure reason; }, { ... }] @@ -100,6 +102,7 @@ class MassMailCase(MailCase, MockLinkTracker): for recipient_info, link_info, record in zip(recipients_info, mail_links_info, records): partner = recipient_info.get('partner', self.env['res.partner']) email = recipient_info.get('email') + email_to_mail = recipient_info.get('email_to_mail') or email email_to_recipients = recipient_info.get('email_to_recipients') status = recipient_info.get('trace_status', 'sent') record = record or recipient_info.get('record') @@ -130,6 +133,8 @@ class MassMailCase(MailCase, MockLinkTracker): fields_values = {'mailing_id': mailing} if 'failure_reason' in recipient_info: fields_values['failure_reason'] = recipient_info['failure_reason'] + if 'email_to_mail' in recipient_info: + fields_values['email_to'] = recipient_info['email_to_mail'] # specific for partner: email_formatted is used if partner: @@ -154,7 +159,7 @@ class MassMailCase(MailCase, MockLinkTracker): ) else: self.assertMailMailWEmails( - [email], state_mapping[status], + [email_to_mail], state_mapping[status], author=author, content=content, email_to_recipients=email_to_recipients, diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 5cc17438806a377bd5e15e6edff30433f0410c96..50a4fb3409aa02b2a18f511313ae2d8ecc50f1de 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -2122,12 +2122,18 @@ class Task(models.Model): # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from) + email_normalized = tools.email_normalize(self.email_from) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email_from or (email_normalized and partner.email_normalized == email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email_from', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('email_from', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email_from', '=', new_partner.email), - ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False) + ]).write({'partner_id': new_partner[0].id}) return super(Task, self)._message_post_after_hook(message, msg_vals) def action_assign_to_me(self): diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py index 983c108953c4266bd51fa52bf628791da21d9323..599401ab6704260cc340acd2f789a8ec66ab3bc7 100644 --- a/addons/test_mail/models/test_mail_models.py +++ b/addons/test_mail/models/test_mail_models.py @@ -27,6 +27,16 @@ class MailTestGateway(models.Model): email_from = fields.Char() custom_field = fields.Char() + @api.model + def message_new(self, msg_dict, custom_values=None): + """ Check override of 'message_new' allowing to update record values + base on incoming email. """ + defaults = { + 'email_from': msg_dict.get('from'), + } + defaults.update(custom_values or {}) + return super().message_new(msg_dict, custom_values=defaults) + class MailTestGatewayGroups(models.Model): """ A model looking like discussion channels / groups (flat thread and diff --git a/addons/test_mail/tests/__init__.py b/addons/test_mail/tests/__init__.py index 8d34da5957ead86588df4ad2476b2ee455b880d9..8d33641ee8b1e5704b7345c771dbb771f2fc8a7c 100644 --- a/addons/test_mail/tests/__init__.py +++ b/addons/test_mail/tests/__init__.py @@ -11,6 +11,7 @@ from . import test_mail_mail from . import test_mail_gateway from . import test_mail_multicompany from . import test_mail_thread_internals +from . import test_mail_thread_mixins from . import test_mail_template from . import test_mail_template_preview from . import test_message_management diff --git a/addons/test_mail/tests/test_mail_activity.py b/addons/test_mail/tests/test_mail_activity.py index 1997ab7be32a562cf0944f648a6728e313238319..6dcccc071872d562e9dfc68bab55754a6be4b894 100644 --- a/addons/test_mail/tests/test_mail_activity.py +++ b/addons/test_mail/tests/test_mail_activity.py @@ -653,41 +653,42 @@ class TestActivityMixin(TestActivityCommon): self.assertEqual(test_record_1, record) -class TestReadProgressBar(tests.TransactionCase): +@tests.tagged('mail_activity') +class TestORM(TestActivityCommon): """Test for read_progress_bar""" def test_week_grouping(self): """The labels associated to each record in read_progress_bar should match the ones from read_group, even in edge cases like en_US locale on sundays """ - model = self.env['mail.test.activity'].with_context(lang='en_US') + MailTestActivityCtx = self.env['mail.test.activity'].with_context({"lang": "en_US"}) # Don't mistake fields date and date_deadline: # * date is just a random value # * date_deadline defines activity_state - model.create({ + self.env['mail.test.activity'].create({ 'date': '2021-05-02', 'name': "Yesterday, all my troubles seemed so far away", }).activity_schedule( 'test_mail.mail_act_test_todo', summary="Make another test super asap (yesterday)", - date_deadline=fields.Date.context_today(model) - timedelta(days=7), + date_deadline=fields.Date.context_today(MailTestActivityCtx) - timedelta(days=7), ) - model.create({ + self.env['mail.test.activity'].create({ 'date': '2021-05-09', 'name': "Things we said today", }).activity_schedule( 'test_mail.mail_act_test_todo', summary="Make another test asap", - date_deadline=fields.Date.context_today(model), + date_deadline=fields.Date.context_today(MailTestActivityCtx), ) - model.create({ + self.env['mail.test.activity'].create({ 'date': '2021-05-16', 'name': "Tomorrow Never Knows", }).activity_schedule( 'test_mail.mail_act_test_todo', summary="Make a test tomorrow", - date_deadline=fields.Date.context_today(model) + timedelta(days=7), + date_deadline=fields.Date.context_today(MailTestActivityCtx) + timedelta(days=7), ) domain = [('date', "!=", False)] @@ -702,8 +703,8 @@ class TestReadProgressBar(tests.TransactionCase): } # call read_group to compute group names - groups = model.read_group(domain, fields=['date'], groupby=[groupby]) - progressbars = model.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar) + groups = MailTestActivityCtx.read_group(domain, fields=['date'], groupby=[groupby]) + progressbars = MailTestActivityCtx.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar) self.assertEqual(len(groups), 3) self.assertEqual(len(progressbars), 3) diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index 94da28be8ad0dbea508c0d29e65eb18f76dac91b..34e443bc9a3fc50677a36bb8f09bf12e771ad9fe 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -571,7 +571,7 @@ class TestComposerResultsComment(TestMailComposer): self.assertEqual(len(self._new_mails.exists()), 2, 'Should not have deleted mail.mail records') @users('employee') - @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') def test_mail_composer_recipients(self): """ Test partner_ids given to composer are given to the final message. """ composer = self.env['mail.compose.message'].with_context( @@ -687,6 +687,146 @@ class TestComposerResultsComment(TestMailComposer): self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(self.test_record.ids)) self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments') + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_recipients_email_fields(self): + """ Test various combinations of corner case / not standard filling of + email fields: multi email, formatted emails, ... on template, used to + post a message using the composer.""" + existing_partners = self.env['res.partner'].search([]) + partner_format_tofind, partner_multi_tofind = self.env['res.partner'].create([ + { + 'email': '"FindMe Format" <find.me.format@test.example.com>', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>', + 'name': 'FindMe Multi', + } + ]) + email_ccs = ['"Raoul" <test.cc.1@example.com>', '"Raoulette" <test.cc.2@example.com>', 'test.cc.2.2@example.com>', 'invalid', ' '] + email_tos = ['"Micheline, l\'Immense" <test.to.1@example.com>', 'test.to.2@example.com', 'wrong', ' '] + + self.template.write({ + 'email_cc': ', '.join(email_ccs), + 'email_from': '{{ user.email_formatted }}', + 'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')), + 'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test', + }) + self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'}) + self.partner_1.write({'email': '"Valid Formatted" <valid.lelitre@agrolait.com>'}) + self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'}) + # 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>', + 'Formatting: wrong formatting due to multi-email') + self.assertEqual( + self.partner_1.email_formatted, + '"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>', + 'Formatting: wrong formatting due to multi-email') + + # instantiate composer, post message + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context( + self.test_record, + add_web=True, + default_template_id=self.template.id, + ) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer.action_send_mail() + + # find partners created during sending (as emails are transformed into partners) + # FIXME: currently email finding based on formatted / multi emails does + # not work + new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)]) + self.assertEqual(len(new_partners), 8, + 'Mail (FIXME): multiple partner creation due to formatted / multi emails: 1 extra partners') + self.assertIn(partner_format_tofind, new_partners) + self.assertIn(partner_multi_tofind, new_partners) + self.assertEqual( + sorted(new_partners.mapped('email')), + sorted(['"FindMe Format" <find.me.format@test.example.com>', + 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', 'test.cc.2@example.com', 'test.cc.2.2@example.com', + 'test.to.1@example.com', 'test.to.2@example.com']), + 'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)' + ) + self.assertEqual( + sorted(new_partners.mapped('email_formatted')), + 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.2@test.example.com" <find.me.multi.2@test.example.com>', + '"test.cc.1@example.com" <test.cc.1@example.com>', + '"test.cc.2@example.com" <test.cc.2@example.com>', + '"test.cc.2.2@example.com" <test.cc.2.2@example.com>', + '"test.to.1@example.com" <test.to.1@example.com>', + '"test.to.2@example.com" <test.to.2@example.com>']), + ) + self.assertEqual( + sorted(new_partners.mapped('name')), + sorted(['FindMe Format', + 'FindMe Multi', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', 'test.to.1@example.com', 'test.to.2@example.com', + 'test.cc.2@example.com', 'test.cc.2.2@example.com']), + 'Mail: currently setting name = email, not taking into account formatted emails' + ) + + # global outgoing: two mail.mail (all customer recipients, then all employee recipients) + # and 11 emails, and 1 inbox notification (admin) + # FIXME template is sent only to partners (email_to are transformed) -> + # wrong / weird emails (see email_formatted of partners) is kept + # FIXME: more partners created than real emails (see above) -> due to + # transformation from email -> partner in template 'generate_recipients' + # there are more partners than email to notify; + self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail') + self.assertEqual( + len(self._mails), len(new_partners) + 3, + f'Should have sent {len(new_partners) + 3} emails, one / recipient ({len(new_partners)} mailed partners + partner_1 + partner_2 + partner_employee)') + self.assertMailMail( + self.partner_employee_2, 'sent', + author=self.partner_employee, + email_values={ + 'body_content': f'TemplateBody {self.test_record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@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')), + }, + mail_message=self.test_record.message_ids[0], + ) + self.assertMailMail( + self.partner_1 + self.partner_2 + new_partners, 'sent', + author=self.partner_employee, + email_to_recipients=[ + [self.partner_1.email_formatted], + [f'"{self.partner_2.name}" <valid.other.1@agrolait.com>', f'"{self.partner_2.name}" <valid.other.cc@agrolait.com>'], + ] + [[new_partners[0]['email_formatted']], + ['"FindMe Multi" <find.me.multi.1@test.example.com>', '"FindMe Multi" <find.me.multi.2@test.example.com>'] + ] + [[email] for email in new_partners[2:].mapped('email_formatted')], + email_values={ + 'body_content': f'TemplateBody {self.test_record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@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')), + }, + mail_message=self.test_record.message_ids[0], + ) + @tagged('mail_composer') class TestComposerResultsMass(TestMailComposer): @@ -953,6 +1093,141 @@ class TestComposerResultsMass(TestMailComposer): with self.mock_mail_gateway(mail_unlink_sent=False), self.assertRaises(ValueError): composer._action_send_mail() + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_recipients_email_fields(self): + """ Test various combinations of corner case / not standard filling of + email fields: multi email, formatted emails, ... """ + existing_partners = self.env['res.partner'].search([]) + partner_format_tofind, partner_multi_tofind = self.env['res.partner'].create([ + { + 'email': '"FindMe Format" <find.me.format@test.example.com>', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>', + 'name': 'FindMe Multi', + } + ]) + email_ccs = ['"Raoul" <test.cc.1@example.com>', '"Raoulette" <test.cc.2@example.com>', 'test.cc.2.2@example.com>', 'invalid', ' '] + email_tos = ['"Micheline, l\'Immense" <test.to.1@example.com>', 'test.to.2@example.com', 'wrong', ' '] + + self.template.write({ + 'email_cc': ', '.join(email_ccs), + 'email_from': '{{ user.email_formatted }}', + 'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')), + 'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test', + }) + self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'}) + self.partner_1.write({'email': '"Valid Formatted" <valid.lelitre@agrolait.com>'}) + self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'}) + # 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>', + 'Formatting: wrong formatting due to multi-email') + self.assertEqual( + self.partner_1.email_formatted, + '"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>', + 'Formatting: wrong formatting due to multi-email') + + # instantiate composer, send mailing + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context( + self.test_records, + add_web=True, + default_template_id=self.template.id, + ) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer.action_send_mail() + + # find partners created during sending (as emails are transformed into partners) + # FIXME: currently email finding based on formatted / multi emails does + # not work + new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)]) + self.assertEqual(len(new_partners), 8, + 'Mail (FIXME): did not find existing partners for formatted / multi emails: 1 extra partners') + self.assertIn(partner_format_tofind, new_partners) + self.assertIn(partner_multi_tofind, new_partners) + self.assertEqual( + sorted(new_partners.mapped('email')), + sorted(['"FindMe Format" <find.me.format@test.example.com>', + 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', 'test.cc.2@example.com', 'test.cc.2.2@example.com', + 'test.to.1@example.com', 'test.to.2@example.com']), + 'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)' + ) + self.assertEqual( + sorted(new_partners.mapped('email_formatted')), + 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.2@test.example.com" <find.me.multi.2@test.example.com>', + '"test.cc.1@example.com" <test.cc.1@example.com>', + '"test.cc.2@example.com" <test.cc.2@example.com>', + '"test.cc.2.2@example.com" <test.cc.2.2@example.com>', + '"test.to.1@example.com" <test.to.1@example.com>', + '"test.to.2@example.com" <test.to.2@example.com>']), + ) + self.assertEqual( + sorted(new_partners.mapped('name')), + sorted(['FindMe Format', + 'FindMe Multi', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', 'test.to.1@example.com', 'test.to.2@example.com', + 'test.cc.2@example.com', 'test.cc.2.2@example.com']), + 'Mail: currently setting name = email, not taking into account formatted emails' + ) + + # global outgoing: one mail.mail (all customer recipients), * 2 records + # Note that employee is not mailed here compared to 'comment' mode as he + # is not in the template recipients, only a follower + # FIXME template is sent only to partners (email_to are transformed) -> + # wrong / weird emails (see email_formatted of partners) is kept + # FIXME: more partners created than real emails (see above) -> due to + # transformation from email -> partner in template 'generate_recipients' + # there are more partners than email to notify; + self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail') + self.assertEqual( + len(self._mails), (len(new_partners) + 2) * 2, + f'Should have sent {(len(new_partners) + 2) * 2} emails, one / recipient ({len(new_partners)} mailed partners + partner_1 + partner_2) * 2 records') + for record in self.test_records: + self.assertMailMail( + self.partner_1 + self.partner_2 + new_partners, + 'sent', + author=self.partner_employee, + email_to_recipients=[ + [self.partner_1.email_formatted], + [f'"{self.partner_2.name}" <valid.other.1@agrolait.com>', f'"{self.partner_2.name}" <valid.other.cc@agrolait.com>'], + ] + [[new_partners[0]['email_formatted']], + ['"FindMe Multi" <find.me.multi.1@test.example.com>', '"FindMe Multi" <find.me.multi.2@test.example.com>'] + ] + [[email] for email in new_partners[2:].mapped('email_formatted')], + email_values={ + 'body_content': f'TemplateBody {record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com')), + 'reply_to': formataddr(( + f'{self.env.user.company_id.name} {record.name}', + f'{self.alias_catchall}@{self.alias_domain}' + )), + 'subject': f'TemplateSubject {record.name}', + }, + fields_values={ + # currently holding multi-email 'email_from' + 'email_from': self.partner_employee.email_formatted, + 'reply_to': formataddr(( + f'{self.env.user.company_id.name} {record.name}', + f'{self.alias_catchall}@{self.alias_domain}' + )), + }, + mail_message=record.message_ids[0], # message copy is kept + ) + @users('employee') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_mail_composer_wtpl_reply_to_force_new(self): diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py index 002823caea63f0f64efc39d2bf9e390c46b3ca98..041c211fb9a0e1e010fc4e66ee3503afb6e2524e 100644 --- a/addons/test_mail/tests/test_mail_gateway.py +++ b/addons/test_mail/tests/test_mail_gateway.py @@ -475,7 +475,32 @@ class TestMailgateway(TestMailCommon): self.assertEqual(record.message_ids[0].email_from, self.partner_1.email) self.assertNotSentEmail() # No notification / bounce should be sent - @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink') + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_email_author_multiemail(self): + """ Incoming email: recognized author: check multi/formatted email in field """ + test_email = 'valid.lelitre@agrolait.com' + # Email not recognized if partner has a multi-email (source = formatted email) + self.partner_1.write({'email': f'{test_email}, "Valid Lelitre" <another.email@test.example.com>'}) + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, f'"Valid Lelitre" <{test_email}>', 'groups@test.com', subject='Test3') + + self.assertEqual(record.message_ids[0].author_id, self.partner_1, + 'message_process: found author based on first found email normalized, even with multi emails') + self.assertEqual(record.message_ids[0].email_from, f'"Valid Lelitre" <{test_email}>') + self.assertNotSentEmail() # No notification / bounce should be sent + + # Email not recognized if partner has a multi-email (source = std email) + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, test_email, 'groups@test.com', subject='Test4') + + self.assertEqual(record.message_ids[0].author_id, self.partner_1, + 'message_process: found author based on first found email normalized, even with multi emails') + self.assertEqual(record.message_ids[0].email_from, test_email) + self.assertNotSentEmail() # No notification / bounce should be sent + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_process_email_partner_find(self): """ Finding the partner based on email, based on partner / user / follower """ self.alias.write({'alias_force_thread_id': self.test_record.id}) @@ -676,6 +701,39 @@ class TestMailgateway(TestMailCommon): # Test: one group created by Raoul (or Sylvie maybe, if we implement it) self.assertEqual(len(record), 1) + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models', 'odoo.tests') + def test_message_process_alias_followers_multiemail(self): + """ Incoming email from a parent document follower on a Followers only + alias depends on email_from / partner recognition, to be tested when + dealing with multi emails / formatted emails. """ + self.alias.write({ + 'alias_contact': 'followers', + 'alias_parent_model_id': self.env['ir.model']._get('mail.test.gateway').id, + 'alias_parent_thread_id': self.test_record.id, + }) + self.test_record.message_subscribe(partner_ids=[self.partner_1.id]) + email_from = formataddr(("Another Name", self.partner_1.email_normalized)) + + for partner_email, passed in [ + (formataddr((self.partner_1.name, self.partner_1.email_normalized)), True), + (f'{self.partner_1.email_normalized}, "Multi Email" <multi.email@test.example.com>', True), + (f'"Multi Email" <multi.email@test.example.com>, {self.partner_1.email_normalized}', False), + ]: + with self.subTest(partner_email=partner_email): + self.partner_1.write({'email': partner_email}) + record = self.format_and_process( + MAIL_TEMPLATE, email_from, 'groups@test.com', + subject=f'Test for {partner_email}') + + if passed: + self.assertEqual(len(record), 1) + self.assertEqual(record.email_from, email_from) + self.assertEqual(record.message_partner_ids, self.partner_1) + # multi emails not recognized (no normalized email, recognition) + else: + self.assertEqual(len(record), 0, + 'Alias check (FIXME): multi-emails bad support for recognition') + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_message_process_alias_update(self): """ Incoming email update discussion + notification email """ @@ -1580,6 +1638,7 @@ class TestMailgateway(TestMailCommon): # Corner cases / Bugs during message process # -------------------------------------------------- + @mute_logger('odoo.addons.mail.models.mail_thread') def test_message_process_file_encoding_ascii(self): """ Incoming email containing an xml attachment with unknown characters (�) but an ASCII charset should not raise an Exception. UTF-8 is used as a safe fallback. @@ -1636,6 +1695,7 @@ class TestMailgateway(TestMailCommon): self.assertFalse(record.message_ids.parent_id) +@tagged('mail_gateway', 'mail_thread') class TestMailThreadCC(TestMailCommon): @classmethod diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py index effc3ee7da7b159a8804031aa6210f23f33f0f97..ea42d98123a09206e49a527a67296059397095dd 100644 --- a/addons/test_mail/tests/test_mail_mail.py +++ b/addons/test_mail/tests/test_mail_mail.py @@ -36,7 +36,7 @@ class TestMailMail(TestMailCommon, MockSmtplibCase): }).with_context({}) @mute_logger('odoo.addons.mail.models.mail_mail') - def test_mail_message_notify_from_mail_mail(self): + def test_mail_mail_notify_from_mail_mail(self): # Due ot post-commit hooks, store send emails in every step mail = self.env['mail.mail'].sudo().create({ 'body_html': '<p>Test</p>', @@ -188,7 +188,116 @@ class TestMailMail(TestMailCommon, MockSmtplibCase): self.assert_email_sent_smtp(message_from='user_2@test_2.com', emails_count=5, from_filter=self.server_domain_2.from_filter) self.assert_email_sent_smtp(message_from='user_1@test_2.com', emails_count=5, from_filter=self.server_domain.from_filter) + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_values_email_formatted(self): + """ Test outgoing email values, with formatting """ + customer = self.env['res.partner'].create({ + 'name': 'Tony Customer', + 'email': '"Formatted Emails" <tony.customer@test.example.com>', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '<p>Test</p>', + 'email_cc': '"Ignasse, le Poilu" <test.cc.1@test.example.com>', + 'email_to': '"Raoul, le Grand" <test.email.1@test.example.com>, "Micheline, l\'immense" <test.email.2@test.example.com>', + 'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient') + self.assertEqual( + sorted(sorted(_mail['email_to']) for _mail in self._mails), + sorted([sorted(['"Raoul, le Grand" <test.email.1@test.example.com>', '"Micheline, l\'immense" <test.email.2@test.example.com>']), + [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))], + [tools.formataddr(("Tony Customer", 'tony.customer@test.example.com'))] + ]), + 'Mail: formatting issues should have been removed as much as possible' + ) + # Currently broken: CC are added to ALL emails (spammy) + self.assertEqual( + [_mail['email_cc'] for _mail in self._mails], + [['test.cc.1@test.example.com']] * 3, + 'Mail: currently always removing formatting in email_cc' + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_values_email_multi(self): + """ Test outgoing email values, with email field holding multi emails """ + # Multi + customer = self.env['res.partner'].create({ + 'name': 'Tony Customer', + 'email': 'tony.customer@test.example.com, norbert.customer@test.example.com', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '<p>Test</p>', + 'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com', + 'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com', + 'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient') + self.assertEqual( + sorted(sorted(_mail['email_to']) for _mail in self._mails), + sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']), + [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))], + sorted([tools.formataddr(("Tony Customer", 'tony.customer@test.example.com')), + tools.formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]), + ]), + 'Mail: formatting issues should have been removed as much as possible (multi emails in a single address are managed ' + 'like separate emails when sending with recipient_ids' + ) + # Currently broken: CC are added to ALL emails (spammy) + self.assertEqual( + [_mail['email_cc'] for _mail in self._mails], + [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3, + ) + # Multi + formatting + customer = self.env['res.partner'].create({ + 'name': 'Tony Customer', + 'email': 'tony.customer@test.example.com, "Norbert Customer" <norbert.customer@test.example.com>', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '<p>Test</p>', + 'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com', + 'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com', + 'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient') + self.assertEqual( + sorted(sorted(_mail['email_to']) for _mail in self._mails), + sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']), + [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))], + sorted([tools.formataddr(("Tony Customer", 'tony.customer@test.example.com')), + tools.formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]), + ]), + 'Mail: formatting issues should have been removed as much as possible (multi emails in a single address are managed ' + 'like separate emails when sending with recipient_ids (and partner name is always used as name part)' + ) + # Currently broken: CC are added to ALL emails (spammy) + self.assertEqual( + [_mail['email_cc'] for _mail in self._mails], + [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3, + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_values_unicode(self): + """ Unicode should be fine. """ + mail = self.env['mail.mail'].create({ + 'body_html': '<p>Test</p>', + 'email_cc': 'test.😊.cc@example.com', + 'email_to': 'test.😊@example.com', + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 1) + self.assertEqual(self._mails[0]['email_cc'], ['test.😊.cc@example.com']) + self.assertEqual(self._mails[0]['email_to'], ['test.😊@example.com']) + + +@tagged('mail_mail') class TestMailMailRace(common.TransactionCase): @mute_logger('odoo.addons.mail.models.mail_mail') diff --git a/addons/test_mail/tests/test_mail_thread_mixins.py b/addons/test_mail/tests/test_mail_thread_mixins.py new file mode 100644 index 0000000000000000000000000000000000000000..a1a7ad2a04bb337bd3b5d159ce6b5f3a828740bc --- /dev/null +++ b/addons/test_mail/tests/test_mail_thread_mixins.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import exceptions, tools +from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients +from odoo.tests.common import tagged +from odoo.tools import mute_logger + + +@tagged('mail_thread', 'mail_blacklist') +class TestMailThread(TestMailCommon, TestRecipients): + + @mute_logger('odoo.models.unlink') + def test_blacklist_mixin_email_normalized(self): + """ Test email_normalized and is_blacklisted fields behavior, notably + when dealing with encapsulated email fields and multi-email input. """ + base_email = 'test.email@test.example.com' + + # test data: source email, expected email normalized + valid_pairs = [ + (base_email, base_email), + (tools.formataddr(('Another Name', base_email)), base_email), + (f'Name That Should Be Escaped <{base_email}>', base_email), + ('test.😊@example.com', 'test.😊@example.com'), + ('"Name 😊" <test.😊@example.com>', 'test.😊@example.com'), + ] + void_pairs = [(False, False), + ('', False), + (' ', False)] + multi_pairs = [ + (f'{base_email}, other.email@test.example.com', + base_email), # multi supports first found + (f'{tools.formataddr(("Another Name", base_email))}, other.email@test.example.com', + base_email), # multi supports first found + ] + for email_from, exp_email_normalized in valid_pairs + void_pairs + multi_pairs: + with self.subTest(email_from=email_from, exp_email_normalized=exp_email_normalized): + new_record = self.env['mail.test.gateway'].create({ + 'email_from': email_from, + 'name': 'BL Test', + }) + self.assertEqual(new_record.email_normalized, exp_email_normalized) + self.assertFalse(new_record.is_blacklisted) + + # blacklist email should fail as void + if email_from in [pair[0] for pair in void_pairs]: + with self.assertRaises(exceptions.UserError): + bl_record = self.env['mail.blacklist']._add(email_from) + # blacklist email currently fails but could not + elif email_from in [pair[0] for pair in multi_pairs]: + with self.assertRaises(exceptions.UserError): + bl_record = self.env['mail.blacklist']._add(email_from) + # blacklist email ok + else: + bl_record = self.env['mail.blacklist']._add(email_from) + self.assertEqual(bl_record.email, exp_email_normalized) + new_record.invalidate_cache(fnames=['is_blacklisted']) + self.assertTrue(new_record.is_blacklisted) + + bl_record.unlink() diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index b62cc66a9941d15883ad79b8dcdb37a7a1882d60..59d9531d34a0d206266943cf8776f514965b2ea5 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -74,12 +74,13 @@ class TestMessagePost(TestMailCommon, TestRecipients): self.assertTrue(user_email) @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') def test_notify_mail_add_signature(self): - self.test_track = self.env['mail.test.track'].with_context(self._test_context).with_user(self.user_employee).create({ + test_track = self.env['mail.test.track'].with_context(self._test_context).with_user(self.user_employee).create({ 'name': 'Test', 'email_from': 'ignasse@example.com' }) - self.test_track.user_id = self.env.user + test_track.user_id = self.env.user signature = self.env.user.signature @@ -87,13 +88,13 @@ class TestMessagePost(TestMailCommon, TestRecipients): self.assertIn("record.user_id.sudo().signature", template.arch) with self.mock_mail_gateway(): - self.test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=True, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow") + test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=True, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow") found_mail = self._new_mails self.assertIn(signature, found_mail.body_html) self.assertEqual(found_mail.body_html.count(signature), 1) with self.mock_mail_gateway(): - self.test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=False, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow") + test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=False, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow") found_mail = self._new_mails self.assertNotIn(signature, found_mail.body_html) self.assertEqual(found_mail.body_html.count(signature), 0) @@ -252,7 +253,7 @@ class TestMessagePost(TestMailCommon, TestRecipients): self.test_record.message_post( body='Test', message_type='comment', subtype_xmlid='mail.mt_comment') - @mute_logger('odoo.addons.mail.models.mail_mail') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') def test_post_notifications(self): _body, _subject = '<p>Test Body</p>', 'Test Subject' @@ -286,6 +287,52 @@ class TestMessagePost(TestMailCommon, TestRecipients): self.assertFalse(copy.notified_partner_ids) @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_post_notifications_email_field(self): + """ Test various combinations of corner case / not standard filling of + email fields: multi email, formatted emails, ... """ + partner_emails = [ + 'valid.lelitre@agrolait.com, valid.lelitre.cc@agrolait.com', # multi email + '"Valid Lelitre" <valid.lelitre@agrolait.com>', # email contains formatted email + 'wrong', # wrong + False, '', ' ', # falsy + ] + expected_tos = [ + # Sends multi-emails + [f'"{self.partner_1.name}" <valid.lelitre@agrolait.com>', + f'"{self.partner_1.name}" <valid.lelitre.cc@agrolait.com>',], + # Avoid double encapsulation + [f'"{self.partner_1.name}" <valid.lelitre@agrolait.com>',], + # sent "normally": formats email based on wrong / falsy email + [f'"{self.partner_1.name}" <@wrong>',], + [f'"{self.partner_1.name}" <@False>',], + [f'"{self.partner_1.name}" <@False>',], + [f'"{self.partner_1.name}" <@ >',], + ] + + for partner_email, expected_to in zip(partner_emails, expected_tos): + with self.subTest(partner_email=partner_email, expected_to=expected_to): + self.partner_1.write({'email': partner_email}) + with self.mock_mail_gateway(): + self.test_record.with_user(self.user_employee).message_post( + body='Test multi email', + message_type='comment', + partner_ids=[self.partner_1.id], + subject='Exotic email', + subtype_xmlid='mt_comment', + ) + + self.assertSentEmail( + self.user_employee.partner_id, + [self.partner_1], + email_to=expected_to, + ) + + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_post_notifications_emails_tweak(self): + pass + # we should check _notification_groups behavior, for emails and buttons + + @mute_logger('odoo.addons.mail.models.mail_mail') def test_post_notifications_keep_emails(self): self.test_record.message_subscribe(partner_ids=[self.user_admin.partner_id.id]) @@ -299,11 +346,6 @@ class TestMessagePost(TestMailCommon, TestRecipients): # notifications emails should not have been deleted: one for customers, one for user self.assertEqual(len(self.env['mail.mail'].sudo().search([('mail_message_id', '=', msg.id)])), 2) - @mute_logger('odoo.addons.mail.models.mail_mail') - def test_post_notifications_emails_tweak(self): - pass - # we should check _notification_groups behavior, for emails and buttons - @mute_logger('odoo.addons.mail.models.mail_mail') def test_post_attachments(self): _attachments = [ @@ -405,7 +447,7 @@ class TestMessagePost(TestMailCommon, TestRecipients): references='%s %s' % (parent_msg.message_id, new_msg.message_id), ) - @mute_logger('odoo.addons.mail.models.mail_mail') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') def test_post_email_with_multiline_subject(self): _body, _body_alt, _subject = '<p>Test Body</p>', 'Test Body', '1st line\n2nd line' msg = self.test_record.with_user(self.user_employee).message_post( diff --git a/addons/test_mass_mailing/models/mailing_models.py b/addons/test_mass_mailing/models/mailing_models.py index cafda8d5cab49abf407553dc4dc063548ca13827..59bc2fae224e2182ccfbff1270c786814824a9f0 100644 --- a/addons/test_mass_mailing/models/mailing_models.py +++ b/addons/test_mass_mailing/models/mailing_models.py @@ -4,9 +4,39 @@ from odoo import api, fields, models +class MailingCustomer(models.Model): + """ A model inheriting from mail.thread with a partner field, to test + mass mailing flows involving checking partner email. """ + _description = 'Mailing with partner' + _name = 'mailing.test.customer' + _inherit = ['mail.thread'] + + name = fields.Char() + email_from = fields.Char(compute='_compute_email_from', readonly=False, store=True) + customer_id = fields.Many2one('res.partner', 'Customer', tracking=True) + + @api.depends('customer_id') + def _compute_email_from(self): + for mailing in self.filtered(lambda rec: not rec.email_from and rec.customer_id): + mailing.email_from = mailing.customer_id.email + + def _message_get_default_recipients(self): + """ Default recipient checks for 'partner_id', here the field is named + 'customer_id'. """ + default_recipients = super()._message_get_default_recipients() + for record in self: + if record.customer_id: + default_recipients[record.id] = { + 'email_cc': False, + 'email_to': False, + 'partner_ids': record.customer_id.ids, + } + return default_recipients + + class MailingSimple(models.Model): - """ A very simple model only inheriting from mail.thread to test pure mass - mailing features and base performances. """ + """ Model only inheriting from mail.thread to test base mailing features and + performances. """ _description = 'Simple Mailing' _name = 'mailing.test.simple' _inherit = ['mail.thread'] @@ -16,7 +46,8 @@ class MailingSimple(models.Model): class MailingUTM(models.Model): - """ Model inheriting from mail.thread and utm.mixin for checking utm of mailing is caught and set on reply """ + """ Model inheriting from mail.thread and utm.mixin for checking utm of mailing + is caught and set on reply """ _description = 'Mailing: UTM enabled to test UTM sync with mailing' _name = 'mailing.test.utm' _inherit = ['mail.thread', 'utm.mixin'] @@ -36,6 +67,19 @@ class MailingBLacklist(models.Model): customer_id = fields.Many2one('res.partner', 'Customer', tracking=True) user_id = fields.Many2one('res.users', 'Responsible', tracking=True) + def _message_get_default_recipients(self): + """ Default recipient checks for 'partner_id', here the field is named + 'customer_id'. """ + default_recipients = super()._message_get_default_recipients() + for record in self: + if record.customer_id: + default_recipients[record.id] = { + 'email_cc': False, + 'email_to': False, + 'partner_ids': record.customer_id.ids, + } + return default_recipients + class MailingOptOut(models.Model): """ Model using blacklist mechanism and a hijacked opt-out mechanism for @@ -59,6 +103,19 @@ class MailingOptOut(models.Model): ]).mapped('email_normalized')) return opt_out_contacts + def _message_get_default_recipients(self): + """ Default recipient checks for 'partner_id', here the field is named + 'customer_id'. """ + default_recipients = super()._message_get_default_recipients() + for record in self: + if record.customer_id: + default_recipients[record.id] = { + 'email_cc': False, + 'email_to': False, + 'partner_ids': record.customer_id.ids, + } + return default_recipients + class MailingPerformance(models.Model): """ A very simple model only inheriting from mail.thread to test pure mass diff --git a/addons/test_mass_mailing/security/ir.model.access.csv b/addons/test_mass_mailing/security/ir.model.access.csv index b21c7be49b14a3b910c38d8686f4e2c8e086c056..617995ff0fac1bfeb8130746c698e825449ccf96 100644 --- a/addons/test_mass_mailing/security/ir.model.access.csv +++ b/addons/test_mass_mailing/security/ir.model.access.csv @@ -1,4 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mailing_test_customer_all,access.mailing.test.customer.all,model_mailing_test_customer,,0,0,0,0 +access_mailing_test_customer_user,access.mailing.test.customer.user,model_mailing_test_customer,base.group_user,1,1,1,1 access_mailing_test_simple_all,access.mailing.test.simple.all,model_mailing_test_simple,,0,0,0,0 access_mailing_test_simple_user,access.mailing.test.simple.user,model_mailing_test_simple,base.group_user,1,1,1,1 access_mailing_test_blacklist_all,access.mailing.test.blacklist.all,model_mailing_test_blacklist,,0,0,0,0 diff --git a/addons/test_mass_mailing/tests/test_mailing.py b/addons/test_mass_mailing/tests/test_mailing.py index b087cf88d90a682be8bcd2556a785d65692ebdcb..43c62d131891f00e3458715f87dd30676296db47 100644 --- a/addons/test_mass_mailing/tests/test_mailing.py +++ b/addons/test_mass_mailing/tests/test_mailing.py @@ -118,6 +118,165 @@ class TestMassMailing(TestMassMailCommon): self.assertMailingStatistics(mailing, expected=5, delivered=4, sent=5, opened=1, clicked=1, bounced=1) self.assertEqual(recipients[1].message_bounce, 1) + @users('user_marketing') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mailing_recipients(self): + """ Test recipient-specific computation, with email, formatting, + multi-emails, ... to test corner cases. Blacklist mixin impact is + tested. """ + (customer_mult, customer_fmt, customer_unic, + customer_case, customer_weird, customer_weird_2 + ) = self.env['res.partner'].create([ + { + 'email': 'customer.multi.1@example.com, "Test Multi 2" <customer.multi.2@example.com>', + 'name': 'MultiEMail', + }, { + 'email': '"Formatted Customer" <test.customer.format@example.com>', + 'name': 'FormattedEmail', + }, { + 'email': '"Unicode Customer" <test.customer.😊@example.com>', + 'name': 'UnicodeEmail', + }, { + 'email': 'TEST.CUSTOMER.CASE@EXAMPLE.COM', + 'name': 'CaseEmail', + }, { + 'email': 'test.customer.weird@example.com Weird Format', + 'name': 'WeirdFormatEmail', + }, { + 'email': 'Weird Format2 test.customer.weird.2@example.com', + 'name': 'WeirdFormatEmail2', + } + ]) + + # check difference of email management between a classic model and a model + # with an 'email_normalized' field (blacklist mixin) + for dst_model in ['mailing.test.customer', 'mailing.test.blacklist']: + with self.subTest(dst_model=dst_model): + (record_p_mult, record_p_fmt, record_p_unic, + record_p_case, record_p_weird, record_p_weird_2, + record_mult, record_fmt, record_unic, + record_case, recod_weird, record_weird_2 + ) = self.env[dst_model].create([ + { + 'customer_id': customer_mult.id, + }, { + 'customer_id': customer_fmt.id, + }, { + 'customer_id': customer_unic.id, + }, { + 'customer_id': customer_case.id, + }, { + 'customer_id': customer_weird.id, + }, { + 'customer_id': customer_weird_2.id, + }, { + 'email_from': 'record.multi.1@example.com, "Record Multi 2" <record.multi.2@example.com>', + }, { + 'email_from': '"Formatted Record" <record.format@example.com>', + }, { + 'email_from': '"Unicode Record" <record.😊@example.com>', + }, { + 'email_from': 'TEST.RECORD.CASE@EXAMPLE.COM', + }, { + 'email_from': 'test.record.weird@example.com Weird Format', + }, { + 'email_from': 'Weird Format2 test.record.weird.2@example.com', + } + ]) + test_records = ( + record_p_mult + record_p_fmt + record_p_unic + + record_p_case + record_p_weird + record_p_weird_2 + + record_mult + record_fmt + record_unic + + record_case + recod_weird + record_weird_2 + ) + mailing = self.env['mailing.mailing'].create({ + 'body_html': """<div><p>Hello ${object.name}</p>""", + 'mailing_domain': [('id', 'in', test_records.ids)], + 'mailing_model_id': self.env['ir.model']._get_id(dst_model), + 'mailing_type': 'mail', + 'name': 'SourceName', + 'preview': 'Hi ${object.name} :)', + 'reply_to_mode': 'update', + 'subject': 'MailingSubject', + }) + + with self.mock_mail_gateway(mail_unlink_sent=False): + mailing.action_send_mail() + + # Difference in email, email_to_recipients and email_to_mail + # -> email: trace email: normalized, to ease its management, mainly technical + # -> email_to_mail: mail.mail email: email_to stored in outgoing mail.mail (can be multi) + # -> email_to_recipients: email_to for outgoing emails, list means several recipients + self.assertMailTraces( + [ + {'email': 'customer.multi.1@example.com, "Test Multi 2" <customer.multi.2@example.com>', + 'email_to_recipients': [[f'"{customer_mult.name}" <customer.multi.1@example.com>', f'"{customer_mult.name}" <customer.multi.2@example.com>']], + 'failure_type': False, + 'partner': customer_mult, + 'trace_status': 'sent'}, + {'email': '"Formatted Customer" <test.customer.format@example.com>', + # mail to avoids double encapsulation + 'email_to_recipients': [[f'"{customer_fmt.name}" <test.customer.format@example.com>']], + 'failure_type': False, + 'partner': customer_fmt, + 'trace_status': 'sent'}, + {'email': '"Unicode Customer" <test.customer.😊@example.com>', + # mail to avoids double encapsulation + 'email_to_recipients': [[f'"{customer_unic.name}" <test.customer.😊@example.com>']], + 'failure_type': False, + 'partner': customer_unic, + 'trace_status': 'sent'}, + {'email': 'TEST.CUSTOMER.CASE@EXAMPLE.COM', + 'email_to_recipients': [[f'"{customer_case.name}" <test.customer.case@example.com>']], + 'failure_type': False, + 'partner': customer_case, + 'trace_status': 'sent'}, # lower cased + {'email': 'test.customer.weird@example.com Weird Format', + 'email_to_recipients': [[f'"{customer_weird.name}" <test.customer.weird@example.comweirdformat>']], + 'failure_type': False, + '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}" <test.customer.weird.2@example.com>']], + 'failure_type': False, + 'partner': customer_weird_2, + 'trace_status': 'sent'}, + {'email': 'record.multi.1@example.com', + 'email_to_mail': 'record.multi.1@example.com,record.multi.2@example.com', + 'email_to_recipients': [['record.multi.1@example.com', 'record.multi.2@example.com']], + 'failure_type': False, + 'trace_status': 'sent'}, + {'email': 'record.format@example.com', + 'email_to_mail': 'record.format@example.com', + 'email_to_recipients': [['record.format@example.com']], + 'failure_type': False, + 'trace_status': 'sent'}, + {'email': 'record.😊@example.com', + 'email_to_mail': 'record.😊@example.com', + 'email_to_recipients': [['record.😊@example.com']], + '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']], + 'failure_type': False, + 'trace_status': 'sent'}, + {'email': 'test.record.weird@example.comweirdformat', + 'email_to_mail': 'test.record.weird@example.comweirdformat', + 'email_to_recipients': [['test.record.weird@example.comweirdformat']], + 'failure_type': False, + 'trace_status': 'sent'}, + {'email': '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'}, + ], + mailing, + test_records, + check_mail=True, + ) + @users('user_marketing') @mute_logger('odoo.addons.mail.models.mail_mail') def test_mailing_reply_to_mode_new(self): diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py index 22b5ffec3b41a98b1e112c956697ebc0dc20b0c4..2ae02b42312376779d7ee01b54d142193a55712a 100644 --- a/addons/website_event_track/models/event_track.py +++ b/addons/website_event_track/models/event_track.py @@ -5,7 +5,7 @@ from datetime import timedelta from pytz import utc from random import randint -from odoo import api, fields, models +from odoo import api, fields, models, tools from odoo.addons.http_routing.models.ir_http import slug from odoo.osv import expression from odoo.tools.mail import is_html_empty @@ -482,15 +482,19 @@ class Track(models.Model): # Contact(s) created from chatter set on track : we verify if at least one is the expected contact # linked to the track. (created from contact_email if any, then partner_email if any) main_email = self.contact_email or self.partner_email - if main_email: - new_partner = message.partner_ids.filtered(lambda partner: partner.email == main_email) - if new_partner: - main_email_string = 'contact_email' if self.contact_email else 'partner_email' - self.search([ - ('partner_id', '=', False), - (main_email_string, '=', new_partner.email), - ('stage_id.is_cancel', '=', False), - ]).write({'partner_id': new_partner.id}) + main_email_normalized = tools.email_normalize(main_email) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == main_email or (main_email_normalized and partner.email_normalized == main_email_normalized) + ) + if new_partner: + mail_email_fname = 'contact_email' if self.contact_email else 'partner_email' + if new_partner[0].email_normalized: + email_domain = (mail_email_fname, 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = (mail_email_fname, '=', new_partner[0].email) + self.search([ + ('partner_id', '=', False), email_domain, ('stage_id.is_cancel', '=', False), + ]).write({'partner_id': new_partner[0].id}) return super(Track, self)._message_post_after_hook(message, msg_vals) def _track_template(self, changes): diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 4fea5a11025842a885e0c3ac99fe6060b181e7ff..d31859e29de4eeb1fbc59fcbffa450c99fbbb9fc 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -133,7 +133,7 @@ class Partner(models.Model): _description = 'Contact' _inherit = ['format.address.mixin', 'avatar.mixin'] _name = "res.partner" - _order = "display_name" + _order = "display_name ASC, id DESC" def _default_category(self): return self.env['res.partner.category'].browse(self._context.get('category_id')) @@ -424,11 +424,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): @@ -770,7 +795,9 @@ class Partner(models.Model): * Raoul <raoul@grosbedon.fr> * "Raoul le Grand" <raoul@grosbedon.fr> - * Raoul raoul@grosbedon.fr (strange fault tolerant support from df40926d2a57c101a3e2d221ecfd08fbb4fea30e) + * Raoul raoul@grosbedon.fr (strange fault tolerant support from + df40926d2a57c101a3e2d221ecfd08fbb4fea30e now supported directly + in 'email_split_tuples'; Otherwise: default, everything is set as the name. Starting from 13.3 returned email will be normalized to have a coherent encoding. @@ -780,12 +807,6 @@ class Partner(models.Model): if split_results: name, email = split_results[0] - if email and not name: - fallback_emails = tools.email_split(text.replace(' ', ',')) - if fallback_emails: - email = fallback_emails[0] - name = text[:text.index(email)].replace('"', '').replace('<', '').strip() - if email: email = tools.email_normalize(email) else: diff --git a/odoo/addons/base/tests/test_base.py b/odoo/addons/base/tests/test_base.py index 534b3a60228df7585944fd3e6cb193ac00f42e67..c5c875f0a2c5637d5651f777fc7b1a01f5a2e41c 100644 --- a/odoo/addons/base/tests/test_base.py +++ b/odoo/addons/base/tests/test_base.py @@ -5,6 +5,7 @@ import ast from odoo import SUPERUSER_ID, Command from odoo.exceptions import RedirectWarning, UserError, ValidationError +from odoo.tests import tagged from odoo.tests.common import TransactionCase, BaseCase from odoo.tools import mute_logger from odoo.tools.safe_eval import safe_eval, const_eval, expr_eval @@ -73,6 +74,7 @@ SAMPLES = [ ] +@tagged('res_partner') class TestBase(TransactionCase): def _check_find_or_create(self, test_string, expected_name, expected_email, check_partner=False, should_create=False): @@ -88,12 +90,13 @@ class TestBase(TransactionCase): def test_00_res_partner_name_create(self): res_partner = self.env['res.partner'] parse = res_partner._parse_partner_name - for text, name, mail in SAMPLES: - self.assertEqual((name, mail.lower()), parse(text)) - partner_id, dummy = res_partner.name_create(text) - partner = res_partner.browse(partner_id) - self.assertEqual(name or mail.lower(), partner.name) - self.assertEqual(mail.lower() or False, partner.email) + for text, expected_name, expected_mail in SAMPLES: + with self.subTest(text=text): + self.assertEqual((expected_name, expected_mail.lower()), parse(text)) + partner_id, dummy = res_partner.name_create(text) + partner = res_partner.browse(partner_id) + self.assertEqual(expected_name or expected_mail.lower(), partner.name) + self.assertEqual(expected_mail.lower() or False, partner.email) # name_create supports default_email fallback partner = self.env['res.partner'].browse( diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py index ee120594ec7099fc24372f358014631b580a6ac1..22c341383c130c038980681bf0b3c4af53c0fb2f 100644 --- a/odoo/addons/base/tests/test_expression.py +++ b/odoo/addons/base/tests/test_expression.py @@ -1088,7 +1088,7 @@ class TestQueries(TransactionCase): ) ) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): Model.search(domain) @@ -1100,7 +1100,7 @@ class TestQueries(TransactionCase): SELECT "res_partner".id FROM "res_partner" WHERE (("res_partner"."active" = %s) AND ("res_partner"."name"::text LIKE %s)) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): Model.search([('name', 'like', 'foo')]) @@ -1215,7 +1215,7 @@ class TestMany2one(TransactionCase): SELECT "res_partner".id FROM "res_partner" WHERE ("res_partner"."company_id" = %s) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id', '=', self.company.id)]) @@ -1227,7 +1227,7 @@ class TestMany2one(TransactionCase): FROM "res_company" WHERE ("res_company"."name"::text like %s) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.name', 'like', self.company.name)]) @@ -1243,7 +1243,7 @@ class TestMany2one(TransactionCase): WHERE ("res_partner"."name"::text LIKE %s) )) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)]) @@ -1259,7 +1259,7 @@ class TestMany2one(TransactionCase): FROM "res_country" WHERE ("res_country"."code"::text LIKE %s) ))) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([ '|', @@ -1278,7 +1278,7 @@ class TestMany2one(TransactionCase): FROM "res_company" WHERE ("res_company"."name"::text like %s) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): company_ids = self.company._search([('name', 'like', self.company.name)], order='id') self.Partner.search([('company_id', 'in', company_ids)]) @@ -1294,7 +1294,7 @@ class TestMany2one(TransactionCase): ORDER BY "res_company"."id" LIMIT 1 )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): company_ids = self.company._search([('name', 'like', self.company.name)], order='id', limit=1) self.Partner.search([('company_id', 'in', company_ids)]) @@ -1311,7 +1311,7 @@ class TestMany2one(TransactionCase): LEFT JOIN "res_company" AS "res_partner__company_id" ON ("res_partner"."company_id" = "res_partner__company_id"."id") WHERE ("res_partner__company_id"."name"::text LIKE %s) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.name', 'like', self.company.name)]) @@ -1325,7 +1325,7 @@ class TestMany2one(TransactionCase): FROM "res_partner" WHERE ("res_partner"."name"::text LIKE %s) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)]) @@ -1344,7 +1344,7 @@ class TestMany2one(TransactionCase): ("res_company"."partner_id" = "res_company__partner_id"."id") WHERE ("res_company__partner_id"."name"::text LIKE %s) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)]) @@ -1361,7 +1361,7 @@ class TestMany2one(TransactionCase): LEFT JOIN "res_partner" AS "res_partner__company_id__partner_id" ON ("res_partner__company_id"."partner_id" = "res_partner__company_id__partner_id"."id") WHERE ("res_partner__company_id__partner_id"."name"::text LIKE %s) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)]) @@ -1383,7 +1383,7 @@ class TestMany2one(TransactionCase): ("res_partner"."company_id" = "res_partner__company_id"."id") WHERE (("res_partner__company_id"."name"::text LIKE %s) OR ("res_partner__country_id"."code"::text LIKE %s)) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([ '|', @@ -1402,7 +1402,7 @@ class TestMany2one(TransactionCase): FROM "res_company" WHERE ("res_company"."name"::text LIKE %s) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id', 'like', self.company.name)]) @@ -1431,7 +1431,7 @@ class TestOne2many(TransactionCase): WHERE ("res_partner"."id" IN ( SELECT "partner_id" FROM "res_partner_bank" WHERE "id" IN %s )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('bank_ids', 'in', self.partner.bank_ids.ids)]) @@ -1443,7 +1443,7 @@ class TestOne2many(TransactionCase): FROM "res_partner_bank" WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('bank_ids.sanitized_acc_number', 'like', '12')]) @@ -1459,7 +1459,7 @@ class TestOne2many(TransactionCase): WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) )) AND "res_partner"."parent_id" IS NOT NULL )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('child_ids.bank_ids.sanitized_acc_number', 'like', '12')]) @@ -1476,7 +1476,7 @@ class TestOne2many(TransactionCase): WHERE ("res_partner"."id" IN ( SELECT "partner_id" FROM "res_partner_bank" WHERE "id" IN %s )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('bank_ids', 'in', self.partner.bank_ids.ids)]) @@ -1488,7 +1488,7 @@ class TestOne2many(TransactionCase): FROM "res_partner_bank" WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('bank_ids.sanitized_acc_number', 'like', '12')]) @@ -1504,7 +1504,7 @@ class TestOne2many(TransactionCase): FROM "res_partner_bank" WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) ))) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([ ('bank_ids.sanitized_acc_number', 'like', '12'), @@ -1523,7 +1523,7 @@ class TestOne2many(TransactionCase): WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) )) AND ("res_partner"."active" = %s)) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('child_ids.bank_ids.sanitized_acc_number', 'like', '12')]) @@ -1553,7 +1553,7 @@ class TestOne2many(TransactionCase): ("res_partner"."name" != %s) OR "res_partner"."name" IS NULL )) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('child_ids.bank_ids.id', 'in', self.partner.bank_ids.ids)]) @@ -1579,7 +1579,7 @@ class TestOne2many(TransactionCase): "res_partner"."active" = %s )) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('child_ids.state_id.country_id.code', 'like', 'US')]) @@ -1594,7 +1594,7 @@ class TestOne2many(TransactionCase): FROM "res_partner_bank" WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('bank_ids', 'like', '12')]) diff --git a/odoo/addons/base/tests/test_mail.py b/odoo/addons/base/tests/test_mail.py index 91b16acf53af8c38946049952d3c962e1c3afdfc..6b5c3e2b0771efd4bd2792c9208989b4b68f4834 100644 --- a/odoo/addons/base/tests/test_mail.py +++ b/odoo/addons/base/tests/test_mail.py @@ -10,9 +10,10 @@ import threading from odoo.addons.base.models.ir_mail_server import extract_rfc2822_addresses from odoo.tests.common import BaseCase, TransactionCase +from odoo.tests import tagged from odoo.tools import ( is_html_empty, html_sanitize, append_content_to_html, plaintext2html, - email_split, email_domain_normalize, + email_domain_normalize, email_normalize, email_split, email_split_and_format, misc, formataddr, prepend_html_content, ) @@ -395,10 +396,67 @@ class TestHtmlTools(BaseCase): self.assertEqual(result, "<html><body><div>test</div><div>test</div></body></html>") +@tagged('mail_tools') class TestEmailTools(BaseCase): """ Test some of our generic utility functions for emails """ + def test_email_normalize(self): + """ 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 + 'deboulonneur@example.com Déboulonneur', # wrong formatting (happens, alas) + '"Super Déboulonneur" <DEBOULONNEUR@example.com>, "Super Déboulonneur 2" <deboulonneur2@EXAMPLE.com>', # multi + case + ' Déboulonneur deboulonneur@example.com déboulonneur deboulonneur2@example.com', # wrong formatting + wrong multi + '"Déboulonneur 😊" <deboulonneur.😊@example.com>', # unicode in name and email left-part + '"Déboulonneur" <déboulonneur@examplé.com>', # utf-8 + '"Déboulonneur" <DéBoulonneur@Examplé.com>', # utf-8 + ] + expected_list = [ + 'deboulonneur@example.com', + 'deboulonneur@example.com', + 'deboulonneur@example.comdéboulonneur', + False, + '@example.com', # funny + 'deboulonneur.😊@example.com', + 'déboulonneur@examplé.com', + 'DéBoulonneur@examplé.com', + ] + expected_fmt_utf8_list = [ + f'"{format_name}" <deboulonneur@example.com>', + f'"{format_name}" <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} <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, force_single=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' """ cases = [ ("John <12345@gmail.com>", ['12345@gmail.com']), # regular form ("d@x; 1@2", ['d@x', '1@2']), # semi-colon + extra space @@ -409,6 +467,51 @@ class TestEmailTools(BaseCase): for text, expected in cases: self.assertEqual(email_split(text), expected, 'email_split is broken') + def test_email_split_and_format(self): + """ Test 'email_split_and_format', notably in case of multi encapsulation + or multi emails. """ + sources = [ + 'deboulonneur@example.com', + '"Super Déboulonneur" <deboulonneur@example.com>', # formatted + # wrong formatting + 'Déboulonneur <deboulonneur@example.com', # with a final typo + 'Déboulonneur deboulonneur@example.com', # wrong formatting + 'deboulonneur@example.com Déboulonneur', # wrong formatting (happens, alas) + # multi + 'Déboulonneur, deboulonneur@example.com', # multi-like with errors + 'deboulonneur@example.com, deboulonneur2@example.com', # multi + ' Déboulonneur deboulonneur@example.com déboulonneur deboulonneur2@example.com', # wrong formatting + wrong multi + # format / misc + '"Déboulonneur" <"Déboulonneur Encapsulated" <deboulonneur@example.com>>', # double formatting + '"Super Déboulonneur" <deboulonneur@example.com>, "Super Déboulonneur 2" <deboulonneur2@example.com>', + '"Super Déboulonneur" <deboulonneur@example.com>, wrong, ', + '"Déboulonneur 😊" <deboulonneur@example.com>', # unicode in name + '"Déboulonneur 😊" <deboulonneur.😊@example.com>', # unicode in name and email left-part + '"Déboulonneur" <déboulonneur@examplé.com>', # utf-8 + ] + expected_list = [ + ['deboulonneur@example.com'], + ['"Super Déboulonneur" <deboulonneur@example.com>'], + # wrong formatting + ['"Déboulonneur" <deboulonneur@example.com>'], + ['"Déboulonneur" <deboulonneur@example.com>'], # extra part correctly considered as a name + ['deboulonneur@example.comDéboulonneur'], # concatenated, not sure why + # multi + ['deboulonneur@example.com'], + ['deboulonneur@example.com', 'deboulonneur2@example.com'], + ['@example.com'], # funny one + # format / misc + ['deboulonneur@example.com'], + ['"Super Déboulonneur" <deboulonneur@example.com>', '"Super Déboulonneur 2" <deboulonneur2@example.com>'], + ['"Super Déboulonneur" <deboulonneur@example.com>'], + ['"Déboulonneur 😊" <deboulonneur@example.com>'], + ['"Déboulonneur 😊" <deboulonneur.😊@example.com>'], + ['"Déboulonneur" <déboulonneur@examplé.com>'], + ] + for source, expected in zip(sources, expected_list): + with self.subTest(source=source): + self.assertEqual(email_split_and_format(source), expected) + def test_email_formataddr(self): email = 'joe@example.com' email_idna = 'joe@examplé.com' diff --git a/odoo/addons/base/tests/test_res_partner.py b/odoo/addons/base/tests/test_res_partner.py index 075f2b8ef3397d098829867bfdae07509a6a553f..ec4ebd66f7240431d1ad675e9a761e0394bd7ffe 100644 --- a/odoo/addons/base/tests/test_res_partner.py +++ b/odoo/addons/base/tests/test_res_partner.py @@ -4,10 +4,89 @@ from odoo.tests import Form from odoo.tests.common import TransactionCase from odoo.exceptions import AccessError, UserError +from odoo.tests import tagged +@tagged('res_partner') class TestPartner(TransactionCase): + def test_email_formatted(self): + """ Test various combinations of name / email, notably to check result + of email_formatted field. """ + # multi create + new_partners = self.env['res.partner'].create([{ + 'name': "Vlad the Impaler", + 'email': f'vlad.the.impaler.{idx:02d}@example.com', + } for idx in range(2)]) + self.assertEqual( + sorted(new_partners.mapped('email_formatted')), + sorted([f'"Vlad the Impaler" <vlad.the.impaler.{idx:02d}@example.com>' for idx in range(2)]), + 'Email formatted should be "name" <email>' + ) + + # test name_create with formatting / multi emails + for source, (exp_name, exp_email, exp_email_formatted) in [ + ( + 'Balázs <vlad.the.negociator@example.com>, vlad.the.impaler@example.com', + ("Balázs", "vlad.the.negociator@example.com", '"Balázs" <vlad.the.negociator@example.com>') + ), + ( + 'Balázs <vlad.the.impaler@example.com>', + ("Balázs", "vlad.the.impaler@example.com", '"Balázs" <vlad.the.impaler@example.com>') + ), + ]: + with self.subTest(source=source): + new_partner_id = self.env['res.partner'].name_create(source)[0] + new_partner = self.env['res.partner'].browse(new_partner_id) + self.assertEqual(new_partner.name, exp_name) + self.assertEqual(new_partner.email, exp_email) + self.assertEqual( + new_partner.email_formatted, exp_email_formatted, + 'Name_create should take first found email' + ) + + # check name updates + for source, exp_email_formatted in [ + ('Vlad the Impaler', '"Vlad the Impaler" <vlad.the.impaler@example.com>'), + ('Balázs', '"Balázs" <vlad.the.impaler@example.com>'), + ('Balázs <email.in.name@example.com>', '"Balázs <email.in.name@example.com>" <vlad.the.impaler@example.com>'), + ]: + with self.subTest(source=source): + new_partner.write({'name': source}) + self.assertEqual(new_partner.email_formatted, exp_email_formatted) + + # check email updates + new_partner.write({'name': 'Balázs'}) + for source, exp_email_formatted in [ + # encapsulated email + ( + "Vlad the Impaler <vlad.the.impaler@example.com>", + '"Balázs" <vlad.the.impaler@example.com>' + ), ( + '"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>' + ), ( + "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.dragon@example.com>' + ), + # falsy emails + (False, False), + ('', False), + (' ', '"Balázs" <@ >'), + ('notanemail', '"Balázs" <@notanemail>'), + ]: + with self.subTest(source=source): + new_partner.write({'email': source}) + self.assertEqual(new_partner.email_formatted, exp_email_formatted) + def test_name_search(self): """ Check name_search on partner, especially with domain based on auto_join user_ids field. Check specific SQL of name_search correctly handle joined tables. """ diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py index 0eb7b3b7d29676831292b221cdfa6b556ad5374b..011784bec03d0e397038c7b23f15a1fd7ad7e4ca 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -465,7 +465,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 @@ -483,14 +482,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`` """ @@ -505,20 +525,62 @@ def email_split_and_format(text): return [] return [formataddr((name, email)) for (name, email) in email_split_tuples(text)] -def email_normalize(text): - """ 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' +def email_normalize(text, force_single=True): + """ 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 force_single: if True, text should contain a single email + (default behavior in stable 14+). If more than one email is found no + normalized email is returned. If False the first found candidate is used + e.g. if email is 'tony@e.com, "Tony2" <tony2@e.com>', result is either + False (force_single=True), either 'tony@e.com' (force_single=False). """ emails = email_split(text) - if not emails or len(emails) != 1: + if not emails or (len(emails) != 1 and force_single): 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 + 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_domain_extract(email): @@ -531,6 +593,7 @@ def email_domain_extract(email): return normalized_email.split('@')[1] return False + def email_domain_normalize(domain): """Return the domain normalized or False if the domain is invalid.""" if not domain or '@' in domain: @@ -538,6 +601,7 @@ def email_domain_normalize(domain): return domain.lower() + def url_domain_extract(url): """ Extract the company domain to be used by IAP services notably. Domain is extracted from an URL e.g: @@ -549,6 +613,7 @@ def url_domain_extract(url): return '.'.join(company_hostname.split('.')[-2:]) # remove subdomains return False + def email_escape_char(email_address): """ Escape problematic characters in the given email address string""" return email_address.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')