diff --git a/addons/mail/tests/test_res_partner.py b/addons/mail/tests/test_res_partner.py
index 97828c0981df4c622d21ce2332e21519bb06ef2b..56fb86f4db59f2a6a2a374f3759e399af9858e9a 100644
--- a/addons/mail/tests/test_res_partner.py
+++ b/addons/mail/tests/test_res_partner.py
@@ -3,8 +3,10 @@
 
 from odoo.addons.mail.tests.common import MailCommon
 from odoo.tests.common import Form, users
+from odoo.tests import tagged
 
 
+@tagged('res_partner')
 class TestPartner(MailCommon):
 
     def test_res_partner_find_or_create(self):
@@ -27,6 +29,73 @@ class TestPartner(MailCommon):
         self.assertEqual(new2.email, '2patrick@example.com')
         self.assertEqual(new2.email_normalized, '2patrick@example.com')
 
+    @users('admin')
+    def test_res_partner_find_or_create_email(self):
+        """ Test 'find_or_create' tool used in mail, notably when linking emails
+        found in recipients to partners when sending emails using the mail
+        composer. """
+        partners = self.env['res.partner'].create([
+            {
+                'email': 'classic.format@test.example.com',
+                'name': 'Classic Format',
+            },
+            {
+                'email': '"FindMe Format" <find.me.format@test.example.com>',
+                'name': 'FindMe Format',
+            }, {
+                'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                'name': 'FindMe Multi',
+            },
+        ])
+        # check data used for finding / searching
+        self.assertEqual(
+            partners.mapped('email_formatted'),
+            ['"Classic Format" <classic.format@test.example.com>',
+             '"FindMe Format" <"FindMe Format" <find.me.format@test.example.com>>',
+             '"FindMe Multi" <find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>>']
+        )
+        self.assertEqual(
+            partners.mapped('email_normalized'),
+            ['classic.format@test.example.com', 'find.me.format@test.example.com', False]
+        )
+
+        # classic find or create: use normalized email to compare records
+        for email in ('CLASSIC.FORMAT@TEST.EXAMPLE.COM', '"Another Name" <classic.format@test.example.com>'):
+            with self.subTest(email=email):
+                self.assertEqual(self.env['res.partner'].find_or_create(email), partners[0])
+        # find on encapsulated email: comparison of normalized should work
+        for email in ('FIND.ME.FORMAT@TEST.EXAMPLE.COM', '"Different Format" <find.me.format@test.example.com>'):
+            with self.subTest(email=email):
+                self.assertEqual(self.env['res.partner'].find_or_create(email), partners[1])
+        # multi-emails -> no normalized email -> fails each time, create new partner (FIXME)
+        for email in ('find.me.multi.1@test.example.com', 'find.me.multi.2@test.example.com'):
+            with self.subTest(email=email):
+                partner = self.env['res.partner'].find_or_create(email)
+                self.assertNotIn(partner, partners)
+                self.assertEqual(partner.email, email)
+                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/tests/common.py b/addons/mass_mailing/tests/common.py
index 9cb2b9bf1f076b499ab49e116387630fa03fdc2c..09f4508326a616482e6f13f7c2f2dfd17baf2b39 100644
--- a/addons/mass_mailing/tests/common.py
+++ b/addons/mass_mailing/tests/common.py
@@ -48,6 +48,8 @@ class MassMailCase(MailCase, MockLinkTracker):
             'record: linked record,
             # MAIL.MAIL
             'content': optional content that should be present in mail.mail body_html;
+            'email_to_mail': optional email used for the mail, when different from the
+              one stored on the trace itself;
             'email_to_recipients': optional, see '_assertMailMail';
             'failure_type': optional failure reason;
             }, { ... }]
@@ -97,6 +99,7 @@ class MassMailCase(MailCase, MockLinkTracker):
         for recipient_info, link_info, record in zip(recipients_info, mail_links_info, records):
             partner = recipient_info.get('partner', self.env['res.partner'])
             email = recipient_info.get('email')
+            email_to_mail = recipient_info.get('email_to_mail') or email
             email_to_recipients = recipient_info.get('email_to_recipients')
             state = recipient_info.get('state', 'sent')
             record = record or recipient_info.get('record')
