From 6124094c94b0876ff353cb91bcbcf73651bf2bf1 Mon Sep 17 00:00:00 2001 From: Parthiv Patel Date: Sun, 3 Sep 2023 08:34:57 +0000 Subject: [PATCH] [PATCH] Upstream patch - 03092023 --- .../models/account_edi_xml_ubl_20.py | 10 +- addons/crm/models/crm_lead.py | 54 ++-- .../crm/tests/test_crm_lead_notification.py | 47 ++- addons/crm/tests/test_crm_pls.py | 3 +- addons/event/models/event_registration.py | 29 +- .../hr_recruitment/models/hr_recruitment.py | 20 +- .../l10n_it_stock_ddt/models/stock_picking.py | 3 +- .../report/l10n_it_ddt_report.xml | 7 +- addons/l10n_it_stock_ddt/tests/test_ddt.py | 6 + addons/mail/models/mail_channel.py | 4 +- addons/mail/models/mail_mail.py | 15 +- addons/mail/models/mail_template.py | 2 +- addons/mail/models/mail_thread.py | 23 +- addons/mail/models/mail_thread_blacklist.py | 2 +- addons/mail/models/models.py | 22 +- addons/mail/static/src/js/utils.js | 3 +- addons/mail/tests/test_res_partner.py | 78 +++++ addons/mass_mailing/models/mailing_contact.py | 13 +- addons/mass_mailing/tests/common.py | 5 +- .../wizard/mail_compose_message.py | 6 +- .../point_of_sale/data/point_of_sale_data.xml | 6 +- addons/project/models/project.py | 14 +- addons/purchase/models/purchase.py | 2 +- addons/purchase_stock/models/stock_rule.py | 11 +- .../tests/test_purchase_lead_time.py | 2 +- .../tests/test_lead_time.py | 14 +- addons/survey/wizard/survey_invite.py | 2 +- addons/test_mail/models/test_mail_models.py | 10 + addons/test_mail/tests/__init__.py | 1 + addons/test_mail/tests/test_mail_activity.py | 21 +- addons/test_mail/tests/test_mail_composer.py | 282 +++++++++++++++++- addons/test_mail/tests/test_mail_gateway.py | 60 ++++ addons/test_mail/tests/test_mail_mail.py | 114 ++++++- .../tests/test_mail_thread_mixins.py | 60 ++++ addons/test_mail/tests/test_mail_tools.py | 132 +++++--- addons/test_mail/tests/test_message_post.py | 64 +++- .../models/mailing_models.py | 63 +++- .../security/ir.model.access.csv | 2 + .../test_mass_mailing/tests/test_mailing.py | 132 ++++++++ .../src/snippets/s_image_gallery/000.js | 2 +- .../views/website_visitor_views.xml | 4 +- .../website_event_track/models/event_track.py | 17 +- 42 files changed, 1191 insertions(+), 176 deletions(-) create mode 100644 addons/test_mail/tests/test_mail_thread_mixins.py diff --git a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py index 981a013abf..23debfbd81 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py +++ b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py @@ -327,15 +327,9 @@ class AccountEdiXmlUBL20(models.AbstractModel): return { 'currency': line.currency_id, 'currency_dp': line.currency_id.decimal_places, - - # The requirement is the id has to be unique by invoice line. - 'id': line.id, - 'invoiced_quantity': line.quantity, 'invoiced_quantity_attrs': {'unitCode': uom}, - 'line_extension_amount': line.price_subtotal + total_fixed_tax_amount, - 'allowance_charge_vals': allowance_charge_vals_list, 'tax_total_vals': self._get_invoice_tax_totals_vals_list(line.move_id, taxes_vals), 'item_vals': self._get_invoice_line_item_vals(line, taxes_vals), @@ -382,9 +376,11 @@ class AccountEdiXmlUBL20(models.AbstractModel): invoice_lines = invoice.invoice_line_ids.filtered(lambda line: not line.display_type) document_allowance_charge_vals_list = self._get_document_allowance_charge_vals_list(invoice) invoice_line_vals_list = [] - for line in invoice_lines: + for line_id, line in enumerate(invoice_lines): line_taxes_vals = taxes_vals['invoice_line_tax_details'][line] line_vals = self._get_invoice_line_vals(line, line_taxes_vals) + if not line_vals.get('id'): + line_vals['id'] = line_id + 1 invoice_line_vals_list.append(line_vals) line_extension_amount += line_vals['line_extension_amount'] diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index fa2c699381..e07007d8c2 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -10,7 +10,7 @@ from psycopg2 import sql from flectra import api, fields, models, tools, SUPERUSER_ID from flectra.osv import expression from flectra.tools.translate import _ -from flectra.tools import email_re, email_split +from flectra.tools import email_split from flectra.exceptions import UserError, AccessError from flectra.addons.phone_validation.tools import phone_validation from collections import OrderedDict, defaultdict @@ -1492,11 +1492,13 @@ class Lead(models.Model): return res def _message_get_default_recipients(self): - return {r.id: { - 'partner_ids': [], - 'email_to': r.email_normalized, - 'email_cc': False} - for r in self} + return { + r.id: { + 'partner_ids': [], + 'email_to': ','.join(tools.email_normalize_all(r.email_from)) or r.email_from, + 'email_cc': False, + } for r in self + } def _message_get_suggested_recipients(self): recipients = super(Lead, self)._message_get_suggested_recipients() @@ -1549,23 +1551,41 @@ class Lead(models.Model): # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email_from or (self.email_normalized and partner.email_normalized == self.email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email_normalized', '=', new_partner[0].email_normalized) + else: + email_domain = ('email_from', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email_from', '=', new_partner.email), - ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False) + ]).write({'partner_id': new_partner[0].id}) return super(Lead, self)._message_post_after_hook(message, msg_vals) def _message_partner_info_from_emails(self, emails, link_mail=False): + """ Try to propose a better recipient when having only an email by populating + it with the partner_name / contact_name field of the lead e.g. if lead + contact_name is "Raoul" and email is "raoul@raoul.fr", suggest + "Raoul" 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 aa3df0f1a6..16fbfcfd25 100644 --- a/addons/crm/tests/test_crm_lead_notification.py +++ b/addons/crm/tests/test_crm_lead_notification.py @@ -1,11 +1,55 @@ # -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. -from .common import TestCrmCommon +from unittest import skip + +from flectra.addons.crm.tests.common import TestCrmCommon +from flectra.tests import tagged, users +from flectra.tools import mute_logger +@tagged('mail_thread', 'mail_gateway') class NewLeadNotification(TestCrmCommon): + @users('user_sales_manager') + @skip('Wait until test_predictive_lead_scoring issue is fixed') + def test_lead_message_get_suggested_recipient(self): + """ Test '_message_get_suggested_recipients' and its override in lead. """ + lead_format, lead_multi, lead_from, lead_partner = self.env['crm.lead'].create([ + { + 'email_from': '"New Customer" ', + '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" ', 'Customer Email'), + (False, '"Multi Name" ', 'Customer Email'), + (False, '"Std Name" ', 'Customer Email'), + (self.contact_1.id, '"Philip J Fry" ', 'Customer'), + ] + ): + with self.subTest(lead=lead, email_from=lead.email_from): + res = lead._message_get_suggested_recipients()[lead.id] + self.assertEqual(len(res), 1) + self.assertEqual(res[0], expected_suggested) + def test_new_lead_notification(self): """ Test newly create leads like from the website. People and channels subscribed to the Sales Team shoud be notified. """ @@ -42,6 +86,7 @@ class NewLeadNotification(TestCrmCommon): lead_user = lead.with_user(self.user_sales_manager) self.assertTrue(lead_user.message_needaction) + @mute_logger('flectra.addons.mail.models.mail_thread') def test_new_lead_from_email_multicompany(self): company0 = self.env.company company1 = self.env['res.company'].create({'name': 'new_company'}) diff --git a/addons/crm/tests/test_crm_pls.py b/addons/crm/tests/test_crm_pls.py index 899b6436ff..56333c302b 100644 --- a/addons/crm/tests/test_crm_pls.py +++ b/addons/crm/tests/test_crm_pls.py @@ -360,7 +360,8 @@ class TestCRMPLS(TransactionCase): Lead._cron_update_automated_probabilities() leads_with_tags.invalidate_cache() - self.assertEqual(tools.float_compare(leads[3].automated_probability, 4.21, 2), 0) + self.assertEqual(tools.float_compare(leads[3].automated_probability, 4.21, 2), 0, + f'PLS: failed with {leads[3].automated_probability}, should be 4.21') self.assertEqual(tools.float_compare(leads[8].automated_probability, 0.23, 2), 0) # remove all pls fields diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py index 64b1ddbd2a..ce58955306 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 flectra import _, api, fields, models, SUPERUSER_ID -from flectra.tools import format_datetime +from flectra.tools import format_datetime, email_normalize, email_normalize_all from flectra.exceptions import AccessError, ValidationError @@ -228,24 +228,31 @@ class EventRegistration(models.Model): def _message_get_default_recipients(self): # Prioritize registration email over partner_id, which may be shared when a single # partner booked multiple seats - return {r.id: { - 'partner_ids': [], - 'email_to': r.email, - 'email_cc': False} - for r in self} + return {r.id: + { + 'partner_ids': [], + 'email_to': ','.join(email_normalize_all(r.email)) or r.email, + 'email_cc': False, + } for r in self + } def _message_post_after_hook(self, message, msg_vals): if self.email and not self.partner_id: # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email) + email_normalized = email_normalize(self.email) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email or (email_normalized and partner.email_normalized == email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('email', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email', '=', new_partner.email), - ('state', 'not in', ['cancel']), - ]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('state', 'not in', ['cancel']), + ]).write({'partner_id': new_partner[0].id}) return super(EventRegistration, self)._message_post_after_hook(message, msg_vals) def action_send_badge_email(self): diff --git a/addons/hr_recruitment/models/hr_recruitment.py b/addons/hr_recruitment/models/hr_recruitment.py index 7833fed2da..8954b22bf2 100644 --- a/addons/hr_recruitment/models/hr_recruitment.py +++ b/addons/hr_recruitment/models/hr_recruitment.py @@ -403,7 +403,7 @@ class Applicant(models.Model): if applicant.partner_id: applicant._message_add_suggested_recipient(recipients, partner=applicant.partner_id, reason=_('Contact')) elif applicant.email_from: - email_from = applicant.email_from + email_from = tools.email_normalize(applicant.email_from) if applicant.partner_name: email_from = tools.formataddr((applicant.partner_name, email_from)) applicant._message_add_suggested_recipient(recipients, email=email_from, reason=_('Contact Email')) @@ -443,18 +443,24 @@ class Applicant(models.Model): # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from) + email_normalized = tools.email_normalize(self.email_from) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email_from or (email_normalized and partner.email_normalized == email_normalized) + ) if new_partner: - if new_partner.create_date.date() == fields.Date.today(): - new_partner.write({ + if new_partner[0].create_date.date() == fields.Date.today(): + new_partner[0].write({ 'type': 'private', 'phone': self.partner_phone, 'mobile': self.partner_mobile, }) + if new_partner[0].email_normalized: + email_domain = ('email_from', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('email_from', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email_from', '=', new_partner.email), - ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False) + ]).write({'partner_id': new_partner[0].id}) return super(Applicant, self)._message_post_after_hook(message, msg_vals) def create_employee_from_applicant(self): diff --git a/addons/l10n_it_stock_ddt/models/stock_picking.py b/addons/l10n_it_stock_ddt/models/stock_picking.py index 605530452e..3b7b5f5b29 100644 --- a/addons/l10n_it_stock_ddt/models/stock_picking.py +++ b/addons/l10n_it_stock_ddt/models/stock_picking.py @@ -74,8 +74,7 @@ class StockPickingType(models.Model): @api.model def create(self, vals): company = self.env['res.company'].browse(vals.get('company_id', False)) or self.env.company - if 'l10n_it_ddt_sequence_id' not in vals or not vals['l10n_it_ddt_sequence_id'] and vals['code'] == 'outgoing' \ - and company.country_id.code == 'IT': + if company.country_id.code == 'IT' and vals['code'] == 'outgoing' and ('l10n_it_ddt_sequence_id' not in vals or not vals['l10n_it_ddt_sequence_id']): ir_seq_name, ir_seq_prefix = self._get_dtt_ir_seq_vals(vals.get('warehouse_id'), vals['sequence_code']) vals['l10n_it_ddt_sequence_id'] = self.env['ir.sequence'].create({ 'name': ir_seq_name, diff --git a/addons/l10n_it_stock_ddt/report/l10n_it_ddt_report.xml b/addons/l10n_it_stock_ddt/report/l10n_it_ddt_report.xml index 6428e50dba..b8103bce1f 100644 --- a/addons/l10n_it_stock_ddt/report/l10n_it_ddt_report.xml +++ b/addons/l10n_it_stock_ddt/report/l10n_it_ddt_report.xml @@ -124,7 +124,12 @@ - + + + + + + diff --git a/addons/l10n_it_stock_ddt/tests/test_ddt.py b/addons/l10n_it_stock_ddt/tests/test_ddt.py index 2fb11951d6..69b55277d3 100644 --- a/addons/l10n_it_stock_ddt/tests/test_ddt.py +++ b/addons/l10n_it_stock_ddt/tests/test_ddt.py @@ -31,6 +31,12 @@ class TestDDT(TestSaleCommon): 'vat': 'IT12345670124' }) + @classmethod + def setup_company_data(cls, company_name, **kwargs): + return super().setup_company_data(company_name, **{ + **kwargs, + 'country_id': cls.env.ref('base.it').id, + }) def test_ddt_flow(self): """ diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 93d172e235..78d6f86ac6 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -9,7 +9,7 @@ from uuid import uuid4 from flectra import _, api, fields, models, modules, tools from flectra.exceptions import UserError, ValidationError from flectra.osv import expression -from flectra.tools import ormcache, formataddr +from flectra.tools import ormcache from flectra.exceptions import AccessError from flectra.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG from flectra.tools import html_escape @@ -378,7 +378,7 @@ class Channel(models.Model): # real mailing list: multiple recipients (hidden by X-Forge-To) if self.alias_domain and self.alias_name: return { - 'email_to': ','.join(formataddr((partner.name, partner.email_normalized)) for partner in whitelist if partner.email_normalized), + 'email_to': ','.join(partner.email_formatted for partner in whitelist if partner.email_normalized), 'recipient_ids': [], } return super(Channel, self)._notify_email_recipient_values(whitelist.ids) diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index c9bc695f45..c7616ce1b9 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -206,7 +206,14 @@ class MailMail(models.Model): body = self._send_prepare_body() body_alternative = tools.html2plaintext(body) if partner: - email_to = [tools.formataddr((partner.name or 'False', partner.email or 'False'))] + emails_normalized = tools.email_normalize_all(partner.email) + if emails_normalized: + email_to = [ + tools.formataddr((partner.name or "False", email or "False")) + for email in emails_normalized + ] + else: + email_to = [tools.formataddr((partner.name or "False", partner.email or "False"))] else: email_to = tools.email_split_and_format(self.email_to) res = { @@ -354,11 +361,15 @@ class MailMail(models.Model): # see rev. 56596e5240ef920df14d99087451ce6f06ac6d36 notifs.flush(fnames=['notification_status', 'failure_type', 'failure_reason'], records=notifs) + # protect against ill-formatted email_from when formataddr was used on an already formatted email + emails_from = tools.email_split_and_format(mail.email_from) + email_from = emails_from[0] if emails_from else mail.email_from + # build an RFC2822 email.message.Message object and send it without queuing res = None for email in email_list: msg = IrMailServer.build_email( - email_from=mail.email_from, + email_from=email_from, email_to=email.get('email_to'), subject=mail.subject, body=email.get('body'), diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 2c2bcbf774..0d7436acd0 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -154,7 +154,7 @@ class MailTemplate(models.Model): partner_to = values.pop('partner_to', '') if partner_to: # placeholders could generate '', 3, 2 due to some empty field values - tpl_partner_ids = [int(pid) for pid in partner_to.split(',') if pid] + tpl_partner_ids = [int(pid.strip()) for pid in partner_to.split(',') if (pid and pid.strip().isdigit())] partner_ids += self.env['res.partner'].sudo().browse(tpl_partner_ids).exists().ids results[res_id]['partner_ids'] = partner_ids return results diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 6ad690d398..96d8ee24c3 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1513,6 +1513,7 @@ class MailThread(models.AbstractModel): recipient in the result dictionary. The form is : partner_id, partner_name or partner_name, reason """ self.ensure_one() + partner_info = {} if email and not partner: # get partner info from email partner_info = self._message_partner_info_from_emails([email])[0] @@ -1527,9 +1528,9 @@ class MailThread(models.AbstractModel): if partner and partner.email: # complete profile: id, name result[self.ids[0]].append((partner.id, partner.email_formatted, reason)) elif partner: # incomplete profile: id, name - result[self.ids[0]].append((partner.id, '%s' % (partner.name), reason)) + result[self.ids[0]].append((partner.id, partner.name, reason)) else: # unknown partner, we are probably managing an email address - result[self.ids[0]].append((False, email, reason)) + result[self.ids[0]].append((False, partner_info.get('full_name') or email, reason)) return result def _message_get_suggested_recipients(self): @@ -1550,7 +1551,7 @@ class MailThread(models.AbstractModel): domain = [('email_normalized', 'in', normalized_emails)] if extra_domain: domain = expression.AND([domain, extra_domain]) - partners = self.env['res.users'].sudo().search(domain, order='name ASC').mapped('partner_id') + partners = self.env['res.users'].sudo().search(domain).mapped('partner_id') # return a search on partner to filter results current user should not see (multi company for example) return self.env['res.partner'].search([('id', 'in', partners.ids)]) @@ -1608,7 +1609,7 @@ class MailThread(models.AbstractModel): return matching_user if not matching_user: - std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1, order='name ASC') + std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1) matching_user = std_users[0] if std_users else self.env['res.users'] if matching_user: return matching_user @@ -1625,6 +1626,9 @@ class MailThread(models.AbstractModel): """ Utility method to find partners from email addresses. If no partner is found, create new partners if force_create is enabled. Search heuristics + * 0: clean incoming email list to use only normalized emails. Exclude + those used in aliases to avoid setting partner emails to emails + used as aliases; * 1: check in records (record set) followers if records is mail.thread enabled and if check_followers parameter is enabled; * 2: search for partners with user; @@ -1644,8 +1648,13 @@ class MailThread(models.AbstractModel): followers = self.env['res.partner'] catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain") - # first, build a normalized email list and remove those linked to aliases to avoid adding aliases as partners - normalized_emails = [tools.email_normalize(contact) for contact in emails if tools.email_normalize(contact)] + # first, build a normalized email list and remove those linked to aliases + # to avoid adding aliases as partners. In case of multi-email input, use + # the first found valid one to be tolerant against multi emails encoding + normalized_emails = [email_normalized + for email_normalized in (tools.email_normalize(contact, force_single=False) for contact in emails) + if email_normalized + ] if catchall_domain: domain_left_parts = [email.split('@')[0] for email in normalized_emails if email and email.split('@')[1] == catchall_domain.lower()] if domain_left_parts: @@ -1666,7 +1675,7 @@ class MailThread(models.AbstractModel): # iterate and keep ordering partners = [] for contact in emails: - normalized_email = tools.email_normalize(contact) + normalized_email = tools.email_normalize(contact, force_single=False) partner = next((partner for partner in done_partners if partner.email_normalized == normalized_email), self.env['res.partner']) if not partner and force_create and normalized_email in normalized_emails: partner = self.env['res.partner'].browse(self.env['res.partner'].name_create(contact)[0]) diff --git a/addons/mail/models/mail_thread_blacklist.py b/addons/mail/models/mail_thread_blacklist.py index d6dedc5138..bcef572b87 100644 --- a/addons/mail/models/mail_thread_blacklist.py +++ b/addons/mail/models/mail_thread_blacklist.py @@ -47,7 +47,7 @@ class MailBlackListMixin(models.AbstractModel): def _compute_email_normalized(self): self._assert_primary_email() for record in self: - record.email_normalized = tools.email_normalize(record[self._primary_email]) + record.email_normalized = tools.email_normalize(record[self._primary_email], force_single=False) @api.model def _search_is_blacklisted(self, operator, value): diff --git a/addons/mail/models/models.py b/addons/mail/models/models.py index 4771887c93..546eeafe2a 100644 --- a/addons/mail/models/models.py +++ b/addons/mail/models/models.py @@ -64,14 +64,20 @@ class BaseModel(models.AbstractModel): recipient_ids, email_to, email_cc = [], False, False if 'partner_id' in record and record.partner_id: recipient_ids.append(record.partner_id.id) - elif 'email_normalized' in record and record.email_normalized: - email_to = record.email_normalized - elif 'email_from' in record and record.email_from: - email_to = record.email_from - elif 'partner_email' in record and record.partner_email: - email_to = record.partner_email - elif 'email' in record and record.email: - email_to = record.email + else: + found_email = False + if 'email_from' in record and record.email_from: + found_email = record.email_from + elif 'partner_email' in record and record.partner_email: + found_email = record.partner_email + elif 'email' in record and record.email: + found_email = record.email + elif 'email_normalized' in record and record.email_normalized: + found_email = record.email_normalized + if found_email: + email_to = ','.join(tools.email_normalize_all(found_email)) + if not email_to: # keep value to ease debug / trace update + email_to = found_email res[record.id] = {'partner_ids': recipient_ids, 'email_to': email_to, 'email_cc': email_cc} return res diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index cad819e47e..f4030dad2b 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -155,7 +155,8 @@ function parseEmail(text) { if (text){ var result = text.match(/(.*)<(.*@.*)>/); if (result) { - return [_.str.trim(result[1]), _.str.trim(result[2])]; + name = _.str.trim(result[1]).replace(/(^"|"$)/g, '') + return [name, _.str.trim(result[2])]; } result = text.match(/(.*@.*)/); if (result) { diff --git a/addons/mail/tests/test_res_partner.py b/addons/mail/tests/test_res_partner.py index 7d6b5e7b24..127705d6e8 100644 --- a/addons/mail/tests/test_res_partner.py +++ b/addons/mail/tests/test_res_partner.py @@ -3,8 +3,10 @@ from flectra.addons.mail.tests.common import MailCommon from flectra.tests.common import Form, users +from flectra.tests import tagged +@tagged('res_partner') class TestPartner(MailCommon): def test_res_partner_find_or_create(self): @@ -27,6 +29,82 @@ class TestPartner(MailCommon): self.assertEqual(new2.email, '2patrick@example.com') self.assertEqual(new2.email_normalized, '2patrick@example.com') + @users('admin') + def test_res_partner_find_or_create_email(self): + """ Test 'find_or_create' tool used in mail, notably when linking emails + found in recipients to partners when sending emails using the mail + composer. """ + partners = self.env['res.partner'].create([ + { + 'email': 'classic.format@test.example.com', + 'name': 'Classic Format', + }, + { + 'email': '"FindMe Format" ', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" ', + 'name': 'FindMe Multi', + }, + ]) + # check data used for finding / searching + self.assertEqual( + partners.mapped('email_formatted'), + ['"Classic Format" ', + '"FindMe Format" ', + '"FindMe Multi" '] + ) + # 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" '): + 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" '): + with self.subTest(email=email): + self.assertEqual(self.env['res.partner'].find_or_create(email), partners[1]) + # multi-emails -> no normalized email -> fails each time, create new partner (FIXME) + for email_input, match_partner in [ + ('find.me.multi.1@test.example.com', partners[2]), + ('find.me.multi.2@test.example.com', self.env['res.partner']), + ]: + with self.subTest(email_input=email_input): + partner = self.env['res.partner'].find_or_create(email_input) + # either matching existing, either new partner + if match_partner: + self.assertEqual(partner, match_partner) + else: + self.assertNotIn(partner, partners) + self.assertEqual(partner.email, email_input) + partner.unlink() # do not mess with subsequent tests + + # now input is multi email -> '_parse_partner_name' used in 'find_or_create' + # before trying to normalize is quite tolerant, allowing positive checks + for email_input, match_partner, exp_email_partner in [ + ('classic.format@test.example.com,another.email@test.example.com', + partners[0], 'classic.format@test.example.com'), # first found email matches existing + ('another.email@test.example.com,classic.format@test.example.com', + self.env['res.partner'], 'another.email@test.example.com'), # first found email does not match + ('find.me.multi.1@test.example.com,find.me.multi.2@test.example.com', + self.env['res.partner'], 'find.me.multi.1@test.example.com'), + ]: + with self.subTest(email_input=email_input): + partner = self.env['res.partner'].find_or_create(email_input) + # either matching existing, either new partner + if match_partner: + self.assertEqual(partner, match_partner) + else: + self.assertNotIn(partner, partners) + self.assertEqual(partner.email, exp_email_partner) + if partner not in partners: + partner.unlink() # do not mess with subsequent tests + @users('admin') def test_res_partner_merge_wizards(self): Partner = self.env['res.partner'] diff --git a/addons/mass_mailing/models/mailing_contact.py b/addons/mass_mailing/models/mailing_contact.py index c9df24ce3b..ba3f206769 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, Flectra. See LICENSE file for full copyright and licensing details. -from flectra import api, fields, models +from flectra import api, fields, models, tools from flectra.osv import expression @@ -162,9 +162,10 @@ class MassMailingContact(models.Model): return contact.name_get()[0] def _message_get_default_recipients(self): - return {r.id: { - 'partner_ids': [], - 'email_to': r.email_normalized, - 'email_cc': False} - for r in self + return { + r.id: { + 'partner_ids': [], + 'email_to': ','.join(tools.email_normalize_all(r.email)) or r.email, + 'email_cc': False, + } for r in self } diff --git a/addons/mass_mailing/tests/common.py b/addons/mass_mailing/tests/common.py index 9775dc9d9a..57d49da45b 100644 --- a/addons/mass_mailing/tests/common.py +++ b/addons/mass_mailing/tests/common.py @@ -48,6 +48,8 @@ class MassMailCase(MailCase, MockLinkTracker): 'record: linked record, # MAIL.MAIL 'content': optional content that should be present in mail.mail body_html; + 'email_to_mail': optional email used for the mail, when different from the + one stored on the trace itself; 'email_to_recipients': optional, see '_assertMailMail'; 'failure_type': optional failure reason; }, { ... }] @@ -97,6 +99,7 @@ class MassMailCase(MailCase, MockLinkTracker): for recipient_info, link_info, record in zip(recipients_info, mail_links_info, records): partner = recipient_info.get('partner', self.env['res.partner']) email = recipient_info.get('email') + email_to_mail = recipient_info.get('email_to_mail') or email email_to_recipients = recipient_info.get('email_to_recipients') state = recipient_info.get('state', 'sent') record = record or recipient_info.get('record') @@ -150,7 +153,7 @@ class MassMailCase(MailCase, MockLinkTracker): ) else: self.assertMailMailWEmails( - [email], state_mapping[state], + [email_to_mail], state_mapping[state], author=author, content=content, email_to_recipients=email_to_recipients, diff --git a/addons/mass_mailing/wizard/mail_compose_message.py b/addons/mass_mailing/wizard/mail_compose_message.py index d0bf4aa15f..3982422b2c 100644 --- a/addons/mass_mailing/wizard/mail_compose_message.py +++ b/addons/mass_mailing/wizard/mail_compose_message.py @@ -60,12 +60,12 @@ class MailComposeMessage(models.TransientModel): for res_id in res_ids: mail_values = res[res_id] if mail_values.get('email_to'): - mail_to = tools.email_normalize(mail_values['email_to']) + mail_to = tools.email_normalize(mail_values['email_to'], force_single=False) else: partner_id = (mail_values.get('recipient_ids') or [(False, '')])[0][1] - mail_to = tools.email_normalize(partners_email.get(partner_id)) + mail_to = tools.email_normalize(partners_email.get(partner_id), force_single=False) if (opt_out_list and mail_to in opt_out_list) or (seen_list and mail_to in seen_list) \ - or (not mail_to or not email_re.findall(mail_to)): + or not mail_to: # prevent sending to blocked addresses that were included by mistake mail_values['state'] = 'cancel' elif seen_list is not None: diff --git a/addons/point_of_sale/data/point_of_sale_data.xml b/addons/point_of_sale/data/point_of_sale_data.xml index 4ab51e1542..2e514b7051 100644 --- a/addons/point_of_sale/data/point_of_sale_data.xml +++ b/addons/point_of_sale/data/point_of_sale_data.xml @@ -1,5 +1,9 @@ + + + + @@ -8,8 +12,6 @@ - - PoS diff --git a/addons/project/models/project.py b/addons/project/models/project.py index b5721cd550..04fd7abd99 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -1360,12 +1360,18 @@ class Task(models.Model): # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from) + email_normalized = tools.email_normalize(self.email_from) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.email_from or (email_normalized and partner.email_normalized == email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('email_from', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('email_from', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('email_from', '=', new_partner.email), - ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False) + ]).write({'partner_id': new_partner[0].id}) return super(Task, self)._message_post_after_hook(message, msg_vals) def action_assign_to_me(self): diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index a52c03c471..3c1a23b5c2 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -1223,7 +1223,7 @@ class PurchaseOrderLine(models.Model): seller = product_id.with_company(company_id)._select_seller( partner_id=partner, quantity=uom_po_qty, - date=po.date_order and po.date_order.date(), + date=max(po.date_order and po.date_order.date(), fields.Date.today()), uom_id=product_id.uom_po_id) taxes = product_id.supplier_taxes_id diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py index 60d6399448..aadafa04cb 100644 --- a/addons/purchase_stock/models/stock_rule.py +++ b/addons/purchase_stock/models/stock_rule.py @@ -60,7 +60,7 @@ class StockRule(models.Model): supplier = procurement.product_id.with_company(procurement.company_id.id)._select_seller( partner_id=procurement.values.get("supplierinfo_name"), quantity=procurement.product_qty, - date=schedule_date.date(), + date=max(schedule_date.date(), fields.Date.today()), uom_id=procurement.product_uom) # Fall back on a supplier for which no price may be defined. Not ideal, but better than @@ -259,17 +259,16 @@ class StockRule(models.Model): res['orderpoint_id'] = orderpoint_id.id return res - def _get_po_date(self, company_id, values): - purchase_date = min([fields.Datetime.from_string(value['date_planned']) - relativedelta(days=int(value['supplier'].delay)) for value in values]) - return purchase_date - relativedelta(days=company_id.po_lead) - def _prepare_purchase_order(self, company_id, origins, values): """ Create a purchase order for procuremets that share the same domain returned by _make_po_get_domain. params values: values of procurements params origins: procuremets origins to write on the PO """ - purchase_date = max(self._get_po_date(company_id, values), fields.Datetime.now()) + purchase_date = min([fields.Datetime.from_string(value['date_planned']) - relativedelta(days=int(value['supplier'].delay)) for value in values]) + + purchase_date = (purchase_date - relativedelta(days=company_id.po_lead)) + # Since the procurements are grouped if they share the same domain for # PO but the PO does not exist. In this case it will create the PO from diff --git a/addons/purchase_stock/tests/test_purchase_lead_time.py b/addons/purchase_stock/tests/test_purchase_lead_time.py index af94271656..97359c9ad3 100644 --- a/addons/purchase_stock/tests/test_purchase_lead_time.py +++ b/addons/purchase_stock/tests/test_purchase_lead_time.py @@ -361,5 +361,5 @@ class TestPurchaseLeadTime(PurchaseTestCommon): purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.partner_1.id)]) today = fields.Datetime.start_of(fields.Datetime.now(), 'day') - self.assertEqual(fields.Datetime.start_of(purchase_order.date_order, 'day'), today) + self.assertEqual(purchase_order.date_order, today) self.assertEqual(fields.Datetime.start_of(purchase_order.date_planned, 'day'), today + timedelta(days=7)) diff --git a/addons/sale_purchase_stock/tests/test_lead_time.py b/addons/sale_purchase_stock/tests/test_lead_time.py index f38dafe94f..2183bb5bd1 100644 --- a/addons/sale_purchase_stock/tests/test_lead_time.py +++ b/addons/sale_purchase_stock/tests/test_lead_time.py @@ -29,18 +29,19 @@ class TestLeadTime(TestCommonSalePurchaseNoChart): def test_supplier_lead_time(self): """ Basic stock configuration and a supplier with a minimum qty and a lead time """ + self.env.user.company_id.po_lead = 7 seller = self.env['product.supplierinfo'].create({ 'name': self.vendor.id, 'min_qty': 1, - 'price': 1, - 'delay': 7, + 'price': 10, + 'date_start': fields.Date.today() - timedelta(days=1), }) product = self.env['product.product'].create({ 'name': 'corpse starch', 'type': 'product', 'seller_ids': [(6, 0, seller.ids)], - 'route_ids': [(6, 0, (self.mto_route + self.buy_route).ids)] + 'route_ids': [(6, 0, (self.mto_route + self.buy_route).ids)], }) so = self.env['sale.order'].with_user(self.user_salesperson).create({ @@ -59,9 +60,4 @@ class TestLeadTime(TestCommonSalePurchaseNoChart): so.action_confirm() po = self.env['purchase.order'].search([('partner_id', '=', self.vendor.id)]) - - start_of_day = lambda x: fields.Datetime.start_of(x, 'day') - today = start_of_day(fields.Datetime.now()) - - self.assertEqual(start_of_day(po.date_order), today) - self.assertEqual(start_of_day(po.date_planned), today + timedelta(days=7)) + self.assertEqual(po.order_line.price_unit, seller.price) diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index 05dbd88903..fcc78486be 100644 --- a/addons/survey/wizard/survey_invite.py +++ b/addons/survey/wizard/survey_invite.py @@ -20,7 +20,7 @@ class SurveyInvite(models.TransientModel): @api.model def _get_default_from(self): if self.env.user.email: - return tools.formataddr((self.env.user.name, self.env.user.email)) + return self.env.user.email_formatted raise UserError(_("Unable to post message, please configure the sender's email address.")) @api.model diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py index e7e1c7627c..6db2402843 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 631462616c..d4baf2016e 100644 --- a/addons/test_mail/tests/__init__.py +++ b/addons/test_mail/tests/__init__.py @@ -13,6 +13,7 @@ from . import test_mail_gateway from . import test_mail_multicompany from . import test_mail_template_preview from . import test_mail_thread_internals +from . import test_mail_thread_mixins from . import test_mail_template from . import test_mail_tools from . import test_message_management diff --git a/addons/test_mail/tests/test_mail_activity.py b/addons/test_mail/tests/test_mail_activity.py index c2e75c98f1..fd16622e55 100644 --- a/addons/test_mail/tests/test_mail_activity.py +++ b/addons/test_mail/tests/test_mail_activity.py @@ -366,41 +366,42 @@ class TestActivityMixin(TestActivityCommon): record = self.env['mail.test.activity'].search([('my_activity_date_deadline', '=', date_today)]) self.assertEqual(test_record_1, record) -class TestReadProgressBar(tests.TransactionCase): +@tests.tagged('mail_activity') +class TestORM(TestActivityCommon): """Test for read_progress_bar""" def test_week_grouping(self): """The labels associated to each record in read_progress_bar should match the ones from read_group, even in edge cases like en_US locale on sundays """ - model = self.env['mail.test.activity'].with_context(lang='en_US') + MailTestActivityCtx = self.env['mail.test.activity'].with_context({"lang": "en_US"}) # Don't mistake fields date and date_deadline: # * date is just a random value # * date_deadline defines activity_state - model.create({ + self.env['mail.test.activity'].create({ 'date': '2021-05-02', 'name': "Yesterday, all my troubles seemed so far away", }).activity_schedule( 'test_mail.mail_act_test_todo', summary="Make another test super asap (yesterday)", - date_deadline=fields.Date.context_today(model) - timedelta(days=7), + date_deadline=fields.Date.context_today(MailTestActivityCtx) - timedelta(days=7), ) - model.create({ + self.env['mail.test.activity'].create({ 'date': '2021-05-09', 'name': "Things we said today", }).activity_schedule( 'test_mail.mail_act_test_todo', summary="Make another test asap", - date_deadline=fields.Date.context_today(model), + date_deadline=fields.Date.context_today(MailTestActivityCtx), ) - model.create({ + self.env['mail.test.activity'].create({ 'date': '2021-05-16', 'name': "Tomorrow Never Knows", }).activity_schedule( 'test_mail.mail_act_test_todo', summary="Make a test tomorrow", - date_deadline=fields.Date.context_today(model) + timedelta(days=7), + date_deadline=fields.Date.context_today(MailTestActivityCtx) + timedelta(days=7), ) domain = [('date', "!=", False)] @@ -415,8 +416,8 @@ class TestReadProgressBar(tests.TransactionCase): } # call read_group to compute group names - groups = model.read_group(domain, fields=['date'], groupby=[groupby]) - progressbars = model.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar) + groups = MailTestActivityCtx.read_group(domain, fields=['date'], groupby=[groupby]) + progressbars = MailTestActivityCtx.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar) self.assertEqual(len(groups), 3) self.assertEqual(len(progressbars), 3) diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index a96bbfcb27..df47f3f30d 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -380,7 +380,7 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.mail_server_id.id, False) @users('employee') - @mute_logger('flectra.tests', 'flectra.addons.mail.models.mail_mail') + @mute_logger('flectra.addons.mail.models.mail_mail', 'flectra.models.unlink', 'flectra.tests') def test_mail_composer_parent(self): """ Test specific management in comment mode when having parent_id set: record_name, subject, parent's partners. """ @@ -397,6 +397,7 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.body, '

Test Body

') self.assertEqual(composer.partner_ids, self.partner_1 + self.partner_2) + @mute_logger('flectra.addons.mail.models.mail_mail', 'flectra.models.unlink', 'flectra.tests') def test_mail_composer_rights_portal(self): portal_user = self._create_portal_user() @@ -447,7 +448,7 @@ class TestComposerResultsComment(TestMailComposer): notification and emails generated during this process. """ @users('employee') - @mute_logger('flectra.tests', 'flectra.addons.mail.models.mail_mail') + @mute_logger('flectra.addons.mail.models.mail_mail', 'flectra.models.unlink', 'flectra.tests') def test_mail_composer_notifications_delete(self): """ Notifications are correctly deleted once sent """ composer = self.env['mail.compose.message'].with_context( @@ -489,7 +490,7 @@ class TestComposerResultsComment(TestMailComposer): self.assertEqual(len(self._new_mails.exists()), 2, 'Should not have deleted mail.mail records') @users('employee') - @mute_logger('flectra.tests', 'flectra.addons.mail.models.mail_mail') + @mute_logger('flectra.addons.mail.models.mail_mail', 'flectra.models.unlink', 'flectra.tests') def test_mail_composer_recipients(self): """ Test partner_ids given to composer are given to the final message. """ composer = self.env['mail.compose.message'].with_context( @@ -605,6 +606,146 @@ class TestComposerResultsComment(TestMailComposer): self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(self.test_record.ids)) self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments') + @users('employee') + @mute_logger('flectra.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" ', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" ', + 'name': 'FindMe Multi', + } + ]) + email_ccs = ['"Raoul" ', '"Raoulette" ', 'test.cc.2.2@example.com>', 'invalid', ' '] + email_tos = ['"Micheline, l\'Immense" ', 'test.to.2@example.com', 'wrong', ' '] + + self.template.write({ + 'email_cc': ', '.join(email_ccs), + 'email_from': '${user.email_formatted | safe}', + 'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')), + 'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test', + }) + self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'}) + self.partner_1.write({'email': '"Valid Formatted" '}) + 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" ', + 'Formatting: wrong formatting due to multi-email') + self.assertEqual( + self.partner_1.email_formatted, + '"Valid Lelitre" ', + 'Formatting: avoid wrong double encapsulation') + self.assertEqual( + self.partner_2.email_formatted, + '"Valid Poilvache" ', + 'Formatting: wrong formatting due to multi-email') + + # instantiate composer, post message + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context( + self.test_record, + add_web=True, + default_template_id=self.template.id, + ) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer.send_mail() + + # find partners created during sending (as emails are transformed into partners) + # FIXME: currently email finding based on formatted / multi emails does + # not work + new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)]) + self.assertEqual(len(new_partners), 8, + 'Mail (FIXME): multiple partner creation due to formatted / multi emails: 1 extra partners') + self.assertIn(partner_format_tofind, new_partners) + self.assertIn(partner_multi_tofind, new_partners) + self.assertEqual( + sorted(new_partners.mapped('email')), + sorted(['"FindMe Format" ', + 'find.me.multi.1@test.example.com, "FindMe Multi" ', + '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" ', + '"FindMe Multi" ', + '"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" ']), + ) + 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}" ', f'"{self.partner_2.name}" '], + ] + [[new_partners[0]['email_formatted']], + ['"FindMe Multi" ', '"FindMe Multi" '] + ] + [[email] for email in new_partners[2:].mapped('email_formatted')], + email_values={ + 'body_content': f'TemplateBody {self.test_record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com')), + 'subject': f'TemplateSubject {self.test_record.name}', + }, + fields_values={ + # currently holding multi-email 'email_from' + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com,email.from.2@test.example.com')), + }, + mail_message=self.test_record.message_ids[0], + ) + @tagged('mail_composer') class TestComposerResultsMass(TestMailComposer): @@ -908,3 +1049,138 @@ class TestComposerResultsMass(TestMailComposer): composer = composer_form.save() with self.mock_mail_gateway(mail_unlink_sent=False), self.assertRaises(ValueError): composer.send_mail() + + @users('employee') + @mute_logger('flectra.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" ', + 'name': 'FindMe Format', + }, { + 'email': 'find.me.multi.1@test.example.com, "FindMe Multi" ', + 'name': 'FindMe Multi', + } + ]) + email_ccs = ['"Raoul" ', '"Raoulette" ', 'test.cc.2.2@example.com>', 'invalid', ' '] + email_tos = ['"Micheline, l\'Immense" ', 'test.to.2@example.com', 'wrong', ' '] + + self.template.write({ + 'email_cc': ', '.join(email_ccs), + 'email_from': '${user.email_formatted | safe}', + 'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')), + 'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test', + }) + self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'}) + self.partner_1.write({'email': '"Valid Formatted" '}) + 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" ', + 'Formatting: wrong formatting due to multi-email') + self.assertEqual( + self.partner_1.email_formatted, + '"Valid Lelitre" ', + 'Formatting: avoid wrong double encapsulation') + self.assertEqual( + self.partner_2.email_formatted, + '"Valid Poilvache" ', + 'Formatting: wrong formatting due to multi-email') + + # instantiate composer, send mailing + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context( + self.test_records, + add_web=True, + default_template_id=self.template.id, + ) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer.send_mail() + + # find partners created during sending (as emails are transformed into partners) + # FIXME: currently email finding based on formatted / multi emails does + # not work + new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)]) + self.assertEqual(len(new_partners), 8, + 'Mail (FIXME): did not find existing partners for formatted / multi emails: 1 extra partners') + self.assertIn(partner_format_tofind, new_partners) + self.assertIn(partner_multi_tofind, new_partners) + self.assertEqual( + sorted(new_partners.mapped('email')), + sorted(['"FindMe Format" ', + 'find.me.multi.1@test.example.com, "FindMe Multi" ', + '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" ', + '"FindMe Multi" ', + '"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" ']), + ) + 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}" ', f'"{self.partner_2.name}" '], + ] + [[new_partners[0]['email_formatted']], + ['"FindMe Multi" ', '"FindMe Multi" '] + ] + [[email] for email in new_partners[2:].mapped('email_formatted')], + email_values={ + 'body_content': f'TemplateBody {record.name}', + # single email event if email field is multi-email + 'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com')), + 'reply_to': formataddr(( + f'{self.env.user.company_id.name} {record.name}', + f'{self.alias_catchall}@{self.alias_domain}' + )), + 'subject': f'TemplateSubject {record.name}', + }, + fields_values={ + # currently holding multi-email 'email_from' + 'email_from': self.partner_employee.email_formatted, + 'reply_to': formataddr(( + f'{self.env.user.company_id.name} {record.name}', + f'{self.alias_catchall}@{self.alias_domain}' + )), + }, + mail_message=record.message_ids[0], # message copy is kept + ) diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py index 7bb01e878a..1a8206cae5 100644 --- a/addons/test_mail/tests/test_mail_gateway.py +++ b/addons/test_mail/tests/test_mail_gateway.py @@ -430,6 +430,31 @@ class TestMailgateway(TestMailCommon): self.assertEqual(record.message_ids[0].email_from, self.partner_1.email) self.assertNotSentEmail() # No notification / bounce should be sent + @mute_logger('flectra.addons.mail.models.mail_thread', 'flectra.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" '}) + 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('flectra.addons.mail.models.mail_thread', 'flectra.models') def test_message_process_email_partner_find(self): """ Finding the partner based on email, based on partner / user / follower """ @@ -621,6 +646,39 @@ class TestMailgateway(TestMailCommon): # Test: one group created by Raoul (or Sylvie maybe, if we implement it) self.assertEqual(len(record), 1) + @mute_logger('flectra.addons.mail.models.mail_thread', 'flectra.models') + def test_message_process_alias_followers_multiemail(self): + """ Incoming email from a parent document follower on a Followers only + alias depends on email_from / partner recognition, to be tested when + dealing with multi emails / formatted emails. """ + self.alias.write({ + 'alias_contact': 'followers', + 'alias_parent_model_id': self.env['ir.model']._get('mail.test.gateway').id, + 'alias_parent_thread_id': self.test_record.id, + }) + self.test_record.message_subscribe(partner_ids=[self.partner_1.id]) + email_from = formataddr(("Another Name", self.partner_1.email_normalized)) + + for partner_email, passed in [ + (formataddr((self.partner_1.name, self.partner_1.email_normalized)), True), + (f'{self.partner_1.email_normalized}, "Multi Email" ', True), + (f'"Multi Email" , {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('flectra.addons.mail.models.mail_thread', 'flectra.models.unlink', 'flectra.addons.mail.models.mail_mail') def test_message_process_alias_update(self): """ Incoming email update discussion + notification email """ @@ -1573,6 +1631,7 @@ class TestMailgateway(TestMailCommon): # Corner cases / Bugs during message process # -------------------------------------------------- + @mute_logger('flectra.addons.mail.models.mail_thread') def test_message_process_file_encoding_ascii(self): """ Incoming email containing an xml attachment with unknown characters (οΏ½) but an ASCII charset should not raise an Exception. UTF-8 is used as a safe fallback. @@ -1598,6 +1657,7 @@ class TestMailgateway(TestMailCommon): self.assertEqual(record.message_main_attachment_id.name, 'bis3.xml') self.assertEqual("ChaussΓ©e de Bruxelles", record.message_main_attachment_id.raw.decode()) +@tagged('mail_gateway', 'mail_thread') class TestMailThreadCC(TestMailCommon): @classmethod diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py index 8eb6d2818b..756c49c076 100644 --- a/addons/test_mail/tests/test_mail_mail.py +++ b/addons/test_mail/tests/test_mail_mail.py @@ -3,7 +3,7 @@ import psycopg2 -from flectra import api +from flectra import api, tools from flectra.addons.test_mail.tests.common import TestMailCommon from flectra.tests import common, tagged from flectra.tools import mute_logger @@ -23,7 +23,7 @@ class TestMailMail(TestMailCommon): }).with_context({}) @mute_logger('flectra.addons.mail.models.mail_mail') - def test_mail_message_notify_from_mail_mail(self): + def test_mail_mail_notify_from_mail_mail(self): # Due ot post-commit hooks, store send emails in every step mail = self.env['mail.mail'].sudo().create({ 'body_html': '

