diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 00a4c09ba601357e53a58e3d89b75725c1893b97..20a4a070aa6d90eec4dd2d0a96d576720c3b9fcd 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -10,7 +10,7 @@ from psycopg2 import sql from odoo import api, fields, models, tools, SUPERUSER_ID from odoo.osv import expression from odoo.tools.translate import _ -from odoo.tools import email_re, email_split +from odoo.tools import email_split from odoo.exceptions import UserError, AccessError from odoo.addons.phone_validation.tools import phone_validation from collections import OrderedDict, defaultdict @@ -1492,11 +1492,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() @@ -1549,23 +1551,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 4b9596ea040e9560bd1f93f60a66186da27e2e80..b81ec66cebbd3d24f006c175ac138830b90fb690 100644 --- a/addons/crm/tests/test_crm_lead_notification.py +++ b/addons/crm/tests/test_crm_lead_notification.py @@ -1,11 +1,55 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from .common import TestCrmCommon +from unittest import skip +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') + @skip('Wait until test_predictive_lead_scoring issue is fixed') + 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. """ @@ -42,6 +86,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/crm/tests/test_crm_pls.py b/addons/crm/tests/test_crm_pls.py index 8963dc8ab4921b517833baee465ec82e000de839..221547b9bdb66aa7ff989361f76afc8e4440928c 100644 --- a/addons/crm/tests/test_crm_pls.py +++ b/addons/crm/tests/test_crm_pls.py @@ -360,7 +360,8 @@ class TestCRMPLS(TransactionCase): Lead._cron_update_automated_probabilities() leads_with_tags.invalidate_cache() - self.assertEqual(tools.float_compare(leads[3].automated_probability, 4.21, 2), 0) + self.assertEqual(tools.float_compare(leads[3].automated_probability, 4.21, 2), 0, + f'PLS: failed with {leads[3].automated_probability}, should be 4.21') self.assertEqual(tools.float_compare(leads[8].automated_probability, 0.23, 2), 0) # remove all pls fields diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py index 5f04a5bebbdef19ac7b0be4495d0b97f62547da4..bf8853ba7439ecaf173f61f29508afc687724533 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 @@ -228,24 +228,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) def action_send_badge_email(self): diff --git a/addons/hr_recruitment/models/hr_recruitment.py b/addons/hr_recruitment/models/hr_recruitment.py index a02a2ef535b02ea4b11f613d1dc12b8912f660a9..8be8a4cf4480785061da4a966aad8b3a728347f3 100644 --- a/addons/hr_recruitment/models/hr_recruitment.py +++ b/addons/hr_recruitment/models/hr_recruitment.py @@ -403,7 +403,7 @@ class Applicant(models.Model): if applicant.partner_id: applicant._message_add_suggested_recipient(recipients, partner=applicant.partner_id, 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')) @@ -443,18 +443,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_channel.py b/addons/mail/models/mail_channel.py index b715705faf369b59167b6f94d751e863df8fc3e1..64ea0f92a9e430e97bed48b8954a7f63f1154fb4 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -9,7 +9,7 @@ from uuid import uuid4 from odoo import _, api, fields, models, modules, tools from odoo.exceptions import UserError, ValidationError from odoo.osv import expression -from odoo.tools import ormcache, formataddr +from odoo.tools import ormcache from odoo.exceptions import AccessError from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG from odoo.tools import html_escape @@ -378,7 +378,7 @@ class Channel(models.Model): # real mailing list: multiple recipients (hidden by X-Forge-To) if self.alias_domain and self.alias_name: return { - 'email_to': ','.join(formataddr((partner.name, partner.email_normalized)) for partner in whitelist if partner.email_normalized), + 'email_to': ','.join(partner.email_formatted for partner in whitelist if partner.email_normalized), 'recipient_ids': [], } return super(Channel, self)._notify_email_recipient_values(whitelist.ids) diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 80d1ece80f38fe6783f6594da854ff52dca33a3e..b29ccc946c90ead3ee64250f5277e41265208b47 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -206,7 +206,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 = { @@ -354,11 +361,15 @@ 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 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 05231920c49de794a7deee247e1fea965dbd7659..287cda96dde407fe1d6e8709cd0d9c709a47e561 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -154,7 +154,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 43111c60a7172f2afbbceb0937f99c7e692b92cb..4b72f366aecbb18fa74e6e73f2e9521ebe81fc0e 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1513,6 +1513,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] @@ -1527,9 +1528,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): @@ -1550,7 +1551,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)]) @@ -1608,7 +1609,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 @@ -1625,6 +1626,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; @@ -1644,8 +1648,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: @@ -1666,7 +1675,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 51ac2f356def602c821fb176d14fe53339087b92..a03b2495d258199a8335a95bd662d59a3d8ceaf8 100644 --- a/addons/mail/models/models.py +++ b/addons/mail/models/models.py @@ -64,14 +64,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 20c2c24cb9511dcb74b16e97288172dabab2103c..9e9c139b92dfc008bb57a057e47a398f083bf838 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -155,7 +155,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_res_partner.py b/addons/mail/tests/test_res_partner.py index 97828c0981df4c622d21ce2332e21519bb06ef2b..18ec6eea73537b086d5b1d485edd423987ab499a 100644 --- a/addons/mail/tests/test_res_partner.py +++ b/addons/mail/tests/test_res_partner.py @@ -3,8 +3,10 @@ from odoo.addons.mail.tests.common import MailCommon from odoo.tests.common import Form, users +from odoo.tests import tagged +@tagged('res_partner') class TestPartner(MailCommon): def test_res_partner_find_or_create(self): @@ -27,6 +29,82 @@ class TestPartner(MailCommon): self.assertEqual(new2.email, '2patrick@example.com') self.assertEqual(new2.email_normalized, '2patrick@example.com') + @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/mass_mailing/models/mailing_contact.py b/addons/mass_mailing/models/mailing_contact.py index 27ce87a24fff7969522821a10d67666afcb5b284..fe34500a51a060a02caf9196e139b937882576b2 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 @@ -162,9 +162,10 @@ 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 } diff --git a/addons/mass_mailing/tests/common.py b/addons/mass_mailing/tests/common.py index 9cb2b9bf1f076b499ab49e116387630fa03fdc2c..09f4508326a616482e6f13f7c2f2dfd17baf2b39 100644 --- a/addons/mass_mailing/tests/common.py +++ b/addons/mass_mailing/tests/common.py @@ -48,6 +48,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; }, { ... }] @@ -97,6 +99,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') state = recipient_info.get('state', 'sent') record = record or recipient_info.get('record') @@ -150,7 +153,7 @@ class MassMailCase(MailCase, MockLinkTracker): ) else: self.assertMailMailWEmails( - [email], state_mapping[state], + [email_to_mail], state_mapping[state], author=author, content=content, email_to_recipients=email_to_recipients, diff --git a/addons/mass_mailing/wizard/mail_compose_message.py b/addons/mass_mailing/wizard/mail_compose_message.py index 8ba5c783a74f3e6f4e334b7b2a436b276daffb78..d9eaf6101c94583ef7c0949a102f0c33f0f25575 100644 --- a/addons/mass_mailing/wizard/mail_compose_message.py +++ b/addons/mass_mailing/wizard/mail_compose_message.py @@ -60,12 +60,12 @@ class MailComposeMessage(models.TransientModel): for res_id in res_ids: mail_values = res[res_id] if mail_values.get('email_to'): - mail_to = tools.email_normalize(mail_values['email_to']) + mail_to = tools.email_normalize(mail_values['email_to'], force_single=False) else: partner_id = (mail_values.get('recipient_ids') or [(False, '')])[0][1] - mail_to = tools.email_normalize(partners_email.get(partner_id)) + mail_to = tools.email_normalize(partners_email.get(partner_id), force_single=False) if (opt_out_list and mail_to in opt_out_list) or (seen_list and mail_to in seen_list) \ - or (not mail_to or not email_re.findall(mail_to)): + or not mail_to: # prevent sending to blocked addresses that were included by mistake mail_values['state'] = 'cancel' elif seen_list is not None: diff --git a/addons/project/models/project.py b/addons/project/models/project.py index e16de398fee92b00410943e05967ae482a8cd603..833659214bbb8eed6d66826233014129703630bc 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -1360,12 +1360,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/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index 29bbff5c7770b6457889e922e19c6a8a30dcc24a..8dd9e4d2110e442493e7cf9f652898571bc609d0 100644 --- a/addons/survey/wizard/survey_invite.py +++ b/addons/survey/wizard/survey_invite.py @@ -20,7 +20,7 @@ class SurveyInvite(models.TransientModel): @api.model def _get_default_from(self): if self.env.user.email: - return tools.formataddr((self.env.user.name, self.env.user.email)) + return self.env.user.email_formatted raise UserError(_("Unable to post message, please configure the sender's email address.")) @api.model diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py index 9b0e15b3337fc0a43ada9f340b6b525d102662b7..3237833991845ff4742a34de4a9d15ad77bd67df 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 631462616cf8f09b426c3ada9423d58a2f25b29f..d4baf2016e83d94b58d84149b5637cb737e5ad02 100644 --- a/addons/test_mail/tests/__init__.py +++ b/addons/test_mail/tests/__init__.py @@ -13,6 +13,7 @@ from . import test_mail_gateway from . import test_mail_multicompany from . import test_mail_template_preview from . import test_mail_thread_internals +from . import test_mail_thread_mixins from . import test_mail_template from . import test_mail_tools 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 e6d4b00374c1ec932329c006615d90a2b26bca83..68f17739a75605bf54968ea38a3ba178682644b8 100644 --- a/addons/test_mail/tests/test_mail_activity.py +++ b/addons/test_mail/tests/test_mail_activity.py @@ -366,41 +366,42 @@ class TestActivityMixin(TestActivityCommon): record = self.env['mail.test.activity'].search([('my_activity_date_deadline', '=', date_today)]) 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)] @@ -415,8 +416,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 a1bc46eee09b990a76d3cf05e6d44e091ba099b0..b4af1a5b201ac4c85584c6b8a291a19a91791334 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -380,7 +380,7 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.mail_server_id.id, False) @users('employee') - @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') def test_mail_composer_parent(self): """ Test specific management in comment mode when having parent_id set: record_name, subject, parent's partners. """ @@ -397,6 +397,7 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.body, '<p>Test Body</p>') self.assertEqual(composer.partner_ids, self.partner_1 + self.partner_2) + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') def test_mail_composer_rights_portal(self): portal_user = self._create_portal_user() @@ -447,7 +448,7 @@ class TestComposerResultsComment(TestMailComposer): notification and emails generated during this process. """ @users('employee') - @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') def test_mail_composer_notifications_delete(self): """ Notifications are correctly deleted once sent """ composer = self.env['mail.compose.message'].with_context( @@ -489,7 +490,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') + @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( @@ -605,6 +606,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 | safe}', + '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.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): @@ -908,3 +1049,138 @@ class TestComposerResultsMass(TestMailComposer): composer = composer_form.save() with self.mock_mail_gateway(mail_unlink_sent=False), self.assertRaises(ValueError): composer.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 | safe}', + '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.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 + ) diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py index 4eb22aa18620bba000e6c13d96f33fcfc161e843..6ed94dce423aa4f063633b71f76ec26d2b4d5ddc 100644 --- a/addons/test_mail/tests/test_mail_gateway.py +++ b/addons/test_mail/tests/test_mail_gateway.py @@ -430,6 +430,31 @@ 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') + 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 """ @@ -621,6 +646,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') + 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 """ @@ -1573,6 +1631,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. @@ -1598,6 +1657,7 @@ class TestMailgateway(TestMailCommon): self.assertEqual(record.message_main_attachment_id.name, 'bis3.xml') self.assertEqual("<Invoice>Chaussée de Bruxelles</Invoice>", record.message_main_attachment_id.raw.decode()) +@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 3ebee26330b69d8a5e57d7200e47bfaff3380c53..194baa3688acaa6fb82c71930328d12e8c38a79f 100644 --- a/addons/test_mail/tests/test_mail_mail.py +++ b/addons/test_mail/tests/test_mail_mail.py @@ -3,7 +3,7 @@ import psycopg2 -from odoo import api +from odoo import api, tools from odoo.addons.test_mail.tests.common import TestMailCommon from odoo.tests import common, tagged from odoo.tools import mute_logger @@ -23,7 +23,7 @@ class TestMailMail(TestMailCommon): }).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>', @@ -35,6 +35,7 @@ class TestMailMail(TestMailCommon): self.assertSentEmail(mail.env.user.partner_id, ['test@example.com']) self.assertEqual(len(self._mails), 1) + @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_return_path(self): # mail without thread-enabled record base_values = { @@ -74,7 +75,116 @@ class TestMailMail(TestMailCommon): mail.send() self.assertEqual(self._mails[0]['headers']['Return-Path'], '%s@%s' % (self.alias_bounce, self.alias_domain)) + @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_mail_tools.py b/addons/test_mail/tests/test_mail_tools.py index b36a1335c5d3c450cac0c62ef8f3dcc45c84de1f..5a62de2a327653cb7c62aa6f301a5ed7c1873af7 100644 --- a/addons/test_mail/tests/test_mail_tools.py +++ b/addons/test_mail/tests/test_mail_tools.py @@ -22,62 +22,124 @@ class TestMailTools(TestMailCommon, TestRecipients): }) @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 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]) + test_partner.sudo().write({'email': f'"Alfred Mighty Power Astaire" <{self._test_email}>'}) + + 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. """ + linked_record = self.env['mail.test.simple'].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') diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index 14673a99ca944fa99b4dca88365ad7c72dfd8e6f..cabdbadef694e882439a4e8f26d96ed67677b530 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -37,12 +37,13 @@ class TestMessagePost(TestMailCommon, TestRecipients): self.assertTrue(isinstance(messageId, int)) @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 @@ -50,13 +51,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) @@ -203,7 +204,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' @@ -238,6 +239,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]) @@ -251,11 +298,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 = [ @@ -357,7 +399,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 54ede3f8e14adc7de1d2d09fbe9f5f964a840660..8748f4792110b56443436d88d97a1bf577654ae5 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 @@ -51,6 +95,19 @@ class MailingOptOut(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 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 41fc0f1df3fea14aa5deda5b6262d5fa1a1999ce..34dabd4b451b3531532aeefd8426fc7cdca1417b 100644 --- a/addons/test_mass_mailing/tests/test_mailing.py +++ b/addons/test_mass_mailing/tests/test_mailing.py @@ -119,6 +119,138 @@ 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 + ) = 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>', + } + ]) + 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 + ) + 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': 'thread', + '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', + '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, + 'state': 'sent'}, + {'email': '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, + 'state': 'sent'}, + {'email': '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, + 'state': '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, + 'state': 'sent'}, # lower cased + {'email': 'test.customer.weird@example.comweirdformat', + 'email_to_recipients': [[f'"{customer_weird.name}" <test.customer.weird@example.comweirdformat>']], + 'failure_type': False, + 'partner': customer_weird, + 'state': 'sent'}, # concatenates everything after domain + {'email': '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, + 'state': '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, + 'state': 'sent'}, + {'email': 'record.format@example.com', + 'failure_type': False, + 'state': 'sent'}, + {'email': 'record.😊@example.com', + 'failure_type': False, + 'state': '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_crm/views/website_visitor_views.xml b/addons/website_crm/views/website_visitor_views.xml index 4674f2c9cffbc05f4d885dd9e2eb1e187ce03282..993a2f1025d1f6b231ad7d659eecf1f66b2186b3 100644 --- a/addons/website_crm/views/website_visitor_views.xml +++ b/addons/website_crm/views/website_visitor_views.xml @@ -60,10 +60,10 @@ <field name="lead_count"/> </field> <xpath expr="//div[@id='o_page_count']" position="after"> - <div>Leads/Opportunities<span class="float-right font-weight-bold"><field name="lead_count"/></span></div> + <div groups="sales_team.group_sale_salesman">Leads/Opportunities<span class="float-right font-weight-bold"><field name="lead_count"/></span></div> </xpath> <xpath expr="//div[hasclass('w_visitor_kanban_actions_ungrouped')]" position="before"> - <div class="col"> + <div class="col" groups="sales_team.group_sale_salesman"> <b><field name="lead_count"/></b> <div>Leads/Opportunities</div> </div> diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py index ea62618405b6f6dd32fb42c67f29749df84567c1..6421f77e83c8b904002a2bba07308c0efe781ab5 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 @@ -394,13 +394,18 @@ class Track(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.partner_email) + email_normalized = tools.email_normalize(self.partner_email) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.partner_email or (email_normalized and partner.email_normalized == email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('partner_email', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('partner_email', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('partner_email', '=', new_partner.email), - ('stage_id.is_cancel', '=', False), - ]).write({'partner_id': new_partner.id}) + ('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) # ------------------------------------------------------------ diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py index 482be79bb6ba07f2fe4c7140ef4c9d2e9c0a48c5..4ad5f15d5ddc3e4fcadcaae28a3adb66a01e4700 100644 --- a/odoo/addons/base/models/ir_module.py +++ b/odoo/addons/base/models/ir_module.py @@ -147,6 +147,12 @@ STATES = [ ('to install', 'To be installed'), ] +XML_DECLARATION = ( + '<?xml version='.encode('utf-8'), + '<?xml version='.encode('utf-16-be'), + '<?xml version='.encode('utf-16-le'), +) + class Module(models.Model): _name = "ir.module.module" _rec_name = "shortdesc" @@ -180,6 +186,11 @@ class Module(models.Model): if path: with tools.file_open(path, 'rb') as desc_file: doc = desc_file.read() + if not doc.startswith(XML_DECLARATION): + try: + doc = doc.decode('utf-8') + except UnicodeDecodeError: + pass html = lxml.html.document_fromstring(doc) for element, attribute, link, pos in html.iterlinks(): if element.get('src') and not '//' in element.get('src') and not 'static/' in element.get('src'): diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 101b15314dc3770398b0fad1bf7863ebd5ae81d3..5d8b93a774212d070af213f1b87967085b37380d 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -134,7 +134,7 @@ class Partner(models.Model): _description = 'Contact' _inherit = ['format.address.mixin', 'image.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')) @@ -379,11 +379,36 @@ class Partner(models.Model): @api.depends('name', 'email') def _compute_email_formatted(self): + """ Compute formatted email for partner, using formataddr. Be defensive + in computation, notably + + * double format: if email already holds a formatted email like + 'Name' <email@domain.com> we should not use it as it to compute + email formatted like "Name <'Name' <email@domain.com>>"; + * multi emails: sometimes this field is used to hold several addresses + like email1@domain.com, email2@domain.com. We currently let this value + untouched, but remove any formatting from multi emails; + * invalid email: if something is wrong, keep it in email_formatted as + this eases management and understanding of failures at mail.mail, + mail.notification and mailing.trace level; + * void email: email_formatted is False, as we cannot do anything with + it; + """ + self.email_formatted = False for partner in self: - if partner.email: - partner.email_formatted = tools.formataddr((partner.name or u"False", partner.email or u"False")) - else: - partner.email_formatted = '' + emails_normalized = tools.email_normalize_all(partner.email) + if emails_normalized: + # note: multi-email input leads to invalid email like "Name" <email1, email2> + # but this is current behavior in Odoo 14+ and some servers allow it + partner.email_formatted = tools.formataddr(( + partner.name or u"False", + ','.join(emails_normalized) + )) + elif partner.email: + partner.email_formatted = tools.formataddr(( + partner.name or u"False", + partner.email + )) @api.depends('is_company') def _compute_company_type(self): @@ -699,7 +724,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. @@ -709,12 +736,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 f28d1f7c2e5c2c08f3602f9ba102121494692cb2..67dde0af0af1cf2b474187fa90c7a37da70e7877 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 from odoo.exceptions import 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 5806e258f6b318e2aa9a9ca52b511c9dee6dcc16..f17264ed00698d9bd5154fbe59c4369cd496aed9 100644 --- a/odoo/addons/base/tests/test_expression.py +++ b/odoo/addons/base/tests/test_expression.py @@ -1086,7 +1086,7 @@ class TestQueries(TransactionCase): ) ) )) - ORDER BY "res_partner"."display_name" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): Model.search(domain) @@ -1098,7 +1098,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')]) @@ -1201,7 +1201,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)]) @@ -1213,7 +1213,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)]) @@ -1229,7 +1229,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)]) @@ -1245,7 +1245,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([ '|', @@ -1264,7 +1264,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)]) @@ -1280,7 +1280,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)]) @@ -1297,7 +1297,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)]) @@ -1311,7 +1311,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)]) @@ -1330,7 +1330,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)]) @@ -1347,7 +1347,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)]) @@ -1369,7 +1369,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([ '|', @@ -1388,7 +1388,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)]) @@ -1417,7 +1417,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)]) @@ -1429,7 +1429,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')]) @@ -1445,7 +1445,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')]) @@ -1462,7 +1462,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)]) @@ -1474,7 +1474,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')]) @@ -1490,7 +1490,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'), @@ -1509,7 +1509,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')]) @@ -1539,7 +1539,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)]) @@ -1565,7 +1565,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')]) @@ -1580,7 +1580,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 01009f5d3c3b015e708a98295b42e5e793d7a212..3df1576325c9cad5ceeebd8f789345c8b47047dc 100644 --- a/odoo/addons/base/tests/test_mail.py +++ b/odoo/addons/base/tests/test_mail.py @@ -8,10 +8,11 @@ import email.message import re import threading +from odoo.tests import tagged from odoo.tests.common import BaseCase, SavepointCase, TransactionCase from odoo.tools import ( is_html_empty, html_sanitize, append_content_to_html, plaintext2html, - email_split, + email_normalize, email_split, email_split_and_format, misc, formataddr, prepend_html_content, ) @@ -388,10 +389,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 @@ -402,6 +460,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 28e6f172f9565a9d010bd9fb6201bb7591fffc5d..3160db404fae627b506e5975ee2884f78a5b7a17 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 3562b1b726c327268e9914cee0dafd41957e236d..77983c8f71acab003b499d4641861afe8be18d15 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -459,7 +459,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 @@ -515,14 +514,35 @@ def email_send(email_from, email_to, subject, body, email_cc=None, email_bcc=Non def email_split_tuples(text): """ Return a list of (name, email) addresse tuples found in ``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`` """ @@ -537,20 +557,61 @@ 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_escape_char(email_address): """ Escape problematic characters in the given email address string"""