@@ -150,7 +153,7 @@ class MassMailCase(MailCase, MockLinkTracker):
                     )
                 else:
                     self.assertMailMailWEmails(
-                        [email], state_mapping[state],
+                        [email_to_mail], state_mapping[state],
                         author=author,
                         content=content,
                         email_to_recipients=email_to_recipients,
diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py
index 9b0e15b3337fc0a43ada9f340b6b525d102662b7..3237833991845ff4742a34de4a9d15ad77bd67df 100644
--- a/addons/test_mail/models/test_mail_models.py
+++ b/addons/test_mail/models/test_mail_models.py
@@ -27,6 +27,16 @@ class MailTestGateway(models.Model):
     email_from = fields.Char()
     custom_field = fields.Char()
 
+    @api.model
+    def message_new(self, msg_dict, custom_values=None):
+        """ Check override of 'message_new' allowing to update record values
+        base on incoming email. """
+        defaults = {
+            'email_from': msg_dict.get('from'),
+        }
+        defaults.update(custom_values or {})
+        return super().message_new(msg_dict, custom_values=defaults)
+
 
 class MailTestGatewayGroups(models.Model):
     """ A model looking like discussion channels / groups (flat thread and
diff --git a/addons/test_mail/tests/__init__.py b/addons/test_mail/tests/__init__.py
index 631462616cf8f09b426c3ada9423d58a2f25b29f..d4baf2016e83d94b58d84149b5637cb737e5ad02 100644
--- a/addons/test_mail/tests/__init__.py
+++ b/addons/test_mail/tests/__init__.py
@@ -13,6 +13,7 @@ from . import test_mail_gateway
 from . import test_mail_multicompany
 from . import test_mail_template_preview
 from . import test_mail_thread_internals
+from . import test_mail_thread_mixins
 from . import test_mail_template
 from . import test_mail_tools
 from . import test_message_management
diff --git a/addons/test_mail/tests/test_mail_activity.py b/addons/test_mail/tests/test_mail_activity.py
index e6d4b00374c1ec932329c006615d90a2b26bca83..68f17739a75605bf54968ea38a3ba178682644b8 100644
--- a/addons/test_mail/tests/test_mail_activity.py
+++ b/addons/test_mail/tests/test_mail_activity.py
@@ -366,41 +366,42 @@ class TestActivityMixin(TestActivityCommon):
             record = self.env['mail.test.activity'].search([('my_activity_date_deadline', '=', date_today)])
             self.assertEqual(test_record_1, record)
 
-class TestReadProgressBar(tests.TransactionCase):
+@tests.tagged('mail_activity')
+class TestORM(TestActivityCommon):
     """Test for read_progress_bar"""
 
     def test_week_grouping(self):
         """The labels associated to each record in read_progress_bar should match
         the ones from read_group, even in edge cases like en_US locale on sundays
         """
-        model = self.env['mail.test.activity'].with_context(lang='en_US')
+        MailTestActivityCtx = self.env['mail.test.activity'].with_context({"lang": "en_US"})
 
         # Don't mistake fields date and date_deadline:
         # * date is just a random value
         # * date_deadline defines activity_state
-        model.create({
+        self.env['mail.test.activity'].create({
             'date': '2021-05-02',
             'name': "Yesterday, all my troubles seemed so far away",
         }).activity_schedule(
             'test_mail.mail_act_test_todo',
             summary="Make another test super asap (yesterday)",
-            date_deadline=fields.Date.context_today(model) - timedelta(days=7),
+            date_deadline=fields.Date.context_today(MailTestActivityCtx) - timedelta(days=7),
         )
-        model.create({
+        self.env['mail.test.activity'].create({
             'date': '2021-05-09',
             'name': "Things we said today",
         }).activity_schedule(
             'test_mail.mail_act_test_todo',
             summary="Make another test asap",
-            date_deadline=fields.Date.context_today(model),
+            date_deadline=fields.Date.context_today(MailTestActivityCtx),
         )
-        model.create({
+        self.env['mail.test.activity'].create({
             'date': '2021-05-16',
             'name': "Tomorrow Never Knows",
         }).activity_schedule(
             'test_mail.mail_act_test_todo',
             summary="Make a test tomorrow",
-            date_deadline=fields.Date.context_today(model) + timedelta(days=7),
+            date_deadline=fields.Date.context_today(MailTestActivityCtx) + timedelta(days=7),
         )
 
         domain = [('date', "!=", False)]
@@ -415,8 +416,8 @@ class TestReadProgressBar(tests.TransactionCase):
         }
 
         # call read_group to compute group names
-        groups = model.read_group(domain, fields=['date'], groupby=[groupby])
-        progressbars = model.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar)
+        groups = MailTestActivityCtx.read_group(domain, fields=['date'], groupby=[groupby])
+        progressbars = MailTestActivityCtx.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar)
         self.assertEqual(len(groups), 3)
         self.assertEqual(len(progressbars), 3)
 
diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py
index a1bc46eee09b990a76d3cf05e6d44e091ba099b0..107f6abad4bf27bb7d7cf711b0250a344f330a8e 100644
--- a/addons/test_mail/tests/test_mail_composer.py
+++ b/addons/test_mail/tests/test_mail_composer.py
@@ -380,7 +380,7 @@ class TestComposerInternals(TestMailComposer):
             self.assertEqual(composer.mail_server_id.id, False)
 
     @users('employee')
-    @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
     def test_mail_composer_parent(self):
         """ Test specific management in comment mode when having parent_id set:
         record_name, subject, parent's partners. """
@@ -397,6 +397,7 @@ class TestComposerInternals(TestMailComposer):
         self.assertEqual(composer.body, '<p>Test Body</p>')
         self.assertEqual(composer.partner_ids, self.partner_1 + self.partner_2)
 
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
     def test_mail_composer_rights_portal(self):
         portal_user = self._create_portal_user()
 
@@ -447,7 +448,7 @@ class TestComposerResultsComment(TestMailComposer):
     notification and emails generated during this process. """
 
     @users('employee')
-    @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
     def test_mail_composer_notifications_delete(self):
         """ Notifications are correctly deleted once sent """
         composer = self.env['mail.compose.message'].with_context(
@@ -489,7 +490,7 @@ class TestComposerResultsComment(TestMailComposer):
         self.assertEqual(len(self._new_mails.exists()), 2, 'Should not have deleted mail.mail records')
 
     @users('employee')
-    @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
     def test_mail_composer_recipients(self):
         """ Test partner_ids given to composer are given to the final message. """
         composer = self.env['mail.compose.message'].with_context(
@@ -605,6 +606,146 @@ class TestComposerResultsComment(TestMailComposer):
         self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(self.test_record.ids))
         self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments')
 
+    @users('employee')
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_composer_wtpl_recipients_email_fields(self):
+        """ Test various combinations of corner case / not standard filling of
+        email fields: multi email, formatted emails, ... on template, used to
+        post a message using the composer."""
+        existing_partners = self.env['res.partner'].search([])
+        partner_format_tofind, partner_multi_tofind = self.env['res.partner'].create([
+            {
+                'email': '"FindMe Format" <find.me.format@test.example.com>',
+                'name': 'FindMe Format',
+            }, {
+                'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                'name': 'FindMe Multi',
+            }
+        ])
+        email_ccs = ['"Raoul" <test.cc.1@example.com>', '"Raoulette" <test.cc.2@example.com>', 'test.cc.2.2@example.com>', 'invalid', '  ']
+        email_tos = ['"Micheline, l\'Immense" <test.to.1@example.com>', 'test.to.2@example.com', 'wrong', '  ']
+
+        self.template.write({
+            'email_cc': ', '.join(email_ccs),
+            'email_from': '${user.email_formatted | safe}',
+            'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')),
+            'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test',
+        })
+        self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'})
+        self.partner_1.write({'email': '"Valid Formatted" <valid.lelitre@agrolait.com>'})
+        self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'})
+        # ensure values used afterwards for testing
+        self.assertEqual(
+            self.partner_employee.email_formatted,
+            '"Ernest Employee" <email.from.1@test.example.com, email.from.2@test.example.com>',
+            'Formatting: wrong formatting due to multi-email')
+        self.assertEqual(
+            self.partner_1.email_formatted,
+            '"Valid Lelitre" <"Valid Formatted" <valid.lelitre@agrolait.com>>',
+            'Formatting: wrong double encapsulation')
+        self.assertEqual(
+            self.partner_2.email_formatted,
+            '"Valid Poilvache" <valid.other.1@agrolait.com, valid.other.cc@agrolait.com>',
+            'Formatting: wrong formatting due to multi-email')
+
+        # instantiate composer, post message
+        composer_form = Form(self.env['mail.compose.message'].with_context(
+            self._get_web_context(
+                self.test_record,
+                add_web=True,
+                default_template_id=self.template.id,
+            )
+        ))
+        composer = composer_form.save()
+        with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app():
+            composer.send_mail()
+
+        # find partners created during sending (as emails are transformed into partners)
+        # FIXME: currently email finding based on formatted / multi emails does
+        # not work
+        new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)])
+        self.assertEqual(len(new_partners), 9,
+                         'Mail (FIXME): multiple partner creation due to formatted / multi emails: 2 extra partners')
+        self.assertIn(partner_format_tofind, new_partners)
+        self.assertIn(partner_multi_tofind, new_partners)
+        self.assertEqual(
+            sorted(new_partners.mapped('email')),
+            sorted(['"FindMe Format" <find.me.format@test.example.com>',
+                    'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                    'find.me.multi.1@test.example.com',
+                    'find.me.multi.2@test.example.com',
+                    'test.cc.1@example.com', 'test.cc.2@example.com', 'test.cc.2.2@example.com',
+                    'test.to.1@example.com', 'test.to.2@example.com']),
+            'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)'
+        )
+        self.assertEqual(
+            sorted(new_partners.mapped('email_formatted')),
+            sorted(['"FindMe Format" <"FindMe Format" <find.me.format@test.example.com>>',
+                    '"FindMe Multi" <find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>>',
+                    '"find.me.multi.1@test.example.com" <find.me.multi.1@test.example.com>',
+                    '"find.me.multi.2@test.example.com" <find.me.multi.2@test.example.com>',
+                    '"test.cc.1@example.com" <test.cc.1@example.com>',
+                    '"test.cc.2@example.com" <test.cc.2@example.com>',
+                    '"test.cc.2.2@example.com" <test.cc.2.2@example.com>',
+                    '"test.to.1@example.com" <test.to.1@example.com>',
+                    '"test.to.2@example.com" <test.to.2@example.com>']),
+        )
+        self.assertEqual(
+            sorted(new_partners.mapped('name')),
+            sorted(['FindMe Format',
+                    'FindMe Multi',
+                    'find.me.multi.1@test.example.com',
+                    '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;
+        # NOTE: 'Findme Multi' is excluded as it has the same email as 'find.me.multi.1@test.example.com'
+        #   (created by template) and comes second in a search based on email
+        mailed_new_partners = new_partners.filtered(lambda p: p.name != 'FindMe Multi')
+        self.assertEqual(len(mailed_new_partners), 8)
+        self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail')
+        self.assertEqual(
+            len(self._mails), len(mailed_new_partners) + 3,
+            f'Should have sent {len(mailed_new_partners) + 3} emails, one / recipient ({len(mailed_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}',
+                # currently holding multi-email 'from'
+                'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com, email.from.2@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 + mailed_new_partners, 'sent',
+            author=self.partner_employee,
+            email_values={
+                'body_content': f'TemplateBody {self.test_record.name}',
+                'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com, email.from.2@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,139 @@ class TestComposerResultsMass(TestMailComposer):
         composer = composer_form.save()
         with self.mock_mail_gateway(mail_unlink_sent=False), self.assertRaises(ValueError):
             composer.send_mail()
+
+    @users('employee')
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_composer_wtpl_recipients_email_fields(self):
+        """ Test various combinations of corner case / not standard filling of
+        email fields: multi email, formatted emails, ... """
+        existing_partners = self.env['res.partner'].search([])
+        partner_format_tofind, partner_multi_tofind = self.env['res.partner'].create([
+            {
+                'email': '"FindMe Format" <find.me.format@test.example.com>',
+                'name': 'FindMe Format',
+            }, {
+                'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                'name': 'FindMe Multi',
+            }
+        ])
+        email_ccs = ['"Raoul" <test.cc.1@example.com>', '"Raoulette" <test.cc.2@example.com>', 'test.cc.2.2@example.com>', 'invalid', '  ']
+        email_tos = ['"Micheline, l\'Immense" <test.to.1@example.com>', 'test.to.2@example.com', 'wrong', '  ']
+
+        self.template.write({
+            'email_cc': ', '.join(email_ccs),
+            'email_from': '${user.email_formatted | safe}',
+            'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')),
+            'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test',
+        })
+        self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'})
+        self.partner_1.write({'email': '"Valid Formatted" <valid.lelitre@agrolait.com>'})
+        self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'})
+        # ensure values used afterwards for testing
+        self.assertEqual(
+            self.partner_employee.email_formatted,
+            '"Ernest Employee" <email.from.1@test.example.com, email.from.2@test.example.com>',
+            'Formatting: wrong formatting due to multi-email')
+        self.assertEqual(
+            self.partner_1.email_formatted,
+            '"Valid Lelitre" <"Valid Formatted" <valid.lelitre@agrolait.com>>',
+            'Formatting: wrong double encapsulation')
+        self.assertEqual(
+            self.partner_2.email_formatted,
+            '"Valid Poilvache" <valid.other.1@agrolait.com, valid.other.cc@agrolait.com>',
+            'Formatting: wrong formatting due to multi-email')
+
+        # instantiate composer, send mailing
+        composer_form = Form(self.env['mail.compose.message'].with_context(
+            self._get_web_context(
+                self.test_records,
+                add_web=True,
+                default_template_id=self.template.id,
+            )
+        ))
+        composer = composer_form.save()
+        with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app():
+            composer.send_mail()
+
+        # find partners created during sending (as emails are transformed into partners)
+        # FIXME: currently email finding based on formatted / multi emails does
+        # not work
+        new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)])
+        self.assertEqual(len(new_partners), 9,
+                         'Mail (FIXME): did not find existing partners for formatted / multi emails: 2 extra partners')
+        self.assertIn(partner_format_tofind, new_partners)
+        self.assertIn(partner_multi_tofind, new_partners)
+        self.assertEqual(
+            sorted(new_partners.mapped('email')),
+            sorted(['"FindMe Format" <find.me.format@test.example.com>',
+                    'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                    'find.me.multi.1@test.example.com',
+                    'find.me.multi.2@test.example.com',
+                    'test.cc.1@example.com', 'test.cc.2@example.com', 'test.cc.2.2@example.com',
+                    'test.to.1@example.com', 'test.to.2@example.com']),
+            'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)'
+        )
+        self.assertEqual(
+            sorted(new_partners.mapped('email_formatted')),
+            sorted(['"FindMe Format" <"FindMe Format" <find.me.format@test.example.com>>',
+                    '"FindMe Multi" <find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>>',
+                    '"find.me.multi.1@test.example.com" <find.me.multi.1@test.example.com>',
+                    '"find.me.multi.2@test.example.com" <find.me.multi.2@test.example.com>',
+                    '"test.cc.1@example.com" <test.cc.1@example.com>',
+                    '"test.cc.2@example.com" <test.cc.2@example.com>',
+                    '"test.cc.2.2@example.com" <test.cc.2.2@example.com>',
+                    '"test.to.1@example.com" <test.to.1@example.com>',
+                    '"test.to.2@example.com" <test.to.2@example.com>']),
+        )
+        self.assertEqual(
+            sorted(new_partners.mapped('name')),
+            sorted(['FindMe Format',
+                    'FindMe Multi',
+                    'find.me.multi.1@test.example.com',
+                    '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;
+        # NOTE: 'Findme Multi' is excluded as it has the same email as 'find.me.multi.1@test.example.com'
+        #   (created by template) and comes second in a search based on email
+        mailed_new_partners = new_partners.filtered(lambda p: p.name != 'FindMe Multi')
+        self.assertEqual(len(mailed_new_partners), 8)
+        self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail')
+        self.assertEqual(
+            len(self._mails), (len(mailed_new_partners) + 2) * 2,
+            f'Should have sent {(len(mailed_new_partners) + 2) * 2} emails, one / recipient ({len(mailed_new_partners)} mailed partners + partner_1 + partner_2) * 2 records')
+        for record in self.test_records:
+            self.assertMailMail(
+                self.partner_1 + self.partner_2 + mailed_new_partners,
+                'sent',
+                author=self.partner_employee,
+                email_values={
+                    'body_content': f'TemplateBody {record.name}',
+                    # 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}'
+                    )),
+                    'subject': f'TemplateSubject {record.name}',
+                },
+                fields_values={
+                    # currently holding multi-email 'email_from'
+                    'email_from': self.partner_employee.email_formatted,
+                    'reply_to': formataddr((
+                        f'{self.env.user.company_id.name} {record.name}',
+                        f'{self.alias_catchall}@{self.alias_domain}'
+                    )),
+                },
+                mail_message=record.message_ids[0],  # message copy is kept
+            )
diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py
index 4eb22aa18620bba000e6c13d96f33fcfc161e843..103877f6b24d4ece0dad0e4e9f560c227fc651cc 100644
--- a/addons/test_mail/tests/test_mail_gateway.py
+++ b/addons/test_mail/tests/test_mail_gateway.py
@@ -430,6 +430,31 @@ class TestMailgateway(TestMailCommon):
         self.assertEqual(record.message_ids[0].email_from, self.partner_1.email)
         self.assertNotSentEmail()  # No notification / bounce should be sent
 
+    @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models')
+    def test_message_process_email_author_multiemail(self):
+        """ Incoming email: recognized author: check multi/formatted email in field """
+        test_email = 'valid.lelitre@agrolait.com'
+        # Email not recognized if partner has a multi-email (source = formatted email)
+        self.partner_1.write({'email': f'{test_email}, "Valid Lelitre" <another.email@test.example.com>'})
+        with self.mock_mail_gateway():
+            record = self.format_and_process(
+                MAIL_TEMPLATE, f'"Valid Lelitre" <{test_email}>', 'groups@test.com', subject='Test3')
+
+        self.assertFalse(record.message_ids[0].author_id,
+                         'message_process (FIXME): unrecognized email -> author_id due to multi email')
+        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.assertFalse(record.message_ids[0].author_id,
+                         'message_process (FIXME): unrecognized email -> author_id due to multi email')
+        self.assertEqual(record.message_ids[0].email_from, test_email)
+        self.assertNotSentEmail()  # No notification / bounce should be sent
+
     @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models')
     def test_message_process_email_partner_find(self):
         """ Finding the partner based on email, based on partner / user / follower """
@@ -621,6 +646,39 @@ class TestMailgateway(TestMailCommon):
         # Test: one group created by Raoul (or Sylvie maybe, if we implement it)
         self.assertEqual(len(record), 1)
 
+    @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models')
+    def test_message_process_alias_followers_multiemail(self):
+        """ Incoming email from a parent document follower on a Followers only
+        alias depends on email_from / partner recognition, to be tested when
+        dealing with multi emails / formatted emails. """
+        self.alias.write({
+            'alias_contact': 'followers',
+            'alias_parent_model_id': self.env['ir.model']._get('mail.test.gateway').id,
+            'alias_parent_thread_id': self.test_record.id,
+        })
+        self.test_record.message_subscribe(partner_ids=[self.partner_1.id])
+        email_from = formataddr(("Another Name", self.partner_1.email_normalized))
+
+        for partner_email, passed in [
+            (formataddr((self.partner_1.name, self.partner_1.email_normalized)), True),
+            (f'{self.partner_1.email_normalized}, "Multi Email" <multi.email@test.example.com>', False),
+            (f'"Multi Email" <multi.email@test.example.com>, {self.partner_1.email_normalized}', False),
+        ]:
+            with self.subTest(partner_email=partner_email):
+                self.partner_1.write({'email': partner_email})
+                record = self.format_and_process(
+                    MAIL_TEMPLATE, email_from, 'groups@test.com',
+                    subject=f'Test for {partner_email}')
+
+                if passed:
+                    self.assertEqual(len(record), 1)
+                    self.assertEqual(record.email_from, email_from)
+                    self.assertEqual(record.message_partner_ids, self.partner_1)
+                # multi emails not recognized (no normalized email, recognition)
+                else:
+                    self.assertEqual(len(record), 0,
+                                     'Alias check (FIXME): multi-emails bad support for recognition')
+
     @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
     def test_message_process_alias_update(self):
         """ Incoming email update discussion + notification email """
@@ -1573,6 +1631,7 @@ class TestMailgateway(TestMailCommon):
     # Corner cases / Bugs during message process
     # --------------------------------------------------
 
+    @mute_logger('odoo.addons.mail.models.mail_thread')
     def test_message_process_file_encoding_ascii(self):
         """ Incoming email containing an xml attachment with unknown characters (�) but an ASCII charset should not
         raise an Exception. UTF-8 is used as a safe fallback.
@@ -1598,6 +1657,7 @@ class TestMailgateway(TestMailCommon):
         self.assertEqual(record.message_main_attachment_id.name, 'bis3.xml')
         self.assertEqual("<Invoice>Chaussée de Bruxelles</Invoice>", record.message_main_attachment_id.raw.decode())
 
+@tagged('mail_gateway', 'mail_thread')
 class TestMailThreadCC(TestMailCommon):
 
     @classmethod
diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py
index 3ebee26330b69d8a5e57d7200e47bfaff3380c53..9cc292c6dfd94eee37e5758cf97d24af1f21bd88 100644
--- a/addons/test_mail/tests/test_mail_mail.py
+++ b/addons/test_mail/tests/test_mail_mail.py
@@ -3,7 +3,7 @@
 
 import psycopg2
 
-from odoo import api
+from odoo import api, tools
 from odoo.addons.test_mail.tests.common import TestMailCommon
 from odoo.tests import common, tagged
 from odoo.tools import mute_logger
@@ -23,7 +23,7 @@ class TestMailMail(TestMailCommon):
         }).with_context({})
 
     @mute_logger('odoo.addons.mail.models.mail_mail')