Test

', @@ -35,6 +35,7 @@ class TestMailMail(TestMailCommon): self.assertSentEmail(mail.env.user.partner_id, ['test@example.com']) self.assertEqual(len(self._mails), 1) + @mute_logger('flectra.addons.mail.models.mail_mail') def test_mail_mail_return_path(self): # mail without thread-enabled record base_values = { @@ -74,7 +75,116 @@ class TestMailMail(TestMailCommon): mail.send() self.assertEqual(self._mails[0]['headers']['Return-Path'], '%s@%s' % (self.alias_bounce, self.alias_domain)) + @mute_logger('flectra.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" ', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '

Test

', + 'email_cc': '"Ignasse, le Poilu" ', + 'email_to': '"Raoul, le Grand" , "Micheline, l\'immense" ', + '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" ', '"Micheline, l\'immense" ']), + [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('flectra.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': '

Test

', + '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" ', + }) + mail = self.env['mail.mail'].create({ + 'body_html': '

Test

', + '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('flectra.addons.mail.models.mail_mail') + def test_mail_mail_values_unicode(self): + """ Unicode should be fine. """ + mail = self.env['mail.mail'].create({ + 'body_html': '

Test

', + 'email_cc': 'test.😊.cc@example.com', + 'email_to': 'test.😊@example.com', + }) + with self.mock_mail_gateway(): + mail.send() + self.assertEqual(len(self._mails), 1) + self.assertEqual(self._mails[0]['email_cc'], ['test.😊.cc@example.com']) + self.assertEqual(self._mails[0]['email_to'], ['test.😊@example.com']) + + +@tagged('mail_mail') class TestMailMailRace(common.TransactionCase): @mute_logger('flectra.addons.mail.models.mail_mail') diff --git a/addons/test_mail/tests/test_mail_thread_mixins.py b/addons/test_mail/tests/test_mail_thread_mixins.py new file mode 100644 index 0000000000..84daecfc61 --- /dev/null +++ b/addons/test_mail/tests/test_mail_thread_mixins.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. + +from flectra import exceptions, tools +from flectra.addons.test_mail.tests.common import TestMailCommon, TestRecipients +from flectra.tests.common import tagged +from flectra.tools import mute_logger + + +@tagged('mail_thread', 'mail_blacklist') +class TestMailThread(TestMailCommon, TestRecipients): + + @mute_logger('flectra.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'), + ] + void_pairs = [(False, False), + ('', False), + (' ', False)] + multi_pairs = [ + (f'{base_email}, other.email@test.example.com', + base_email), # multi supports first found + (f'{tools.formataddr(("Another Name", base_email))}, other.email@test.example.com', + base_email), # multi supports first found + ] + for email_from, exp_email_normalized in valid_pairs + void_pairs + multi_pairs: + with self.subTest(email_from=email_from, exp_email_normalized=exp_email_normalized): + new_record = self.env['mail.test.gateway'].create({ + 'email_from': email_from, + 'name': 'BL Test', + }) + self.assertEqual(new_record.email_normalized, exp_email_normalized) + self.assertFalse(new_record.is_blacklisted) + + # blacklist email should fail as void + if email_from in [pair[0] for pair in void_pairs]: + with self.assertRaises(exceptions.UserError): + bl_record = self.env['mail.blacklist']._add(email_from) + # blacklist email currently fails but could not + elif email_from in [pair[0] for pair in multi_pairs]: + with self.assertRaises(exceptions.UserError): + bl_record = self.env['mail.blacklist']._add(email_from) + # blacklist email ok + else: + bl_record = self.env['mail.blacklist']._add(email_from) + self.assertEqual(bl_record.email, exp_email_normalized) + new_record.invalidate_cache(fnames=['is_blacklisted']) + self.assertTrue(new_record.is_blacklisted) + + bl_record.unlink() diff --git a/addons/test_mail/tests/test_mail_tools.py b/addons/test_mail/tests/test_mail_tools.py index 95fedb0b08..306dd47b72 100644 --- a/addons/test_mail/tests/test_mail_tools.py +++ b/addons/test_mail/tests/test_mail_tools.py @@ -22,62 +22,124 @@ class TestMailTools(TestMailCommon, TestRecipients): }) @users('employee') - def test_find_partner_from_emails(self): + def test_mail_find_partner_from_emails(self): Partner = self.env['res.partner'] test_partner = Partner.browse(self.test_partner.ids) self.assertEqual(test_partner.email, self._test_email) - # test direct match - found = Partner._mail_find_partner_from_emails([self._test_email]) - self.assertEqual(found, [test_partner]) - - # test encapsulated email - found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email]) - self.assertEqual(found, [test_partner]) + sources = [ + self._test_email, # test direct match + f'"Norbert Poiluchette" <{self._test_email}>', # encapsulated + 'fredoastaire@test.example.com', # partial email -> should not match ! + ] + expected_partners = [ + test_partner, + test_partner, + self.env['res.partner'], + ] + for source, expected_partner in zip(sources, expected_partners): + with self.subTest(source=source): + found = Partner._mail_find_partner_from_emails([source]) + self.assertEqual(found, [expected_partner]) # test with wildcard "_" found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com']) self.assertEqual(found, [self.env['res.partner']]) - # sub-check: this search does not consider _ as a wildcard found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com']) self.assertEqual(found, self.env['res.partner']) # test partners with encapsulated emails # ------------------------------------------------------------ - test_partner.sudo().write({'email': '"Alfred Mighty Power Astaire" <%s>' % self._test_email}) + test_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}) + @users('employee') + def test_mail_find_partner_from_emails_followers(self): + """ Test '_mail_find_partner_from_emails' when dealing with records on + which followers have to be found based on email. Check multi email + and encapsulated email support. """ + linked_record = self.env['mail.test.simple'].create({'name': 'Record for followers'}) + follower_partner = self.env['res.partner'].sudo().create({ + 'email': self._test_email, + 'name': 'Duplicated, follower of record', + }) + linked_record.message_subscribe(partner_ids=follower_partner.ids) + test_partner = self.test_partner.with_env(self.env) - # 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']]) + # 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" ' + (follower_partner + test_partner).sudo().write({'email': f'{self._test_email}, {_test_email_2}'}) + sources = [ + (self._test_email, True), # first email + (self._test_email, False), # first email + (_test_email_2, True), # second email + (_test_email_2, False), # second email + ('not.alfredoastaire@test.example.com', True), # normalized second email in field + ('not.alfredoastaire@test.example.com', False), # normalized second email in field + (f'{self._test_email}, {_test_email_2}', True), # multi-email, both matching, depends on comparison + (f'{self._test_email}, {_test_email_2}', False) # multi-email, both matching, depends on comparison + ] + expected = [follower_partner, test_partner, + self.env['res.partner'], self.env['res.partner'], + self.env['res.partner'], self.env['res.partner'], + follower_partner, test_partner] + for (source, follower_check), expected in zip(sources, expected): + with self.subTest(source=source, follower_check=follower_check): + partner = self.env['res.partner']._mail_find_partner_from_emails( + [source], records=linked_record if follower_check else None + )[0] + self.assertEqual(partner, expected, + 'Mail (FIXME): partial recognition of multi email through email_normalize') diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index c660bf4bc2..d9d4f7d7fe 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -37,12 +37,13 @@ class TestMessagePost(TestMailCommon, TestRecipients): self.assertTrue(isinstance(messageId, int)) @users('employee') + @mute_logger('flectra.addons.mail.models.mail_mail') def test_notify_mail_add_signature(self): - self.test_track = self.env['mail.test.track'].with_context(self._test_context).with_user(self.user_employee).create({ + test_track = self.env['mail.test.track'].with_context(self._test_context).with_user(self.user_employee).create({ 'name': 'Test', 'email_from': 'ignasse@example.com' }) - self.test_track.user_id = self.env.user + test_track.user_id = self.env.user signature = self.env.user.signature @@ -50,13 +51,13 @@ class TestMessagePost(TestMailCommon, TestRecipients): self.assertIn("record.user_id.sudo().signature", template.arch) with self.mock_mail_gateway(): - self.test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=True, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow") + test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=True, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow") found_mail = self._new_mails self.assertIn(signature, found_mail.body_html) self.assertEqual(found_mail.body_html.count(signature), 1) with self.mock_mail_gateway(): - self.test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=False, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow") + test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=False, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow") found_mail = self._new_mails self.assertNotIn(signature, found_mail.body_html) self.assertEqual(found_mail.body_html.count(signature), 0) @@ -203,7 +204,7 @@ class TestMessagePost(TestMailCommon, TestRecipients): self.test_record.message_post( body='Test', message_type='comment', subtype_xmlid='mail.mt_comment') - @mute_logger('flectra.addons.mail.models.mail_mail') + @mute_logger('flectra.addons.mail.models.mail_mail', 'flectra.tests') def test_post_notifications(self): _body, _subject = '

Test Body

', 'Test Subject' @@ -238,6 +239,52 @@ class TestMessagePost(TestMailCommon, TestRecipients): self.assertFalse(copy.notified_partner_ids) @mute_logger('flectra.addons.mail.models.mail_mail', 'flectra.tests') + def test_post_notifications_email_field(self): + """ Test various combinations of corner case / not standard filling of + email fields: multi email, formatted emails, ... """ + partner_emails = [ + 'valid.lelitre@agrolait.com, valid.lelitre.cc@agrolait.com', # multi email + '"Valid Lelitre" ', # email contains formatted email + 'wrong', # wrong + False, '', ' ', # falsy + ] + expected_tos = [ + # Sends multi-emails + [f'"{self.partner_1.name}" ', + f'"{self.partner_1.name}" ',], + # Avoid double encapsulation + [f'"{self.partner_1.name}" ',], + # sent "normally": formats email based on wrong / falsy email + [f'"{self.partner_1.name}" <@wrong>',], + [f'"{self.partner_1.name}" <@False>',], + [f'"{self.partner_1.name}" <@False>',], + [f'"{self.partner_1.name}" <@ >',], + ] + + for partner_email, expected_to in zip(partner_emails, expected_tos): + with self.subTest(partner_email=partner_email, expected_to=expected_to): + self.partner_1.write({'email': partner_email}) + with self.mock_mail_gateway(): + self.test_record.with_user(self.user_employee).message_post( + body='Test multi email', + message_type='comment', + partner_ids=[self.partner_1.id], + subject='Exotic email', + subtype_xmlid='mt_comment', + ) + + self.assertSentEmail( + self.user_employee.partner_id, + [self.partner_1], + email_to=expected_to, + ) + + @mute_logger('flectra.addons.mail.models.mail_mail', 'flectra.models.unlink') + def test_post_notifications_emails_tweak(self): + pass + # we should check _notification_groups behavior, for emails and buttons + + @mute_logger('flectra.addons.mail.models.mail_mail') def test_post_notifications_keep_emails(self): self.test_record.message_subscribe(partner_ids=[self.user_admin.partner_id.id]) @@ -251,11 +298,6 @@ class TestMessagePost(TestMailCommon, TestRecipients): # notifications emails should not have been deleted: one for customers, one for user self.assertEqual(len(self.env['mail.mail'].sudo().search([('mail_message_id', '=', msg.id)])), 2) - @mute_logger('flectra.addons.mail.models.mail_mail') - def test_post_notifications_emails_tweak(self): - pass - # we should check _notification_groups behavior, for emails and buttons - @mute_logger('flectra.addons.mail.models.mail_mail') def test_post_attachments(self): _attachments = [ @@ -357,7 +399,7 @@ class TestMessagePost(TestMailCommon, TestRecipients): references='%s %s' % (parent_msg.message_id, new_msg.message_id), ) - @mute_logger('flectra.addons.mail.models.mail_mail') + @mute_logger('flectra.addons.mail.models.mail_mail', 'flectra.tests') def test_post_email_with_multiline_subject(self): _body, _body_alt, _subject = '

Test Body

', 'Test Body', '1st line\n2nd line' msg = self.test_record.with_user(self.user_employee).message_post( diff --git a/addons/test_mass_mailing/models/mailing_models.py b/addons/test_mass_mailing/models/mailing_models.py index 5687390525..31bb8fd6fc 100644 --- a/addons/test_mass_mailing/models/mailing_models.py +++ b/addons/test_mass_mailing/models/mailing_models.py @@ -4,9 +4,39 @@ from flectra import api, fields, models +class MailingCustomer(models.Model): + """ A model inheriting from mail.thread with a partner field, to test + mass mailing flows involving checking partner email. """ + _description = 'Mailing with partner' + _name = 'mailing.test.customer' + _inherit = ['mail.thread'] + + name = fields.Char() + email_from = fields.Char(compute='_compute_email_from', readonly=False, store=True) + customer_id = fields.Many2one('res.partner', 'Customer', tracking=True) + + @api.depends('customer_id') + def _compute_email_from(self): + for mailing in self.filtered(lambda rec: not rec.email_from and rec.customer_id): + mailing.email_from = mailing.customer_id.email + + def _message_get_default_recipients(self): + """ Default recipient checks for 'partner_id', here the field is named + 'customer_id'. """ + default_recipients = super()._message_get_default_recipients() + for record in self: + if record.customer_id: + default_recipients[record.id] = { + 'email_cc': False, + 'email_to': False, + 'partner_ids': record.customer_id.ids, + } + return default_recipients + + class MailingSimple(models.Model): - """ A very simple model only inheriting from mail.thread to test pure mass - mailing features and base performances. """ + """ Model only inheriting from mail.thread to test base mailing features and + performances. """ _description = 'Simple Mailing' _name = 'mailing.test.simple' _inherit = ['mail.thread'] @@ -16,7 +46,8 @@ class MailingSimple(models.Model): class MailingUTM(models.Model): - """ Model inheriting from mail.thread and utm.mixin for checking utm of mailing is caught and set on reply """ + """ Model inheriting from mail.thread and utm.mixin for checking utm of mailing + is caught and set on reply """ _description = 'Mailing: UTM enabled to test UTM sync with mailing' _name = 'mailing.test.utm' _inherit = ['mail.thread', 'utm.mixin'] @@ -36,6 +67,19 @@ class MailingBLacklist(models.Model): customer_id = fields.Many2one('res.partner', 'Customer', tracking=True) user_id = fields.Many2one('res.users', 'Responsible', tracking=True) + def _message_get_default_recipients(self): + """ Default recipient checks for 'partner_id', here the field is named + 'customer_id'. """ + default_recipients = super()._message_get_default_recipients() + for record in self: + if record.customer_id: + default_recipients[record.id] = { + 'email_cc': False, + 'email_to': False, + 'partner_ids': record.customer_id.ids, + } + return default_recipients + class MailingOptOut(models.Model): """ Model using blacklist mechanism and a hijacked opt-out mechanism for @@ -51,6 +95,19 @@ class MailingOptOut(models.Model): customer_id = fields.Many2one('res.partner', 'Customer', tracking=True) user_id = fields.Many2one('res.users', 'Responsible', tracking=True) + def _message_get_default_recipients(self): + """ Default recipient checks for 'partner_id', here the field is named + 'customer_id'. """ + default_recipients = super()._message_get_default_recipients() + for record in self: + if record.customer_id: + default_recipients[record.id] = { + 'email_cc': False, + 'email_to': False, + 'partner_ids': record.customer_id.ids, + } + return default_recipients + class MailingPerformance(models.Model): """ A very simple model only inheriting from mail.thread to test pure mass diff --git a/addons/test_mass_mailing/security/ir.model.access.csv b/addons/test_mass_mailing/security/ir.model.access.csv index b21c7be49b..617995ff0f 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 b8d94f7426..90498639ef 100644 --- a/addons/test_mass_mailing/tests/test_mailing.py +++ b/addons/test_mass_mailing/tests/test_mailing.py @@ -119,6 +119,138 @@ class TestMassMailing(TestMassMailCommon): self.assertMailingStatistics(mailing, expected=5, delivered=4, sent=5, opened=1, clicked=1, bounced=1) self.assertEqual(recipients[1].message_bounce, 1) + @users('user_marketing') + @mute_logger('flectra.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" ', + 'name': 'MultiEMail', + }, { + 'email': '"Formatted Customer" ', + 'name': 'FormattedEmail', + }, { + 'email': '"Unicode Customer" ', + 'name': 'UnicodeEmail', + }, { + 'email': 'TEST.CUSTOMER.CASE@EXAMPLE.COM', + 'name': 'CaseEmail', + }, { + 'email': 'test.customer.weird@example.com Weird Format', + 'name': 'WeirdFormatEmail', + }, { + 'email': 'Weird Format2 test.customer.weird.2@example.com', + 'name': 'WeirdFormatEmail2', + } + ]) + + # check difference of email management between a classic model and a model + # with an 'email_normalized' field (blacklist mixin) + for dst_model in ['mailing.test.customer', 'mailing.test.blacklist']: + with self.subTest(dst_model=dst_model): + (record_p_mult, record_p_fmt, record_p_unic, + record_p_case, record_p_weird, record_p_weird_2, + record_mult, record_fmt, record_unic + ) = self.env[dst_model].create([ + { + 'customer_id': customer_mult.id, + }, { + 'customer_id': customer_fmt.id, + }, { + 'customer_id': customer_unic.id, + }, { + 'customer_id': customer_case.id, + }, { + 'customer_id': customer_weird.id, + }, { + 'customer_id': customer_weird_2.id, + }, { + 'email_from': 'record.multi.1@example.com, "Record Multi 2" ', + }, { + 'email_from': '"Formatted Record" ', + }, { + 'email_from': '"Unicode Record" ', + } + ]) + test_records = ( + record_p_mult + record_p_fmt + record_p_unic + + record_p_case + record_p_weird + record_p_weird_2 + + record_mult + record_fmt + record_unic + ) + mailing = self.env['mailing.mailing'].create({ + 'body_html': """

Hello ${object.name}

""", + 'mailing_domain': [('id', 'in', test_records.ids)], + 'mailing_model_id': self.env['ir.model']._get_id(dst_model), + 'mailing_type': 'mail', + 'name': 'SourceName', + 'preview': 'Hi ${object.name} :)', + 'reply_to_mode': 'thread', + 'subject': 'MailingSubject', + }) + + with self.mock_mail_gateway(mail_unlink_sent=False): + mailing.action_send_mail() + + # Difference in email, email_to_recipients and email_to_mail + # -> email: trace email: normalized, to ease its management, mainly technical + # -> email_to_mail: mail.mail email: email_to stored in outgoing mail.mail (can be multi) + # -> email_to_recipients: email_to for outgoing emails, list means several recipients + self.assertMailTraces( + [ + {'email': 'customer.multi.1@example.com', + 'email_to_recipients': [[f'"{customer_mult.name}" ', f'"{customer_mult.name}" ']], + 'failure_type': False, + 'partner': customer_mult, + 'state': 'sent'}, + {'email': 'test.customer.format@example.com', + # mail to avoids double encapsulation + 'email_to_recipients': [[f'"{customer_fmt.name}" ']], + 'failure_type': False, + 'partner': customer_fmt, + 'state': 'sent'}, + {'email': 'test.customer.😊@example.com', + # mail to avoids double encapsulation + 'email_to_recipients': [[f'"{customer_unic.name}" ']], + 'failure_type': False, + 'partner': customer_unic, + 'state': 'sent'}, + {'email': 'test.customer.case@example.com', + 'email_to_recipients': [[f'"{customer_case.name}" ']], + 'failure_type': False, + 'partner': customer_case, + 'state': 'sent'}, # lower cased + {'email': 'test.customer.weird@example.comweirdformat', + 'email_to_recipients': [[f'"{customer_weird.name}" ']], + 'failure_type': False, + 'partner': customer_weird, + 'state': 'sent'}, # concatenates everything after domain + {'email': 'test.customer.weird.2@example.com', + 'email_to_recipients': [[f'"{customer_weird_2.name}" ']], + 'failure_type': False, + 'partner': customer_weird_2, + 'state': 'sent'}, + {'email': 'record.multi.1@example.com', + 'email_to_mail': 'record.multi.1@example.com,record.multi.2@example.com', + 'email_to_recipients': [['record.multi.1@example.com', 'record.multi.2@example.com']], + 'failure_type': False, + 'state': 'sent'}, + {'email': 'record.format@example.com', + 'failure_type': False, + 'state': 'sent'}, + {'email': 'record.😊@example.com', + 'failure_type': False, + 'state': 'sent'}, + ], + mailing, + test_records, + check_mail=True, + ) + @users('user_marketing') @mute_logger('flectra.addons.mail.models.mail_mail') def test_mailing_reply_to_mode_new(self): diff --git a/addons/website/static/src/snippets/s_image_gallery/000.js b/addons/website/static/src/snippets/s_image_gallery/000.js index e2adc2d05a..b0b6f625c4 100644 --- a/addons/website/static/src/snippets/s_image_gallery/000.js +++ b/addons/website/static/src/snippets/s_image_gallery/000.js @@ -26,7 +26,7 @@ const GalleryWidget = publicWidget.Widget.extend({ * @param {Event} ev */ _onClickImg: function (ev) { - if (this.$modal) { + if (this.$modal || ev.currentTarget.matches("a > img")) { return; } var self = this; diff --git a/addons/website_crm/views/website_visitor_views.xml b/addons/website_crm/views/website_visitor_views.xml index 40cc40e868..f6c84a9a73 100644 --- a/addons/website_crm/views/website_visitor_views.xml +++ b/addons/website_crm/views/website_visitor_views.xml @@ -60,10 +60,10 @@ -
Leads/Opportunities
+
Leads/Opportunities
-
+
Leads/Opportunities
diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py index 00b87496f7..6452314b1f 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 flectra import api, fields, models +from flectra import api, fields, models, tools from flectra.addons.http_routing.models.ir_http import slug from flectra.osv import expression from flectra.tools.mail import is_html_empty @@ -394,13 +394,18 @@ class Track(models.Model): # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. - new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.partner_email) + email_normalized = tools.email_normalize(self.partner_email) + new_partner = message.partner_ids.filtered( + lambda partner: partner.email == self.partner_email or (email_normalized and partner.email_normalized == email_normalized) + ) if new_partner: + if new_partner[0].email_normalized: + email_domain = ('partner_email', 'in', [new_partner[0].email, new_partner[0].email_normalized]) + else: + email_domain = ('partner_email', '=', new_partner[0].email) self.search([ - ('partner_id', '=', False), - ('partner_email', '=', new_partner.email), - ('stage_id.is_cancel', '=', False), - ]).write({'partner_id': new_partner.id}) + ('partner_id', '=', False), email_domain, ('stage_id.is_cancel', '=', False), + ]).write({'partner_id': new_partner[0].id}) return super(Track, self)._message_post_after_hook(message, msg_vals) # ------------------------------------------------------------