diff --git a/addons/base_automation/tests/__init__.py b/addons/base_automation/tests/__init__.py index 92a8a4959d9..a6533bd6153 100644 --- a/addons/base_automation/tests/__init__.py +++ b/addons/base_automation/tests/__init__.py @@ -1,3 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + from . import test_automation +from . import test_mail_composer diff --git a/addons/base_automation/tests/test_mail_composer.py b/addons/base_automation/tests/test_mail_composer.py new file mode 100644 index 00000000000..2609466facd --- /dev/null +++ b/addons/base_automation/tests/test_mail_composer.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import tagged, HttpCase + + +@tagged('-at_install', 'post_install', 'mail_composer') +class TestMailFullComposer(HttpCase): + + def test_full_composer_tour(self): + self.env['mail.template'].create({ + 'name': 'Test template', # name hardcoded for test + 'partner_to': '${object.id}', + 'lang': '${object.lang}', + 'auto_delete': True, + 'model_id': self.ref('base.model_res_partner'), + }) + test_user = self.env['res.users'].create({ + 'email': 'testuser@testuser.com', + 'groups_id': [ + (6, 0, [self.ref('base.group_user'), self.ref('base.group_partner_manager')]), + ], + 'name': 'Test User', + 'login': 'testuser', + 'password': 'testuser', + }) + + automated_action = self.env['base.automation'].create({ + 'name': 'Test', + 'active': True, + 'trigger': 'on_change', + 'on_change_field_ids': (4, self.ref('mail.field_mail_compose_message__template_id'),), + 'state': 'code', + 'model_id': self.env.ref('mail.model_mail_compose_message').id, + }) + + self.start_tour("/web#id=%d&model=res.partner" % test_user.partner_id, 'mail/static/tests/tours/mail_full_composer_test_tour.js', login='testuser') + + automated_action.unlink() diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 5c9dee9ae8e..462ec514378 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1199,8 +1199,8 @@ class MailThread(models.AbstractModel): Indeed those aspects should be covered by the html_sanitize method located in tools. """ body, attachments = payload_dict['body'], payload_dict['attachments'] - if not body: - return payload_dict + if not body.strip(): + return {'body': body, 'attachments': attachments} try: root = lxml.html.fromstring(body) except ValueError: @@ -1321,7 +1321,9 @@ class MailThread(models.AbstractModel): if dsn_part and len(dsn_part.get_payload()) > 1: dsn = dsn_part.get_payload()[1] final_recipient_data = tools.decode_message_header(dsn, 'Final-Recipient') - bounced_email = tools.email_normalize(final_recipient_data.split(';', 1)[1].strip()) + # old servers may hold void or invalid Final-Recipient header + if final_recipient_data and ";" in final_recipient_data: + bounced_email = tools.email_normalize(final_recipient_data.split(';', 1)[1].strip()) if bounced_email: bounced_partner = self.env['res.partner'].sudo().search([('email_normalized', '=', bounced_email)]) diff --git a/addons/mail/tests/common.py b/addons/mail/tests/common.py index a2bd6db544a..67883fdc223 100644 --- a/addons/mail/tests/common.py +++ b/addons/mail/tests/common.py @@ -109,20 +109,26 @@ class MockEmail(common.BaseCase): def format(self, template, to='groups@example.com, other@gmail.com', subject='Frogs', email_from='Sylvie Lelitre ', return_path='', cc='', - extra='', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>'): + extra='', msg_id='<1198923581.41972151344608186760.JavaMail@agrolait.com>', + **kwargs): return template.format( subject=subject, to=to, cc=cc, email_from=email_from, return_path=return_path, - extra=extra, msg_id=msg_id) + extra=extra, msg_id=msg_id, + **kwargs) def format_and_process(self, template, email_from, to, subject='Frogs', cc='', return_path='', extra='', msg_id=False, - model=None, target_model='mail.test.gateway', target_field='name'): + model=None, target_model='mail.test.gateway', target_field='name', + **kwargs): self.assertFalse(self.env[target_model].search([(target_field, '=', subject)])) if not msg_id: msg_id = "<%.7f-test@iron.sky>" % (time.time()) - mail = self.format(template, to=to, subject=subject, cc=cc, return_path=return_path, extra=extra, email_from=email_from, msg_id=msg_id) + mail = self.format(template, to=to, subject=subject, cc=cc, + return_path=return_path, extra=extra, + email_from=email_from, msg_id=msg_id, + **kwargs) self.env['mail.thread'].message_process(model, mail) return self.env[target_model].search([(target_field, '=', subject)]) diff --git a/addons/mail/tests/test_mail_full_composer.py b/addons/mail/tests/test_mail_full_composer.py index 1456dba505a..0921a698f8f 100644 --- a/addons/mail/tests/test_mail_full_composer.py +++ b/addons/mail/tests/test_mail_full_composer.py @@ -5,7 +5,7 @@ from odoo.tests.common import tagged, HttpCase from odoo import Command -@tagged('-at_install', 'post_install') +@tagged('-at_install', 'post_install', 'mail_composer') class TestMailFullComposer(HttpCase): def test_full_composer_tour(self): diff --git a/addons/mail/tests/test_res_partner.py b/addons/mail/tests/test_res_partner.py index 8032b9d9714..be9784d0717 100644 --- a/addons/mail/tests/test_res_partner.py +++ b/addons/mail/tests/test_res_partner.py @@ -2,6 +2,8 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.mail.tests.common import MailCommon +from odoo.tests.common import Form, users + # samples use effective TLDs from the Mozilla public suffix # list at http://publicsuffix.org @@ -123,3 +125,47 @@ class TestPartner(MailCommon): new_msg = new_user.message_ids[0] self.assertIn('Portal Access Granted', new_msg.body) self.assertEqual(new_msg.subtype_id, subtype_note) + + @users('admin') + def test_res_partner_merge_wizards(self): + Partner = self.env['res.partner'] + + p1 = Partner.create({'name': 'Customer1', 'email': 'test1@test.example.com'}) + p1_msg_ids_init = p1.message_ids + p2 = Partner.create({'name': 'Customer2', 'email': 'test2@test.example.com'}) + p2_msg_ids_init = p2.message_ids + p3 = Partner.create({'name': 'Other (dup email)', 'email': 'test1@test.example.com'}) + + # add some mail related documents + p1.message_subscribe(partner_ids=p3.ids) + p1_act1 = p1.activity_schedule(act_type_xmlid='mail.mail_activity_data_todo') + p1_msg1 = p1.message_post( + body='

Log on P1

', + subtype_id=self.env.ref('mail.mt_comment').id + ) + self.assertEqual(p1.activity_ids, p1_act1) + self.assertEqual(p1.message_follower_ids.partner_id, self.partner_admin + p3) + self.assertEqual(p1.message_ids, p1_msg_ids_init + p1_msg1) + self.assertEqual(p2.activity_ids, self.env['mail.activity']) + self.assertEqual(p2.message_follower_ids.partner_id, self.partner_admin) + self.assertEqual(p2.message_ids, p2_msg_ids_init) + + MergeForm = Form(self.env['base.partner.merge.automatic.wizard'].with_context( + active_model='res.partner', + active_ids=(p1 + p2).ids + )) + self.assertEqual(MergeForm.partner_ids[:], p1 + p2) + self.assertEqual(MergeForm.dst_partner_id, p2) + merge_form = MergeForm.save() + merge_form.action_merge() + + # check destination and removal + self.assertFalse(p1.exists()) + self.assertTrue(p2.exists()) + # check mail documents have been moved + self.assertEqual(p2.activity_ids, p1_act1) + # TDE note: currently not working as soon as there is a single partner duplicated -> should be improved + # self.assertEqual(p2.message_follower_ids.partner_id, self.partner_admin + p3) + all_msg = p2_msg_ids_init + p1_msg_ids_init + p1_msg1 + self.assertEqual(len(p2.message_ids), len(all_msg) + 1, 'Should have original messages + a log') + self.assertTrue(all(msg in p2.message_ids for msg in all_msg)) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index f9f032e04f7..9415fa2c543 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -75,6 +75,10 @@ class MailComposer(models.TransientModel): if result.get('composition_mode') == 'comment' and (set(fields) & set(['model', 'res_id', 'partner_ids', 'record_name', 'subject'])): result.update(self.get_record_data(result)) + # when being in new mode, create_uid is not granted -> ACLs issue may arise + if 'create_uid' in fields and 'create_uid' not in result: + result['create_uid'] = self.env.uid + filtered_result = dict((fname, result[fname]) for fname in result if fname in fields) return filtered_result diff --git a/addons/test_mail/data/test_mail_data.py b/addons/test_mail/data/test_mail_data.py index d6e38846930..81a8f456d7a 100644 --- a/addons/test_mail/data/test_mail_data.py +++ b/addons/test_mail/data/test_mail_data.py @@ -43,6 +43,50 @@ Content-Transfer-Encoding: quoted-printable ------=_Part_4200734_24778174.1344608186754-- """ +MAIL_TEMPLATE_EXTRA_HTML = """Return-Path: +To: {to} +cc: {cc} +Received: by mail1.openerp.com (Postfix, from userid 10002) + id 5DF9ABFB2A; Fri, 10 Aug 2012 16:16:39 +0200 (CEST) +From: {email_from} +Subject: {subject} +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="----=_Part_4200734_24778174.1344608186754" +Date: Fri, 10 Aug 2012 14:16:26 +0000 +Message-ID: {msg_id} +{extra} +------=_Part_4200734_24778174.1344608186754 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +Please call me as soon as possible this afternoon! + +-- +Sylvie +------=_Part_4200734_24778174.1344608186754 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + + + + =20 + + =20 + =20 + +

Please call me as soon as possible this afternoon!

+ {extra_html} + +

--
+ Sylvie +

+ + +------=_Part_4200734_24778174.1344608186754-- +""" + + MAIL_TEMPLATE_PLAINTEXT = """Return-Path: To: {to} Received: by mail1.openerp.com (Postfix, from userid 10002) @@ -883,3 +927,89 @@ OyI+T2RvbzwvYT4uCjwvcD4KPC9kaXY+CiAgICAgICAg --92726A5F09.1555335666/mail2.test.ironsky-- """ + +MAIL_NO_BODY = '''\ +Return-Path: <{email_from}> +Delivered-To: catchall@xxxx.xxxx +Received: from in66.mail.ovh.net (unknown [10.101.4.66]) + by vr38.mail.ovh.net (Postfix) with ESMTP id 4GLCGr70Kyz1myr75 + for ; Thu, 8 Jul 2021 10:30:12 +0000 (UTC) +X-Comment: SPF check N/A for local connections - client-ip=213.186.33.59; helo=mail663.ha.ovh.net; envelope-from={email_from}; receiver=catchall@xxxx.xxxx +Authentication-Results: in66.mail.ovh.net; dkim=none; dkim-atps=neutral +Delivered-To: xxxx.xxxx-{email_to} +X-ME-Helo: opme11oxm23aub.bagnolet.francetelecom.fr +X-ME-Auth: ZnJlZGVyaWMuYmxhY2hvbjA3QG9yYW5nZS5mcg== +X-ME-Date: Thu, 08 Jul 2021 12:30:11 +0200 +X-ME-IP: 86.221.151.111 +Date: Thu, 8 Jul 2021 12:30:11 +0200 (CEST) +From: =?UTF-8?Q?Fr=C3=A9d=C3=A9ric_BLACHON?= <{email_from}> +Reply-To: + =?UTF-8?Q?Fr=C3=A9d=C3=A9ric_BLACHON?= <{email_from}> +To: {email_to} +Message-ID: <1024471522.82574.1625740211606.JavaMail.open-xchange@opme11oxm23aub.bagnolet.francetelecom.fr> +Subject: transport autorisation 19T +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_Part_82573_178179506.1625740211587" + +------=_Part_82573_178179506.1625740211587 +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + + + + + + +''' + +MAIL_NO_FINAL_RECIPIENT = """\ +Return-Path: +Delivered-To: catchall@xxxx.xxxx +Received: from in58.mail.ovh.net (unknown [10.101.4.58]) + by vr46.mail.ovh.net (Postfix) with ESMTP id 4GvFsq2QLYz1t0N7r + for ; Tue, 24 Aug 2021 17:07:43 +0000 (UTC) +Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=46.105.72.169; helo=40.mo36.mail-out.ovh.net; envelope-from=bounce-md_9656353.6125275c.v1-f28f7746389e45f0bfbf9faefe9e0dc8@mandrillapp.com; receiver=catchall@xxxx.xxxx +Authentication-Results: in58.mail.ovh.net; + dkim=pass (1024-bit key; unprotected) header.d=mandrillapp.com header.i=bounces-noreply@mandrillapp.com header.b="TDzUcdJs"; + dkim=pass (1024-bit key) header.d=mandrillapp.com header.i=@mandrillapp.com header.b="MyjddTY5"; + dkim-atps=neutral +Delivered-To: xxxx.xxxx-{email_to} +Authentication-Results: in62.mail.ovh.net; + dkim=pass (1024-bit key; unprotected) header.d=mandrillapp.com header.i=bounces-noreply@mandrillapp.com header.b="TDzUcdJs"; + dkim=pass (1024-bit key) header.d=mandrillapp.com header.i=@mandrillapp.com header.b="MyjddTY5"; + dkim-atps=neutral +From: MAILER-DAEMON +Subject: Undelivered Mail Returned to Sender +To: {email_to} +X-Report-Abuse: Please forward a copy of this message, including all headers, to abuse@mandrill.com +X-Report-Abuse: You can also report abuse here: http://mandrillapp.com/contact/abuse?id=9656353.f28f7746389e45f0bfbf9faefe9e0dc8 +X-Mandrill-User: md_9656353 +Feedback-ID: 9656353:9656353.20210824:md +Message-Id: <9656353.20210824170740.6125275cf21879.17950539@mail9.us4.mandrillapp.com> +Date: Tue, 24 Aug 2021 17:07:40 +0000 +MIME-Version: 1.0 +Content-Type: multipart/report; boundary="_av-UfLe6y6qxNo54-urtAxbJQ" + +--_av-UfLe6y6qxNo54-urtAxbJQ +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 7bit + + --- The following addresses had delivery problems --- + +<{email_from}> (5.7.1 <{email_from}>: Recipient address rejected: Access denied) + + +--_av-UfLe6y6qxNo54-urtAxbJQ +Content-Type: message/delivery-status +Content-Transfer-Encoding: 7bit + +Original-Recipient: <{email_from}> +Action: failed +Diagnostic-Code: smtp; 554 5.7.1 <{email_from}>: Recipient address rejected: Access denied +Remote-MTA: 10.245.192.40 + + + +--_av-UfLe6y6qxNo54-urtAxbJQ--""" diff --git a/addons/test_mail/models/__init__.py b/addons/test_mail/models/__init__.py index 0f5cea0a294..64cc5bb37b9 100644 --- a/addons/test_mail/models/__init__.py +++ b/addons/test_mail/models/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import test_mail_models from . import test_mail_corner_case_models +from . import test_mail_models from . import test_mail_thread_models diff --git a/addons/test_mail/models/test_mail_corner_case_models.py b/addons/test_mail/models/test_mail_corner_case_models.py index c540676d497..8272a559d17 100644 --- a/addons/test_mail/models/test_mail_corner_case_models.py +++ b/addons/test_mail/models/test_mail_corner_case_models.py @@ -32,6 +32,28 @@ class MailPerformanceTracking(models.Model): field_2 = fields.Char(tracking=True) +class MailTestFieldType(models.Model): + """ Test default values, notably type, messing through models during gateway + processing (i.e. lead.type versus attachment.type). """ + _description = 'Test Field Type' + _name = 'mail.test.field.type' + _inherit = ['mail.thread'] + + name = fields.Char() + email_from = fields.Char() + datetime = fields.Datetime(default=fields.Datetime.now) + customer_id = fields.Many2one('res.partner', 'Customer') + type = fields.Selection([('first', 'First'), ('second', 'Second')]) + user_id = fields.Many2one('res.users', 'Responsible', tracking=True) + + @api.model_create_multi + def create(self, vals_list): + # Emulate an addon that alters the creation context, such as `crm` + if not self._context.get('default_type'): + self = self.with_context(default_type='first') + return super(MailTestFieldType, self).create(vals_list) + + class MailTestLang(models.Model): """ A simple chatter model with lang-based capabilities, allowing to test translations. """ diff --git a/addons/test_mail/security/ir.model.access.csv b/addons/test_mail/security/ir.model.access.csv index e5feacb7a54..9c4a96ae8ba 100644 --- a/addons/test_mail/security/ir.model.access.csv +++ b/addons/test_mail/security/ir.model.access.csv @@ -9,6 +9,8 @@ access_mail_test_track_portal,mail.test.track.portal,model_mail_test_track,base. access_mail_test_track_user,mail.test.track.user.employee,model_mail_test_track,base.group_user,1,1,1,1 access_mail_test_activity_portal,mail.test.activity.portal,model_mail_test_activity,base.group_portal,1,0,0,0 access_mail_test_activity_user,mail.test.activity.user,model_mail_test_activity,base.group_user,1,1,1,1 +access_mail_test_field_type_portal,mail.test.field.type.portal,model_mail_test_field_type,base.group_portal,0,0,0,0 +access_mail_test_field_type_user,mail.test.field.type.user,model_mail_test_field_type,base.group_user,1,1,1,1 access_mail_test_ticket_portal,mail.test.ticket.portal,model_mail_test_ticket,base.group_portal,0,0,0,0 access_mail_test_ticket_user,mail.test.ticket.user,model_mail_test_ticket,base.group_user,1,1,1,1 access_mail_test_composer_mixin_all,mail.test.composer.mixin.all,model_mail_test_composer_mixin,,0,0,0,0 diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py index 0720a9c9eb7..aff9f63bf71 100644 --- a/addons/test_mail/tests/test_mail_gateway.py +++ b/addons/test_mail/tests/test_mail_gateway.py @@ -55,6 +55,15 @@ class TestEmailParsing(TestMailCommon): res = self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_MULTIPART_WEIRD_FILENAME)) self.assertEqual(res['attachments'][0][0], '62_@;,][)=.(ÇÀÉ.txt') + def test_message_parse_bugs(self): + """ Various corner cases or message parsing """ + # message without Final-Recipient + self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_NO_FINAL_RECIPIENT)) + + # message with empty body (including only void characters) + res = self.env['mail.thread'].message_parse(self.from_string(test_mail_data.MAIL_NO_BODY)) + self.assertEqual(res['body'], '\n \n', 'Gateway should not crash with void content') + def test_message_parse_eml(self): # Test that the parsing of mail with embedded emails as eml(msg) which generates empty attachments, can be processed. mail = self.format(test_mail_data.MAIL_EML_ATTACHMENT, email_from='"Sylvie Lelitre" ', to='generic@test.com') @@ -1131,6 +1140,55 @@ class TestMailgateway(TestMailCommon): self.assertFalse(incoming_msg.parent_id) self.assertTrue(incoming_msg.res_id == res_test.id) + # -------------------------------------------------- + # Gateway / Record synchronization + # -------------------------------------------------- + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_gateway_values_base64_image(self): + """New record with mail that contains base64 inline image.""" + target_model = "mail.test.field.type" + alias = self.env["mail.alias"].create({ + "alias_name": "base64-lover", + "alias_model_id": self.env["ir.model"]._get(target_model).id, + "alias_defaults": "{}", + "alias_contact": "everyone", + }) + record = self.format_and_process( + test_mail_data.MAIL_TEMPLATE_EXTRA_HTML, self.email_from, + '%s@%s' % (alias.alias_name, self.alias_catchall), + subject='base64 image to alias', + target_model=target_model, + extra_html='', + ) + self.assertEqual(record.type, "first") + self.assertEqual(len(record.message_ids[0].attachment_ids), 1) + self.assertEqual(record.message_ids[0].attachment_ids[0].name, "image0") + self.assertEqual(record.message_ids[0].attachment_ids[0].type, "binary") + + @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models') + def test_gateway_values_base64_image_walias(self): + """New record with mail that contains base64 inline image + default values + coming from alias.""" + target_model = "mail.test.field.type" + alias = self.env["mail.alias"].create({ + "alias_name": "base64-lover", + "alias_model_id": self.env["ir.model"]._get(target_model).id, + "alias_defaults": "{'type': 'second'}", + "alias_contact": "everyone", + }) + record = self.format_and_process( + test_mail_data.MAIL_TEMPLATE_EXTRA_HTML, self.email_from, + '%s@%s' % (alias.alias_name, self.alias_catchall), + subject='base64 image to alias', + target_model=target_model, + extra_html='', + ) + self.assertEqual(record.type, "second") + self.assertEqual(len(record.message_ids[0].attachment_ids), 1) + self.assertEqual(record.message_ids[0].attachment_ids[0].name, "image0") + self.assertEqual(record.message_ids[0].attachment_ids[0].type, "binary") + # -------------------------------------------------- # Thread formation: mail gateway corner cases # -------------------------------------------------- diff --git a/odoo/addons/base/wizard/base_partner_merge.py b/odoo/addons/base/wizard/base_partner_merge.py index d862d35b532..cc55e20a164 100644 --- a/odoo/addons/base/wizard/base_partner_merge.py +++ b/odoo/addons/base/wizard/base_partner_merge.py @@ -204,6 +204,7 @@ class MergePartnerAutomatic(models.TransientModel): update_records('calendar', src=partner, field_model='model_id.model') update_records('ir.attachment', src=partner, field_model='res_model') update_records('mail.followers', src=partner, field_model='res_model') + update_records('mail.activity', src=partner, field_model='res_model') update_records('mail.message', src=partner) update_records('ir.model.data', src=partner)