-    def test_mail_message_notify_from_mail_mail(self):
+    def test_mail_mail_notify_from_mail_mail(self):
         # Due ot post-commit hooks, store send emails in every step
         mail = self.env['mail.mail'].sudo().create({
             'body_html': '<p>Test</p>',
@@ -35,6 +35,7 @@ class TestMailMail(TestMailCommon):
         self.assertSentEmail(mail.env.user.partner_id, ['test@example.com'])
         self.assertEqual(len(self._mails), 1)
 
+    @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_mail_mail_return_path(self):
         # mail without thread-enabled record
         base_values = {
@@ -74,7 +75,112 @@ class TestMailMail(TestMailCommon):
             mail.send()
         self.assertEqual(self._mails[0]['headers']['Return-Path'], '%s@%s' % (self.alias_bounce, self.alias_domain))
 
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_mail_values_email_formatted(self):
+        """ Test outgoing email values, with formatting """
+        customer = self.env['res.partner'].create({
+            'name': 'Tony Customer',
+            'email': '"Formatted Emails" <tony.customer@test.example.com>',
+        })
+        mail = self.env['mail.mail'].create({
+            'body_html': '<p>Test</p>',
+            'email_cc': '"Ignasse, le Poilu" <test.cc.1@test.example.com>',
+            'email_to': '"Raoul, le Grand" <test.email.1@test.example.com>, "Micheline, l\'immense" <test.email.2@test.example.com>',
+            'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)]
+        })
+        with self.mock_mail_gateway():
+            mail.send()
+        self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient')
+        self.assertEqual(
+            sorted(sorted(_mail['email_to']) for _mail in self._mails),
+            sorted([sorted(['"Raoul, le Grand" <test.email.1@test.example.com>', '"Micheline, l\'immense" <test.email.2@test.example.com>']),
+                    [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))],
+                    [tools.formataddr(("Tony Customer", '"Formatted Emails" <tony.customer@test.example.com>'))]
+                   ]),
+            'Mail (FIXME): double encapsulation of emails ("Tony" <"Formatted" <tony@e.com>>)'
+        )
+        # Currently broken: CC are added to ALL emails (spammy)
+        self.assertEqual(
+            [_mail['email_cc'] for _mail in self._mails],
+            [['test.cc.1@test.example.com']] * 3,
+            'Mail: currently always removing formatting in email_cc'
+        )
+
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_mail_values_email_multi(self):
+        """ Test outgoing email values, with email field holding multi emails """
+        # Multi
+        customer = self.env['res.partner'].create({
+            'name': 'Tony Customer',
+            'email': 'tony.customer@test.example.com, norbert.customer@test.example.com',
+        })
+        mail = self.env['mail.mail'].create({
+            'body_html': '<p>Test</p>',
+            'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com',
+            'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com',
+            'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)]
+        })
+        with self.mock_mail_gateway():
+            mail.send()
+        self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient')
+        self.assertEqual(
+            sorted(sorted(_mail['email_to']) for _mail in self._mails),
+            sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']),
+                    [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))],
+                    [tools.formataddr(("Tony Customer", 'tony.customer@test.example.com, norbert.customer@test.example.com'))]
+                   ]),
+            'Mail: currenty broken (multi email in a single address) but supported by some providers ("Tony" <tony@e.com, tony2@e.com>)'
+        )
+        # Currently broken: CC are added to ALL emails (spammy)
+        self.assertEqual(
+            [_mail['email_cc'] for _mail in self._mails],
+            [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3,
+        )
+
+        # Multi + formatting
+        customer = self.env['res.partner'].create({
+            'name': 'Tony Customer',
+            'email': 'tony.customer@test.example.com, "Norbert Customer" <norbert.customer@test.example.com>',
+        })
+        mail = self.env['mail.mail'].create({
+            'body_html': '<p>Test</p>',
+            'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com',
+            'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com',
+            'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)]
+        })
+        with self.mock_mail_gateway():
+            mail.send()
+        self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient')
+        self.assertEqual(
+            sorted(sorted(_mail['email_to']) for _mail in self._mails),
+            sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']),
+                    [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))],
+                    [tools.formataddr(("Tony Customer", 'tony.customer@test.example.com, "Norbert Customer" <norbert.customer@test.example.com>'))]
+                   ]),
+            'Mail: currently broken, double encapsulation with formatting ("Tony" <tony@e.com, "Tony2" <tony2@e.com>>)'
+        )
+        # Currently broken: CC are added to ALL emails (spammy)
+        self.assertEqual(
+            [_mail['email_cc'] for _mail in self._mails],
+            [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3,
+        )
+
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_mail_values_unicode(self):
+        """ Unicode should be fine. """
+        mail = self.env['mail.mail'].create({
+            'body_html': '<p>Test</p>',
+            'email_cc': 'test.😊.cc@example.com',
+            'email_to': 'test.😊@example.com',
+        })
+        with self.mock_mail_gateway():
+            mail.send()
+        self.assertEqual(len(self._mails), 1)
+        self.assertEqual(self._mails[0]['email_cc'], ['test.😊.cc@example.com'])
+        self.assertEqual(self._mails[0]['email_to'], ['test.😊@example.com'])
+
 
+@tagged('mail_mail')
 class TestMailMailRace(common.TransactionCase):
 
     @mute_logger('odoo.addons.mail.models.mail_mail')
diff --git a/addons/test_mail/tests/test_mail_thread_mixins.py b/addons/test_mail/tests/test_mail_thread_mixins.py
new file mode 100644
index 0000000000000000000000000000000000000000..875a0e8aa0b89c23437df0fd8e4ccf90eea5e930
--- /dev/null
+++ b/addons/test_mail/tests/test_mail_thread_mixins.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import exceptions, tools
+from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients
+from odoo.tests.common import tagged
+from odoo.tools import mute_logger
+
+
+@tagged('mail_thread', 'mail_blacklist')
+class TestMailThread(TestMailCommon, TestRecipients):
+
+    @mute_logger('odoo.models.unlink')
+    def test_blacklist_mixin_email_normalized(self):
+        """ Test email_normalized and is_blacklisted fields behavior, notably
+        when dealing with encapsulated email fields and multi-email input. """
+        base_email = 'test.email@test.example.com'
+
+        # test data: source email, expected email normalized
+        valid_pairs = [
+            (base_email, base_email),
+            (tools.formataddr(('Another Name', base_email)), base_email),
+            (f'Name That Should Be Escaped <{base_email}>', base_email),
+            ('test.😊@example.com', 'test.😊@example.com'),
+            ('"Name 😊" <test.😊@example.com>', 'test.😊@example.com'),
+        ]
+        void_pairs = [(False, False),
+                      ('', False),
+                      (' ', False)]
+        multi_pairs = [
+            (f'{base_email}, other.email@test.example.com',
+             False),  # multi not supported currently
+            (f'{tools.formataddr(("Another Name", base_email))}, other.email@test.example.com',
+             False),  # multi not supported currently
+        ]
+        for email_from, exp_email_normalized in valid_pairs + void_pairs + multi_pairs:
+            with self.subTest(email_from=email_from, exp_email_normalized=exp_email_normalized):
+                new_record = self.env['mail.test.gateway'].create({
+                    'email_from': email_from,
+                    'name': 'BL Test',
+                })
+                self.assertEqual(new_record.email_normalized, exp_email_normalized)
+                self.assertFalse(new_record.is_blacklisted)
+
+                # blacklist email should fail as void
+                if email_from in [pair[0] for pair in void_pairs]:
+                    with self.assertRaises(exceptions.UserError):
+                        bl_record = self.env['mail.blacklist']._add(email_from)
+                # blacklist email currently fails but could not
+                elif email_from in [pair[0] for pair in multi_pairs]:
+                    with self.assertRaises(exceptions.UserError):
+                        bl_record = self.env['mail.blacklist']._add(email_from)
+                # blacklist email ok
+                else:
+                    bl_record = self.env['mail.blacklist']._add(email_from)
+                    self.assertEqual(bl_record.email, exp_email_normalized)
+                    new_record.invalidate_cache(fnames=['is_blacklisted'])
+                    self.assertTrue(new_record.is_blacklisted)
+
+                bl_record.unlink()
diff --git a/addons/test_mail/tests/test_mail_tools.py b/addons/test_mail/tests/test_mail_tools.py
index b36a1335c5d3c450cac0c62ef8f3dcc45c84de1f..284b3623a83d4f9e13dba8e049e470069a5ca3f8 100644
--- a/addons/test_mail/tests/test_mail_tools.py
+++ b/addons/test_mail/tests/test_mail_tools.py
@@ -22,62 +22,124 @@ class TestMailTools(TestMailCommon, TestRecipients):
         })
 
     @users('employee')
