diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index ec0729afa35112cbed399822ad2e5ebb1bb61aae..f0664e39334a3d0f3c4a93054d480614c0d13b79 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -15,7 +15,7 @@ from odoo.addons.phone_validation.tools import phone_validation from odoo.exceptions import UserError, AccessError from odoo.osv import expression from odoo.tools.translate import _ -from odoo.tools import date_utils, email_re, email_split, is_html_empty, groupby +from odoo.tools import date_utils, email_split, is_html_empty, groupby from odoo.tools.misc import get_lang from . import crm_stage @@ -1912,11 +1912,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() @@ -1965,23 +1967,41 @@ class Lead(models.Model): # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email_from or (self.email_normalized and partner.email_normalized == self.email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email_normalized', '=', new_partner[0].email_normalized) + else: + email_domain = ('email_from', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email_from', '=', new_partner.email), - ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False) + ]).write({'partner_id': new_partner[0].id}) return super(Lead, self)._message_post_after_hook(message, msg_vals) def _message_partner_info_from_emails(self, emails, link_mail=False): + """ Try to propose a better recipient when having only an email by populating + it with the partner_name / contact_name field of the lead e.g. if lead + contact_name is "Raoul" and email is "raoul@raoul.fr", suggest + "Raoul" <raoul@raoul.fr> as recipient. """ result = super(Lead, self)._message_partner_info_from_emails(emails, link_mail=link_mail) - for partner_info in result: - if not partner_info.get('partner_id') and (self.partner_name or self.contact_name): - emails = email_re.findall(partner_info['full_name'] or '') - email = emails and emails[0] or '' - if email and self.email_from and email.lower() == self.email_from.lower(): - partner_info['full_name'] = tools.formataddr((self.contact_name or self.partner_name, email)) - break + for email, partner_info in zip(emails, result): + if partner_info.get('partner_id') or not email or not (self.partner_name or self.contact_name): + continue + # reformat email if no name information + name_emails = tools.email_split_tuples(email) + name_from_email = name_emails[0][0] if name_emails else False + if name_from_email: + continue # already containing name + email + name_from_email = self.partner_name or self.contact_name + emails_normalized = tools.email_normalize_all(email) + email_normalized = emails_normalized[0] if emails_normalized else False + if email.lower() == self.email_from.lower() or (email_normalized and self.email_normalized == email_normalized): + partner_info['full_name'] = tools.formataddr(( + name_from_email, + ','.join(emails_normalized) if emails_normalized else email)) + break return result def _phone_get_number_fields(self): diff --git a/addons/crm/tests/test_crm_lead_notification.py b/addons/crm/tests/test_crm_lead_notification.py index 7cc3143d7b6e5749a68fb6769af196f8f1c2c817..41a9c765c3acbca61180cddb2a64118edf86f5d0 100644 --- a/addons/crm/tests/test_crm_lead_notification.py +++ b/addons/crm/tests/test_crm_lead_notification.py @@ -1,11 +1,52 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from .common import TestCrmCommon +from odoo.addons.crm.tests.common import TestCrmCommon +from odoo.tests import tagged, users +from odoo.tools import mute_logger +@tagged('mail_thread', 'mail_gateway') class NewLeadNotification(TestCrmCommon): + @users('user_sales_manager') + def test_lead_message_get_suggested_recipient(self): + """ Test '_message_get_suggested_recipients' and its override in lead. """ + lead_format, lead_multi, lead_from, lead_partner = self.env['crm.lead'].create([ + { + 'email_from': '"New Customer" <new.customer.format@test.example.com>', + 'name': 'Test Suggestion (email_from with format)', + 'partner_name': 'Format Name', + 'user_id': self.user_sales_leads.id, + }, { + 'email_from': 'new.customer.multi.1@test.example.com, new.customer.2@test.example.com', + 'name': 'Test Suggestion (email_from multi)', + 'partner_name': 'Multi Name', + 'user_id': self.user_sales_leads.id, + }, { + 'email_from': 'new.customer.simple@test.example.com', + 'name': 'Test Suggestion (email_from)', + 'partner_name': 'Std Name', + 'user_id': self.user_sales_leads.id, + }, { + 'name': 'Test Suggestion (partner_id)', + 'partner_id': self.contact_1.id, + 'user_id': self.user_sales_leads.id, + } + ]) + for lead, expected_suggested in zip( + lead_format + lead_multi + lead_from + lead_partner, + [(False, '"New Customer" <new.customer.format@test.example.com>', None, 'Customer Email'), + (False, '"Multi Name" <new.customer.multi.1@test.example.com,new.customer.2@test.example.com>', None, 'Customer Email'), + (False, '"Std Name" <new.customer.simple@test.example.com>', None, 'Customer Email'), + (self.contact_1.id, '"Philip J Fry" <philip.j.fry@test.example.com>', self.contact_1.lang, 'Customer'), + ] + ): + with self.subTest(lead=lead, email_from=lead.email_from): + res = lead._message_get_suggested_recipients()[lead.id] + self.assertEqual(len(res), 1) + self.assertEqual(res[0], expected_suggested) + def test_new_lead_notification(self): """ Test newly create leads like from the website. People and channels subscribed to the Sales Team shoud be notified. """ @@ -39,6 +80,7 @@ class NewLeadNotification(TestCrmCommon): lead_user = lead.with_user(self.user_sales_manager) self.assertTrue(lead_user.message_needaction) + @mute_logger('odoo.addons.mail.models.mail_thread') def test_new_lead_from_email_multicompany(self): company0 = self.env.company company1 = self.env['res.company'].create({'name': 'new_company'}) diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py index 7b9cf027632e19f341fa60bd501582ece64f1040..b5015e8f5ad2a95bf86cbf5fcf984e8c8e5f8180 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_date +from odoo.tools import format_date, email_normalize, email_normalize_all from odoo.exceptions import AccessError, ValidationError # phone_validation is not officially in the depends of event, but we would like @@ -326,24 +326,31 @@ class EventRegistration(models.Model): def _message_get_default_recipients(self): # Prioritize registration email over partner_id, which may be shared when a single # partner booked multiple seats - return {r.id: { - 'partner_ids': [], - 'email_to': r.email, - 'email_cc': False} - for r in self} + return {r.id: + { + 'partner_ids': [], + 'email_to': ','.join(email_normalize_all(r.email)) or r.email, + 'email_cc': False, + } for r in self + } def _message_post_after_hook(self, message, msg_vals): if self.email and not self.partner_id: # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email) + email_normalized = email_normalize(self.email) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email or (email_normalized and partner.email_normalized == email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('email', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email', '=', new_partner.email), - ('state', 'not in', ['cancel']), - ]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('state', 'not in', ['cancel']), + ]).write({'partner_id': new_partner[0].id}) return super(EventRegistration, self)._message_post_after_hook(message, msg_vals) # ------------------------------------------------------------ diff --git a/addons/hr_recruitment/models/hr_recruitment.py b/addons/hr_recruitment/models/hr_recruitment.py index 6dca38eaaa04e4ea73705b3f862902d43dd94d5d..9745b036dbe24ffb618071ce7dfa59370dff53f7 100644 --- a/addons/hr_recruitment/models/hr_recruitment.py +++ b/addons/hr_recruitment/models/hr_recruitment.py @@ -591,7 +591,7 @@ class Applicant(models.Model): if applicant.partner_id: applicant._message_add_suggested_recipient(recipients, partner=applicant.partner_id.sudo(), reason=_('Contact')) elif applicant.email_from: - email_from = applicant.email_from + email_from = tools.email_normalize(applicant.email_from) if applicant.partner_name: email_from = tools.formataddr((applicant.partner_name, email_from)) applicant._message_add_suggested_recipient(recipients, email=email_from, reason=_('Contact Email')) @@ -639,19 +639,25 @@ 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', 'name': self.partner_name or self.email_from, 'phone': self.partner_phone, 'mobile': self.partner_mobile, }) + if new_partner[0].email_normalized: + email_domain = ('email_from', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('email_from', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email_from', '=', new_partner.email), - ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False) + ]).write({'partner_id': new_partner[0].id}) return super(Applicant, self)._message_post_after_hook(message, msg_vals) def create_employee_from_applicant(self): diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 211ed5f8cf4fada87b64160a934540a4e5c80db8..c8d88669aa88d7d79f528091ac933b9f95297cef 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -332,7 +332,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 = { @@ -497,13 +504,17 @@ class MailMail(models.Model): # see rev. 56596e5240ef920df14d99087451ce6f06ac6d36 notifs.flush_recordset(['notification_status', 'failure_type', 'failure_reason']) + # protect against ill-formatted email_from when formataddr was used on an already formatted email + emails_from = tools.email_split_and_format(mail.email_from) + email_from = emails_from[0] if emails_from else mail.email_from + # build an RFC2822 email.message.Message object and send it without queuing res = None # TDE note: could be great to pre-detect missing to/cc and skip sending it # to go directly to failed state update for email in email_list: msg = IrMailServer.build_email( - email_from=mail.email_from, + email_from=email_from, email_to=email.get('email_to'), subject=mail.subject, body=email.get('body'), diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 3939d56ae4c70db59b7fbaad7a71de461099a12e..86f8905d1f7833fa00095a2e218613add4fc496c 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -221,7 +221,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 3a4489b11541585fb134cd6463165f6f9d1bc683..b819e5a6cc3736e74e3e7ad314cbe527b96f6a7d 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -685,7 +685,7 @@ class MailThread(models.AbstractModel): 'email_to': bounce_to, 'auto_delete': True, } - bounce_from = self.env['ir.mail_server']._get_default_bounce_address() + bounce_from = tools.email_normalize(self.env['ir.mail_server']._get_default_bounce_address() or '') if bounce_from: bounce_mail_values['email_from'] = tools.formataddr(('MAILER-DAEMON', bounce_from)) elif self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias") not in message['To']: @@ -1612,6 +1612,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] @@ -1626,9 +1627,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, lang, reason)) elif partner: # incomplete profile: id, name - result[self.ids[0]].append((partner.id, '%s' % (partner.name), lang, reason)) + result[self.ids[0]].append((partner.id, partner.name, lang, reason)) else: # unknown partner, we are probably managing an email address - result[self.ids[0]].append((False, email, lang, reason)) + result[self.ids[0]].append((False, partner_info.get('full_name') or email, lang, reason)) return result def _message_get_suggested_recipients(self): @@ -1649,7 +1650,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)]) @@ -1707,7 +1708,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 @@ -1724,6 +1725,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; @@ -1743,8 +1747,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, strict=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: @@ -1765,7 +1774,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, strict=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 c7c32fc1b4f49d34e613e4f8852fe4db316720e5..a1eed2714bf90d49c56e5773c54042b5c80bacff 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], strict=False) @api.model def _search_is_blacklisted(self, operator, value): diff --git a/addons/mail/models/mail_thread_cc.py b/addons/mail/models/mail_thread_cc.py index 0a5de43ca3d914f378f10cfe5086dad47d8a39f4..fb1dcf6f877ceff841923dc74481d48d3a776cb5 100644 --- a/addons/mail/models/mail_thread_cc.py +++ b/addons/mail/models/mail_thread_cc.py @@ -15,8 +15,10 @@ class MailCCMixin(models.AbstractModel): '''return a dict of sanitize_email:raw_email from a string of cc''' if not cc_string: return {} - return {tools.email_normalize(email): tools.formataddr((name, tools.email_normalize(email))) - for (name, email) in tools.email_split_tuples(cc_string)} + return { + tools.email_normalize(email): tools.formataddr((name, tools.email_normalize(email))) + for (name, email) in tools.email_split_tuples(cc_string) + } @api.model def message_new(self, msg_dict, custom_values=None): diff --git a/addons/mail/models/models.py b/addons/mail/models/models.py index 6e2e0efe44d80e4cb0ebdf943d18db467cfe9389..301737d40f2b4dea11665d38bf547c4e1537f157 100644 --- a/addons/mail/models/models.py +++ b/addons/mail/models/models.py @@ -74,14 +74,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 164c3765264f690f2640fff7ffe3ba91a6738fa4..73c34a58de0b2abef9528d6529413fa737431052 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/common.py b/addons/mail/tests/common.py index 3078279376f602dee7a3c6f43b9ace8af6502a4a..6013331d3cbb5946efd144f06cbf49fa3dea1f67 100644 --- a/addons/mail/tests/common.py +++ b/addons/mail/tests/common.py @@ -19,7 +19,7 @@ from odoo.addons.mail.models.mail_mail import MailMail from odoo.addons.mail.models.mail_message import Message from odoo.addons.mail.models.mail_notification import MailNotification from odoo.tests import common, new_test_user -from odoo.tools import formataddr, mute_logger, pycompat +from odoo.tools import email_normalize, formataddr, mute_logger, pycompat from odoo.tools.translate import code_translations mail_new_test_user = partial(new_test_user, context={'mail_create_nolog': True, @@ -580,7 +580,7 @@ class MockEmail(common.BaseCase, MockSmtplibCase): raise NotImplementedError('Unsupported %s' % ', '.join(unknown)) if isinstance(author, self.env['res.partner'].__class__): - expected['email_from'] = formataddr((author.name, author.email)) + expected['email_from'] = formataddr((author.name, email_normalize(author.email, strict=False) or author.email)) else: expected['email_from'] = author @@ -590,7 +590,7 @@ class MockEmail(common.BaseCase, MockSmtplibCase): email_to_list = [] for email_to in recipients: if isinstance(email_to, self.env['res.partner'].__class__): - email_to_list.append(formataddr((email_to.name, email_to.email or 'False'))) + email_to_list.append(formataddr((email_to.name, email_normalize(email_to.email, strict=False) or email_to.email))) else: email_to_list.append(email_to) expected['email_to'] = email_to_list diff --git a/addons/mail/tests/test_mail_tools.py b/addons/mail/tests/test_mail_tools.py index f66a586a93e9d4f29d1643a84014e8749bd1a2d9..88c68f6f566bddea4632103ca3c80d0b6dd72ade 100644 --- a/addons/mail/tests/test_mail_tools.py +++ b/addons/mail/tests/test_mail_tools.py @@ -47,65 +47,128 @@ class TestMailTools(MailCommon): ] @users('employee') - def test_find_partner_from_emails(self): + def test_mail_find_partner_from_emails(self): Partner = self.env['res.partner'] test_partner = Partner.browse(self.test_partner.ids) self.assertEqual(test_partner.email, self._test_email) - # test direct match - found = Partner._mail_find_partner_from_emails([self._test_email]) - self.assertEqual(found, [test_partner]) - - # test encapsulated email - found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email]) - self.assertEqual(found, [test_partner]) + sources = [ + self._test_email, # test direct match + f'"Norbert Poiluchette" <{self._test_email}>', # encapsulated + 'fredoastaire@test.example.com', # partial email -> should not match ! + ] + expected_partners = [ + test_partner, + test_partner, + self.env['res.partner'], + ] + for source, expected_partner in zip(sources, expected_partners): + with self.subTest(source=source): + found = Partner._mail_find_partner_from_emails([source]) + self.assertEqual(found, [expected_partner]) # test with wildcard "_" found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com']) self.assertEqual(found, [self.env['res.partner']]) - # sub-check: this search does not consider _ as a wildcard found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com']) self.assertEqual(found, self.env['res.partner']) # test partners with encapsulated emails # ------------------------------------------------------------ - test_partner.sudo().write({'email': '"Alfred Mighty Power Astaire" <%s>' % self._test_email}) + test_partner.sudo().write({'email': f'"Alfred Mighty Power Astaire" <{self._test_email}>'}) - # test direct match - found = Partner._mail_find_partner_from_emails([self._test_email]) - self.assertEqual(found, [test_partner]) - - # test encapsulated email - found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email]) - self.assertEqual(found, [test_partner]) + sources = [ + self._test_email, # test direct match + f'"Norbert Poiluchette" <{self._test_email}>', # encapsulated + ] + expected_partners = [ + test_partner, + test_partner, + ] + for source, expected_partner in zip(sources, expected_partners): + with self.subTest(source=source): + found = Partner._mail_find_partner_from_emails([source]) + self.assertEqual(found, [expected_partner]) # test with wildcard "_" found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com']) self.assertEqual(found, [self.env['res.partner']]) - # sub-check: this search does not consider _ as a wildcard found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com']) self.assertEqual(found, self.env['res.partner']) - # test partners with look-alike emails - # ------------------------------------------------------------ - for email_lookalike in [ - 'alfred.astaire@test.example.com', - 'alfredoastaire@example.com', - 'aalfredoastaire@test.example.com', - 'alfredoastaire@test.example.comm']: - test_partner.sudo().write({'email': '"Alfred Astaire" <%s>' % email_lookalike}) - - # test direct match - found = Partner._mail_find_partner_from_emails([self._test_email]) - self.assertEqual(found, [self.env['res.partner']]) - # test encapsulated email - found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email]) - self.assertEqual(found, [self.env['res.partner']]) - # test with wildcard "_" - found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com']) - self.assertEqual(found, [self.env['res.partner']]) + @users('employee') + def test_mail_find_partner_from_emails_followers(self): + """ Test '_mail_find_partner_from_emails' when dealing with records on + which followers have to be found based on email. Check multi email + and encapsulated email support. """ + # create partner just for the follow mechanism + linked_record = self.env['res.partner'].sudo().create({'name': 'Record for followers'}) + follower_partner = self.env['res.partner'].sudo().create({ + 'email': self._test_email, + 'name': 'Duplicated, follower of record', + }) + linked_record.message_subscribe(partner_ids=follower_partner.ids) + test_partner = self.test_partner.with_env(self.env) + + # standard test, no multi-email, to assert base behavior + sources = [(self._test_email, True), (self._test_email, False),] + expected = [follower_partner, test_partner] + for (source, follower_check), expected in zip(sources, expected): + with self.subTest(source=source, follower_check=follower_check): + partner = self.env['res.partner']._mail_find_partner_from_emails( + [source], records=linked_record if follower_check else None + )[0] + self.assertEqual(partner, expected) + + # formatted email + encapsulated_test_email = f'"Robert Astaire" <{self._test_email}>' + (follower_partner + test_partner).sudo().write({'email': encapsulated_test_email}) + sources = [ + (self._test_email, True), # normalized + (self._test_email, False), # normalized + (encapsulated_test_email, True), # encapsulated, same + (encapsulated_test_email, False), # encapsulated, same + (f'"AnotherName" <{self._test_email}', True), # same normalized, other name + (f'"AnotherName" <{self._test_email}', False), # same normalized, other name + ] + expected = [follower_partner, test_partner, + follower_partner, test_partner, + follower_partner, test_partner, + follower_partner, test_partner] + for (source, follower_check), expected in zip(sources, expected): + with self.subTest(source=source, follower_check=follower_check): + partner = self.env['res.partner']._mail_find_partner_from_emails( + [source], records=linked_record if follower_check else None + )[0] + self.assertEqual(partner, expected, + 'Mail: formatted email is recognized through usage of normalized email') + + # multi-email + _test_email_2 = '"Robert Astaire" <not.alfredoastaire@test.example.com>' + (follower_partner + test_partner).sudo().write({'email': f'{self._test_email}, {_test_email_2}'}) + sources = [ + (self._test_email, True), # first email + (self._test_email, False), # first email + (_test_email_2, True), # second email + (_test_email_2, False), # second email + ('not.alfredoastaire@test.example.com', True), # normalized second email in field + ('not.alfredoastaire@test.example.com', False), # normalized second email in field + (f'{self._test_email}, {_test_email_2}', True), # multi-email, both matching, depends on comparison + (f'{self._test_email}, {_test_email_2}', False) # multi-email, both matching, depends on comparison + ] + expected = [follower_partner, test_partner, + self.env['res.partner'], self.env['res.partner'], + self.env['res.partner'], self.env['res.partner'], + follower_partner, test_partner] + for (source, follower_check), expected in zip(sources, expected): + with self.subTest(source=source, follower_check=follower_check): + partner = self.env['res.partner']._mail_find_partner_from_emails( + [source], records=linked_record if follower_check else None + )[0] + self.assertEqual(partner, expected, + 'Mail (FIXME): partial recognition of multi email through email_normalize') @users('employee') def test_tools_email_re(self): @@ -152,14 +215,14 @@ class TestMailTools(MailCommon): [('Fredo The Great', 'alfred.astaire@test.example.com'), ('Evelyne The Goat', 'evelyne.gargouillis@test.example.com')], [('Fredo The Great', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')], [('Fredo The Great', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')], - # text containing email -> probably not designed for that - [('', 'Hello alfred.astaire@test.example.comhowareyou?')], - [('', 'Hello alfred.astaire@test.example.com')], - # text containing emails -> probably not designed for that + # text containing email -> fallback on parsing to extract text from email + [('Hello', 'alfred.astaire@test.example.comhowareyou?')], + [('Hello', 'alfred.astaire@test.example.com')], [('Hello Fredo', 'alfred.astaire@test.example.com'), ('', 'evelyne.gargouillis@test.example.com')], - [('Hello Fredo', 'alfred.astaire@test.example.com'), ('', 'and evelyne.gargouillis@test.example.com')], + [('Hello Fredo', 'alfred.astaire@test.example.com'), ('and', 'evelyne.gargouillis@test.example.com')], # falsy -> probably not designed for that - [], [('', "j'adore écrire des@gmail.comou"), ('', '@gmail.com')], [], + [], + [('j\'adore écrire', "des@gmail.comou"), ('', '@gmail.com')], [], ] for src, exp in zip(self.sources, expected): diff --git a/addons/mail/tests/test_res_partner.py b/addons/mail/tests/test_res_partner.py index 8354c7c32f9fbdeed2b075d17d1d16f3a68b961e..9cb035c47b648ea21c3e16c5da0efac5d7caefd4 100644 --- a/addons/mail/tests/test_res_partner.py +++ b/addons/mail/tests/test_res_partner.py @@ -5,6 +5,7 @@ from uuid import uuid4 from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user from odoo.tests.common import Form, users +from odoo.tests import tagged # samples use effective TLDs from the Mozilla public suffix @@ -22,6 +23,7 @@ SAMPLES = [ ] +@tagged('res_partner', 'mail_tools') class TestPartner(MailCommon): def _check_find_or_create(self, test_string, expected_name, expected_email, expected_email_normalized=False, check_partner=False, should_create=False): @@ -36,6 +38,7 @@ class TestPartner(MailCommon): self.assertEqual(partner.email_normalized or '', expected_email_normalized) return partner + @users('admin') def test_res_partner_find_or_create(self): Partner = self.env['res.partner'] @@ -98,6 +101,94 @@ class TestPartner(MailCommon): with self.assertRaises(ValueError): self.env['res.partner'].find_or_create("Raoul chirurgiens-dentistes.fr", assert_valid_email=True) + @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 + + def test_res_partner_get_mention_suggestions_priority(self): + name = uuid4() # unique name to avoid conflict with already existing users + self.env['res.partner'].create([{'name': f'{name}-{i}-not-user'} for i in range(0, 2)]) + for i in range(0, 2): + mail_new_test_user(self.env, login=f'{name}-{i}-portal-user', groups='base.group_portal') + mail_new_test_user(self.env, login=f'{name}-{i}-internal-user', groups='base.group_user') + partners_format = self.env['res.partner'].get_mention_suggestions(name, limit=5) + self.assertEqual(len(partners_format), 5, "should have found limit (5) partners") + # return format for user is either a dict (there is a user and the dict is data) or a list of command (clear) + self.assertEqual(list(map(lambda p: isinstance(p['user'], dict) and p['user']['isInternalUser'], partners_format)), [True, True, False, False, False], "should return internal users in priority") + self.assertEqual(list(map(lambda p: isinstance(p['user'], dict), partners_format)), [True, True, True, True, False], "should return partners without users last") + def test_res_partner_log_portal_group(self): Users = self.env['res.users'] subtype_note = self.env.ref('mail.mt_note') @@ -131,18 +222,6 @@ class TestPartner(MailCommon): self.assertIn('Portal Access Granted', new_msg.body) self.assertEqual(new_msg.subtype_id, subtype_note) - def test_res_partner_get_mention_suggestions_priority(self): - name = uuid4() # unique name to avoid conflict with already existing users - self.env['res.partner'].create([{'name': f'{name}-{i}-not-user'} for i in range(0, 2)]) - for i in range(0, 2): - mail_new_test_user(self.env, login=f'{name}-{i}-portal-user', groups='base.group_portal') - mail_new_test_user(self.env, login=f'{name}-{i}-internal-user', groups='base.group_user') - partners_format = self.env['res.partner'].get_mention_suggestions(name, limit=5) - self.assertEqual(len(partners_format), 5, "should have found limit (5) partners") - # return format for user is either a dict (there is a user and the dict is data) or a list of command (clear) - self.assertEqual(list(map(lambda p: isinstance(p['user'], dict) and p['user']['isInternalUser'], partners_format)), [True, True, False, False, False], "should return internal users in priority") - self.assertEqual(list(map(lambda p: isinstance(p['user'], dict), partners_format)), [True, True, True, True, False], "should return partners without users last") - @users('admin') def test_res_partner_merge_wizards(self): Partner = self.env['res.partner'] diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index ee58cb9f1fed50d82bf5cd9710138aaaed62ff0e..47a799e993ce84f34ae6d831572952f3449fd8c4 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -8,7 +8,6 @@ import re from odoo import _, api, fields, models, tools, Command from odoo.exceptions import UserError from odoo.osv import expression -from odoo.tools import email_re def _reopen(self, res_id, model, context=None): @@ -466,25 +465,27 @@ class MailComposer(models.TransientModel): recipients_info = {} for record_id, mail_values in mail_values_dict.items(): - mail_to = [] - if mail_values.get('email_to'): - mail_to += email_re.findall(mail_values['email_to']) - # if unrecognized email in email_to -> keep it as used for further processing - if not mail_to: - mail_to.append(mail_values['email_to']) + # add email from email_to; if unrecognized email in email_to keep + # it as used for further processing + mail_to = tools.email_split_and_format(mail_values.get('email_to')) + if not mail_to and mail_values.get('email_to'): + mail_to.append(mail_values['email_to']) # add email from recipients (res.partner) mail_to += [ recipient_emails[recipient_command[1]] for recipient_command in mail_values.get('recipient_ids') or [] if recipient_command[1] ] - mail_to = list(set(mail_to)) + # uniquify, keep ordering + seen = set() + mail_to = [email for email in mail_to if email not in seen and not seen.add(email)] + recipients_info[record_id] = { 'mail_to': mail_to, 'mail_to_normalized': [ - tools.email_normalize(mail) + tools.email_normalize(mail, strict=False) for mail in mail_to - if tools.email_normalize(mail) + if tools.email_normalize(mail, strict=False) ] } return recipients_info @@ -525,7 +526,7 @@ class MailComposer(models.TransientModel): elif not mail_to: mail_values['state'] = 'cancel' mail_values['failure_type'] = 'mail_email_missing' - elif not mail_to_normalized or not email_re.findall(mail_to): + elif not mail_to_normalized: mail_values['state'] = 'cancel' mail_values['failure_type'] = 'mail_email_invalid' elif done_emails is not None and not mailing_document_based: diff --git a/addons/mass_mailing/models/mailing_contact.py b/addons/mass_mailing/models/mailing_contact.py index 567bba5d1b34d0fd4757cb376f5e3bd81943a3a6..a0fcb69859f79b04bcb7bde24ecf26036e9b1644 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.exceptions import UserError from odoo.osv import expression @@ -178,11 +178,12 @@ class MassMailingContact(models.Model): return contact.name_get()[0] def _message_get_default_recipients(self): - return {r.id: { - 'partner_ids': [], - 'email_to': r.email_normalized, - 'email_cc': False} - for r in self + return { + r.id: { + 'partner_ids': [], + 'email_to': ','.join(tools.email_normalize_all(r.email)) or r.email, + 'email_cc': False, + } for r in self } def action_add_to_mailing_list(self): diff --git a/addons/mass_mailing/tests/common.py b/addons/mass_mailing/tests/common.py index 651428ed4f4033a335995ef924083d33ed0c45c3..51ef32b401d49a8ec2121c03336246ea70c12c0f 100644 --- a/addons/mass_mailing/tests/common.py +++ b/addons/mass_mailing/tests/common.py @@ -52,6 +52,8 @@ class MassMailCase(MailCase, MockLinkTracker): 'record: linked record, # MAIL.MAIL 'content': optional content that should be present in mail.mail body_html; + 'email_to_mail': optional email used for the mail, when different from the + one stored on the trace itself; 'email_to_recipients': optional, see '_assertMailMail'; 'failure_type': optional failure reason; }, { ... }] @@ -100,6 +102,7 @@ class MassMailCase(MailCase, MockLinkTracker): for recipient_info, link_info, record in zip(recipients_info, mail_links_info, records): partner = recipient_info.get('partner', self.env['res.partner']) email = recipient_info.get('email') + email_to_mail = recipient_info.get('email_to_mail') or email email_to_recipients = recipient_info.get('email_to_recipients') status = recipient_info.get('trace_status', 'sent') record = record or recipient_info.get('record') @@ -130,6 +133,8 @@ class MassMailCase(MailCase, MockLinkTracker): fields_values = {'mailing_id': mailing} if 'failure_reason' in recipient_info: fields_values['failure_reason'] = recipient_info['failure_reason'] + if 'email_to_mail' in recipient_info: + fields_values['email_to'] = recipient_info['email_to_mail'] # specific for partner: email_formatted is used if partner: @@ -154,7 +159,7 @@ class MassMailCase(MailCase, MockLinkTracker): ) else: self.assertMailMailWEmails( - [email], state_mapping[status], + [email_to_mail], state_mapping[status], author=author, content=content, email_to_recipients=email_to_recipients, diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 10a116be6e114d897a0b50dffe3a5c58f68220ac..929a8ba0a76c73446b6442c08516ce04210d171c 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -2467,12 +2467,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), - ('is_closed', '=', False)]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False) + ]).write({'partner_id': new_partner[0].id}) # use the sanitized body of the email from the message thread to populate the task's description if not self.description and message.subtype_id == self._creation_subtype() and self.partner_id == message.author_id: self.description = message.body diff --git a/addons/sale/wizard/sale_order_cancel.py b/addons/sale/wizard/sale_order_cancel.py index 58577ec599e7cd90f9ae8734892c4b943b1e8c1f..4f2960a678755a6aa6bbe38aac8abe1c594f6846 100644 --- a/addons/sale/wizard/sale_order_cancel.py +++ b/addons/sale/wizard/sale_order_cancel.py @@ -3,7 +3,6 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError -from odoo.tools import formataddr class SaleOrderCancel(models.TransientModel): @@ -14,7 +13,7 @@ class SaleOrderCancel(models.TransientModel): @api.model def _default_email_from(self): if self.env.user.email: - return 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 b957d58e9edef8b48b30755a2356c72e72a5d4fe..47a644c6efde0887a1c9778dd94438f7a4b463b1 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 bd325b1003eb57388d444f5407ba16560b355ebc..0fa0e81e09a3ac4eb59fcf9f0883e72e04fc2afa 100644 --- a/addons/test_mail/tests/__init__.py +++ b/addons/test_mail/tests/__init__.py @@ -11,6 +11,7 @@ from . import test_mail_mail from . import test_mail_gateway from . import test_mail_multicompany from . import test_mail_thread_internals +from . import test_mail_thread_mixins from . import test_mail_template from . import test_mail_template_preview from . import test_message_management diff --git a/addons/test_mail/tests/test_mail_activity.py b/addons/test_mail/tests/test_mail_activity.py index 91d59b93d3ca8a20d25e9f484653afec7dd1cc12..86325393b2021eea001bb87944b368a70499a3f4 100644 --- a/addons/test_mail/tests/test_mail_activity.py +++ b/addons/test_mail/tests/test_mail_activity.py @@ -694,41 +694,42 @@ class TestActivityMixin(TestActivityCommon): self.assertEqual(test_record_1, record) -class TestReadProgressBar(tests.TransactionCase): +@tests.tagged('mail_activity') +class TestORM(TestActivityCommon): """Test for read_progress_bar""" def test_week_grouping(self): """The labels associated to each record in read_progress_bar should match the ones from read_group, even in edge cases like en_US locale on sundays """ - model = self.env['mail.test.activity'].with_context(lang='en_US') + MailTestActivityCtx = self.env['mail.test.activity'].with_context({"lang": "en_US"}) # Don't mistake fields date and date_deadline: # * date is just a random value # * date_deadline defines activity_state - model.create({ + self.env['mail.test.activity'].create({ 'date': '2021-05-02', 'name': "Yesterday, all my troubles seemed so far away", }).activity_schedule( 'test_mail.mail_act_test_todo', summary="Make another test super asap (yesterday)", - date_deadline=fields.Date.context_today(model) - timedelta(days=7), + date_deadline=fields.Date.context_today(MailTestActivityCtx) - timedelta(days=7), ) - model.create({ + self.env['mail.test.activity'].create({ 'date': '2021-05-09', 'name': "Things we said today", }).activity_schedule( 'test_mail.mail_act_test_todo', summary="Make another test asap", - date_deadline=fields.Date.context_today(model), + date_deadline=fields.Date.context_today(MailTestActivityCtx), ) - model.create({ + self.env['mail.test.activity'].create({ 'date': '2021-05-16', 'name': "Tomorrow Never Knows", }).activity_schedule( 'test_mail.mail_act_test_todo', summary="Make a test tomorrow", - date_deadline=fields.Date.context_today(model) + timedelta(days=7), + date_deadline=fields.Date.context_today(MailTestActivityCtx) + timedelta(days=7), ) domain = [('date', "!=", False)] @@ -743,8 +744,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 a557013189d0cc4c502c830eb2030e68a5b05e94..c346726d0980a7bf593169b18f5f391bfbdfd1df 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -10,7 +10,7 @@ from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients from odoo.exceptions import AccessError from odoo.tests import tagged from odoo.tests.common import users, Form -from odoo.tools import mute_logger, formataddr +from odoo.tools import email_normalize, mute_logger, formataddr @tagged('mail_composer') @@ -1327,6 +1327,146 @@ class TestComposerResultsComment(TestMailComposer): self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(test_record.ids)) self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments') + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_recipients_email_fields(self): + """ Test various combinations of corner case / not standard filling of + email fields: multi email, formatted emails, ... on template, used to + post a message using the composer.""" + existing_partners = self.env['res.partner'].search([]) + partner_format_tofind, partner_multi_tofind = self.env['res.partner'].create([ + { + 'email': '"FindMe Format" <find.me.format@test.example.com>', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>', + 'name': 'FindMe Multi', + } + ]) + email_ccs = ['"Raoul" <test.cc.1@example.com>', '"Raoulette" <test.cc.2@example.com>', 'test.cc.2.2@example.com>', 'invalid', ' '] + email_tos = ['"Micheline, l\'Immense" <test.to.1@example.com>', 'test.to.2@example.com', 'wrong', ' '] + + self.template.write({ + 'email_cc': ', '.join(email_ccs), + 'email_from': '{{ user.email_formatted }}', + 'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')), + 'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test', + }) + self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'}) + self.partner_1.write({'email': '"Valid Formatted" <valid.lelitre@agrolait.com>'}) + self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'}) + # ensure values used afterwards for testing + self.assertEqual( + self.partner_employee.email_formatted, + '"Ernest Employee" <email.from.1@test.example.com,email.from.2@test.example.com>', + 'Formatting: wrong formatting due to multi-email') + self.assertEqual( + self.partner_1.email_formatted, + '"Valid Lelitre" <valid.lelitre@agrolait.com>', + 'Formatting: avoid wrong double encapsulation') + self.assertEqual( + self.partner_2.email_formatted, + '"Valid Poilvache" <valid.other.1@agrolait.com,valid.other.cc@agrolait.com>', + 'Formatting: wrong formatting due to multi-email') + + # instantiate composer, post message + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context( + self.test_record, + add_web=True, + default_template_id=self.template.id, + ) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer.action_send_mail() + + # find partners created during sending (as emails are transformed into partners) + # FIXME: currently email finding based on formatted / multi emails does + # not work + new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)]) + self.assertEqual(len(new_partners), 8, + 'Mail (FIXME): multiple partner creation due to formatted / multi emails: 1 extra partners') + self.assertIn(partner_format_tofind, new_partners) + self.assertIn(partner_multi_tofind, new_partners) + self.assertEqual( + sorted(new_partners.mapped('email')), + sorted(['"FindMe Format" <find.me.format@test.example.com>', + 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', 'test.cc.2@example.com', 'test.cc.2.2@example.com', + 'test.to.1@example.com', 'test.to.2@example.com']), + 'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)' + ) + self.assertEqual( + sorted(new_partners.mapped('email_formatted')), + sorted(['"FindMe Format" <find.me.format@test.example.com>', + '"FindMe Multi" <find.me.multi.1@test.example.com,find.me.multi.2@test.example.com>', + '"find.me.multi.2@test.example.com" <find.me.multi.2@test.example.com>', + '"test.cc.1@example.com" <test.cc.1@example.com>', + '"test.cc.2@example.com" <test.cc.2@example.com>', + '"test.cc.2.2@example.com" <test.cc.2.2@example.com>', + '"test.to.1@example.com" <test.to.1@example.com>', + '"test.to.2@example.com" <test.to.2@example.com>']), + ) + self.assertEqual( + sorted(new_partners.mapped('name')), + sorted(['FindMe Format', + 'FindMe Multi', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', 'test.to.1@example.com', 'test.to.2@example.com', + 'test.cc.2@example.com', 'test.cc.2.2@example.com']), + 'Mail: currently setting name = email, not taking into account formatted emails' + ) + + # global outgoing: two mail.mail (all customer recipients, then all employee recipients) + # and 11 emails, and 1 inbox notification (admin) + # FIXME template is sent only to partners (email_to are transformed) -> + # wrong / weird emails (see email_formatted of partners) is kept + # FIXME: more partners created than real emails (see above) -> due to + # transformation from email -> partner in template 'generate_recipients' + # there are more partners than email to notify; + self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail') + self.assertEqual( + len(self._mails), len(new_partners) + 3, + f'Should have sent {len(new_partners) + 3} emails, one / recipient ({len(new_partners)} mailed partners + partner_1 + partner_2 + partner_employee)') + self.assertMailMail( + self.partner_employee_2, 'sent', + author=self.partner_employee, + email_values={ + 'body_content': f'TemplateBody {self.test_record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com')), + 'subject': f'TemplateSubject {self.test_record.name}', + }, + fields_values={ + # currently holding multi-email 'email_from' + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com,email.from.2@test.example.com')), + }, + mail_message=self.test_record.message_ids[0], + ) + self.assertMailMail( + self.partner_1 + self.partner_2 + new_partners, 'sent', + author=self.partner_employee, + email_to_recipients=[ + [self.partner_1.email_formatted], + [f'"{self.partner_2.name}" <valid.other.1@agrolait.com>', f'"{self.partner_2.name}" <valid.other.cc@agrolait.com>'], + ] + [[new_partners[0]['email_formatted']], + ['"FindMe Multi" <find.me.multi.1@test.example.com>', '"FindMe Multi" <find.me.multi.2@test.example.com>'] + ] + [[email] for email in new_partners[2:].mapped('email_formatted')], + email_values={ + 'body_content': f'TemplateBody {self.test_record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com')), + 'subject': f'TemplateSubject {self.test_record.name}', + }, + fields_values={ + # currently holding multi-email 'email_from' + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com,email.from.2@test.example.com')), + }, + mail_message=self.test_record.message_ids[0], + ) + @tagged('mail_composer', 'mail_blacklist') class TestComposerResultsCommentStatus(TestMailComposer): @@ -1661,13 +1801,21 @@ class TestComposerResultsMass(TestMailComposer): # check layouting and language. Note that standard layout # is not tested against translations, only the custom one # to ease translations checks. - email = self._find_sent_email(self.partner_employee_2.email_formatted, [record.customer_id.email_formatted]) - self.assertTrue(bool(email), 'Email not found, check recipients') - + sent_mail = self._find_sent_email( + self.partner_employee_2.email_formatted, + [formataddr((record.customer_id.name, email_normalize(record.customer_id.email, strict=False)))] + ) + debug_info = '' + if not sent_mail: + debug_info = '-'.join('From: %s-To: %s' % (mail['email_from'], mail['email_to']) for mail in self._mails) + self.assertTrue( + bool(sent_mail), + f'Expected mail from {self.partner_employee_2.email_formatted} to {formataddr((record.customer_id.name, record.customer_id.email))} not found in {debug_info}' + ) # Currently layouting in mailing mode is not supported. # Hence no translations. self.assertEqual( - email['body'], + sent_mail['body'], f'<p>TemplateBody {record.name}</p>' ) @@ -1748,6 +1896,141 @@ class TestComposerResultsMass(TestMailComposer): composer._action_send_mail() self.assertNotSentEmail() + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_recipients_email_fields(self): + """ Test various combinations of corner case / not standard filling of + email fields: multi email, formatted emails, ... """ + existing_partners = self.env['res.partner'].search([]) + partner_format_tofind, partner_multi_tofind = self.env['res.partner'].create([ + { + 'email': '"FindMe Format" <find.me.format@test.example.com>', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>', + 'name': 'FindMe Multi', + } + ]) + email_ccs = ['"Raoul" <test.cc.1@example.com>', '"Raoulette" <test.cc.2@example.com>', 'test.cc.2.2@example.com>', 'invalid', ' '] + email_tos = ['"Micheline, l\'Immense" <test.to.1@example.com>', 'test.to.2@example.com', 'wrong', ' '] + + self.template.write({ + 'email_cc': ', '.join(email_ccs), + 'email_from': '{{ user.email_formatted }}', + 'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')), + 'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test', + }) + self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'}) + self.partner_1.write({'email': '"Valid Formatted" <valid.lelitre@agrolait.com>'}) + self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'}) + # ensure values used afterwards for testing + self.assertEqual( + self.partner_employee.email_formatted, + '"Ernest Employee" <email.from.1@test.example.com,email.from.2@test.example.com>', + 'Formatting: wrong formatting due to multi-email') + self.assertEqual( + self.partner_1.email_formatted, + '"Valid Lelitre" <valid.lelitre@agrolait.com>', + 'Formatting: avoid wrong double encapsulation') + self.assertEqual( + self.partner_2.email_formatted, + '"Valid Poilvache" <valid.other.1@agrolait.com,valid.other.cc@agrolait.com>', + 'Formatting: wrong formatting due to multi-email') + + # instantiate composer, send mailing + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context( + self.test_records, + add_web=True, + default_template_id=self.template.id, + ) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer.action_send_mail() + + # find partners created during sending (as emails are transformed into partners) + # FIXME: currently email finding based on formatted / multi emails does + # not work + new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)]) + self.assertEqual(len(new_partners), 8, + 'Mail (FIXME): did not find existing partners for formatted / multi emails: 1 extra partners') + self.assertIn(partner_format_tofind, new_partners) + self.assertIn(partner_multi_tofind, new_partners) + self.assertEqual( + sorted(new_partners.mapped('email')), + sorted(['"FindMe Format" <find.me.format@test.example.com>', + 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', 'test.cc.2@example.com', 'test.cc.2.2@example.com', + 'test.to.1@example.com', 'test.to.2@example.com']), + 'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)' + ) + self.assertEqual( + sorted(new_partners.mapped('email_formatted')), + sorted(['"FindMe Format" <find.me.format@test.example.com>', + '"FindMe Multi" <find.me.multi.1@test.example.com,find.me.multi.2@test.example.com>', + '"find.me.multi.2@test.example.com" <find.me.multi.2@test.example.com>', + '"test.cc.1@example.com" <test.cc.1@example.com>', + '"test.cc.2@example.com" <test.cc.2@example.com>', + '"test.cc.2.2@example.com" <test.cc.2.2@example.com>', + '"test.to.1@example.com" <test.to.1@example.com>', + '"test.to.2@example.com" <test.to.2@example.com>']), + ) + self.assertEqual( + sorted(new_partners.mapped('name')), + sorted(['FindMe Format', + 'FindMe Multi', + 'find.me.multi.2@test.example.com', + 'test.cc.1@example.com', 'test.to.1@example.com', 'test.to.2@example.com', + 'test.cc.2@example.com', 'test.cc.2.2@example.com']), + 'Mail: currently setting name = email, not taking into account formatted emails' + ) + + # global outgoing: one mail.mail (all customer recipients), * 2 records + # Note that employee is not mailed here compared to 'comment' mode as he + # is not in the template recipients, only a follower + # FIXME template is sent only to partners (email_to are transformed) -> + # wrong / weird emails (see email_formatted of partners) is kept + # FIXME: more partners created than real emails (see above) -> due to + # transformation from email -> partner in template 'generate_recipients' + # there are more partners than email to notify; + self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail') + self.assertEqual( + len(self._mails), (len(new_partners) + 2) * 2, + f'Should have sent {(len(new_partners) + 2) * 2} emails, one / recipient ({len(new_partners)} mailed partners + partner_1 + partner_2) * 2 records') + for record in self.test_records: + self.assertMailMail( + self.partner_1 + self.partner_2 + new_partners, + 'sent', + author=self.partner_employee, + email_to_recipients=[ + [self.partner_1.email_formatted], + [f'"{self.partner_2.name}" <valid.other.1@agrolait.com>', f'"{self.partner_2.name}" <valid.other.cc@agrolait.com>'], + ] + [[new_partners[0]['email_formatted']], + ['"FindMe Multi" <find.me.multi.1@test.example.com>', '"FindMe Multi" <find.me.multi.2@test.example.com>'] + ] + [[email] for email in new_partners[2:].mapped('email_formatted')], + email_values={ + 'body_content': f'TemplateBody {record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com')), + 'reply_to': formataddr(( + f'{self.env.user.company_id.name} {record.name}', + f'{self.alias_catchall}@{self.alias_domain}' + )), + 'subject': f'TemplateSubject {record.name}', + }, + fields_values={ + # currently holding multi-email 'email_from' + 'email_from': self.partner_employee.email_formatted, + 'reply_to': formataddr(( + f'{self.env.user.company_id.name} {record.name}', + f'{self.alias_catchall}@{self.alias_domain}' + )), + }, + mail_message=record.message_ids[0], # message copy is kept + ) + @users('employee') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_mail_composer_wtpl_reply_to(self): diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py index 044d6052f47915d0d7c88493a11505ee45437c09..74ca6b91179acd4dc2afcc09bc35d4a8621ce1d0 100644 --- a/addons/test_mail/tests/test_mail_gateway.py +++ b/addons/test_mail/tests/test_mail_gateway.py @@ -476,7 +476,32 @@ class TestMailgateway(TestMailCommon): self.assertEqual(record.message_ids[0].email_from, self.partner_1.email) self.assertNotSentEmail() # No notification / bounce should be sent - @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink') + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_message_process_email_author_multiemail(self): + """ Incoming email: recognized author: check multi/formatted email in field """ + test_email = 'valid.lelitre@agrolait.com' + # Email not recognized if partner has a multi-email (source = formatted email) + self.partner_1.write({'email': f'{test_email}, "Valid Lelitre" <another.email@test.example.com>'}) + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, f'"Valid Lelitre" <{test_email}>', 'groups@test.com', subject='Test3') + + self.assertEqual(record.message_ids[0].author_id, self.partner_1, + 'message_process: found author based on first found email normalized, even with multi emails') + self.assertEqual(record.message_ids[0].email_from, f'"Valid Lelitre" <{test_email}>') + self.assertNotSentEmail() # No notification / bounce should be sent + + # Email not recognized if partner has a multi-email (source = std email) + with self.mock_mail_gateway(): + record = self.format_and_process( + MAIL_TEMPLATE, test_email, 'groups@test.com', subject='Test4') + + self.assertEqual(record.message_ids[0].author_id, self.partner_1, + 'message_process: found author based on first found email normalized, even with multi emails') + self.assertEqual(record.message_ids[0].email_from, test_email) + self.assertNotSentEmail() # No notification / bounce should be sent + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') def test_message_process_email_partner_find(self): """ Finding the partner based on email, based on partner / user / follower """ self.alias.write({'alias_force_thread_id': self.test_record.id}) @@ -677,6 +702,39 @@ class TestMailgateway(TestMailCommon): # Test: one group created by Raoul (or Sylvie maybe, if we implement it) self.assertEqual(len(record), 1) + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models', 'odoo.tests') + def test_message_process_alias_followers_multiemail(self): + """ Incoming email from a parent document follower on a Followers only + alias depends on email_from / partner recognition, to be tested when + dealing with multi emails / formatted emails. """ + self.alias.write({ + 'alias_contact': 'followers', + 'alias_parent_model_id': self.env['ir.model']._get('mail.test.gateway').id, + 'alias_parent_thread_id': self.test_record.id, + }) + self.test_record.message_subscribe(partner_ids=[self.partner_1.id]) + email_from = formataddr(("Another Name", self.partner_1.email_normalized)) + + for partner_email, passed in [ + (formataddr((self.partner_1.name, self.partner_1.email_normalized)), True), + (f'{self.partner_1.email_normalized}, "Multi Email" <multi.email@test.example.com>', True), + (f'"Multi Email" <multi.email@test.example.com>, {self.partner_1.email_normalized}', False), + ]: + with self.subTest(partner_email=partner_email): + self.partner_1.write({'email': partner_email}) + record = self.format_and_process( + MAIL_TEMPLATE, email_from, 'groups@test.com', + subject=f'Test for {partner_email}') + + if passed: + self.assertEqual(len(record), 1) + self.assertEqual(record.email_from, email_from) + self.assertEqual(record.message_partner_ids, self.partner_1) + # multi emails not recognized (no normalized email, recognition) + else: + self.assertEqual(len(record), 0, + 'Alias check (FIXME): multi-emails bad support for recognition') + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_message_process_alias_update(self): """ Incoming email update discussion + notification email """ @@ -1678,6 +1736,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. @@ -1734,6 +1793,7 @@ class TestMailgateway(TestMailCommon): self.assertFalse(record.message_ids.parent_id) +@tagged('mail_gateway', 'mail_thread') class TestMailThreadCC(TestMailCommon): @classmethod diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py index 5979b1a3030775ce81f5a80e015ca976914817db..0248e097fecb5bf20adb012d960ea4c2e9abfa7c 100644 --- a/addons/test_mail/tests/test_mail_mail.py +++ b/addons/test_mail/tests/test_mail_mail.py @@ -696,6 +696,114 @@ class TestMailMail(TestMailCommon): self.assert_email_sent_smtp(message_from='user_2@test_2.com', emails_count=5, from_filter=self.server_domain_2.from_filter) self.assert_email_sent_smtp(message_from='user_1@test_2.com', emails_count=5, from_filter=self.server_domain.from_filter) + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_values_email_formatted(self): + """ Test outgoing email values, with formatting """ + customer = self.env['res.partner'].create({ + 'name': 'Tony Customer', + 'email': '"Formatted Emails" <tony.customer@test.example.com>', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '<p>Test</p>', + 'email_cc': '"Ignasse, le Poilu" <test.cc.1@test.example.com>', + 'email_to': '"Raoul, le Grand" <test.email.1@test.example.com>, "Micheline, l\'immense" <test.email.2@test.example.com>', + 'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient') + self.assertEqual( + sorted(sorted(_mail['email_to']) for _mail in self._mails), + sorted([sorted(['"Raoul, le Grand" <test.email.1@test.example.com>', '"Micheline, l\'immense" <test.email.2@test.example.com>']), + [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))], + [tools.formataddr(("Tony Customer", 'tony.customer@test.example.com'))] + ]), + 'Mail: formatting issues should have been removed as much as possible' + ) + # Currently broken: CC are added to ALL emails (spammy) + self.assertEqual( + [_mail['email_cc'] for _mail in self._mails], + [['test.cc.1@test.example.com']] * 3, + 'Mail: currently always removing formatting in email_cc' + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_values_email_multi(self): + """ Test outgoing email values, with email field holding multi emails """ + # Multi + customer = self.env['res.partner'].create({ + 'name': 'Tony Customer', + 'email': 'tony.customer@test.example.com, norbert.customer@test.example.com', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '<p>Test</p>', + 'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com', + 'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com', + 'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient') + self.assertEqual( + sorted(sorted(_mail['email_to']) for _mail in self._mails), + sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']), + [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))], + sorted([tools.formataddr(("Tony Customer", 'tony.customer@test.example.com')), + tools.formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]), + ]), + 'Mail: formatting issues should have been removed as much as possible (multi emails in a single address are managed ' + 'like separate emails when sending with recipient_ids' + ) + # Currently broken: CC are added to ALL emails (spammy) + self.assertEqual( + [_mail['email_cc'] for _mail in self._mails], + [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3, + ) + + # Multi + formatting + customer = self.env['res.partner'].create({ + 'name': 'Tony Customer', + 'email': 'tony.customer@test.example.com, "Norbert Customer" <norbert.customer@test.example.com>', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '<p>Test</p>', + 'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com', + 'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com', + 'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)] + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient') + self.assertEqual( + sorted(sorted(_mail['email_to']) for _mail in self._mails), + sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']), + [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))], + sorted([tools.formataddr(("Tony Customer", 'tony.customer@test.example.com')), + tools.formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]), + ]), + 'Mail: formatting issues should have been removed as much as possible (multi emails in a single address are managed ' + 'like separate emails when sending with recipient_ids (and partner name is always used as name part)' + ) + # Currently broken: CC are added to ALL emails (spammy) + self.assertEqual( + [_mail['email_cc'] for _mail in self._mails], + [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3, + ) + + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_values_unicode(self): + """ Unicode should be fine. """ + mail = self.env['mail.mail'].create({ + 'body_html': '<p>Test</p>', + 'email_cc': 'test.😊.cc@example.com', + 'email_to': 'test.😊@example.com', + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 1) + self.assertEqual(self._mails[0]['email_cc'], ['test.😊.cc@example.com']) + self.assertEqual(self._mails[0]['email_to'], ['test.😊@example.com']) + @tagged('mail_mail') class TestMailMailRace(common.TransactionCase): 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..fb2c2e203cedd4e06fe9f78120e78132da0dec5d --- /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_recordset(fnames=['is_blacklisted']) + self.assertTrue(new_record.is_blacklisted) + + bl_record.unlink() diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index 773d2676e82ebb00a466cd5237193872b897021a..6e205a9b6eef07d2e590a488e5391aab2427b822 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -650,6 +650,47 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): # notifications emails should not have been deleted: one for customers, one for user self.assertEqual(self.env['mail.mail'].sudo().search_count([('mail_message_id', '=', msg.id)]), 2) + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') + def test_message_post_recipients_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, + ) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_message_schedule', 'odoo.models.unlink') def test_message_post_schedule(self): diff --git a/addons/test_mass_mailing/models/mailing_models.py b/addons/test_mass_mailing/models/mailing_models.py index 1b4d3bb78c40debb8ef6530e4d54d3b666e302e9..b5e61c7a057fa8be466061c553996d462820db78 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'] @@ -17,7 +47,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'] @@ -37,6 +68,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 @@ -60,6 +104,19 @@ class MailingOptOut(models.Model): ]).mapped('email_normalized')) return opt_out_contacts + def _message_get_default_recipients(self): + """ Default recipient checks for 'partner_id', here the field is named + 'customer_id'. """ + default_recipients = super()._message_get_default_recipients() + for record in self: + if record.customer_id: + default_recipients[record.id] = { + 'email_cc': False, + 'email_to': False, + 'partner_ids': record.customer_id.ids, + } + return default_recipients + class MailingPerformance(models.Model): """ A very simple model only inheriting from mail.thread to test pure mass diff --git a/addons/test_mass_mailing/security/ir.model.access.csv b/addons/test_mass_mailing/security/ir.model.access.csv index b21c7be49b14a3b910c38d8686f4e2c8e086c056..617995ff0fac1bfeb8130746c698e825449ccf96 100644 --- a/addons/test_mass_mailing/security/ir.model.access.csv +++ b/addons/test_mass_mailing/security/ir.model.access.csv @@ -1,4 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mailing_test_customer_all,access.mailing.test.customer.all,model_mailing_test_customer,,0,0,0,0 +access_mailing_test_customer_user,access.mailing.test.customer.user,model_mailing_test_customer,base.group_user,1,1,1,1 access_mailing_test_simple_all,access.mailing.test.simple.all,model_mailing_test_simple,,0,0,0,0 access_mailing_test_simple_user,access.mailing.test.simple.user,model_mailing_test_simple,base.group_user,1,1,1,1 access_mailing_test_blacklist_all,access.mailing.test.blacklist.all,model_mailing_test_blacklist,,0,0,0,0 diff --git a/addons/test_mass_mailing/tests/test_mailing.py b/addons/test_mass_mailing/tests/test_mailing.py index d9315a3bde1b945427465ecd7b0b4c63e199d8ee..ec2c7e4311ad57d729e430e53a3bad44b25a58a3 100644 --- a/addons/test_mass_mailing/tests/test_mailing.py +++ b/addons/test_mass_mailing/tests/test_mailing.py @@ -118,6 +118,165 @@ class TestMassMailing(TestMassMailCommon): self.assertMailingStatistics(mailing, expected=5, delivered=4, sent=5, opened=1, clicked=1, bounced=1) self.assertEqual(recipients[1].message_bounce, 1) + @users('user_marketing') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mailing_recipients(self): + """ Test recipient-specific computation, with email, formatting, + multi-emails, ... to test corner cases. Blacklist mixin impact is + tested. """ + (customer_mult, customer_fmt, customer_unic, + customer_case, customer_weird, customer_weird_2 + ) = self.env['res.partner'].create([ + { + 'email': 'customer.multi.1@example.com, "Test Multi 2" <customer.multi.2@example.com>', + 'name': 'MultiEMail', + }, { + 'email': '"Formatted Customer" <test.customer.format@example.com>', + 'name': 'FormattedEmail', + }, { + 'email': '"Unicode Customer" <test.customer.😊@example.com>', + 'name': 'UnicodeEmail', + }, { + 'email': 'TEST.CUSTOMER.CASE@EXAMPLE.COM', + 'name': 'CaseEmail', + }, { + 'email': 'test.customer.weird@example.com Weird Format', + 'name': 'WeirdFormatEmail', + }, { + 'email': 'Weird Format2 test.customer.weird.2@example.com', + 'name': 'WeirdFormatEmail2', + } + ]) + + # check difference of email management between a classic model and a model + # with an 'email_normalized' field (blacklist mixin) + for dst_model in ['mailing.test.customer', 'mailing.test.blacklist']: + with self.subTest(dst_model=dst_model): + (record_p_mult, record_p_fmt, record_p_unic, + record_p_case, record_p_weird, record_p_weird_2, + record_mult, record_fmt, record_unic, + record_case, recod_weird, record_weird_2 + ) = self.env[dst_model].create([ + { + 'customer_id': customer_mult.id, + }, { + 'customer_id': customer_fmt.id, + }, { + 'customer_id': customer_unic.id, + }, { + 'customer_id': customer_case.id, + }, { + 'customer_id': customer_weird.id, + }, { + 'customer_id': customer_weird_2.id, + }, { + 'email_from': 'record.multi.1@example.com, "Record Multi 2" <record.multi.2@example.com>', + }, { + 'email_from': '"Formatted Record" <record.format@example.com>', + }, { + 'email_from': '"Unicode Record" <record.😊@example.com>', + }, { + 'email_from': 'TEST.RECORD.CASE@EXAMPLE.COM', + }, { + 'email_from': 'test.record.weird@example.com Weird Format', + }, { + 'email_from': 'Weird Format2 test.record.weird.2@example.com', + } + ]) + test_records = ( + record_p_mult + record_p_fmt + record_p_unic + + record_p_case + record_p_weird + record_p_weird_2 + + record_mult + record_fmt + record_unic + + record_case + recod_weird + record_weird_2 + ) + mailing = self.env['mailing.mailing'].create({ + 'body_html': """<div><p>Hello ${object.name}</p>""", + 'mailing_domain': [('id', 'in', test_records.ids)], + 'mailing_model_id': self.env['ir.model']._get_id(dst_model), + 'mailing_type': 'mail', + 'name': 'SourceName', + 'preview': 'Hi ${object.name} :)', + 'reply_to_mode': 'update', + 'subject': 'MailingSubject', + }) + + with self.mock_mail_gateway(mail_unlink_sent=False): + mailing.action_send_mail() + + # Difference in email, email_to_recipients and email_to_mail + # -> email: trace email: normalized, to ease its management, mainly technical + # -> email_to_mail: mail.mail email: email_to stored in outgoing mail.mail (can be multi) + # -> email_to_recipients: email_to for outgoing emails, list means several recipients + self.assertMailTraces( + [ + {'email': 'customer.multi.1@example.com, "Test Multi 2" <customer.multi.2@example.com>', + 'email_to_recipients': [[f'"{customer_mult.name}" <customer.multi.1@example.com>', f'"{customer_mult.name}" <customer.multi.2@example.com>']], + 'failure_type': False, + 'partner': customer_mult, + 'trace_status': 'sent'}, + {'email': '"Formatted Customer" <test.customer.format@example.com>', + # mail to avoids double encapsulation + 'email_to_recipients': [[f'"{customer_fmt.name}" <test.customer.format@example.com>']], + 'failure_type': False, + 'partner': customer_fmt, + 'trace_status': 'sent'}, + {'email': '"Unicode Customer" <test.customer.😊@example.com>', + # mail to avoids double encapsulation + 'email_to_recipients': [[f'"{customer_unic.name}" <test.customer.😊@example.com>']], + 'failure_type': False, + 'partner': customer_unic, + 'trace_status': 'sent'}, + {'email': 'TEST.CUSTOMER.CASE@EXAMPLE.COM', + 'email_to_recipients': [[f'"{customer_case.name}" <test.customer.case@example.com>']], + 'failure_type': False, + 'partner': customer_case, + 'trace_status': 'sent'}, # lower cased + {'email': 'test.customer.weird@example.com Weird Format', + 'email_to_recipients': [[f'"{customer_weird.name}" <test.customer.weird@example.comweirdformat>']], + 'failure_type': False, + 'partner': customer_weird, + 'trace_status': 'sent'}, # concatenates everything after domain + {'email': 'Weird Format2 test.customer.weird.2@example.com', + 'email_to_recipients': [[f'"{customer_weird_2.name}" <test.customer.weird.2@example.com>']], + 'failure_type': False, + 'partner': customer_weird_2, + 'trace_status': 'sent'}, + {'email': 'record.multi.1@example.com', + 'email_to_mail': 'record.multi.1@example.com,record.multi.2@example.com', + 'email_to_recipients': [['record.multi.1@example.com', 'record.multi.2@example.com']], + 'failure_type': False, + 'trace_status': 'sent'}, + {'email': 'record.format@example.com', + 'email_to_mail': 'record.format@example.com', + 'email_to_recipients': [['record.format@example.com']], + 'failure_type': False, + 'trace_status': 'sent'}, + {'email': 'record.😊@example.com', + 'email_to_mail': 'record.😊@example.com', + 'email_to_recipients': [['record.😊@example.com']], + 'failure_type': False, + 'trace_status': 'sent'}, + {'email': 'test.record.case@example.com', + 'email_to_mail': 'test.record.case@example.com', + 'email_to_recipients': [['test.record.case@example.com']], + 'failure_type': False, + 'trace_status': 'sent'}, + {'email': 'test.record.weird@example.comweirdformat', + 'email_to_mail': 'test.record.weird@example.comweirdformat', + 'email_to_recipients': [['test.record.weird@example.comweirdformat']], + 'failure_type': False, + 'trace_status': 'sent'}, + {'email': 'test.record.weird.2@example.com', + 'email_to_mail': 'test.record.weird.2@example.com', + 'email_to_recipients': [['test.record.weird.2@example.com']], + 'failure_type': False, + 'trace_status': 'sent'}, + ], + mailing, + test_records, + check_mail=True, + ) + @users('user_marketing') @mute_logger('odoo.addons.mail.models.mail_mail') def test_mailing_reply_to_mode_new(self): diff --git a/addons/website_event_booth/controllers/event_booth.py b/addons/website_event_booth/controllers/event_booth.py index 67b990cc747ef1191bf283fa56ddb77c0ee07fe7..2da3651a1654e06f961e40130ce5bff17e030304 100644 --- a/addons/website_event_booth/controllers/event_booth.py +++ b/addons/website_event_booth/controllers/event_booth.py @@ -102,7 +102,8 @@ class WebsiteEventBoothController(WebsiteEventController): def _prepare_booth_registration_partner_values(self, event, kwargs): if request.env.user._is_public(): - contact_name_email = tools.formataddr((kwargs['contact_name'], kwargs['contact_email'])) + conctact_email_normalized = tools.email_normalize(kwargs['contact_email']) + contact_name_email = tools.formataddr((kwargs['contact_name'], conctact_email_normalized)) partner = request.env['res.partner'].sudo().find_or_create(contact_name_email) if not partner.name and kwargs.get('contact_name'): partner.name = kwargs['contact_name'] diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py index 4c1a23a19212823fba9c5682927744ef3f8ae39d..f796982ec78121cf7ae708c0b11ef124ce27848c 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 @@ -450,7 +450,7 @@ class Track(models.Model): return { track.id: { 'partner_ids': [], - 'email_to': track.contact_email or track.partner_email, + 'email_to': ','.join(tools.email_normalize_all(track.contact_email or track.partner_email)) or track.contact_email or track.partner_email, 'email_cc': False } for track in self } @@ -477,15 +477,19 @@ class Track(models.Model): # Contact(s) created from chatter set on track : we verify if at least one is the expected contact # linked to the track. (created from contact_email if any, then partner_email if any) main_email = self.contact_email or self.partner_email - if main_email: - new_partner = message.partner_ids.filtered(lambda partner: partner.email == main_email) - if new_partner: - main_email_string = 'contact_email' if self.contact_email else 'partner_email' - self.search([ - ('partner_id', '=', False), - (main_email_string, '=', new_partner.email), - ('stage_id.is_cancel', '=', False), - ]).write({'partner_id': new_partner.id}) + main_email_normalized = tools.email_normalize(main_email) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == main_email or (main_email_normalized and partner.email_normalized == main_email_normalized) + ) + if new_partner: + mail_email_fname = 'contact_email' if self.contact_email else 'partner_email' + if new_partner[0].email_normalized: + email_domain = (mail_email_fname, 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = (mail_email_fname, '=', new_partner[0].email) + self.search([ + ('partner_id', '=', False), email_domain, ('stage_id.is_cancel', '=', False), + ]).write({'partner_id': new_partner[0].id}) return super(Track, self)._message_post_after_hook(message, msg_vals) def _track_template(self, changes): diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 06ba0b04b469f917d42200ff0288b65ae6e97319..3f7886eb292322ed6f43f7dfebbd68e66c67786c 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -172,7 +172,7 @@ class Partner(models.Model): _description = 'Contact' _inherit = ['format.address.mixin', 'avatar.mixin'] _name = "res.partner" - _order = "display_name, id" + _order = "display_name ASC, id DESC" _rec_names_search = ['display_name', 'email', 'ref', 'vat', 'company_registry'] # TODO vat must be sanitized the same way for storing/searching def _default_category(self): @@ -503,11 +503,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): @@ -854,7 +879,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. @@ -864,12 +891,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 a68ec268587e509e12a05d4974642ee45ecec3f9..15a00e2cac65bc3dfa185fc78c8f67da1ceb0883 100644 --- a/odoo/addons/base/tests/test_base.py +++ b/odoo/addons/base/tests/test_base.py @@ -5,6 +5,7 @@ import ast from odoo import SUPERUSER_ID, Command from odoo.exceptions import RedirectWarning, UserError, ValidationError +from odoo.tests import tagged from odoo.tests.common import TransactionCase, BaseCase from odoo.tools import mute_logger from odoo.tools.safe_eval import safe_eval, const_eval, expr_eval @@ -73,6 +74,7 @@ SAMPLES = [ ] +@tagged('res_partner') class TestBase(TransactionCase): def _check_find_or_create(self, test_string, expected_name, expected_email, check_partner=False, should_create=False): @@ -88,12 +90,13 @@ class TestBase(TransactionCase): def test_00_res_partner_name_create(self): res_partner = self.env['res.partner'] parse = res_partner._parse_partner_name - for text, name, mail in SAMPLES: - self.assertEqual((name, mail.lower()), parse(text)) - partner_id, dummy = res_partner.name_create(text) - partner = res_partner.browse(partner_id) - self.assertEqual(name or mail.lower(), partner.name) - self.assertEqual(mail.lower() or False, partner.email) + for text, expected_name, expected_mail in SAMPLES: + with self.subTest(text=text): + self.assertEqual((expected_name, expected_mail.lower()), parse(text)) + partner_id, dummy = res_partner.name_create(text) + partner = res_partner.browse(partner_id) + self.assertEqual(expected_name or expected_mail.lower(), partner.name) + self.assertEqual(expected_mail.lower() or False, partner.email) # name_create supports default_email fallback partner = self.env['res.partner'].browse( diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py index 5b248a502a4b322af1fcf08caa04f66a8d505dc1..f0ea01292c4fbab45d4cec6962e84beda4501161 100644 --- a/odoo/addons/base/tests/test_expression.py +++ b/odoo/addons/base/tests/test_expression.py @@ -1115,7 +1115,7 @@ class TestQueries(TransactionCase): ) ) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): Model.search(domain) @@ -1127,7 +1127,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", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): Model.search([('name', 'like', 'foo')]) @@ -1281,7 +1281,7 @@ class TestMany2one(TransactionCase): SELECT "res_partner".id FROM "res_partner" WHERE ("res_partner"."company_id" = %s) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id', '=', self.company.id)]) @@ -1293,7 +1293,7 @@ class TestMany2one(TransactionCase): FROM "res_company" WHERE ("res_company"."name"::text like %s) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.name', 'like', self.company.name)]) @@ -1309,7 +1309,7 @@ class TestMany2one(TransactionCase): WHERE ("res_partner"."name"::text LIKE %s) )) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)]) @@ -1325,7 +1325,7 @@ class TestMany2one(TransactionCase): FROM "res_country" WHERE ("res_country"."code"::text LIKE %s) ))) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([ '|', @@ -1344,7 +1344,7 @@ class TestMany2one(TransactionCase): FROM "res_company" WHERE (("res_company"."active" = %s) AND ("res_company"."name"::text like %s)) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + 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)]) @@ -1360,7 +1360,7 @@ class TestMany2one(TransactionCase): ORDER BY "res_company"."id" LIMIT 1 )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + 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)]) @@ -1377,7 +1377,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", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.name', 'like', self.company.name)]) @@ -1391,7 +1391,7 @@ class TestMany2one(TransactionCase): FROM "res_partner" WHERE ("res_partner"."name"::text LIKE %s) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)]) @@ -1410,7 +1410,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", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)]) @@ -1427,7 +1427,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", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)]) @@ -1449,7 +1449,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", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([ '|', @@ -1468,7 +1468,7 @@ class TestMany2one(TransactionCase): FROM "res_company" WHERE ("res_company"."name"::text LIKE %s) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id', 'like', self.company.name)]) @@ -1480,7 +1480,7 @@ class TestMany2one(TransactionCase): FROM "res_company" WHERE (("res_company"."name"::text not like %s) OR "res_company"."name" IS NULL)) ) OR "res_partner"."company_id" IS NULL) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('company_id', 'not like', "blablabla")]) @@ -1509,7 +1509,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", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('bank_ids', 'in', self.partner.bank_ids.ids)]) @@ -1521,7 +1521,7 @@ class TestOne2many(TransactionCase): FROM "res_partner_bank" WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('bank_ids.sanitized_acc_number', 'like', '12')]) @@ -1537,7 +1537,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", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('child_ids.bank_ids.sanitized_acc_number', 'like', '12')]) @@ -1554,7 +1554,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", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('bank_ids', 'in', self.partner.bank_ids.ids)]) @@ -1566,7 +1566,7 @@ class TestOne2many(TransactionCase): FROM "res_partner_bank" WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('bank_ids.sanitized_acc_number', 'like', '12')]) @@ -1582,7 +1582,7 @@ class TestOne2many(TransactionCase): FROM "res_partner_bank" WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) ))) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([ ('bank_ids.sanitized_acc_number', 'like', '12'), @@ -1601,7 +1601,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", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('child_ids.bank_ids.sanitized_acc_number', 'like', '12')]) @@ -1631,7 +1631,7 @@ class TestOne2many(TransactionCase): ("res_partner"."name" != %s) OR "res_partner"."name" IS NULL )) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + 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)]) @@ -1657,7 +1657,7 @@ class TestOne2many(TransactionCase): "res_partner"."active" = %s )) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc ''']): self.Partner.search([('child_ids.state_id.country_id.code', 'like', 'US')]) @@ -1672,7 +1672,7 @@ class TestOne2many(TransactionCase): FROM "res_partner_bank" WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s) )) - ORDER BY "res_partner"."display_name", "res_partner"."id" + 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 3c5df3bac73eb30335e83d723170241f701d9296..b69ef6138efb803c5e742692e5b19897b844f3a4 100644 --- a/odoo/addons/base/tests/test_mail.py +++ b/odoo/addons/base/tests/test_mail.py @@ -10,9 +10,10 @@ import threading from odoo.addons.base.models.ir_mail_server import extract_rfc2822_addresses from odoo.tests.common import BaseCase, TransactionCase +from odoo.tests import tagged from odoo.tools import ( is_html_empty, html_to_inner_content, html_sanitize, append_content_to_html, plaintext2html, - email_split, email_domain_normalize, + email_domain_normalize, email_normalize, email_split, email_split_and_format, misc, formataddr, prepend_html_content, config, @@ -409,10 +410,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, strict=True), expected) + # standard usage of formataddr + self.assertEqual(formataddr((format_name, (expected or '')), charset='utf-8'), expected_utf8_fmt) + # check using INDA at format time, using ascii charset as done when + # sending emails (see extract_rfc2822_addresses) + self.assertEqual(formataddr((format_name, (expected or '')), charset='ascii'), expected_ascii_fmt) + def test_email_split(self): + """ Test 'email_split' """ cases = [ ("John <12345@gmail.com>", ['12345@gmail.com']), # regular form ("d@x; 1@2", ['d@x', '1@2']), # semi-colon + extra space @@ -423,6 +481,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 5ccd605d83c31b79f54d5ebba1d1cf69e92d5635..3ed937f148bb51ef0ca3d3df27a785728b224a71 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 03db1e746c6005e6e999298774c75e73d7414727..c57cf4b5731fa9d2f759eadad15ed5e105cf56a9 100644 --- a/odoo/tools/mail.py +++ b/odoo/tools/mail.py @@ -510,7 +510,6 @@ mail_header_msgid_re = re.compile('<[^<>]+>') email_addr_escapes_re = re.compile(r'[\\"]') - def generate_tracking_message_id(res_id): """Returns a string that can be used in the Message-ID RFC822 header field @@ -528,14 +527,35 @@ def email_split_tuples(text): """ Return a list of (name, email) address tuples found in ``text`` . Note that text should be an email header or a stringified email list as it may give broader results than expected on actual text. """ + def _parse_based_on_spaces(pair): + """ With input 'name email@domain.com' (missing quotes for a formatting) + getaddresses returns ('', 'name email@domain.com). This when having no + name and an email a fallback to enhance parsing is to redo a getaddresses + by replacing spaces by commas. The new email will be split into sub pairs + allowing to find the email and name parts, allowing to make a new name / + email pair. Emails should not contain spaces thus this is coherent with + email formation. """ + name, email = pair + if not name and email and ' ' in email: + inside_pairs = getaddresses([email.replace(' ', ',')]) + name_parts, found_email = [], False + for pair in inside_pairs: + if pair[1] and '@' not in pair[1]: + name_parts.append(pair[1]) + if pair[1] and '@' in pair[1]: + found_email = pair[1] + name, email = (' '.join(name_parts), found_email) if found_email else (name, email) + return (name, email) + if not text: return [] - return [(addr[0], addr[1]) for addr in getaddresses([text]) - # getaddresses() returns '' when email parsing fails, and - # sometimes returns emails without at least '@'. The '@' - # is strictly required in RFC2822's `addr-spec`. - if addr[1] - if '@' in addr[1]] + return list(map(_parse_based_on_spaces, [ + (addr[0], addr[1]) for addr in getaddresses([text]) + # getaddresses() returns '' when email parsing fails, and + # sometimes returns emails without at least '@'. The '@' + # is strictly required in RFC2822's `addr-spec`. + if addr[1] and '@' in addr[1] + ])) def email_split(text): """ Return a list of the email addresses found in ``text`` """ @@ -551,17 +571,32 @@ def email_split_and_format(text): return [formataddr((name, email)) for (name, email) in email_split_tuples(text)] def email_normalize(text, strict=True): - """ Sanitize and standardize email address entries. - A normalized email is considered as : - - having a left part + @ + a right part (the domain can be without '.something') - - being lower case - - having no name before the address. Typically, having no 'Name <>' - Ex: - - Possible Input Email : 'Name <NaMe@DoMaIn.CoM>' - - Normalized Output Email : 'name@domain.com' - - :param bool strict: text should contain exactly one email (default behavior - and unique behavior before Odoo16); + """ Sanitize and standardize email address entries. As of rfc5322 section + 3.4.1 local-part is case-sensitive. However most main providers do consider + the local-part as case insensitive. With the introduction of smtp-utf8 + within odoo, this assumption is certain to fall short for international + emails. We now consider that + + * if local part is ascii: normalize still 'lower' ; + * else: use as it, SMTP-UF8 is made for non-ascii local parts; + + Concerning domain part of the address, as of v14 international domain (IDNA) + are handled fine. The domain is always lowercase, lowering it is fine as it + is probably an error. With the introduction of IDNA, there is an encoding + that allow non-ascii characters to be encoded to ascii ones, using 'idna.encode'. + + A normalized email is considered as : + - having a left part + @ + a right part (the domain can be without '.something') + - having no name before the address. Typically, having no 'Name <>' + Ex: + - Possible Input Email : 'Name <NaMe@DoMaIn.CoM>' + - Normalized Output Email : 'name@domain.com' + + :param boolean strict: if True, text should contain a single email + (default behavior in stable 14+). If more than one email is found no + 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 (strict=True), either 'tony@e.com' (strict=False). :return: False if no email found (or if more than 1 email found when being in strict mode); normalized email otherwise; @@ -569,7 +604,30 @@ def email_normalize(text, strict=True): emails = email_split(text) if not emails or (strict and len(emails) != 1): return False - return emails[0].lower() + + local_part, at, domain = emails[0].rpartition('@') + try: + local_part.encode('ascii') + except UnicodeEncodeError: + pass + else: + local_part = local_part.lower() + + return local_part + at + domain.lower() + +def email_normalize_all(text): + """ Tool method allowing to extract email addresses from a text input and returning + normalized version of all found emails. If no email is found, a void list + is returned. + + e.g. if email is 'tony@e.com, "Tony2" <tony2@e.com' returned result is ['tony@e.com, tony2@e.com'] + + :return list: list of normalized emails found in text + """ + if not text: + return [] + emails = email_split(text) + return list(filter(None, [email_normalize(email) for email in emails])) def email_domain_extract(email): """ Extract the company domain to be used by IAP services notably. Domain