-    def test_find_partner_from_emails(self):
+    def test_mail_find_partner_from_emails(self):
         Partner = self.env['res.partner']
         test_partner = Partner.browse(self.test_partner.ids)
         self.assertEqual(test_partner.email, self._test_email)
 
-        # test direct match
-        found = Partner._mail_find_partner_from_emails([self._test_email])
-        self.assertEqual(found, [test_partner])
-
-        # test encapsulated email
-        found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email])
-        self.assertEqual(found, [test_partner])
+        sources = [
+            self._test_email,  # test direct match
+            f'"Norbert Poiluchette" <{self._test_email}>',  # encapsulated
+            'fredoastaire@test.example.com',  # partial email -> should not match !
+        ]
+        expected_partners = [
+            test_partner,
+            test_partner,
+            self.env['res.partner'],
+        ]
+        for source, expected_partner in zip(sources, expected_partners):
+            with self.subTest(source=source):
+                found = Partner._mail_find_partner_from_emails([source])
+                self.assertEqual(found, [expected_partner])
 
         # test with wildcard "_"
         found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com'])
         self.assertEqual(found, [self.env['res.partner']])
-
         # sub-check: this search does not consider _ as a wildcard
         found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com'])
         self.assertEqual(found, self.env['res.partner'])
 
         # test partners with encapsulated emails
         # ------------------------------------------------------------
-        test_partner.sudo().write({'email': '"Alfred Mighty Power Astaire" <%s>' % self._test_email})
-
-        # test direct match
-        found = Partner._mail_find_partner_from_emails([self._test_email])
-        self.assertEqual(found, [test_partner])
-
-        # test encapsulated email
-        found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email])
-        self.assertEqual(found, [test_partner])
+        test_partner.sudo().write({'email': f'"Alfred Mighty Power Astaire" <{self._test_email}>'})
+
+        sources = [
+            self._test_email,  # test direct match
+            f'"Norbert Poiluchette" <{self._test_email}>',  # encapsulated
+        ]
+        expected_partners = [
+            test_partner,
+            test_partner,
+        ]
+        for source, expected_partner in zip(sources, expected_partners):
+            with self.subTest(source=source):
+                found = Partner._mail_find_partner_from_emails([source])
+                self.assertEqual(found, [expected_partner])
 
         # test with wildcard "_"
         found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com'])
         self.assertEqual(found, [self.env['res.partner']])
-
         # sub-check: this search does not consider _ as a wildcard
         found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com'])
         self.assertEqual(found, self.env['res.partner'])
 
-        # test partners with look-alike emails
-        # ------------------------------------------------------------
-        for email_lookalike in [
-                'alfred.astaire@test.example.com',
-                'alfredoastaire@example.com',
-                'aalfredoastaire@test.example.com',
-                'alfredoastaire@test.example.comm']:
-            test_partner.sudo().write({'email': '"Alfred Astaire" <%s>' % email_lookalike})
-
-            # test direct match
-            found = Partner._mail_find_partner_from_emails([self._test_email])
-            self.assertEqual(found, [self.env['res.partner']])
-            # test encapsulated email
-            found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email])
-            self.assertEqual(found, [self.env['res.partner']])
-            # test with wildcard "_"
-            found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com'])
-            self.assertEqual(found, [self.env['res.partner']])
+    @users('employee')
+    def test_mail_find_partner_from_emails_followers(self):
+        """ Test '_mail_find_partner_from_emails' when dealing with records on
+        which followers have to be found based on email. Check multi email
+        and encapsulated email support. """
+        linked_record = self.env['mail.test.simple'].create({'name': 'Record for followers'})
+        follower_partner = self.env['res.partner'].sudo().create({
+            'email': self._test_email,
+            'name': 'Duplicated, follower of record',
+        })
+        linked_record.message_subscribe(partner_ids=follower_partner.ids)
+        test_partner = self.test_partner.with_env(self.env)
+
+        # standard test, no multi-email, to assert base behavior
+        sources = [(self._test_email, True), (self._test_email, False),]
+        expected = [follower_partner, test_partner]
+        for (source, follower_check), expected in zip(sources, expected):
+            with self.subTest(source=source, follower_check=follower_check):
+                partner = self.env['res.partner']._mail_find_partner_from_emails(
+                    [source], records=linked_record if follower_check else None
+                )[0]
+                self.assertEqual(partner, expected)
+
+        # formatted email
+        encapsulated_test_email = f'"Robert Astaire" <{self._test_email}>'
+        (follower_partner + test_partner).sudo().write({'email': encapsulated_test_email})
+        sources = [
+            (self._test_email, True),  # normalized
+            (self._test_email, False),  # normalized
+            (encapsulated_test_email, True),  # encapsulated, same
+            (encapsulated_test_email, False),  # encapsulated, same
+            (f'"AnotherName" <{self._test_email}', True),  # same normalized, other name
+            (f'"AnotherName" <{self._test_email}', False),  # same normalized, other name
+        ]
+        expected = [follower_partner, test_partner,
+                    follower_partner, test_partner,
+                    follower_partner, test_partner,
+                    follower_partner, test_partner]
+        for (source, follower_check), expected in zip(sources, expected):
+            with self.subTest(source=source, follower_check=follower_check):
+                partner = self.env['res.partner']._mail_find_partner_from_emails(
+                    [source], records=linked_record if follower_check else None
+                )[0]
+                self.assertEqual(partner, expected,
+                                'Mail: formatted email is recognized through usage of normalized email')
+
+        # multi-email
+        _test_email_2 = '"Robert Astaire" <not.alfredoastaire@test.example.com>'
+        (follower_partner + test_partner).sudo().write({'email': f'{self._test_email}, {_test_email_2}'})
+        sources = [
+            (self._test_email, True),  # first email
+            (self._test_email, False),  # first email
+            (_test_email_2, True),  # second email
+            (_test_email_2, False),  # second email
+            ('not.alfredoastaire@test.example.com', True),  # normalized second email in field
+            ('not.alfredoastaire@test.example.com', False),  # normalized second email in field
+            (f'{self._test_email}, {_test_email_2}', True),  # multi-email, both matching, depends on comparison
+            (f'{self._test_email}, {_test_email_2}', False)  # multi-email, both matching, depends on comparison
+        ]
+        expected = [self.env['res.partner'], self.env['res.partner'],
+                    self.env['res.partner'], self.env['res.partner'],
+                    self.env['res.partner'], self.env['res.partner'],
+                    self.env['res.partner'], self.env['res.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): not recognized due to usage of email_normalize that does not accept multi emails')
diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py
index 14673a99ca944fa99b4dca88365ad7c72dfd8e6f..d2b450100a9c7cdee23e3cf8e75f02bbe5d07a07 100644
--- a/addons/test_mail/tests/test_message_post.py
+++ b/addons/test_mail/tests/test_message_post.py
@@ -37,12 +37,13 @@ class TestMessagePost(TestMailCommon, TestRecipients):
         self.assertTrue(isinstance(messageId, int))
 
     @users('employee')
+    @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_notify_mail_add_signature(self):
-        self.test_track = self.env['mail.test.track'].with_context(self._test_context).with_user(self.user_employee).create({
+        test_track = self.env['mail.test.track'].with_context(self._test_context).with_user(self.user_employee).create({
             'name': 'Test',
             'email_from': 'ignasse@example.com'
         })
-        self.test_track.user_id = self.env.user
+        test_track.user_id = self.env.user
 
         signature = self.env.user.signature
 
@@ -50,13 +51,13 @@ class TestMessagePost(TestMailCommon, TestRecipients):
         self.assertIn("record.user_id.sudo().signature", template.arch)
 
         with self.mock_mail_gateway():
-            self.test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=True, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow")
+            test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=True, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow")
         found_mail = self._new_mails
         self.assertIn(signature, found_mail.body_html)
         self.assertEqual(found_mail.body_html.count(signature), 1)
 
         with self.mock_mail_gateway():
-            self.test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=False, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow")
+            test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=False, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow")
         found_mail = self._new_mails
         self.assertNotIn(signature, found_mail.body_html)
         self.assertEqual(found_mail.body_html.count(signature), 0)
@@ -203,7 +204,7 @@ class TestMessagePost(TestMailCommon, TestRecipients):
             self.test_record.message_post(
                 body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
 
-    @mute_logger('odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
     def test_post_notifications(self):
         _body, _subject = '<p>Test Body</p>', 'Test Subject'
 
@@ -238,6 +239,51 @@ class TestMessagePost(TestMailCommon, TestRecipients):
         self.assertFalse(copy.notified_partner_ids)
 
     @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
+    def test_post_notifications_email_field(self):
+        """ Test various combinations of corner case / not standard filling of
+        email fields: multi email, formatted emails, ... """
+        partner_emails = [
+            'valid.lelitre@agrolait.com, valid.lelitre.cc@agrolait.com',  # multi email
+            '"Valid Lelitre" <valid.lelitre@agrolait.com>',  # email contains formatted email
+            'wrong',  # wrong
+            False, '', ' ',  # falsy
+        ]
+        expected_tos = [
+            # Currently semi-broken: sending to an incorrect email "Name" <address1, address2>
+            [f'"{self.partner_1.name}" <valid.lelitre@agrolait.com, valid.lelitre.cc@agrolait.com>',],
+            # Currently broken: sending to an incorrect email "Name" <"Name" <address>>
+            [f'"{self.partner_1.name}" <"Valid Lelitre" <valid.lelitre@agrolait.com>>',],
+            # sent "normally": formats email based on wrong / falsy email
+            [f'"{self.partner_1.name}" <@wrong>',],
+            [f'"{self.partner_1.name}" <@False>',],
+            [f'"{self.partner_1.name}" <@False>',],
+            [f'"{self.partner_1.name}" <@ >',],
+        ]
+
+        for partner_email, expected_to in zip(partner_emails, expected_tos):
+            with self.subTest(partner_email=partner_email, expected_to=expected_to):
+                self.partner_1.write({'email': partner_email})
+                with self.mock_mail_gateway():
+                    self.test_record.with_user(self.user_employee).message_post(
+                        body='Test multi email',
+                        message_type='comment',
+                        partner_ids=[self.partner_1.id],
+                        subject='Exotic email',
+                        subtype_xmlid='mt_comment',
+                    )
+
+                self.assertSentEmail(
+                    self.user_employee.partner_id,
+                    [self.partner_1],
+                    email_to=expected_to,
+                )
+
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
+    def test_post_notifications_emails_tweak(self):
+        pass
+        # we should check _notification_groups behavior, for emails and buttons
+
+    @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_post_notifications_keep_emails(self):
         self.test_record.message_subscribe(partner_ids=[self.user_admin.partner_id.id])
 
@@ -251,11 +297,6 @@ class TestMessagePost(TestMailCommon, TestRecipients):
         # notifications emails should not have been deleted: one for customers, one for user
         self.assertEqual(len(self.env['mail.mail'].sudo().search([('mail_message_id', '=', msg.id)])), 2)
 
-    @mute_logger('odoo.addons.mail.models.mail_mail')
-    def test_post_notifications_emails_tweak(self):
-        pass
-        # we should check _notification_groups behavior, for emails and buttons
-
     @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_post_attachments(self):
         _attachments = [
@@ -357,7 +398,7 @@ class TestMessagePost(TestMailCommon, TestRecipients):
             references='%s %s' % (parent_msg.message_id, new_msg.message_id),
         )
 
-    @mute_logger('odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
     def test_post_email_with_multiline_subject(self):
         _body, _body_alt, _subject = '<p>Test Body</p>', 'Test Body', '1st line\n2nd line'
         msg = self.test_record.with_user(self.user_employee).message_post(
diff --git a/addons/test_mass_mailing/models/mailing_models.py b/addons/test_mass_mailing/models/mailing_models.py
index 54ede3f8e14adc7de1d2d09fbe9f5f964a840660..8748f4792110b56443436d88d97a1bf577654ae5 100644
--- a/addons/test_mass_mailing/models/mailing_models.py
+++ b/addons/test_mass_mailing/models/mailing_models.py
@@ -4,9 +4,39 @@
 from odoo import api, fields, models
 
 
+class MailingCustomer(models.Model):
+    """ A model inheriting from mail.thread with a partner field, to test
+    mass mailing flows involving checking partner email. """
+    _description = 'Mailing with partner'
+    _name = 'mailing.test.customer'
+    _inherit = ['mail.thread']
+
+    name = fields.Char()
+    email_from = fields.Char(compute='_compute_email_from', readonly=False, store=True)
+    customer_id = fields.Many2one('res.partner', 'Customer', tracking=True)
+
+    @api.depends('customer_id')
+    def _compute_email_from(self):
+        for mailing in self.filtered(lambda rec: not rec.email_from and rec.customer_id):
+            mailing.email_from = mailing.customer_id.email
+
+    def _message_get_default_recipients(self):
+        """ Default recipient checks for 'partner_id', here the field is named
+        'customer_id'. """
+        default_recipients = super()._message_get_default_recipients()
+        for record in self:
+            if record.customer_id:
+                default_recipients[record.id] = {
+                    'email_cc': False,
+                    'email_to': False,
+                    'partner_ids': record.customer_id.ids,
+                }
+        return default_recipients
+
+
 class MailingSimple(models.Model):
-    """ A very simple model only inheriting from mail.thread to test pure mass
-    mailing features and base performances. """
+    """ Model only inheriting from mail.thread to test base mailing features and
+    performances. """
     _description = 'Simple Mailing'
     _name = 'mailing.test.simple'
     _inherit = ['mail.thread']
@@ -16,7 +46,8 @@ class MailingSimple(models.Model):
 
 
 class MailingUTM(models.Model):
-    """ Model inheriting from mail.thread and utm.mixin for checking utm of mailing is caught and set on reply """
+    """ Model inheriting from mail.thread and utm.mixin for checking utm of mailing
+    is caught and set on reply """
     _description = 'Mailing: UTM enabled to test UTM sync with mailing'
     _name = 'mailing.test.utm'
     _inherit = ['mail.thread', 'utm.mixin']
@@ -36,6 +67,19 @@ class MailingBLacklist(models.Model):
     customer_id = fields.Many2one('res.partner', 'Customer', tracking=True)
     user_id = fields.Many2one('res.users', 'Responsible', tracking=True)
 
+    def _message_get_default_recipients(self):
+        """ Default recipient checks for 'partner_id', here the field is named
+        'customer_id'. """
+        default_recipients = super()._message_get_default_recipients()
+        for record in self:
+            if record.customer_id:
+                default_recipients[record.id] = {
+                    'email_cc': False,
+                    'email_to': False,
+                    'partner_ids': record.customer_id.ids,
+                }
+        return default_recipients
+
 
 class MailingOptOut(models.Model):
     """ Model using blacklist mechanism and a hijacked opt-out mechanism for
@@ -51,6 +95,19 @@ class MailingOptOut(models.Model):
     customer_id = fields.Many2one('res.partner', 'Customer', tracking=True)
     user_id = fields.Many2one('res.users', 'Responsible', tracking=True)
 
+    def _message_get_default_recipients(self):
+        """ Default recipient checks for 'partner_id', here the field is named
+        'customer_id'. """
+        default_recipients = super()._message_get_default_recipients()
+        for record in self:
+            if record.customer_id:
+                default_recipients[record.id] = {
+                    'email_cc': False,
+                    'email_to': False,
+                    'partner_ids': record.customer_id.ids,
+                }
+        return default_recipients
+
 
 class MailingPerformance(models.Model):
     """ A very simple model only inheriting from mail.thread to test pure mass
diff --git a/addons/test_mass_mailing/security/ir.model.access.csv b/addons/test_mass_mailing/security/ir.model.access.csv
index b21c7be49b14a3b910c38d8686f4e2c8e086c056..617995ff0fac1bfeb8130746c698e825449ccf96 100644
--- a/addons/test_mass_mailing/security/ir.model.access.csv
+++ b/addons/test_mass_mailing/security/ir.model.access.csv
@@ -1,4 +1,6 @@
 id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_mailing_test_customer_all,access.mailing.test.customer.all,model_mailing_test_customer,,0,0,0,0
+access_mailing_test_customer_user,access.mailing.test.customer.user,model_mailing_test_customer,base.group_user,1,1,1,1
 access_mailing_test_simple_all,access.mailing.test.simple.all,model_mailing_test_simple,,0,0,0,0
 access_mailing_test_simple_user,access.mailing.test.simple.user,model_mailing_test_simple,base.group_user,1,1,1,1
 access_mailing_test_blacklist_all,access.mailing.test.blacklist.all,model_mailing_test_blacklist,,0,0,0,0
diff --git a/addons/test_mass_mailing/tests/test_mailing.py b/addons/test_mass_mailing/tests/test_mailing.py
index 41fc0f1df3fea14aa5deda5b6262d5fa1a1999ce..83c6bb2838043272e3e919b7ab4d74988b92a299 100644
--- a/addons/test_mass_mailing/tests/test_mailing.py
+++ b/addons/test_mass_mailing/tests/test_mailing.py
@@ -119,6 +119,134 @@ class TestMassMailing(TestMassMailCommon):
         self.assertMailingStatistics(mailing, expected=5, delivered=4, sent=5, opened=1, clicked=1, bounced=1)
         self.assertEqual(recipients[1].message_bounce, 1)
 
+    @users('user_marketing')
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mailing_recipients(self):
+        """ Test recipient-specific computation, with email, formatting,
+        multi-emails, ... to test corner cases. Blacklist mixin impact is
+        tested. """
+        (customer_mult, customer_fmt, customer_unic,
+         customer_case, customer_weird, customer_weird_2
+        ) = self.env['res.partner'].create([
+            {
+                'email': 'customer.multi.1@example.com, "Test Multi 2" <customer.multi.2@example.com>',
+                'name': 'MultiEMail',
+            }, {
+                'email': '"Formatted Customer" <test.customer.format@example.com>',
+                'name': 'FormattedEmail',
+            }, {
+                'email': '"Unicode Customer" <test.customer.😊@example.com>',
+                'name': 'UnicodeEmail',
+            }, {
+                'email': 'TEST.CUSTOMER.CASE@EXAMPLE.COM',
+                'name': 'CaseEmail',
+            }, {
+                'email': 'test.customer.weird@example.com Weird Format',
+                'name': 'WeirdFormatEmail',
+            }, {
+                'email': 'Weird Format2 test.customer.weird.2@example.com',
+                'name': 'WeirdFormatEmail2',
+            }
+        ])
+
+        # check difference of email management between a classic model and a model
+        # with an 'email_normalized' field (blacklist mixin)
+        for dst_model in ['mailing.test.customer', 'mailing.test.blacklist']:
+            with self.subTest(dst_model=dst_model):
+                (record_p_mult, record_p_fmt, record_p_unic,
+                 record_p_case, record_p_weird, record_p_weird_2,
+                 record_mult, record_fmt, record_unic
+                ) = self.env[dst_model].create([
+                    {
+                        'customer_id': customer_mult.id,
+                    }, {
+                        'customer_id': customer_fmt.id,
+                    }, {
+                        'customer_id': customer_unic.id,
+                    }, {
+                        'customer_id': customer_case.id,
+                    }, {
+                        'customer_id': customer_weird.id,
+                    }, {
+                        'customer_id': customer_weird_2.id,
+                    }, {
+                        'email_from': 'record.multi.1@example.com, "Record Multi 2" <record.multi.2@example.com>',
+                    }, {
+                        'email_from': '"Formatted Record" <record.format@example.com>',
+                    }, {
+                        'email_from': '"Unicode Record" <record.😊@example.com>',
+                    }
+                ])
+                test_records = (
+                    record_p_mult + record_p_fmt + record_p_unic +
+                    record_p_case + record_p_weird + record_p_weird_2 +
+                    record_mult + record_fmt + record_unic
+                )
+                mailing = self.env['mailing.mailing'].create({
+                    'body_html': """<div><p>Hello ${object.name}</p>""",
+                    'mailing_domain': [('id', 'in', test_records.ids)],
+                    'mailing_model_id': self.env['ir.model']._get_id(dst_model),
+                    'mailing_type': 'mail',
+                    'name': 'SourceName',
+                    'preview': 'Hi ${object.name} :)',
+                    'reply_to_mode': 'thread',
+                    'subject': 'MailingSubject',
+                })
+
+                with self.mock_mail_gateway(mail_unlink_sent=False):
+                    mailing.action_send_mail()
+
+                # difference in email_to management when inheriting from blacklist mixin
+                # as it uses email_normalized if possible
+                if dst_model == 'mailing.test.customer':
+                    formatted_mailmail_email = '"Formatted Record" <record.format@example.com>'
+                    unicode_mailmail_email = '"Unicode Record" <record.😊@example.com>'
+                else:
+                    formatted_mailmail_email = 'record.format@example.com'
+                    unicode_mailmail_email = 'record.😊@example.com'
+                self.assertMailTraces(
+                    [
+                        {'email': False,
+                         'failure_type': False,
+                         'partner': customer_mult,
+                         'state': 'ignored'},
+                        {'email': 'test.customer.format@example.com',
+                         'failure_type': False,
+                         'partner': customer_fmt,
+                         'state': 'sent'},
+                        {'email': 'test.customer.😊@example.com',
+                         'failure_type': False,
+                         'partner': customer_unic,
+                         'state': 'ignored'},  # email_re usage forbids mailing to unicode
+                        {'email': 'test.customer.case@example.com',
+                         'failure_type': False,
+                         'partner': customer_case,
+                         'state': 'sent'},  # lower cased
+                        {'email': 'test.customer.weird@example.comweirdformat',
+                         'failure_type': False,
+                         'partner': customer_weird,
+                         'state': 'sent'},  # concatenates everything after domain
+                        {'email': 'weird format2 test.customer.weird.2@example.com',
+                         'failure_type': False,
+                         'partner': customer_weird_2,
+                         'state': 'sent'},
+                        {'email': 'record.multi.1@example.com, "Record Multi 2" <record.multi.2@example.com>',
+                         'failure_type': False,
+                         'state': 'ignored'},
+                        {'email': 'record.format@example.com',
+                         'email_to_mail': formatted_mailmail_email,
+                         'failure_type': False,
+                         'state': 'sent'},
+                        {'email': 'record.😊@example.com',
+                         'email_to_mail': unicode_mailmail_email,
+                         'failure_type': False,
+                         'state': 'ignored'},  # email_re usage forbids mailing to unicode
+                    ],
+                    mailing,
+                    test_records,
+                    check_mail=True,
+                )
+
     @users('user_marketing')
     @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_mailing_reply_to_mode_new(self):
diff --git a/odoo/addons/base/tests/test_base.py b/odoo/addons/base/tests/test_base.py
index f28d1f7c2e5c2c08f3602f9ba102121494692cb2..67dde0af0af1cf2b474187fa90c7a37da70e7877 100644
--- a/odoo/addons/base/tests/test_base.py
+++ b/odoo/addons/base/tests/test_base.py
@@ -5,6 +5,7 @@ import ast
 
 from odoo import SUPERUSER_ID
 from odoo.exceptions import UserError, ValidationError
+from odoo.tests import tagged
 from odoo.tests.common import TransactionCase, BaseCase
 from odoo.tools import mute_logger
 from odoo.tools.safe_eval import safe_eval, const_eval, expr_eval
@@ -73,6 +74,7 @@ SAMPLES = [
 ]
 
 
+@tagged('res_partner')
 class TestBase(TransactionCase):
 
     def _check_find_or_create(self, test_string, expected_name, expected_email, check_partner=False, should_create=False):
@@ -88,12 +90,13 @@ class TestBase(TransactionCase):
     def test_00_res_partner_name_create(self):
         res_partner = self.env['res.partner']
         parse = res_partner._parse_partner_name
-        for text, name, mail in SAMPLES:
-            self.assertEqual((name, mail.lower()), parse(text))
-            partner_id, dummy = res_partner.name_create(text)
-            partner = res_partner.browse(partner_id)
-            self.assertEqual(name or mail.lower(), partner.name)
-            self.assertEqual(mail.lower() or False, partner.email)
+        for text, expected_name, expected_mail in SAMPLES:
+            with self.subTest(text=text):
+                self.assertEqual((expected_name, expected_mail.lower()), parse(text))
+                partner_id, dummy = res_partner.name_create(text)
+                partner = res_partner.browse(partner_id)
+                self.assertEqual(expected_name or expected_mail.lower(), partner.name)
+                self.assertEqual(expected_mail.lower() or False, partner.email)
 
         # name_create supports default_email fallback
         partner = self.env['res.partner'].browse(
diff --git a/odoo/addons/base/tests/test_mail.py b/odoo/addons/base/tests/test_mail.py
index 01009f5d3c3b015e708a98295b42e5e793d7a212..7ed4164e36e8405ffc95928dbb4c672742040f6b 100644
--- a/odoo/addons/base/tests/test_mail.py
+++ b/odoo/addons/base/tests/test_mail.py
@@ -8,10 +8,11 @@ import email.message
 import re
 import threading
 
+from odoo.tests import tagged
 from odoo.tests.common import BaseCase, SavepointCase, TransactionCase
 from odoo.tools import (
     is_html_empty, html_sanitize, append_content_to_html, plaintext2html,
-    email_split,
+    email_normalize, email_split, email_split_and_format,
     misc, formataddr,
     prepend_html_content,
 )
@@ -388,10 +389,40 @@ class TestHtmlTools(BaseCase):
         self.assertEqual(result, "<html><body><div>test</div><div>test</div></body></html>")
 
 
+@tagged('mail_tools')
 class TestEmailTools(BaseCase):
     """ Test some of our generic utility functions for emails """
 
+    def test_email_normalize(self):
+        """ Test 'email_normalize'. Note that it is built on 'email_split' so
+        some use cases are already managed in 'test_email_split(_and_format)'
+        hence having more specific test cases here about normalization itself. """
+        sources = [
+            '"Super Déboulonneur" <deboulonneur@example.com>',  # formatted
+            'Déboulonneur deboulonneur@example.com',  # wrong formatting
+            'deboulonneur@example.com Déboulonneur',  # wrong formatting (happens, alas)
+            '"Super Déboulonneur" <DEBOULONNEUR@example.com>, "Super Déboulonneur 2" <deboulonneur2@EXAMPLE.com>',  # multi + case
+            ' Déboulonneur deboulonneur@example.com déboulonneur deboulonneur2@example.com',  # wrong formatting + wrong multi
+            '"Déboulonneur 😊" <deboulonneur.😊@example.com>',  # unicode in name and email left-part
+            '"Déboulonneur" <déboulonneur@examplé.com>',  # utf-8
+            '"Déboulonneur" <DéBoulonneur@Examplé.com>',  # utf-8
+        ]
+        expected_list = [
+            'deboulonneur@example.com',
+            'déboulonneur deboulonneur@example.com',
+            'deboulonneur@example.comdéboulonneur',
+            False,
+            '@example.com',  # funny
+            'deboulonneur.😊@example.com',
+            'déboulonneur@examplé.com',
+            'déboulonneur@examplé.com',
+        ]
+        for source, expected in zip(sources, expected_list):
+            with self.subTest(source=source):
+                self.assertEqual(email_normalize(source), expected)
+
     def test_email_split(self):
+        """ Test 'email_split' """
         cases = [
             ("John <12345@gmail.com>", ['12345@gmail.com']),  # regular form
             ("d@x; 1@2", ['d@x', '1@2']),  # semi-colon + extra space
@@ -402,6 +433,51 @@ class TestEmailTools(BaseCase):
         for text, expected in cases:
             self.assertEqual(email_split(text), expected, 'email_split is broken')
 
+    def test_email_split_and_format(self):
+        """ Test 'email_split_and_format', notably in case of multi encapsulation
+        or multi emails. """
+        sources = [
+            'deboulonneur@example.com',
+            '"Super Déboulonneur" <deboulonneur@example.com>',  # formatted
+            # wrong formatting
+            'Déboulonneur <deboulonneur@example.com',  # with a final typo
+            'Déboulonneur deboulonneur@example.com',  # wrong formatting
+            'deboulonneur@example.com Déboulonneur',  # wrong formatting (happens, alas)
+            # multi
+            'Déboulonneur, deboulonneur@example.com',  # multi-like with errors
+            'deboulonneur@example.com, deboulonneur2@example.com',  # multi
+            ' Déboulonneur deboulonneur@example.com déboulonneur deboulonneur2@example.com',  # wrong formatting + wrong multi
+            # format / misc
+            '"Déboulonneur" <"Déboulonneur Encapsulated" <deboulonneur@example.com>>',  # double formatting
+            '"Super Déboulonneur" <deboulonneur@example.com>, "Super Déboulonneur 2" <deboulonneur2@example.com>',
+            '"Super Déboulonneur" <deboulonneur@example.com>, wrong, ',
+            '"Déboulonneur 😊" <deboulonneur@example.com>',  # unicode in name
+            '"Déboulonneur 😊" <deboulonneur.😊@example.com>',  # unicode in name and email left-part
+            '"Déboulonneur" <déboulonneur@examplé.com>',  # utf-8
+        ]
+        expected_list = [
+            ['deboulonneur@example.com'],
+            ['"Super Déboulonneur" <deboulonneur@example.com>'],
+            # wrong formatting
+            ['"Déboulonneur" <deboulonneur@example.com>'],
+            ['Déboulonneur deboulonneur@example.com'],  # returned as it, a bit strange but hey
+            ['deboulonneur@example.comDéboulonneur'],  # concatenated, not sure why
+            # multi
+            ['deboulonneur@example.com'],
+            ['deboulonneur@example.com', 'deboulonneur2@example.com'],
+            ['@example.com'],  # funny one
+            # format / misc
+            ['deboulonneur@example.com'],
+            ['"Super Déboulonneur" <deboulonneur@example.com>', '"Super Déboulonneur 2" <deboulonneur2@example.com>'],
+            ['"Super Déboulonneur" <deboulonneur@example.com>'],
+            ['"Déboulonneur 😊" <deboulonneur@example.com>'],
+            ['"Déboulonneur 😊" <deboulonneur.😊@example.com>'],
+            ['"Déboulonneur" <déboulonneur@examplé.com>'],
+        ]
+        for source, expected in zip(sources, expected_list):
+            with self.subTest(source=source):
+                self.assertEqual(email_split_and_format(source), expected)
+
     def test_email_formataddr(self):
         email = 'joe@example.com'
         email_idna = 'joe@examplé.com'
diff --git a/odoo/addons/base/tests/test_res_partner.py b/odoo/addons/base/tests/test_res_partner.py
index 28e6f172f9565a9d010bd9fb6201bb7591fffc5d..2ec3458de8034a87fb15f940f18ed5ca94b89165 100644
--- a/odoo/addons/base/tests/test_res_partner.py
+++ b/odoo/addons/base/tests/test_res_partner.py
@@ -4,10 +4,89 @@
 from odoo.tests import Form
 from odoo.tests.common import TransactionCase
 from odoo.exceptions import AccessError, UserError
+from odoo.tests import tagged
 
 
+@tagged('res_partner')
 class TestPartner(TransactionCase):
 
+    def test_email_formatted(self):
+        """ Test various combinations of name / email, notably to check result
+        of email_formatted field. """
+        # multi create
+        new_partners = self.env['res.partner'].create([{
+            'name': "Vlad the Impaler",
+            'email': f'vlad.the.impaler.{idx:02d}@example.com',
+        } for idx in range(2)])
+        self.assertEqual(
+            sorted(new_partners.mapped('email_formatted')),
+            sorted([f'"Vlad the Impaler" <vlad.the.impaler.{idx:02d}@example.com>' for idx in range(2)]),
+            'Email formatted should be "name" <email>'
+        )
+
+        # test name_create with formatting / multi emails
+        for source, (exp_name, exp_email, exp_email_formatted) in [
+            (
+                'Balázs <vlad.the.negociator@example.com>, vlad.the.impaler@example.com',
+                ("Balázs", "vlad.the.negociator@example.com", '"Balázs" <vlad.the.negociator@example.com>')
+            ),
+            (
+                'Balázs <vlad.the.impaler@example.com>',
+                ("Balázs", "vlad.the.impaler@example.com", '"Balázs" <vlad.the.impaler@example.com>')
+            ),
+        ]:
+            with self.subTest(source=source):
+                new_partner_id = self.env['res.partner'].name_create(source)[0]
+                new_partner = self.env['res.partner'].browse(new_partner_id)
+                self.assertEqual(new_partner.name, exp_name)
+                self.assertEqual(new_partner.email, exp_email)
+                self.assertEqual(
+                    new_partner.email_formatted, exp_email_formatted,
+                    'Name_create should take first found email'
+                )
+
+        # check name updates
+        for source, exp_email_formatted in [
+            ('Vlad the Impaler', '"Vlad the Impaler" <vlad.the.impaler@example.com>'),
+            ('Balázs', '"Balázs" <vlad.the.impaler@example.com>'),
+            ('Balázs <email.in.name@example.com>', '"Balázs <email.in.name@example.com>" <vlad.the.impaler@example.com>'),
+        ]:
+            with self.subTest(source=source):
+                new_partner.write({'name': source})
+                self.assertEqual(new_partner.email_formatted, exp_email_formatted)
+
+        # check email updates
+        new_partner.write({'name': 'Balázs'})
+        for source, exp_email_formatted in [
+            # encapsulated email
+            (
+                "Vlad the Impaler <vlad.the.impaler@example.com>",
+                '"Balázs" <Vlad the Impaler <vlad.the.impaler@example.com>>'
+            ), (
+                '"Balázs" <balazs@adam.hu>',
+                '"Balázs" <"Balázs" <balazs@adam.hu>>'
+            ),
+            # multi email
+            (
+                "vlad.the.impaler@example.com, vlad.the.dragon@example.com",
+                '"Balázs" <vlad.the.impaler@example.com, vlad.the.dragon@example.com>'
+            ), (
+                "vlad.the.impaler.com, vlad.the.dragon@example.com",
+                '"Balázs" <vlad.the.impaler.com, vlad.the.dragon@example.com>'
+            ), (
+                'vlad.the.impaler.com, "Vlad the Dragon" <vlad.the.dragon@example.com>',
+                '"Balázs" <vlad.the.impaler.com, "Vlad the Dragon" <vlad.the.dragon@example.com>>'
+            ),
+            # falsy emails
+            (False, ''),
+            ('', ''),
+            (' ', '"Balázs" <@ >'),
+            ('notanemail', '"Balázs" <@notanemail>'),
+        ]:
+            with self.subTest(source=source):
+                new_partner.write({'email': source})
+                self.assertEqual(new_partner.email_formatted, exp_email_formatted)
+
     def test_name_search(self):
         """ Check name_search on partner, especially with domain based on auto_join
         user_ids field. Check specific SQL of name_search correctly handle joined tables. """