diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py
index 00a4c09ba601357e53a58e3d89b75725c1893b97..20a4a070aa6d90eec4dd2d0a96d576720c3b9fcd 100644
--- a/addons/crm/models/crm_lead.py
+++ b/addons/crm/models/crm_lead.py
@@ -10,7 +10,7 @@ from psycopg2 import sql
 from odoo import api, fields, models, tools, SUPERUSER_ID
 from odoo.osv import expression
 from odoo.tools.translate import _
-from odoo.tools import email_re, email_split
+from odoo.tools import email_split
 from odoo.exceptions import UserError, AccessError
 from odoo.addons.phone_validation.tools import phone_validation
 from collections import OrderedDict, defaultdict
@@ -1492,11 +1492,13 @@ class Lead(models.Model):
         return res
 
     def _message_get_default_recipients(self):
-        return {r.id: {
-            'partner_ids': [],
-            'email_to': r.email_normalized,
-            'email_cc': False}
-            for r in self}
+        return {
+            r.id: {
+                'partner_ids': [],
+                'email_to': ','.join(tools.email_normalize_all(r.email_from)) or r.email_from,
+                'email_cc': False,
+            } for r in self
+        }
 
     def _message_get_suggested_recipients(self):
         recipients = super(Lead, self)._message_get_suggested_recipients()
@@ -1549,23 +1551,41 @@ class Lead(models.Model):
             # we consider that posting a message with a specified recipient (not a follower, a specific one)
             # on a document without customer means that it was created through the chatter using
             # suggested recipients. This heuristic allows to avoid ugly hacks in JS.
-            new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from)
+            new_partner = message.partner_ids.filtered(
+                lambda partner: partner.email == self.email_from or (self.email_normalized and partner.email_normalized == self.email_normalized)
+            )
             if new_partner:
+                if new_partner[0].email_normalized:
+                    email_domain = ('email_normalized', '=', new_partner[0].email_normalized)
+                else:
+                    email_domain = ('email_from', '=', new_partner[0].email)
                 self.search([
-                    ('partner_id', '=', False),
-                    ('email_from', '=', new_partner.email),
-                    ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id})
+                    ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False)
+                ]).write({'partner_id': new_partner[0].id})
         return super(Lead, self)._message_post_after_hook(message, msg_vals)
 
     def _message_partner_info_from_emails(self, emails, link_mail=False):
+        """ Try to propose a better recipient when having only an email by populating
+        it with the partner_name / contact_name field of the lead e.g. if lead
+        contact_name is "Raoul" and email is "raoul@raoul.fr", suggest
+        "Raoul" <raoul@raoul.fr> as recipient. """
         result = super(Lead, self)._message_partner_info_from_emails(emails, link_mail=link_mail)
-        for partner_info in result:
-            if not partner_info.get('partner_id') and (self.partner_name or self.contact_name):
-                emails = email_re.findall(partner_info['full_name'] or '')
-                email = emails and emails[0] or ''
-                if email and self.email_from and email.lower() == self.email_from.lower():
-                    partner_info['full_name'] = tools.formataddr((self.contact_name or self.partner_name, email))
-                    break
+        for email, partner_info in zip(emails, result):
+            if partner_info.get('partner_id') or not email or not (self.partner_name or self.contact_name):
+                continue
+            # reformat email if no name information
+            name_emails = tools.email_split_tuples(email)
+            name_from_email = name_emails[0][0] if name_emails else False
+            if name_from_email:
+                continue  # already containing name + email
+            name_from_email = self.partner_name or self.contact_name
+            emails_normalized = tools.email_normalize_all(email)
+            email_normalized = emails_normalized[0] if emails_normalized else False
+            if email.lower() == self.email_from.lower() or (email_normalized and self.email_normalized == email_normalized):
+                partner_info['full_name'] = tools.formataddr((
+                    name_from_email,
+                    ','.join(emails_normalized) if emails_normalized else email))
+                break
         return result
 
     def _phone_get_number_fields(self):
diff --git a/addons/crm/tests/test_crm_lead_notification.py b/addons/crm/tests/test_crm_lead_notification.py
index 4b9596ea040e9560bd1f93f60a66186da27e2e80..b81ec66cebbd3d24f006c175ac138830b90fb690 100644
--- a/addons/crm/tests/test_crm_lead_notification.py
+++ b/addons/crm/tests/test_crm_lead_notification.py
@@ -1,11 +1,55 @@
 # -*- coding: utf-8 -*-
 # Part of Odoo. See LICENSE file for full copyright and licensing details.
 
-from .common import TestCrmCommon
+from unittest import skip
 
+from odoo.addons.crm.tests.common import TestCrmCommon
+from odoo.tests import tagged, users
+from odoo.tools import mute_logger
 
+
+@tagged('mail_thread', 'mail_gateway')
 class NewLeadNotification(TestCrmCommon):
 
+    @users('user_sales_manager')
+    @skip('Wait until test_predictive_lead_scoring issue is fixed')
+    def test_lead_message_get_suggested_recipient(self):
+        """ Test '_message_get_suggested_recipients' and its override in lead. """
+        lead_format, lead_multi, lead_from, lead_partner = self.env['crm.lead'].create([
+            {
+                'email_from': '"New Customer" <new.customer.format@test.example.com>',
+                'name': 'Test Suggestion (email_from with format)',
+                'partner_name': 'Format Name',
+                'user_id': self.user_sales_leads.id,
+            }, {
+                'email_from': 'new.customer.multi.1@test.example.com, new.customer.2@test.example.com',
+                'name': 'Test Suggestion (email_from multi)',
+                'partner_name': 'Multi Name',
+                'user_id': self.user_sales_leads.id,
+            }, {
+                'email_from': 'new.customer.simple@test.example.com',
+                'name': 'Test Suggestion (email_from)',
+                'partner_name': 'Std Name',
+                'user_id': self.user_sales_leads.id,
+            }, {
+                'name': 'Test Suggestion (partner_id)',
+                'partner_id': self.contact_1.id,
+                'user_id': self.user_sales_leads.id,
+            }
+        ])
+        for lead, expected_suggested in zip(
+            lead_format + lead_multi + lead_from + lead_partner,
+            [(False, '"New Customer" <new.customer.format@test.example.com>', 'Customer Email'),
+             (False, '"Multi Name" <new.customer.multi.1@test.example.com,new.customer.2@test.example.com>', 'Customer Email'),
+             (False, '"Std Name" <new.customer.simple@test.example.com>', 'Customer Email'),
+             (self.contact_1.id, '"Philip J Fry" <philip.j.fry@test.example.com>', 'Customer'),
+            ]
+        ):
+            with self.subTest(lead=lead, email_from=lead.email_from):
+                res = lead._message_get_suggested_recipients()[lead.id]
+                self.assertEqual(len(res), 1)
+                self.assertEqual(res[0], expected_suggested)
+
     def test_new_lead_notification(self):
         """ Test newly create leads like from the website. People and channels
         subscribed to the Sales Team shoud be notified. """
@@ -42,6 +86,7 @@ class NewLeadNotification(TestCrmCommon):
         lead_user = lead.with_user(self.user_sales_manager)
         self.assertTrue(lead_user.message_needaction)
 
+    @mute_logger('odoo.addons.mail.models.mail_thread')
     def test_new_lead_from_email_multicompany(self):
         company0 = self.env.company
         company1 = self.env['res.company'].create({'name': 'new_company'})
diff --git a/addons/crm/tests/test_crm_pls.py b/addons/crm/tests/test_crm_pls.py
index 8963dc8ab4921b517833baee465ec82e000de839..221547b9bdb66aa7ff989361f76afc8e4440928c 100644
--- a/addons/crm/tests/test_crm_pls.py
+++ b/addons/crm/tests/test_crm_pls.py
@@ -360,7 +360,8 @@ class TestCRMPLS(TransactionCase):
         Lead._cron_update_automated_probabilities()
         leads_with_tags.invalidate_cache()
 
-        self.assertEqual(tools.float_compare(leads[3].automated_probability, 4.21, 2), 0)
+        self.assertEqual(tools.float_compare(leads[3].automated_probability, 4.21, 2), 0,
+                        f'PLS: failed with {leads[3].automated_probability}, should be 4.21')
         self.assertEqual(tools.float_compare(leads[8].automated_probability, 0.23, 2), 0)
 
         # remove all pls fields
diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py
index 5f04a5bebbdef19ac7b0be4495d0b97f62547da4..bf8853ba7439ecaf173f61f29508afc687724533 100644
--- a/addons/event/models/event_registration.py
+++ b/addons/event/models/event_registration.py
@@ -4,7 +4,7 @@
 from dateutil.relativedelta import relativedelta
 
 from odoo import _, api, fields, models, SUPERUSER_ID
-from odoo.tools import format_datetime
+from odoo.tools import format_datetime, email_normalize, email_normalize_all
 from odoo.exceptions import AccessError, ValidationError
 
 
@@ -228,24 +228,31 @@ class EventRegistration(models.Model):
     def _message_get_default_recipients(self):
         # Prioritize registration email over partner_id, which may be shared when a single
         # partner booked multiple seats
-        return {r.id: {
-            'partner_ids': [],
-            'email_to': r.email,
-            'email_cc': False}
-            for r in self}
+        return {r.id:
+            {
+                'partner_ids': [],
+                'email_to': ','.join(email_normalize_all(r.email)) or r.email,
+                'email_cc': False,
+            } for r in self
+        }
 
     def _message_post_after_hook(self, message, msg_vals):
         if self.email and not self.partner_id:
             # we consider that posting a message with a specified recipient (not a follower, a specific one)
             # on a document without customer means that it was created through the chatter using
             # suggested recipients. This heuristic allows to avoid ugly hacks in JS.
-            new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email)
+            email_normalized = email_normalize(self.email)
+            new_partner = message.partner_ids.filtered(
+                lambda partner: partner.email == self.email or (email_normalized and partner.email_normalized == email_normalized)
+            )
             if new_partner:
+                if new_partner[0].email_normalized:
+                    email_domain = ('email', 'in', [new_partner[0].email, new_partner[0].email_normalized])
+                else:
+                    email_domain = ('email', '=', new_partner[0].email)
                 self.search([
-                    ('partner_id', '=', False),
-                    ('email', '=', new_partner.email),
-                    ('state', 'not in', ['cancel']),
-                ]).write({'partner_id': new_partner.id})
+                    ('partner_id', '=', False), email_domain, ('state', 'not in', ['cancel']),
+                ]).write({'partner_id': new_partner[0].id})
         return super(EventRegistration, self)._message_post_after_hook(message, msg_vals)
 
     def action_send_badge_email(self):
diff --git a/addons/hr_recruitment/models/hr_recruitment.py b/addons/hr_recruitment/models/hr_recruitment.py
index a02a2ef535b02ea4b11f613d1dc12b8912f660a9..8be8a4cf4480785061da4a966aad8b3a728347f3 100644
--- a/addons/hr_recruitment/models/hr_recruitment.py
+++ b/addons/hr_recruitment/models/hr_recruitment.py
@@ -403,7 +403,7 @@ class Applicant(models.Model):
             if applicant.partner_id:
                 applicant._message_add_suggested_recipient(recipients, partner=applicant.partner_id, reason=_('Contact'))
             elif applicant.email_from:
-                email_from = applicant.email_from
+                email_from = tools.email_normalize(applicant.email_from)
                 if applicant.partner_name:
                     email_from = tools.formataddr((applicant.partner_name, email_from))
                 applicant._message_add_suggested_recipient(recipients, email=email_from, reason=_('Contact Email'))
@@ -443,18 +443,24 @@ class Applicant(models.Model):
             # we consider that posting a message with a specified recipient (not a follower, a specific one)
             # on a document without customer means that it was created through the chatter using
             # suggested recipients. This heuristic allows to avoid ugly hacks in JS.
-            new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from)
+            email_normalized = tools.email_normalize(self.email_from)
+            new_partner = message.partner_ids.filtered(
+                lambda partner: partner.email == self.email_from or (email_normalized and partner.email_normalized == email_normalized)
+            )
             if new_partner:
-                if new_partner.create_date.date() == fields.Date.today():
-                    new_partner.write({
+                if new_partner[0].create_date.date() == fields.Date.today():
+                    new_partner[0].write({
                         'type': 'private',
                         'phone': self.partner_phone,
                         'mobile': self.partner_mobile,
                     })
+                if new_partner[0].email_normalized:
+                    email_domain = ('email_from', 'in', [new_partner[0].email, new_partner[0].email_normalized])
+                else:
+                    email_domain = ('email_from', '=', new_partner[0].email)
                 self.search([
-                    ('partner_id', '=', False),
-                    ('email_from', '=', new_partner.email),
-                    ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id})
+                    ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False)
+                ]).write({'partner_id': new_partner[0].id})
         return super(Applicant, self)._message_post_after_hook(message, msg_vals)
 
     def create_employee_from_applicant(self):
diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py
index b715705faf369b59167b6f94d751e863df8fc3e1..64ea0f92a9e430e97bed48b8954a7f63f1154fb4 100644
--- a/addons/mail/models/mail_channel.py
+++ b/addons/mail/models/mail_channel.py
@@ -9,7 +9,7 @@ from uuid import uuid4
 from odoo import _, api, fields, models, modules, tools
 from odoo.exceptions import UserError, ValidationError
 from odoo.osv import expression
-from odoo.tools import ormcache, formataddr
+from odoo.tools import ormcache
 from odoo.exceptions import AccessError
 from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
 from odoo.tools import html_escape
@@ -378,7 +378,7 @@ class Channel(models.Model):
         # real mailing list: multiple recipients (hidden by X-Forge-To)
         if self.alias_domain and self.alias_name:
             return {
-                'email_to': ','.join(formataddr((partner.name, partner.email_normalized)) for partner in whitelist if partner.email_normalized),
+                'email_to': ','.join(partner.email_formatted for partner in whitelist if partner.email_normalized),
                 'recipient_ids': [],
             }
         return super(Channel, self)._notify_email_recipient_values(whitelist.ids)
diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py
index 80d1ece80f38fe6783f6594da854ff52dca33a3e..b29ccc946c90ead3ee64250f5277e41265208b47 100644
--- a/addons/mail/models/mail_mail.py
+++ b/addons/mail/models/mail_mail.py
@@ -206,7 +206,14 @@ class MailMail(models.Model):
         body = self._send_prepare_body()
         body_alternative = tools.html2plaintext(body)
         if partner:
-            email_to = [tools.formataddr((partner.name or 'False', partner.email or 'False'))]
+            emails_normalized = tools.email_normalize_all(partner.email)
+            if emails_normalized:
+                email_to = [
+                    tools.formataddr((partner.name or "False", email or "False"))
+                    for email in emails_normalized
+                ]
+            else:
+                email_to = [tools.formataddr((partner.name or "False", partner.email or "False"))]
         else:
             email_to = tools.email_split_and_format(self.email_to)
         res = {
@@ -354,11 +361,15 @@ class MailMail(models.Model):
                     # see rev. 56596e5240ef920df14d99087451ce6f06ac6d36
                     notifs.flush(fnames=['notification_status', 'failure_type', 'failure_reason'], records=notifs)
 
+                # protect against ill-formatted email_from when formataddr was used on an already formatted email
+                emails_from = tools.email_split_and_format(mail.email_from)
+                email_from = emails_from[0] if emails_from else mail.email_from
+
                 # build an RFC2822 email.message.Message object and send it without queuing
                 res = None
                 for email in email_list:
                     msg = IrMailServer.build_email(
-                        email_from=mail.email_from,
+                        email_from=email_from,
                         email_to=email.get('email_to'),
                         subject=mail.subject,
                         body=email.get('body'),
diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py
index 05231920c49de794a7deee247e1fea965dbd7659..287cda96dde407fe1d6e8709cd0d9c709a47e561 100644
--- a/addons/mail/models/mail_template.py
+++ b/addons/mail/models/mail_template.py
@@ -154,7 +154,7 @@ class MailTemplate(models.Model):
             partner_to = values.pop('partner_to', '')
             if partner_to:
                 # placeholders could generate '', 3, 2 due to some empty field values
-                tpl_partner_ids = [int(pid) for pid in partner_to.split(',') if pid]
+                tpl_partner_ids = [int(pid.strip()) for pid in partner_to.split(',') if (pid and pid.strip().isdigit())]
                 partner_ids += self.env['res.partner'].sudo().browse(tpl_partner_ids).exists().ids
             results[res_id]['partner_ids'] = partner_ids
         return results
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index 43111c60a7172f2afbbceb0937f99c7e692b92cb..4b72f366aecbb18fa74e6e73f2e9521ebe81fc0e 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -1513,6 +1513,7 @@ class MailThread(models.AbstractModel):
             recipient in the result dictionary. The form is :
                 partner_id, partner_name<partner_email> or partner_name, reason """
         self.ensure_one()
+        partner_info = {}
         if email and not partner:
             # get partner info from email
             partner_info = self._message_partner_info_from_emails([email])[0]
@@ -1527,9 +1528,9 @@ class MailThread(models.AbstractModel):
         if partner and partner.email:  # complete profile: id, name <email>
             result[self.ids[0]].append((partner.id, partner.email_formatted, reason))
         elif partner:  # incomplete profile: id, name
-            result[self.ids[0]].append((partner.id, '%s' % (partner.name), reason))
+            result[self.ids[0]].append((partner.id, partner.name, reason))
         else:  # unknown partner, we are probably managing an email address
-            result[self.ids[0]].append((False, email, reason))
+            result[self.ids[0]].append((False, partner_info.get('full_name') or email, reason))
         return result
 
     def _message_get_suggested_recipients(self):
@@ -1550,7 +1551,7 @@ class MailThread(models.AbstractModel):
         domain = [('email_normalized', 'in', normalized_emails)]
         if extra_domain:
             domain = expression.AND([domain, extra_domain])
-        partners = self.env['res.users'].sudo().search(domain, order='name ASC').mapped('partner_id')
+        partners = self.env['res.users'].sudo().search(domain).mapped('partner_id')
         # return a search on partner to filter results current user should not see (multi company for example)
         return self.env['res.partner'].search([('id', 'in', partners.ids)])
 
@@ -1608,7 +1609,7 @@ class MailThread(models.AbstractModel):
             return matching_user
 
         if not matching_user:
-            std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1, order='name ASC')
+            std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1)
             matching_user = std_users[0] if std_users else self.env['res.users']
         if matching_user:
             return matching_user
@@ -1625,6 +1626,9 @@ class MailThread(models.AbstractModel):
         """ Utility method to find partners from email addresses. If no partner is
         found, create new partners if force_create is enabled. Search heuristics
 
+          * 0: clean incoming email list to use only normalized emails. Exclude
+               those used in aliases to avoid setting partner emails to emails
+               used as aliases;
           * 1: check in records (record set) followers if records is mail.thread
                enabled and if check_followers parameter is enabled;
           * 2: search for partners with user;
@@ -1644,8 +1648,13 @@ class MailThread(models.AbstractModel):
             followers = self.env['res.partner']
         catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
 
-        # first, build a normalized email list and remove those linked to aliases to avoid adding aliases as partners
-        normalized_emails = [tools.email_normalize(contact) for contact in emails if tools.email_normalize(contact)]
+        # first, build a normalized email list and remove those linked to aliases
+        # to avoid adding aliases as partners. In case of multi-email input, use
+        # the first found valid one to be tolerant against multi emails encoding
+        normalized_emails = [email_normalized
+                             for email_normalized in (tools.email_normalize(contact, force_single=False) for contact in emails)
+                             if email_normalized
+                            ]
         if catchall_domain:
             domain_left_parts = [email.split('@')[0] for email in normalized_emails if email and email.split('@')[1] == catchall_domain.lower()]
             if domain_left_parts:
@@ -1666,7 +1675,7 @@ class MailThread(models.AbstractModel):
         # iterate and keep ordering
         partners = []
         for contact in emails:
-            normalized_email = tools.email_normalize(contact)
+            normalized_email = tools.email_normalize(contact, force_single=False)
             partner = next((partner for partner in done_partners if partner.email_normalized == normalized_email), self.env['res.partner'])
             if not partner and force_create and normalized_email in normalized_emails:
                 partner = self.env['res.partner'].browse(self.env['res.partner'].name_create(contact)[0])
diff --git a/addons/mail/models/mail_thread_blacklist.py b/addons/mail/models/mail_thread_blacklist.py
index 48b9e734ab80b3d859cd35049bd13fe3aae3a094..171a4b1562a435c5faaf4b3db8e90882b4407f73 100644
--- a/addons/mail/models/mail_thread_blacklist.py
+++ b/addons/mail/models/mail_thread_blacklist.py
@@ -47,7 +47,7 @@ class MailBlackListMixin(models.AbstractModel):
     def _compute_email_normalized(self):
         self._assert_primary_email()
         for record in self:
-            record.email_normalized = tools.email_normalize(record[self._primary_email])
+            record.email_normalized = tools.email_normalize(record[self._primary_email], force_single=False)
 
     @api.model
     def _search_is_blacklisted(self, operator, value):
diff --git a/addons/mail/models/models.py b/addons/mail/models/models.py
index 51ac2f356def602c821fb176d14fe53339087b92..a03b2495d258199a8335a95bd662d59a3d8ceaf8 100644
--- a/addons/mail/models/models.py
+++ b/addons/mail/models/models.py
@@ -64,14 +64,20 @@ class BaseModel(models.AbstractModel):
             recipient_ids, email_to, email_cc = [], False, False
             if 'partner_id' in record and record.partner_id:
                 recipient_ids.append(record.partner_id.id)
-            elif 'email_normalized' in record and record.email_normalized:
-                email_to = record.email_normalized
-            elif 'email_from' in record and record.email_from:
-                email_to = record.email_from
-            elif 'partner_email' in record and record.partner_email:
-                email_to = record.partner_email
-            elif 'email' in record and record.email:
-                email_to = record.email
+            else:
+                found_email = False
+                if 'email_from' in record and record.email_from:
+                    found_email = record.email_from
+                elif 'partner_email' in record and record.partner_email:
+                    found_email = record.partner_email
+                elif 'email' in record and record.email:
+                    found_email = record.email
+                elif 'email_normalized' in record and record.email_normalized:
+                    found_email = record.email_normalized
+                if found_email:
+                    email_to = ','.join(tools.email_normalize_all(found_email))
+                if not email_to:  # keep value to ease debug / trace update
+                    email_to = found_email
             res[record.id] = {'partner_ids': recipient_ids, 'email_to': email_to, 'email_cc': email_cc}
         return res
 
diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js
index 20c2c24cb9511dcb74b16e97288172dabab2103c..9e9c139b92dfc008bb57a057e47a398f083bf838 100644
--- a/addons/mail/static/src/js/utils.js
+++ b/addons/mail/static/src/js/utils.js
@@ -155,7 +155,8 @@ function parseEmail(text) {
     if (text){
         var result = text.match(/(.*)<(.*@.*)>/);
         if (result) {
-            return [_.str.trim(result[1]), _.str.trim(result[2])];
+            name = _.str.trim(result[1]).replace(/(^"|"$)/g, '')
+            return [name, _.str.trim(result[2])];
         }
         result = text.match(/(.*@.*)/);
         if (result) {
diff --git a/addons/mail/tests/test_res_partner.py b/addons/mail/tests/test_res_partner.py
index 97828c0981df4c622d21ce2332e21519bb06ef2b..18ec6eea73537b086d5b1d485edd423987ab499a 100644
--- a/addons/mail/tests/test_res_partner.py
+++ b/addons/mail/tests/test_res_partner.py
@@ -3,8 +3,10 @@
 
 from odoo.addons.mail.tests.common import MailCommon
 from odoo.tests.common import Form, users
+from odoo.tests import tagged
 
 
+@tagged('res_partner')
 class TestPartner(MailCommon):
 
     def test_res_partner_find_or_create(self):
@@ -27,6 +29,82 @@ class TestPartner(MailCommon):
         self.assertEqual(new2.email, '2patrick@example.com')
         self.assertEqual(new2.email_normalized, '2patrick@example.com')
 
+    @users('admin')
+    def test_res_partner_find_or_create_email(self):
+        """ Test 'find_or_create' tool used in mail, notably when linking emails
+        found in recipients to partners when sending emails using the mail
+        composer. """
+        partners = self.env['res.partner'].create([
+            {
+                'email': 'classic.format@test.example.com',
+                'name': 'Classic Format',
+            },
+            {
+                'email': '"FindMe Format" <find.me.format@test.example.com>',
+                'name': 'FindMe Format',
+            }, {
+                'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                'name': 'FindMe Multi',
+            },
+        ])
+        # check data used for finding / searching
+        self.assertEqual(
+            partners.mapped('email_formatted'),
+            ['"Classic Format" <classic.format@test.example.com>',
+             '"FindMe Format" <find.me.format@test.example.com>',
+             '"FindMe Multi" <find.me.multi.1@test.example.com,find.me.multi.2@test.example.com>']
+        )
+        # when having multi emails, first found one is taken as normalized email
+        self.assertEqual(
+            partners.mapped('email_normalized'),
+            ['classic.format@test.example.com', 'find.me.format@test.example.com',
+             'find.me.multi.1@test.example.com']
+        )
+
+        # classic find or create: use normalized email to compare records
+        for email in ('CLASSIC.FORMAT@TEST.EXAMPLE.COM', '"Another Name" <classic.format@test.example.com>'):
+            with self.subTest(email=email):
+                self.assertEqual(self.env['res.partner'].find_or_create(email), partners[0])
+        # find on encapsulated email: comparison of normalized should work
+        for email in ('FIND.ME.FORMAT@TEST.EXAMPLE.COM', '"Different Format" <find.me.format@test.example.com>'):
+            with self.subTest(email=email):
+                self.assertEqual(self.env['res.partner'].find_or_create(email), partners[1])
+        # multi-emails -> no normalized email -> fails each time, create new partner (FIXME)
+        for email_input, match_partner in [
+            ('find.me.multi.1@test.example.com', partners[2]),
+            ('find.me.multi.2@test.example.com', self.env['res.partner']),
+        ]:
+            with self.subTest(email_input=email_input):
+                partner = self.env['res.partner'].find_or_create(email_input)
+                # either matching existing, either new partner
+                if match_partner:
+                    self.assertEqual(partner, match_partner)
+                else:
+                    self.assertNotIn(partner, partners)
+                    self.assertEqual(partner.email, email_input)
+                partner.unlink()  # do not mess with subsequent tests
+
+        # now input is multi email -> '_parse_partner_name' used in 'find_or_create'
+        # before trying to normalize is quite tolerant, allowing positive checks
+        for email_input, match_partner, exp_email_partner in [
+            ('classic.format@test.example.com,another.email@test.example.com',
+              partners[0], 'classic.format@test.example.com'),  # first found email matches existing
+            ('another.email@test.example.com,classic.format@test.example.com',
+             self.env['res.partner'], 'another.email@test.example.com'),  # first found email does not match
+            ('find.me.multi.1@test.example.com,find.me.multi.2@test.example.com',
+             self.env['res.partner'], 'find.me.multi.1@test.example.com'),
+        ]:
+            with self.subTest(email_input=email_input):
+                partner = self.env['res.partner'].find_or_create(email_input)
+                # either matching existing, either new partner
+                if match_partner:
+                    self.assertEqual(partner, match_partner)
+                else:
+                    self.assertNotIn(partner, partners)
+                self.assertEqual(partner.email, exp_email_partner)
+                if partner not in partners:
+                    partner.unlink()  # do not mess with subsequent tests
+
     @users('admin')
     def test_res_partner_merge_wizards(self):
         Partner = self.env['res.partner']
diff --git a/addons/mass_mailing/models/mailing_contact.py b/addons/mass_mailing/models/mailing_contact.py
index 27ce87a24fff7969522821a10d67666afcb5b284..fe34500a51a060a02caf9196e139b937882576b2 100644
--- a/addons/mass_mailing/models/mailing_contact.py
+++ b/addons/mass_mailing/models/mailing_contact.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # Part of Odoo. See LICENSE file for full copyright and licensing details.
 
-from odoo import api, fields, models
+from odoo import api, fields, models, tools
 from odoo.osv import expression
 
 
@@ -162,9 +162,10 @@ class MassMailingContact(models.Model):
         return contact.name_get()[0]
 
     def _message_get_default_recipients(self):
-        return {r.id: {
-            'partner_ids': [],
-            'email_to': r.email_normalized,
-            'email_cc': False}
-            for r in self
+        return {
+            r.id: {
+                'partner_ids': [],
+                'email_to': ','.join(tools.email_normalize_all(r.email)) or r.email,
+                'email_cc': False,
+            } for r in self
         }
diff --git a/addons/mass_mailing/tests/common.py b/addons/mass_mailing/tests/common.py
index 9cb2b9bf1f076b499ab49e116387630fa03fdc2c..09f4508326a616482e6f13f7c2f2dfd17baf2b39 100644
--- a/addons/mass_mailing/tests/common.py
+++ b/addons/mass_mailing/tests/common.py
@@ -48,6 +48,8 @@ class MassMailCase(MailCase, MockLinkTracker):
             'record: linked record,
             # MAIL.MAIL
             'content': optional content that should be present in mail.mail body_html;
+            'email_to_mail': optional email used for the mail, when different from the
+              one stored on the trace itself;
             'email_to_recipients': optional, see '_assertMailMail';
             'failure_type': optional failure reason;
             }, { ... }]
@@ -97,6 +99,7 @@ class MassMailCase(MailCase, MockLinkTracker):
         for recipient_info, link_info, record in zip(recipients_info, mail_links_info, records):
             partner = recipient_info.get('partner', self.env['res.partner'])
             email = recipient_info.get('email')
+            email_to_mail = recipient_info.get('email_to_mail') or email
             email_to_recipients = recipient_info.get('email_to_recipients')
             state = recipient_info.get('state', 'sent')
             record = record or recipient_info.get('record')
@@ -150,7 +153,7 @@ class MassMailCase(MailCase, MockLinkTracker):
                     )
                 else:
                     self.assertMailMailWEmails(
-                        [email], state_mapping[state],
+                        [email_to_mail], state_mapping[state],
                         author=author,
                         content=content,
                         email_to_recipients=email_to_recipients,
diff --git a/addons/mass_mailing/wizard/mail_compose_message.py b/addons/mass_mailing/wizard/mail_compose_message.py
index 8ba5c783a74f3e6f4e334b7b2a436b276daffb78..d9eaf6101c94583ef7c0949a102f0c33f0f25575 100644
--- a/addons/mass_mailing/wizard/mail_compose_message.py
+++ b/addons/mass_mailing/wizard/mail_compose_message.py
@@ -60,12 +60,12 @@ class MailComposeMessage(models.TransientModel):
             for res_id in res_ids:
                 mail_values = res[res_id]
                 if mail_values.get('email_to'):
-                    mail_to = tools.email_normalize(mail_values['email_to'])
+                    mail_to = tools.email_normalize(mail_values['email_to'], force_single=False)
                 else:
                     partner_id = (mail_values.get('recipient_ids') or [(False, '')])[0][1]
-                    mail_to = tools.email_normalize(partners_email.get(partner_id))
+                    mail_to = tools.email_normalize(partners_email.get(partner_id), force_single=False)
                 if (opt_out_list and mail_to in opt_out_list) or (seen_list and mail_to in seen_list) \
-                        or (not mail_to or not email_re.findall(mail_to)):
+                        or not mail_to:
                     # prevent sending to blocked addresses that were included by mistake
                     mail_values['state'] = 'cancel'
                 elif seen_list is not None:
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index e16de398fee92b00410943e05967ae482a8cd603..833659214bbb8eed6d66826233014129703630bc 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -1360,12 +1360,18 @@ class Task(models.Model):
             # we consider that posting a message with a specified recipient (not a follower, a specific one)
             # on a document without customer means that it was created through the chatter using
             # suggested recipients. This heuristic allows to avoid ugly hacks in JS.
-            new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from)
+            email_normalized = tools.email_normalize(self.email_from)
+            new_partner = message.partner_ids.filtered(
+                lambda partner: partner.email == self.email_from or (email_normalized and partner.email_normalized == email_normalized)
+            )
             if new_partner:
+                if new_partner[0].email_normalized:
+                    email_domain = ('email_from', 'in', [new_partner[0].email, new_partner[0].email_normalized])
+                else:
+                    email_domain = ('email_from', '=', new_partner[0].email)
                 self.search([
-                    ('partner_id', '=', False),
-                    ('email_from', '=', new_partner.email),
-                    ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id})
+                    ('partner_id', '=', False), email_domain, ('stage_id.fold', '=', False)
+                ]).write({'partner_id': new_partner[0].id})
         return super(Task, self)._message_post_after_hook(message, msg_vals)
 
     def action_assign_to_me(self):
diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py
index 29bbff5c7770b6457889e922e19c6a8a30dcc24a..8dd9e4d2110e442493e7cf9f652898571bc609d0 100644
--- a/addons/survey/wizard/survey_invite.py
+++ b/addons/survey/wizard/survey_invite.py
@@ -20,7 +20,7 @@ class SurveyInvite(models.TransientModel):
     @api.model
     def _get_default_from(self):
         if self.env.user.email:
-            return tools.formataddr((self.env.user.name, self.env.user.email))
+            return self.env.user.email_formatted
         raise UserError(_("Unable to post message, please configure the sender's email address."))
 
     @api.model
diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py
index 9b0e15b3337fc0a43ada9f340b6b525d102662b7..3237833991845ff4742a34de4a9d15ad77bd67df 100644
--- a/addons/test_mail/models/test_mail_models.py
+++ b/addons/test_mail/models/test_mail_models.py
@@ -27,6 +27,16 @@ class MailTestGateway(models.Model):
     email_from = fields.Char()
     custom_field = fields.Char()
 
+    @api.model
+    def message_new(self, msg_dict, custom_values=None):
+        """ Check override of 'message_new' allowing to update record values
+        base on incoming email. """
+        defaults = {
+            'email_from': msg_dict.get('from'),
+        }
+        defaults.update(custom_values or {})
+        return super().message_new(msg_dict, custom_values=defaults)
+
 
 class MailTestGatewayGroups(models.Model):
     """ A model looking like discussion channels / groups (flat thread and
diff --git a/addons/test_mail/tests/__init__.py b/addons/test_mail/tests/__init__.py
index 631462616cf8f09b426c3ada9423d58a2f25b29f..d4baf2016e83d94b58d84149b5637cb737e5ad02 100644
--- a/addons/test_mail/tests/__init__.py
+++ b/addons/test_mail/tests/__init__.py
@@ -13,6 +13,7 @@ from . import test_mail_gateway
 from . import test_mail_multicompany
 from . import test_mail_template_preview
 from . import test_mail_thread_internals
+from . import test_mail_thread_mixins
 from . import test_mail_template
 from . import test_mail_tools
 from . import test_message_management
diff --git a/addons/test_mail/tests/test_mail_activity.py b/addons/test_mail/tests/test_mail_activity.py
index e6d4b00374c1ec932329c006615d90a2b26bca83..68f17739a75605bf54968ea38a3ba178682644b8 100644
--- a/addons/test_mail/tests/test_mail_activity.py
+++ b/addons/test_mail/tests/test_mail_activity.py
@@ -366,41 +366,42 @@ class TestActivityMixin(TestActivityCommon):
             record = self.env['mail.test.activity'].search([('my_activity_date_deadline', '=', date_today)])
             self.assertEqual(test_record_1, record)
 
-class TestReadProgressBar(tests.TransactionCase):
+@tests.tagged('mail_activity')
+class TestORM(TestActivityCommon):
     """Test for read_progress_bar"""
 
     def test_week_grouping(self):
         """The labels associated to each record in read_progress_bar should match
         the ones from read_group, even in edge cases like en_US locale on sundays
         """
-        model = self.env['mail.test.activity'].with_context(lang='en_US')
+        MailTestActivityCtx = self.env['mail.test.activity'].with_context({"lang": "en_US"})
 
         # Don't mistake fields date and date_deadline:
         # * date is just a random value
         # * date_deadline defines activity_state
-        model.create({
+        self.env['mail.test.activity'].create({
             'date': '2021-05-02',
             'name': "Yesterday, all my troubles seemed so far away",
         }).activity_schedule(
             'test_mail.mail_act_test_todo',
             summary="Make another test super asap (yesterday)",
-            date_deadline=fields.Date.context_today(model) - timedelta(days=7),
+            date_deadline=fields.Date.context_today(MailTestActivityCtx) - timedelta(days=7),
         )
-        model.create({
+        self.env['mail.test.activity'].create({
             'date': '2021-05-09',
             'name': "Things we said today",
         }).activity_schedule(
             'test_mail.mail_act_test_todo',
             summary="Make another test asap",
-            date_deadline=fields.Date.context_today(model),
+            date_deadline=fields.Date.context_today(MailTestActivityCtx),
         )
-        model.create({
+        self.env['mail.test.activity'].create({
             'date': '2021-05-16',
             'name': "Tomorrow Never Knows",
         }).activity_schedule(
             'test_mail.mail_act_test_todo',
             summary="Make a test tomorrow",
-            date_deadline=fields.Date.context_today(model) + timedelta(days=7),
+            date_deadline=fields.Date.context_today(MailTestActivityCtx) + timedelta(days=7),
         )
 
         domain = [('date', "!=", False)]
@@ -415,8 +416,8 @@ class TestReadProgressBar(tests.TransactionCase):
         }
 
         # call read_group to compute group names
-        groups = model.read_group(domain, fields=['date'], groupby=[groupby])
-        progressbars = model.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar)
+        groups = MailTestActivityCtx.read_group(domain, fields=['date'], groupby=[groupby])
+        progressbars = MailTestActivityCtx.read_progress_bar(domain, group_by=groupby, progress_bar=progress_bar)
         self.assertEqual(len(groups), 3)
         self.assertEqual(len(progressbars), 3)
 
diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py
index a1bc46eee09b990a76d3cf05e6d44e091ba099b0..b4af1a5b201ac4c85584c6b8a291a19a91791334 100644
--- a/addons/test_mail/tests/test_mail_composer.py
+++ b/addons/test_mail/tests/test_mail_composer.py
@@ -380,7 +380,7 @@ class TestComposerInternals(TestMailComposer):
             self.assertEqual(composer.mail_server_id.id, False)
 
     @users('employee')
-    @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
     def test_mail_composer_parent(self):
         """ Test specific management in comment mode when having parent_id set:
         record_name, subject, parent's partners. """
@@ -397,6 +397,7 @@ class TestComposerInternals(TestMailComposer):
         self.assertEqual(composer.body, '<p>Test Body</p>')
         self.assertEqual(composer.partner_ids, self.partner_1 + self.partner_2)
 
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
     def test_mail_composer_rights_portal(self):
         portal_user = self._create_portal_user()
 
@@ -447,7 +448,7 @@ class TestComposerResultsComment(TestMailComposer):
     notification and emails generated during this process. """
 
     @users('employee')
-    @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
     def test_mail_composer_notifications_delete(self):
         """ Notifications are correctly deleted once sent """
         composer = self.env['mail.compose.message'].with_context(
@@ -489,7 +490,7 @@ class TestComposerResultsComment(TestMailComposer):
         self.assertEqual(len(self._new_mails.exists()), 2, 'Should not have deleted mail.mail records')
 
     @users('employee')
-    @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
     def test_mail_composer_recipients(self):
         """ Test partner_ids given to composer are given to the final message. """
         composer = self.env['mail.compose.message'].with_context(
@@ -605,6 +606,146 @@ class TestComposerResultsComment(TestMailComposer):
         self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(self.test_record.ids))
         self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments')
 
+    @users('employee')
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_composer_wtpl_recipients_email_fields(self):
+        """ Test various combinations of corner case / not standard filling of
+        email fields: multi email, formatted emails, ... on template, used to
+        post a message using the composer."""
+        existing_partners = self.env['res.partner'].search([])
+        partner_format_tofind, partner_multi_tofind = self.env['res.partner'].create([
+            {
+                'email': '"FindMe Format" <find.me.format@test.example.com>',
+                'name': 'FindMe Format',
+            }, {
+                'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                'name': 'FindMe Multi',
+            }
+        ])
+        email_ccs = ['"Raoul" <test.cc.1@example.com>', '"Raoulette" <test.cc.2@example.com>', 'test.cc.2.2@example.com>', 'invalid', '  ']
+        email_tos = ['"Micheline, l\'Immense" <test.to.1@example.com>', 'test.to.2@example.com', 'wrong', '  ']
+
+        self.template.write({
+            'email_cc': ', '.join(email_ccs),
+            'email_from': '${user.email_formatted | safe}',
+            'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')),
+            'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test',
+        })
+        self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'})
+        self.partner_1.write({'email': '"Valid Formatted" <valid.lelitre@agrolait.com>'})
+        self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'})
+        # ensure values used afterwards for testing
+        self.assertEqual(
+            self.partner_employee.email_formatted,
+            '"Ernest Employee" <email.from.1@test.example.com,email.from.2@test.example.com>',
+            'Formatting: wrong formatting due to multi-email')
+        self.assertEqual(
+            self.partner_1.email_formatted,
+            '"Valid Lelitre" <valid.lelitre@agrolait.com>',
+            'Formatting: avoid wrong double encapsulation')
+        self.assertEqual(
+            self.partner_2.email_formatted,
+            '"Valid Poilvache" <valid.other.1@agrolait.com,valid.other.cc@agrolait.com>',
+            'Formatting: wrong formatting due to multi-email')
+
+        # instantiate composer, post message
+        composer_form = Form(self.env['mail.compose.message'].with_context(
+            self._get_web_context(
+                self.test_record,
+                add_web=True,
+                default_template_id=self.template.id,
+            )
+        ))
+        composer = composer_form.save()
+        with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app():
+            composer.send_mail()
+
+        # find partners created during sending (as emails are transformed into partners)
+        # FIXME: currently email finding based on formatted / multi emails does
+        # not work
+        new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)])
+        self.assertEqual(len(new_partners), 8,
+                         'Mail (FIXME): multiple partner creation due to formatted / multi emails: 1 extra partners')
+        self.assertIn(partner_format_tofind, new_partners)
+        self.assertIn(partner_multi_tofind, new_partners)
+        self.assertEqual(
+            sorted(new_partners.mapped('email')),
+            sorted(['"FindMe Format" <find.me.format@test.example.com>',
+                    'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                    'find.me.multi.2@test.example.com',
+                    'test.cc.1@example.com', 'test.cc.2@example.com', 'test.cc.2.2@example.com',
+                    'test.to.1@example.com', 'test.to.2@example.com']),
+            'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)'
+        )
+        self.assertEqual(
+            sorted(new_partners.mapped('email_formatted')),
+            sorted(['"FindMe Format" <find.me.format@test.example.com>',
+                    '"FindMe Multi" <find.me.multi.1@test.example.com,find.me.multi.2@test.example.com>',
+                    '"find.me.multi.2@test.example.com" <find.me.multi.2@test.example.com>',
+                    '"test.cc.1@example.com" <test.cc.1@example.com>',
+                    '"test.cc.2@example.com" <test.cc.2@example.com>',
+                    '"test.cc.2.2@example.com" <test.cc.2.2@example.com>',
+                    '"test.to.1@example.com" <test.to.1@example.com>',
+                    '"test.to.2@example.com" <test.to.2@example.com>']),
+        )
+        self.assertEqual(
+            sorted(new_partners.mapped('name')),
+            sorted(['FindMe Format',
+                    'FindMe Multi',
+                    'find.me.multi.2@test.example.com',
+                    'test.cc.1@example.com', 'test.to.1@example.com', 'test.to.2@example.com',
+                    'test.cc.2@example.com', 'test.cc.2.2@example.com']),
+            'Mail: currently setting name = email, not taking into account formatted emails'
+        )
+
+        # global outgoing: two mail.mail (all customer recipients, then all employee recipients)
+        # and 11 emails, and 1 inbox notification (admin)
+        # FIXME template is sent only to partners (email_to are transformed) ->
+        #   wrong / weird emails (see email_formatted of partners) is kept
+        # FIXME: more partners created than real emails (see above) -> due to
+        #   transformation from email -> partner in template 'generate_recipients'
+        #   there are more partners than email to notify;
+        self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail')
+        self.assertEqual(
+            len(self._mails), len(new_partners) + 3,
+            f'Should have sent {len(new_partners) + 3} emails, one / recipient ({len(new_partners)} mailed partners + partner_1 + partner_2 + partner_employee)')
+        self.assertMailMail(
+            self.partner_employee_2, 'sent',
+            author=self.partner_employee,
+            email_values={
+                'body_content': f'TemplateBody {self.test_record.name}',
+                # single email event if email field is multi-email
+                'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com')),
+                'subject': f'TemplateSubject {self.test_record.name}',
+            },
+            fields_values={
+                # currently holding multi-email 'email_from'
+                'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com,email.from.2@test.example.com')),
+            },
+            mail_message=self.test_record.message_ids[0],
+        )
+        self.assertMailMail(
+            self.partner_1 + self.partner_2 + new_partners, 'sent',
+            author=self.partner_employee,
+            email_to_recipients=[
+                [self.partner_1.email_formatted],
+                [f'"{self.partner_2.name}" <valid.other.1@agrolait.com>', f'"{self.partner_2.name}" <valid.other.cc@agrolait.com>'],
+            ] + [[new_partners[0]['email_formatted']],
+                 ['"FindMe Multi" <find.me.multi.1@test.example.com>', '"FindMe Multi" <find.me.multi.2@test.example.com>']
+            ] + [[email] for email in new_partners[2:].mapped('email_formatted')],
+            email_values={
+                'body_content': f'TemplateBody {self.test_record.name}',
+                # single email event if email field is multi-email
+                'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com')),
+                'subject': f'TemplateSubject {self.test_record.name}',
+            },
+            fields_values={
+                # currently holding multi-email 'email_from'
+                'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com,email.from.2@test.example.com')),
+            },
+            mail_message=self.test_record.message_ids[0],
+        )
+
 
 @tagged('mail_composer')
 class TestComposerResultsMass(TestMailComposer):
@@ -908,3 +1049,138 @@ class TestComposerResultsMass(TestMailComposer):
         composer = composer_form.save()
         with self.mock_mail_gateway(mail_unlink_sent=False), self.assertRaises(ValueError):
             composer.send_mail()
+
+    @users('employee')
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_composer_wtpl_recipients_email_fields(self):
+        """ Test various combinations of corner case / not standard filling of
+        email fields: multi email, formatted emails, ... """
+        existing_partners = self.env['res.partner'].search([])
+        partner_format_tofind, partner_multi_tofind = self.env['res.partner'].create([
+            {
+                'email': '"FindMe Format" <find.me.format@test.example.com>',
+                'name': 'FindMe Format',
+            }, {
+                'email': 'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                'name': 'FindMe Multi',
+            }
+        ])
+        email_ccs = ['"Raoul" <test.cc.1@example.com>', '"Raoulette" <test.cc.2@example.com>', 'test.cc.2.2@example.com>', 'invalid', '  ']
+        email_tos = ['"Micheline, l\'Immense" <test.to.1@example.com>', 'test.to.2@example.com', 'wrong', '  ']
+
+        self.template.write({
+            'email_cc': ', '.join(email_ccs),
+            'email_from': '${user.email_formatted | safe}',
+            'email_to':', '.join(email_tos + (partner_format_tofind + partner_multi_tofind).mapped('email')),
+            'partner_to': f'{self.partner_1.id},{self.partner_2.id},0,test',
+        })
+        self.user_employee.write({'email': 'email.from.1@test.example.com, email.from.2@test.example.com'})
+        self.partner_1.write({'email': '"Valid Formatted" <valid.lelitre@agrolait.com>'})
+        self.partner_2.write({'email': 'valid.other.1@agrolait.com, valid.other.cc@agrolait.com'})
+        # ensure values used afterwards for testing
+        self.assertEqual(
+            self.partner_employee.email_formatted,
+            '"Ernest Employee" <email.from.1@test.example.com,email.from.2@test.example.com>',
+            'Formatting: wrong formatting due to multi-email')
+        self.assertEqual(
+            self.partner_1.email_formatted,
+            '"Valid Lelitre" <valid.lelitre@agrolait.com>',
+            'Formatting: avoid wrong double encapsulation')
+        self.assertEqual(
+            self.partner_2.email_formatted,
+            '"Valid Poilvache" <valid.other.1@agrolait.com,valid.other.cc@agrolait.com>',
+            'Formatting: wrong formatting due to multi-email')
+
+        # instantiate composer, send mailing
+        composer_form = Form(self.env['mail.compose.message'].with_context(
+            self._get_web_context(
+                self.test_records,
+                add_web=True,
+                default_template_id=self.template.id,
+            )
+        ))
+        composer = composer_form.save()
+        with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app():
+            composer.send_mail()
+
+        # find partners created during sending (as emails are transformed into partners)
+        # FIXME: currently email finding based on formatted / multi emails does
+        # not work
+        new_partners = self.env['res.partner'].search([]).search([('id', 'not in', existing_partners.ids)])
+        self.assertEqual(len(new_partners), 8,
+                         'Mail (FIXME): did not find existing partners for formatted / multi emails: 1 extra partners')
+        self.assertIn(partner_format_tofind, new_partners)
+        self.assertIn(partner_multi_tofind, new_partners)
+        self.assertEqual(
+            sorted(new_partners.mapped('email')),
+            sorted(['"FindMe Format" <find.me.format@test.example.com>',
+                    'find.me.multi.1@test.example.com, "FindMe Multi" <find.me.multi.2@test.example.com>',
+                    'find.me.multi.2@test.example.com',
+                    'test.cc.1@example.com', 'test.cc.2@example.com', 'test.cc.2.2@example.com',
+                    'test.to.1@example.com', 'test.to.2@example.com']),
+            'Mail: created partners for valid emails (wrong / invalid not taken into account) + did not find corner cases (FIXME)'
+        )
+        self.assertEqual(
+            sorted(new_partners.mapped('email_formatted')),
+            sorted(['"FindMe Format" <find.me.format@test.example.com>',
+                    '"FindMe Multi" <find.me.multi.1@test.example.com,find.me.multi.2@test.example.com>',
+                    '"find.me.multi.2@test.example.com" <find.me.multi.2@test.example.com>',
+                    '"test.cc.1@example.com" <test.cc.1@example.com>',
+                    '"test.cc.2@example.com" <test.cc.2@example.com>',
+                    '"test.cc.2.2@example.com" <test.cc.2.2@example.com>',
+                    '"test.to.1@example.com" <test.to.1@example.com>',
+                    '"test.to.2@example.com" <test.to.2@example.com>']),
+        )
+        self.assertEqual(
+            sorted(new_partners.mapped('name')),
+            sorted(['FindMe Format',
+                    'FindMe Multi',
+                    'find.me.multi.2@test.example.com',
+                    'test.cc.1@example.com', 'test.to.1@example.com', 'test.to.2@example.com',
+                    'test.cc.2@example.com', 'test.cc.2.2@example.com']),
+            'Mail: currently setting name = email, not taking into account formatted emails'
+        )
+
+        # global outgoing: one mail.mail (all customer recipients), * 2 records
+        #   Note that employee is not mailed here compared to 'comment' mode as he
+        #   is not in the template recipients, only a follower
+        # FIXME template is sent only to partners (email_to are transformed) ->
+        #   wrong / weird emails (see email_formatted of partners) is kept
+        # FIXME: more partners created than real emails (see above) -> due to
+        #   transformation from email -> partner in template 'generate_recipients'
+        #   there are more partners than email to notify;
+        self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail')
+        self.assertEqual(
+            len(self._mails), (len(new_partners) + 2) * 2,
+            f'Should have sent {(len(new_partners) + 2) * 2} emails, one / recipient ({len(new_partners)} mailed partners + partner_1 + partner_2) * 2 records')
+        for record in self.test_records:
+            self.assertMailMail(
+                self.partner_1 + self.partner_2 + new_partners,
+                'sent',
+                author=self.partner_employee,
+                email_to_recipients=[
+                    [self.partner_1.email_formatted],
+                    [f'"{self.partner_2.name}" <valid.other.1@agrolait.com>', f'"{self.partner_2.name}" <valid.other.cc@agrolait.com>'],
+                ] + [[new_partners[0]['email_formatted']],
+                     ['"FindMe Multi" <find.me.multi.1@test.example.com>', '"FindMe Multi" <find.me.multi.2@test.example.com>']
+                ] + [[email] for email in new_partners[2:].mapped('email_formatted')],
+                email_values={
+                    'body_content': f'TemplateBody {record.name}',
+                    # single email event if email field is multi-email
+                    'email_from': formataddr((self.user_employee.name, 'email.from.1@test.example.com')),
+                    'reply_to': formataddr((
+                        f'{self.env.user.company_id.name} {record.name}',
+                        f'{self.alias_catchall}@{self.alias_domain}'
+                    )),
+                    'subject': f'TemplateSubject {record.name}',
+                },
+                fields_values={
+                    # currently holding multi-email 'email_from'
+                    'email_from': self.partner_employee.email_formatted,
+                    'reply_to': formataddr((
+                        f'{self.env.user.company_id.name} {record.name}',
+                        f'{self.alias_catchall}@{self.alias_domain}'
+                    )),
+                },
+                mail_message=record.message_ids[0],  # message copy is kept
+            )
diff --git a/addons/test_mail/tests/test_mail_gateway.py b/addons/test_mail/tests/test_mail_gateway.py
index 4eb22aa18620bba000e6c13d96f33fcfc161e843..6ed94dce423aa4f063633b71f76ec26d2b4d5ddc 100644
--- a/addons/test_mail/tests/test_mail_gateway.py
+++ b/addons/test_mail/tests/test_mail_gateway.py
@@ -430,6 +430,31 @@ class TestMailgateway(TestMailCommon):
         self.assertEqual(record.message_ids[0].email_from, self.partner_1.email)
         self.assertNotSentEmail()  # No notification / bounce should be sent
 
+    @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models')
+    def test_message_process_email_author_multiemail(self):
+        """ Incoming email: recognized author: check multi/formatted email in field """
+        test_email = 'valid.lelitre@agrolait.com'
+        # Email not recognized if partner has a multi-email (source = formatted email)
+        self.partner_1.write({'email': f'{test_email}, "Valid Lelitre" <another.email@test.example.com>'})
+        with self.mock_mail_gateway():
+            record = self.format_and_process(
+                MAIL_TEMPLATE, f'"Valid Lelitre" <{test_email}>', 'groups@test.com', subject='Test3')
+
+        self.assertEqual(record.message_ids[0].author_id, self.partner_1,
+                         'message_process: found author based on first found email normalized, even with multi emails')
+        self.assertEqual(record.message_ids[0].email_from, f'"Valid Lelitre" <{test_email}>')
+        self.assertNotSentEmail()  # No notification / bounce should be sent
+
+        # Email not recognized if partner has a multi-email (source = std email)
+        with self.mock_mail_gateway():
+            record = self.format_and_process(
+                MAIL_TEMPLATE, test_email, 'groups@test.com', subject='Test4')
+
+        self.assertEqual(record.message_ids[0].author_id, self.partner_1,
+                         'message_process: found author based on first found email normalized, even with multi emails')
+        self.assertEqual(record.message_ids[0].email_from, test_email)
+        self.assertNotSentEmail()  # No notification / bounce should be sent
+
     @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models')
     def test_message_process_email_partner_find(self):
         """ Finding the partner based on email, based on partner / user / follower """
@@ -621,6 +646,39 @@ class TestMailgateway(TestMailCommon):
         # Test: one group created by Raoul (or Sylvie maybe, if we implement it)
         self.assertEqual(len(record), 1)
 
+    @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models')
+    def test_message_process_alias_followers_multiemail(self):
+        """ Incoming email from a parent document follower on a Followers only
+        alias depends on email_from / partner recognition, to be tested when
+        dealing with multi emails / formatted emails. """
+        self.alias.write({
+            'alias_contact': 'followers',
+            'alias_parent_model_id': self.env['ir.model']._get('mail.test.gateway').id,
+            'alias_parent_thread_id': self.test_record.id,
+        })
+        self.test_record.message_subscribe(partner_ids=[self.partner_1.id])
+        email_from = formataddr(("Another Name", self.partner_1.email_normalized))
+
+        for partner_email, passed in [
+            (formataddr((self.partner_1.name, self.partner_1.email_normalized)), True),
+            (f'{self.partner_1.email_normalized}, "Multi Email" <multi.email@test.example.com>', True),
+            (f'"Multi Email" <multi.email@test.example.com>, {self.partner_1.email_normalized}', False),
+        ]:
+            with self.subTest(partner_email=partner_email):
+                self.partner_1.write({'email': partner_email})
+                record = self.format_and_process(
+                    MAIL_TEMPLATE, email_from, 'groups@test.com',
+                    subject=f'Test for {partner_email}')
+
+                if passed:
+                    self.assertEqual(len(record), 1)
+                    self.assertEqual(record.email_from, email_from)
+                    self.assertEqual(record.message_partner_ids, self.partner_1)
+                # multi emails not recognized (no normalized email, recognition)
+                else:
+                    self.assertEqual(len(record), 0,
+                                     'Alias check (FIXME): multi-emails bad support for recognition')
+
     @mute_logger('odoo.addons.mail.models.mail_thread', 'odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
     def test_message_process_alias_update(self):
         """ Incoming email update discussion + notification email """
@@ -1573,6 +1631,7 @@ class TestMailgateway(TestMailCommon):
     # Corner cases / Bugs during message process
     # --------------------------------------------------
 
+    @mute_logger('odoo.addons.mail.models.mail_thread')
     def test_message_process_file_encoding_ascii(self):
         """ Incoming email containing an xml attachment with unknown characters (�) but an ASCII charset should not
         raise an Exception. UTF-8 is used as a safe fallback.
@@ -1598,6 +1657,7 @@ class TestMailgateway(TestMailCommon):
         self.assertEqual(record.message_main_attachment_id.name, 'bis3.xml')
         self.assertEqual("<Invoice>Chaussée de Bruxelles</Invoice>", record.message_main_attachment_id.raw.decode())
 
+@tagged('mail_gateway', 'mail_thread')
 class TestMailThreadCC(TestMailCommon):
 
     @classmethod
diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py
index 3ebee26330b69d8a5e57d7200e47bfaff3380c53..194baa3688acaa6fb82c71930328d12e8c38a79f 100644
--- a/addons/test_mail/tests/test_mail_mail.py
+++ b/addons/test_mail/tests/test_mail_mail.py
@@ -3,7 +3,7 @@
 
 import psycopg2
 
-from odoo import api
+from odoo import api, tools
 from odoo.addons.test_mail.tests.common import TestMailCommon
 from odoo.tests import common, tagged
 from odoo.tools import mute_logger
@@ -23,7 +23,7 @@ class TestMailMail(TestMailCommon):
         }).with_context({})
 
     @mute_logger('odoo.addons.mail.models.mail_mail')
-    def test_mail_message_notify_from_mail_mail(self):
+    def test_mail_mail_notify_from_mail_mail(self):
         # Due ot post-commit hooks, store send emails in every step
         mail = self.env['mail.mail'].sudo().create({
             'body_html': '<p>Test</p>',
@@ -35,6 +35,7 @@ class TestMailMail(TestMailCommon):
         self.assertSentEmail(mail.env.user.partner_id, ['test@example.com'])
         self.assertEqual(len(self._mails), 1)
 
+    @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_mail_mail_return_path(self):
         # mail without thread-enabled record
         base_values = {
@@ -74,7 +75,116 @@ class TestMailMail(TestMailCommon):
             mail.send()
         self.assertEqual(self._mails[0]['headers']['Return-Path'], '%s@%s' % (self.alias_bounce, self.alias_domain))
 
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_mail_values_email_formatted(self):
+        """ Test outgoing email values, with formatting """
+        customer = self.env['res.partner'].create({
+            'name': 'Tony Customer',
+            'email': '"Formatted Emails" <tony.customer@test.example.com>',
+        })
+        mail = self.env['mail.mail'].create({
+            'body_html': '<p>Test</p>',
+            'email_cc': '"Ignasse, le Poilu" <test.cc.1@test.example.com>',
+            'email_to': '"Raoul, le Grand" <test.email.1@test.example.com>, "Micheline, l\'immense" <test.email.2@test.example.com>',
+            'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)]
+        })
+        with self.mock_mail_gateway():
+            mail.send()
+        self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient')
+        self.assertEqual(
+            sorted(sorted(_mail['email_to']) for _mail in self._mails),
+            sorted([sorted(['"Raoul, le Grand" <test.email.1@test.example.com>', '"Micheline, l\'immense" <test.email.2@test.example.com>']),
+                    [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))],
+                    [tools.formataddr(("Tony Customer", 'tony.customer@test.example.com'))]
+                   ]),
+            'Mail: formatting issues should have been removed as much as possible'
+        )
+        # Currently broken: CC are added to ALL emails (spammy)
+        self.assertEqual(
+            [_mail['email_cc'] for _mail in self._mails],
+            [['test.cc.1@test.example.com']] * 3,
+            'Mail: currently always removing formatting in email_cc'
+        )
+
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_mail_values_email_multi(self):
+        """ Test outgoing email values, with email field holding multi emails """
+        # Multi
+        customer = self.env['res.partner'].create({
+            'name': 'Tony Customer',
+            'email': 'tony.customer@test.example.com, norbert.customer@test.example.com',
+        })
+        mail = self.env['mail.mail'].create({
+            'body_html': '<p>Test</p>',
+            'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com',
+            'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com',
+            'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)]
+        })
+        with self.mock_mail_gateway():
+            mail.send()
+        self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient')
+        self.assertEqual(
+            sorted(sorted(_mail['email_to']) for _mail in self._mails),
+            sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']),
+                    [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))],
+                    sorted([tools.formataddr(("Tony Customer", 'tony.customer@test.example.com')),
+                            tools.formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]),
+                   ]),
+            'Mail: formatting issues should have been removed as much as possible (multi emails in a single address are managed '
+            'like separate emails when sending with recipient_ids'
+        )
+        # Currently broken: CC are added to ALL emails (spammy)
+        self.assertEqual(
+            [_mail['email_cc'] for _mail in self._mails],
+            [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3,
+        )
+
+        # Multi + formatting
+        customer = self.env['res.partner'].create({
+            'name': 'Tony Customer',
+            'email': 'tony.customer@test.example.com, "Norbert Customer" <norbert.customer@test.example.com>',
+        })
+        mail = self.env['mail.mail'].create({
+            'body_html': '<p>Test</p>',
+            'email_cc': 'test.cc.1@test.example.com, test.cc.2@test.example.com',
+            'email_to': 'test.email.1@test.example.com, test.email.2@test.example.com',
+            'recipient_ids': [(4, self.user_employee.partner_id.id), (4, customer.id)]
+        })
+        with self.mock_mail_gateway():
+            mail.send()
+        self.assertEqual(len(self._mails), 3, 'Mail: sent 3 emails: 1 for email_to, 1 / recipient')
+        self.assertEqual(
+            sorted(sorted(_mail['email_to']) for _mail in self._mails),
+            sorted([sorted(['test.email.1@test.example.com', 'test.email.2@test.example.com']),
+                    [tools.formataddr((self.user_employee.name, self.user_employee.email_normalized))],
+                    sorted([tools.formataddr(("Tony Customer", 'tony.customer@test.example.com')),
+                            tools.formataddr(("Tony Customer", 'norbert.customer@test.example.com'))]),
+                   ]),
+            'Mail: formatting issues should have been removed as much as possible (multi emails in a single address are managed '
+            'like separate emails when sending with recipient_ids (and partner name is always used as name part)'
+        )
+        # Currently broken: CC are added to ALL emails (spammy)
+        self.assertEqual(
+            [_mail['email_cc'] for _mail in self._mails],
+            [['test.cc.1@test.example.com', 'test.cc.2@test.example.com']] * 3,
+        )
+
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mail_mail_values_unicode(self):
+        """ Unicode should be fine. """
+        mail = self.env['mail.mail'].create({
+            'body_html': '<p>Test</p>',
+            'email_cc': 'test.😊.cc@example.com',
+            'email_to': 'test.😊@example.com',
+        })
+        with self.mock_mail_gateway():
+            mail.send()
+        self.assertEqual(len(self._mails), 1)
+        self.assertEqual(self._mails[0]['email_cc'], ['test.😊.cc@example.com'])
+        self.assertEqual(self._mails[0]['email_to'], ['test.😊@example.com'])
+
 
+@tagged('mail_mail')
 class TestMailMailRace(common.TransactionCase):
 
     @mute_logger('odoo.addons.mail.models.mail_mail')
diff --git a/addons/test_mail/tests/test_mail_thread_mixins.py b/addons/test_mail/tests/test_mail_thread_mixins.py
new file mode 100644
index 0000000000000000000000000000000000000000..a1a7ad2a04bb337bd3b5d159ce6b5f3a828740bc
--- /dev/null
+++ b/addons/test_mail/tests/test_mail_thread_mixins.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import exceptions, tools
+from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients
+from odoo.tests.common import tagged
+from odoo.tools import mute_logger
+
+
+@tagged('mail_thread', 'mail_blacklist')
+class TestMailThread(TestMailCommon, TestRecipients):
+
+    @mute_logger('odoo.models.unlink')
+    def test_blacklist_mixin_email_normalized(self):
+        """ Test email_normalized and is_blacklisted fields behavior, notably
+        when dealing with encapsulated email fields and multi-email input. """
+        base_email = 'test.email@test.example.com'
+
+        # test data: source email, expected email normalized
+        valid_pairs = [
+            (base_email, base_email),
+            (tools.formataddr(('Another Name', base_email)), base_email),
+            (f'Name That Should Be Escaped <{base_email}>', base_email),
+            ('test.😊@example.com', 'test.😊@example.com'),
+            ('"Name 😊" <test.😊@example.com>', 'test.😊@example.com'),
+        ]
+        void_pairs = [(False, False),
+                      ('', False),
+                      (' ', False)]
+        multi_pairs = [
+            (f'{base_email}, other.email@test.example.com',
+             base_email),  # multi supports first found
+            (f'{tools.formataddr(("Another Name", base_email))}, other.email@test.example.com',
+             base_email),  # multi supports first found
+        ]
+        for email_from, exp_email_normalized in valid_pairs + void_pairs + multi_pairs:
+            with self.subTest(email_from=email_from, exp_email_normalized=exp_email_normalized):
+                new_record = self.env['mail.test.gateway'].create({
+                    'email_from': email_from,
+                    'name': 'BL Test',
+                })
+                self.assertEqual(new_record.email_normalized, exp_email_normalized)
+                self.assertFalse(new_record.is_blacklisted)
+
+                # blacklist email should fail as void
+                if email_from in [pair[0] for pair in void_pairs]:
+                    with self.assertRaises(exceptions.UserError):
+                        bl_record = self.env['mail.blacklist']._add(email_from)
+                # blacklist email currently fails but could not
+                elif email_from in [pair[0] for pair in multi_pairs]:
+                    with self.assertRaises(exceptions.UserError):
+                        bl_record = self.env['mail.blacklist']._add(email_from)
+                # blacklist email ok
+                else:
+                    bl_record = self.env['mail.blacklist']._add(email_from)
+                    self.assertEqual(bl_record.email, exp_email_normalized)
+                    new_record.invalidate_cache(fnames=['is_blacklisted'])
+                    self.assertTrue(new_record.is_blacklisted)
+
+                bl_record.unlink()
diff --git a/addons/test_mail/tests/test_mail_tools.py b/addons/test_mail/tests/test_mail_tools.py
index b36a1335c5d3c450cac0c62ef8f3dcc45c84de1f..5a62de2a327653cb7c62aa6f301a5ed7c1873af7 100644
--- a/addons/test_mail/tests/test_mail_tools.py
+++ b/addons/test_mail/tests/test_mail_tools.py
@@ -22,62 +22,124 @@ class TestMailTools(TestMailCommon, TestRecipients):
         })
 
     @users('employee')
-    def test_find_partner_from_emails(self):
+    def test_mail_find_partner_from_emails(self):
         Partner = self.env['res.partner']
         test_partner = Partner.browse(self.test_partner.ids)
         self.assertEqual(test_partner.email, self._test_email)
 
-        # test direct match
-        found = Partner._mail_find_partner_from_emails([self._test_email])
-        self.assertEqual(found, [test_partner])
-
-        # test encapsulated email
-        found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email])
-        self.assertEqual(found, [test_partner])
+        sources = [
+            self._test_email,  # test direct match
+            f'"Norbert Poiluchette" <{self._test_email}>',  # encapsulated
+            'fredoastaire@test.example.com',  # partial email -> should not match !
+        ]
+        expected_partners = [
+            test_partner,
+            test_partner,
+            self.env['res.partner'],
+        ]
+        for source, expected_partner in zip(sources, expected_partners):
+            with self.subTest(source=source):
+                found = Partner._mail_find_partner_from_emails([source])
+                self.assertEqual(found, [expected_partner])
 
         # test with wildcard "_"
         found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com'])
         self.assertEqual(found, [self.env['res.partner']])
-
         # sub-check: this search does not consider _ as a wildcard
         found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com'])
         self.assertEqual(found, self.env['res.partner'])
 
         # test partners with encapsulated emails
         # ------------------------------------------------------------
-        test_partner.sudo().write({'email': '"Alfred Mighty Power Astaire" <%s>' % self._test_email})
-
-        # test direct match
-        found = Partner._mail_find_partner_from_emails([self._test_email])
-        self.assertEqual(found, [test_partner])
-
-        # test encapsulated email
-        found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email])
-        self.assertEqual(found, [test_partner])
+        test_partner.sudo().write({'email': f'"Alfred Mighty Power Astaire" <{self._test_email}>'})
+
+        sources = [
+            self._test_email,  # test direct match
+            f'"Norbert Poiluchette" <{self._test_email}>',  # encapsulated
+        ]
+        expected_partners = [
+            test_partner,
+            test_partner,
+        ]
+        for source, expected_partner in zip(sources, expected_partners):
+            with self.subTest(source=source):
+                found = Partner._mail_find_partner_from_emails([source])
+                self.assertEqual(found, [expected_partner])
 
         # test with wildcard "_"
         found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com'])
         self.assertEqual(found, [self.env['res.partner']])
-
         # sub-check: this search does not consider _ as a wildcard
         found = Partner._mail_search_on_partner(['alfred_astaire@test.example.com'])
         self.assertEqual(found, self.env['res.partner'])
 
-        # test partners with look-alike emails
-        # ------------------------------------------------------------
-        for email_lookalike in [
-                'alfred.astaire@test.example.com',
-                'alfredoastaire@example.com',
-                'aalfredoastaire@test.example.com',
-                'alfredoastaire@test.example.comm']:
-            test_partner.sudo().write({'email': '"Alfred Astaire" <%s>' % email_lookalike})
-
-            # test direct match
-            found = Partner._mail_find_partner_from_emails([self._test_email])
-            self.assertEqual(found, [self.env['res.partner']])
-            # test encapsulated email
-            found = Partner._mail_find_partner_from_emails(['"Norbert Poiluchette" <%s>' % self._test_email])
-            self.assertEqual(found, [self.env['res.partner']])
-            # test with wildcard "_"
-            found = Partner._mail_find_partner_from_emails(['alfred_astaire@test.example.com'])
-            self.assertEqual(found, [self.env['res.partner']])
+    @users('employee')
+    def test_mail_find_partner_from_emails_followers(self):
+        """ Test '_mail_find_partner_from_emails' when dealing with records on
+        which followers have to be found based on email. Check multi email
+        and encapsulated email support. """
+        linked_record = self.env['mail.test.simple'].create({'name': 'Record for followers'})
+        follower_partner = self.env['res.partner'].sudo().create({
+            'email': self._test_email,
+            'name': 'Duplicated, follower of record',
+        })
+        linked_record.message_subscribe(partner_ids=follower_partner.ids)
+        test_partner = self.test_partner.with_env(self.env)
+
+        # standard test, no multi-email, to assert base behavior
+        sources = [(self._test_email, True), (self._test_email, False),]
+        expected = [follower_partner, test_partner]
+        for (source, follower_check), expected in zip(sources, expected):
+            with self.subTest(source=source, follower_check=follower_check):
+                partner = self.env['res.partner']._mail_find_partner_from_emails(
+                    [source], records=linked_record if follower_check else None
+                )[0]
+                self.assertEqual(partner, expected)
+
+        # formatted email
+        encapsulated_test_email = f'"Robert Astaire" <{self._test_email}>'
+        (follower_partner + test_partner).sudo().write({'email': encapsulated_test_email})
+        sources = [
+            (self._test_email, True),  # normalized
+            (self._test_email, False),  # normalized
+            (encapsulated_test_email, True),  # encapsulated, same
+            (encapsulated_test_email, False),  # encapsulated, same
+            (f'"AnotherName" <{self._test_email}', True),  # same normalized, other name
+            (f'"AnotherName" <{self._test_email}', False),  # same normalized, other name
+        ]
+        expected = [follower_partner, test_partner,
+                    follower_partner, test_partner,
+                    follower_partner, test_partner,
+                    follower_partner, test_partner]
+        for (source, follower_check), expected in zip(sources, expected):
+            with self.subTest(source=source, follower_check=follower_check):
+                partner = self.env['res.partner']._mail_find_partner_from_emails(
+                    [source], records=linked_record if follower_check else None
+                )[0]
+                self.assertEqual(partner, expected,
+                                'Mail: formatted email is recognized through usage of normalized email')
+
+        # multi-email
+        _test_email_2 = '"Robert Astaire" <not.alfredoastaire@test.example.com>'
+        (follower_partner + test_partner).sudo().write({'email': f'{self._test_email}, {_test_email_2}'})
+        sources = [
+            (self._test_email, True),  # first email
+            (self._test_email, False),  # first email
+            (_test_email_2, True),  # second email
+            (_test_email_2, False),  # second email
+            ('not.alfredoastaire@test.example.com', True),  # normalized second email in field
+            ('not.alfredoastaire@test.example.com', False),  # normalized second email in field
+            (f'{self._test_email}, {_test_email_2}', True),  # multi-email, both matching, depends on comparison
+            (f'{self._test_email}, {_test_email_2}', False)  # multi-email, both matching, depends on comparison
+        ]
+        expected = [follower_partner, test_partner,
+                    self.env['res.partner'], self.env['res.partner'],
+                    self.env['res.partner'], self.env['res.partner'],
+                    follower_partner, test_partner]
+        for (source, follower_check), expected in zip(sources, expected):
+            with self.subTest(source=source, follower_check=follower_check):
+                partner = self.env['res.partner']._mail_find_partner_from_emails(
+                    [source], records=linked_record if follower_check else None
+                )[0]
+                self.assertEqual(partner, expected,
+                                'Mail (FIXME): partial recognition of multi email through email_normalize')
diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py
index 14673a99ca944fa99b4dca88365ad7c72dfd8e6f..cabdbadef694e882439a4e8f26d96ed67677b530 100644
--- a/addons/test_mail/tests/test_message_post.py
+++ b/addons/test_mail/tests/test_message_post.py
@@ -37,12 +37,13 @@ class TestMessagePost(TestMailCommon, TestRecipients):
         self.assertTrue(isinstance(messageId, int))
 
     @users('employee')
+    @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_notify_mail_add_signature(self):
-        self.test_track = self.env['mail.test.track'].with_context(self._test_context).with_user(self.user_employee).create({
+        test_track = self.env['mail.test.track'].with_context(self._test_context).with_user(self.user_employee).create({
             'name': 'Test',
             'email_from': 'ignasse@example.com'
         })
-        self.test_track.user_id = self.env.user
+        test_track.user_id = self.env.user
 
         signature = self.env.user.signature
 
@@ -50,13 +51,13 @@ class TestMessagePost(TestMailCommon, TestRecipients):
         self.assertIn("record.user_id.sudo().signature", template.arch)
 
         with self.mock_mail_gateway():
-            self.test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=True, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow")
+            test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=True, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow")
         found_mail = self._new_mails
         self.assertIn(signature, found_mail.body_html)
         self.assertEqual(found_mail.body_html.count(signature), 1)
 
         with self.mock_mail_gateway():
-            self.test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=False, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow")
+            test_track.message_post(body="Test body", mail_auto_delete=False, add_sign=False, partner_ids=[self.partner_1.id, self.partner_2.id], email_layout_xmlid="mail.mail_notification_paynow")
         found_mail = self._new_mails
         self.assertNotIn(signature, found_mail.body_html)
         self.assertEqual(found_mail.body_html.count(signature), 0)
@@ -203,7 +204,7 @@ class TestMessagePost(TestMailCommon, TestRecipients):
             self.test_record.message_post(
                 body='Test', message_type='comment', subtype_xmlid='mail.mt_comment')
 
-    @mute_logger('odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
     def test_post_notifications(self):
         _body, _subject = '<p>Test Body</p>', 'Test Subject'
 
@@ -238,6 +239,52 @@ class TestMessagePost(TestMailCommon, TestRecipients):
         self.assertFalse(copy.notified_partner_ids)
 
     @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
+    def test_post_notifications_email_field(self):
+        """ Test various combinations of corner case / not standard filling of
+        email fields: multi email, formatted emails, ... """
+        partner_emails = [
+            'valid.lelitre@agrolait.com, valid.lelitre.cc@agrolait.com',  # multi email
+            '"Valid Lelitre" <valid.lelitre@agrolait.com>',  # email contains formatted email
+            'wrong',  # wrong
+            False, '', ' ',  # falsy
+        ]
+        expected_tos = [
+            # Sends multi-emails
+            [f'"{self.partner_1.name}" <valid.lelitre@agrolait.com>',
+             f'"{self.partner_1.name}" <valid.lelitre.cc@agrolait.com>',],
+            # Avoid double encapsulation
+            [f'"{self.partner_1.name}" <valid.lelitre@agrolait.com>',],
+            # sent "normally": formats email based on wrong / falsy email
+            [f'"{self.partner_1.name}" <@wrong>',],
+            [f'"{self.partner_1.name}" <@False>',],
+            [f'"{self.partner_1.name}" <@False>',],
+            [f'"{self.partner_1.name}" <@ >',],
+        ]
+
+        for partner_email, expected_to in zip(partner_emails, expected_tos):
+            with self.subTest(partner_email=partner_email, expected_to=expected_to):
+                self.partner_1.write({'email': partner_email})
+                with self.mock_mail_gateway():
+                    self.test_record.with_user(self.user_employee).message_post(
+                        body='Test multi email',
+                        message_type='comment',
+                        partner_ids=[self.partner_1.id],
+                        subject='Exotic email',
+                        subtype_xmlid='mt_comment',
+                    )
+
+                self.assertSentEmail(
+                    self.user_employee.partner_id,
+                    [self.partner_1],
+                    email_to=expected_to,
+                )
+
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink')
+    def test_post_notifications_emails_tweak(self):
+        pass
+        # we should check _notification_groups behavior, for emails and buttons
+
+    @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_post_notifications_keep_emails(self):
         self.test_record.message_subscribe(partner_ids=[self.user_admin.partner_id.id])
 
@@ -251,11 +298,6 @@ class TestMessagePost(TestMailCommon, TestRecipients):
         # notifications emails should not have been deleted: one for customers, one for user
         self.assertEqual(len(self.env['mail.mail'].sudo().search([('mail_message_id', '=', msg.id)])), 2)
 
-    @mute_logger('odoo.addons.mail.models.mail_mail')
-    def test_post_notifications_emails_tweak(self):
-        pass
-        # we should check _notification_groups behavior, for emails and buttons
-
     @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_post_attachments(self):
         _attachments = [
@@ -357,7 +399,7 @@ class TestMessagePost(TestMailCommon, TestRecipients):
             references='%s %s' % (parent_msg.message_id, new_msg.message_id),
         )
 
-    @mute_logger('odoo.addons.mail.models.mail_mail')
+    @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
     def test_post_email_with_multiline_subject(self):
         _body, _body_alt, _subject = '<p>Test Body</p>', 'Test Body', '1st line\n2nd line'
         msg = self.test_record.with_user(self.user_employee).message_post(
diff --git a/addons/test_mass_mailing/models/mailing_models.py b/addons/test_mass_mailing/models/mailing_models.py
index 54ede3f8e14adc7de1d2d09fbe9f5f964a840660..8748f4792110b56443436d88d97a1bf577654ae5 100644
--- a/addons/test_mass_mailing/models/mailing_models.py
+++ b/addons/test_mass_mailing/models/mailing_models.py
@@ -4,9 +4,39 @@
 from odoo import api, fields, models
 
 
+class MailingCustomer(models.Model):
+    """ A model inheriting from mail.thread with a partner field, to test
+    mass mailing flows involving checking partner email. """
+    _description = 'Mailing with partner'
+    _name = 'mailing.test.customer'
+    _inherit = ['mail.thread']
+
+    name = fields.Char()
+    email_from = fields.Char(compute='_compute_email_from', readonly=False, store=True)
+    customer_id = fields.Many2one('res.partner', 'Customer', tracking=True)
+
+    @api.depends('customer_id')
+    def _compute_email_from(self):
+        for mailing in self.filtered(lambda rec: not rec.email_from and rec.customer_id):
+            mailing.email_from = mailing.customer_id.email
+
+    def _message_get_default_recipients(self):
+        """ Default recipient checks for 'partner_id', here the field is named
+        'customer_id'. """
+        default_recipients = super()._message_get_default_recipients()
+        for record in self:
+            if record.customer_id:
+                default_recipients[record.id] = {
+                    'email_cc': False,
+                    'email_to': False,
+                    'partner_ids': record.customer_id.ids,
+                }
+        return default_recipients
+
+
 class MailingSimple(models.Model):
-    """ A very simple model only inheriting from mail.thread to test pure mass
-    mailing features and base performances. """
+    """ Model only inheriting from mail.thread to test base mailing features and
+    performances. """
     _description = 'Simple Mailing'
     _name = 'mailing.test.simple'
     _inherit = ['mail.thread']
@@ -16,7 +46,8 @@ class MailingSimple(models.Model):
 
 
 class MailingUTM(models.Model):
-    """ Model inheriting from mail.thread and utm.mixin for checking utm of mailing is caught and set on reply """
+    """ Model inheriting from mail.thread and utm.mixin for checking utm of mailing
+    is caught and set on reply """
     _description = 'Mailing: UTM enabled to test UTM sync with mailing'
     _name = 'mailing.test.utm'
     _inherit = ['mail.thread', 'utm.mixin']
@@ -36,6 +67,19 @@ class MailingBLacklist(models.Model):
     customer_id = fields.Many2one('res.partner', 'Customer', tracking=True)
     user_id = fields.Many2one('res.users', 'Responsible', tracking=True)
 
+    def _message_get_default_recipients(self):
+        """ Default recipient checks for 'partner_id', here the field is named
+        'customer_id'. """
+        default_recipients = super()._message_get_default_recipients()
+        for record in self:
+            if record.customer_id:
+                default_recipients[record.id] = {
+                    'email_cc': False,
+                    'email_to': False,
+                    'partner_ids': record.customer_id.ids,
+                }
+        return default_recipients
+
 
 class MailingOptOut(models.Model):
     """ Model using blacklist mechanism and a hijacked opt-out mechanism for
@@ -51,6 +95,19 @@ class MailingOptOut(models.Model):
     customer_id = fields.Many2one('res.partner', 'Customer', tracking=True)
     user_id = fields.Many2one('res.users', 'Responsible', tracking=True)
 
+    def _message_get_default_recipients(self):
+        """ Default recipient checks for 'partner_id', here the field is named
+        'customer_id'. """
+        default_recipients = super()._message_get_default_recipients()
+        for record in self:
+            if record.customer_id:
+                default_recipients[record.id] = {
+                    'email_cc': False,
+                    'email_to': False,
+                    'partner_ids': record.customer_id.ids,
+                }
+        return default_recipients
+
 
 class MailingPerformance(models.Model):
     """ A very simple model only inheriting from mail.thread to test pure mass
diff --git a/addons/test_mass_mailing/security/ir.model.access.csv b/addons/test_mass_mailing/security/ir.model.access.csv
index b21c7be49b14a3b910c38d8686f4e2c8e086c056..617995ff0fac1bfeb8130746c698e825449ccf96 100644
--- a/addons/test_mass_mailing/security/ir.model.access.csv
+++ b/addons/test_mass_mailing/security/ir.model.access.csv
@@ -1,4 +1,6 @@
 id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_mailing_test_customer_all,access.mailing.test.customer.all,model_mailing_test_customer,,0,0,0,0
+access_mailing_test_customer_user,access.mailing.test.customer.user,model_mailing_test_customer,base.group_user,1,1,1,1
 access_mailing_test_simple_all,access.mailing.test.simple.all,model_mailing_test_simple,,0,0,0,0
 access_mailing_test_simple_user,access.mailing.test.simple.user,model_mailing_test_simple,base.group_user,1,1,1,1
 access_mailing_test_blacklist_all,access.mailing.test.blacklist.all,model_mailing_test_blacklist,,0,0,0,0
diff --git a/addons/test_mass_mailing/tests/test_mailing.py b/addons/test_mass_mailing/tests/test_mailing.py
index 41fc0f1df3fea14aa5deda5b6262d5fa1a1999ce..34dabd4b451b3531532aeefd8426fc7cdca1417b 100644
--- a/addons/test_mass_mailing/tests/test_mailing.py
+++ b/addons/test_mass_mailing/tests/test_mailing.py
@@ -119,6 +119,138 @@ class TestMassMailing(TestMassMailCommon):
         self.assertMailingStatistics(mailing, expected=5, delivered=4, sent=5, opened=1, clicked=1, bounced=1)
         self.assertEqual(recipients[1].message_bounce, 1)
 
+    @users('user_marketing')
+    @mute_logger('odoo.addons.mail.models.mail_mail')
+    def test_mailing_recipients(self):
+        """ Test recipient-specific computation, with email, formatting,
+        multi-emails, ... to test corner cases. Blacklist mixin impact is
+        tested. """
+        (customer_mult, customer_fmt, customer_unic,
+         customer_case, customer_weird, customer_weird_2
+        ) = self.env['res.partner'].create([
+            {
+                'email': 'customer.multi.1@example.com, "Test Multi 2" <customer.multi.2@example.com>',
+                'name': 'MultiEMail',
+            }, {
+                'email': '"Formatted Customer" <test.customer.format@example.com>',
+                'name': 'FormattedEmail',
+            }, {
+                'email': '"Unicode Customer" <test.customer.😊@example.com>',
+                'name': 'UnicodeEmail',
+            }, {
+                'email': 'TEST.CUSTOMER.CASE@EXAMPLE.COM',
+                'name': 'CaseEmail',
+            }, {
+                'email': 'test.customer.weird@example.com Weird Format',
+                'name': 'WeirdFormatEmail',
+            }, {
+                'email': 'Weird Format2 test.customer.weird.2@example.com',
+                'name': 'WeirdFormatEmail2',
+            }
+        ])
+
+        # check difference of email management between a classic model and a model
+        # with an 'email_normalized' field (blacklist mixin)
+        for dst_model in ['mailing.test.customer', 'mailing.test.blacklist']:
+            with self.subTest(dst_model=dst_model):
+                (record_p_mult, record_p_fmt, record_p_unic,
+                 record_p_case, record_p_weird, record_p_weird_2,
+                 record_mult, record_fmt, record_unic
+                ) = self.env[dst_model].create([
+                    {
+                        'customer_id': customer_mult.id,
+                    }, {
+                        'customer_id': customer_fmt.id,
+                    }, {
+                        'customer_id': customer_unic.id,
+                    }, {
+                        'customer_id': customer_case.id,
+                    }, {
+                        'customer_id': customer_weird.id,
+                    }, {
+                        'customer_id': customer_weird_2.id,
+                    }, {
+                        'email_from': 'record.multi.1@example.com, "Record Multi 2" <record.multi.2@example.com>',
+                    }, {
+                        'email_from': '"Formatted Record" <record.format@example.com>',
+                    }, {
+                        'email_from': '"Unicode Record" <record.😊@example.com>',
+                    }
+                ])
+                test_records = (
+                    record_p_mult + record_p_fmt + record_p_unic +
+                    record_p_case + record_p_weird + record_p_weird_2 +
+                    record_mult + record_fmt + record_unic
+                )
+                mailing = self.env['mailing.mailing'].create({
+                    'body_html': """<div><p>Hello ${object.name}</p>""",
+                    'mailing_domain': [('id', 'in', test_records.ids)],
+                    'mailing_model_id': self.env['ir.model']._get_id(dst_model),
+                    'mailing_type': 'mail',
+                    'name': 'SourceName',
+                    'preview': 'Hi ${object.name} :)',
+                    'reply_to_mode': 'thread',
+                    'subject': 'MailingSubject',
+                })
+
+                with self.mock_mail_gateway(mail_unlink_sent=False):
+                    mailing.action_send_mail()
+
+                # Difference in email, email_to_recipients and email_to_mail
+                # -> email: trace email: normalized, to ease its management, mainly technical
+                # -> email_to_mail: mail.mail email: email_to stored in outgoing mail.mail (can be multi)
+                # -> email_to_recipients: email_to for outgoing emails, list means several recipients
+                self.assertMailTraces(
+                    [
+                        {'email': 'customer.multi.1@example.com',
+                         'email_to_recipients': [[f'"{customer_mult.name}" <customer.multi.1@example.com>', f'"{customer_mult.name}" <customer.multi.2@example.com>']],
+                         'failure_type': False,
+                         'partner': customer_mult,
+                         'state': 'sent'},
+                        {'email': 'test.customer.format@example.com',
+                         # mail to avoids double encapsulation
+                         'email_to_recipients': [[f'"{customer_fmt.name}" <test.customer.format@example.com>']],
+                         'failure_type': False,
+                         'partner': customer_fmt,
+                         'state': 'sent'},
+                        {'email': 'test.customer.😊@example.com',
+                         # mail to avoids double encapsulation
+                         'email_to_recipients': [[f'"{customer_unic.name}" <test.customer.😊@example.com>']],
+                         'failure_type': False,
+                         'partner': customer_unic,
+                         'state': 'sent'},
+                        {'email': 'test.customer.case@example.com',
+                         'email_to_recipients': [[f'"{customer_case.name}" <test.customer.case@example.com>']],
+                         'failure_type': False,
+                         'partner': customer_case,
+                         'state': 'sent'},  # lower cased
+                        {'email': 'test.customer.weird@example.comweirdformat',
+                         'email_to_recipients': [[f'"{customer_weird.name}" <test.customer.weird@example.comweirdformat>']],
+                         'failure_type': False,
+                         'partner': customer_weird,
+                         'state': 'sent'},  # concatenates everything after domain
+                        {'email': 'test.customer.weird.2@example.com',
+                        'email_to_recipients': [[f'"{customer_weird_2.name}" <test.customer.weird.2@example.com>']],
+                         'failure_type': False,
+                         'partner': customer_weird_2,
+                         'state': 'sent'},
+                        {'email': 'record.multi.1@example.com',
+                         'email_to_mail': 'record.multi.1@example.com,record.multi.2@example.com',
+                         'email_to_recipients': [['record.multi.1@example.com', 'record.multi.2@example.com']],
+                         'failure_type': False,
+                         'state': 'sent'},
+                        {'email': 'record.format@example.com',
+                         'failure_type': False,
+                         'state': 'sent'},
+                        {'email': 'record.😊@example.com',
+                         'failure_type': False,
+                         'state': 'sent'},
+                    ],
+                    mailing,
+                    test_records,
+                    check_mail=True,
+                )
+
     @users('user_marketing')
     @mute_logger('odoo.addons.mail.models.mail_mail')
     def test_mailing_reply_to_mode_new(self):
diff --git a/addons/website_crm/views/website_visitor_views.xml b/addons/website_crm/views/website_visitor_views.xml
index 4674f2c9cffbc05f4d885dd9e2eb1e187ce03282..993a2f1025d1f6b231ad7d659eecf1f66b2186b3 100644
--- a/addons/website_crm/views/website_visitor_views.xml
+++ b/addons/website_crm/views/website_visitor_views.xml
@@ -60,10 +60,10 @@
                 <field name="lead_count"/>
             </field>
             <xpath expr="//div[@id='o_page_count']" position="after">
-                <div>Leads/Opportunities<span class="float-right font-weight-bold"><field name="lead_count"/></span></div>
+                <div groups="sales_team.group_sale_salesman">Leads/Opportunities<span class="float-right font-weight-bold"><field name="lead_count"/></span></div>
             </xpath>
             <xpath expr="//div[hasclass('w_visitor_kanban_actions_ungrouped')]" position="before">
-                <div class="col">
+                <div class="col" groups="sales_team.group_sale_salesman">
                     <b><field name="lead_count"/></b>
                     <div>Leads/Opportunities</div>
                 </div>
diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py
index ea62618405b6f6dd32fb42c67f29749df84567c1..6421f77e83c8b904002a2bba07308c0efe781ab5 100644
--- a/addons/website_event_track/models/event_track.py
+++ b/addons/website_event_track/models/event_track.py
@@ -5,7 +5,7 @@ from datetime import timedelta
 from pytz import utc
 from random import randint
 
-from odoo import api, fields, models
+from odoo import api, fields, models, tools
 from odoo.addons.http_routing.models.ir_http import slug
 from odoo.osv import expression
 from odoo.tools.mail import is_html_empty
@@ -394,13 +394,18 @@ class Track(models.Model):
             # we consider that posting a message with a specified recipient (not a follower, a specific one)
             # on a document without customer means that it was created through the chatter using
             # suggested recipients. This heuristic allows to avoid ugly hacks in JS.
-            new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.partner_email)
+            email_normalized = tools.email_normalize(self.partner_email)
+            new_partner = message.partner_ids.filtered(
+                lambda partner: partner.email == self.partner_email or (email_normalized and partner.email_normalized == email_normalized)
+            )
             if new_partner:
+                if new_partner[0].email_normalized:
+                    email_domain = ('partner_email', 'in', [new_partner[0].email, new_partner[0].email_normalized])
+                else:
+                    email_domain = ('partner_email', '=', new_partner[0].email)
                 self.search([
-                    ('partner_id', '=', False),
-                    ('partner_email', '=', new_partner.email),
-                    ('stage_id.is_cancel', '=', False),
-                ]).write({'partner_id': new_partner.id})
+                    ('partner_id', '=', False), email_domain, ('stage_id.is_cancel', '=', False),
+                ]).write({'partner_id': new_partner[0].id})
         return super(Track, self)._message_post_after_hook(message, msg_vals)
 
     # ------------------------------------------------------------
diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py
index 482be79bb6ba07f2fe4c7140ef4c9d2e9c0a48c5..4ad5f15d5ddc3e4fcadcaae28a3adb66a01e4700 100644
--- a/odoo/addons/base/models/ir_module.py
+++ b/odoo/addons/base/models/ir_module.py
@@ -147,6 +147,12 @@ STATES = [
     ('to install', 'To be installed'),
 ]
 
+XML_DECLARATION = (
+    '<?xml version='.encode('utf-8'),
+    '<?xml version='.encode('utf-16-be'),
+    '<?xml version='.encode('utf-16-le'),
+)
+
 class Module(models.Model):
     _name = "ir.module.module"
     _rec_name = "shortdesc"
@@ -180,6 +186,11 @@ class Module(models.Model):
             if path:
                 with tools.file_open(path, 'rb') as desc_file:
                     doc = desc_file.read()
+                    if not doc.startswith(XML_DECLARATION):
+                        try:
+                            doc = doc.decode('utf-8')
+                        except UnicodeDecodeError:
+                            pass
                     html = lxml.html.document_fromstring(doc)
                     for element, attribute, link, pos in html.iterlinks():
                         if element.get('src') and not '//' in element.get('src') and not 'static/' in element.get('src'):
diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py
index 101b15314dc3770398b0fad1bf7863ebd5ae81d3..5d8b93a774212d070af213f1b87967085b37380d 100644
--- a/odoo/addons/base/models/res_partner.py
+++ b/odoo/addons/base/models/res_partner.py
@@ -134,7 +134,7 @@ class Partner(models.Model):
     _description = 'Contact'
     _inherit = ['format.address.mixin', 'image.mixin']
     _name = "res.partner"
-    _order = "display_name"
+    _order = "display_name ASC, id DESC"
 
     def _default_category(self):
         return self.env['res.partner.category'].browse(self._context.get('category_id'))
@@ -379,11 +379,36 @@ class Partner(models.Model):
 
     @api.depends('name', 'email')
     def _compute_email_formatted(self):
+        """ Compute formatted email for partner, using formataddr. Be defensive
+        in computation, notably
+
+          * double format: if email already holds a formatted email like
+            'Name' <email@domain.com> we should not use it as it to compute
+            email formatted like "Name <'Name' <email@domain.com>>";
+          * multi emails: sometimes this field is used to hold several addresses
+            like email1@domain.com, email2@domain.com. We currently let this value
+            untouched, but remove any formatting from multi emails;
+          * invalid email: if something is wrong, keep it in email_formatted as
+            this eases management and understanding of failures at mail.mail,
+            mail.notification and mailing.trace level;
+          * void email: email_formatted is False, as we cannot do anything with
+            it;
+        """
+        self.email_formatted = False
         for partner in self:
-            if partner.email:
-                partner.email_formatted = tools.formataddr((partner.name or u"False", partner.email or u"False"))
-            else:
-                partner.email_formatted = ''
+            emails_normalized = tools.email_normalize_all(partner.email)
+            if emails_normalized:
+                # note: multi-email input leads to invalid email like "Name" <email1, email2>
+                # but this is current behavior in Odoo 14+ and some servers allow it
+                partner.email_formatted = tools.formataddr((
+                    partner.name or u"False",
+                    ','.join(emails_normalized)
+                ))
+            elif partner.email:
+                partner.email_formatted = tools.formataddr((
+                    partner.name or u"False",
+                    partner.email
+                ))
 
     @api.depends('is_company')
     def _compute_company_type(self):
@@ -699,7 +724,9 @@ class Partner(models.Model):
 
           * Raoul <raoul@grosbedon.fr>
           * "Raoul le Grand" <raoul@grosbedon.fr>
-          * Raoul raoul@grosbedon.fr (strange fault tolerant support from df40926d2a57c101a3e2d221ecfd08fbb4fea30e)
+          * Raoul raoul@grosbedon.fr (strange fault tolerant support from
+            df40926d2a57c101a3e2d221ecfd08fbb4fea30e now supported directly
+            in 'email_split_tuples';
 
         Otherwise: default, everything is set as the name. Starting from 13.3
         returned email will be normalized to have a coherent encoding.
@@ -709,12 +736,6 @@ class Partner(models.Model):
         if split_results:
             name, email = split_results[0]
 
-        if email and not name:
-            fallback_emails = tools.email_split(text.replace(' ', ','))
-            if fallback_emails:
-                email = fallback_emails[0]
-                name = text[:text.index(email)].replace('"', '').replace('<', '').strip()
-
         if email:
             email = tools.email_normalize(email)
         else:
diff --git a/odoo/addons/base/tests/test_base.py b/odoo/addons/base/tests/test_base.py
index f28d1f7c2e5c2c08f3602f9ba102121494692cb2..67dde0af0af1cf2b474187fa90c7a37da70e7877 100644
--- a/odoo/addons/base/tests/test_base.py
+++ b/odoo/addons/base/tests/test_base.py
@@ -5,6 +5,7 @@ import ast
 
 from odoo import SUPERUSER_ID
 from odoo.exceptions import UserError, ValidationError
+from odoo.tests import tagged
 from odoo.tests.common import TransactionCase, BaseCase
 from odoo.tools import mute_logger
 from odoo.tools.safe_eval import safe_eval, const_eval, expr_eval
@@ -73,6 +74,7 @@ SAMPLES = [
 ]
 
 
+@tagged('res_partner')
 class TestBase(TransactionCase):
 
     def _check_find_or_create(self, test_string, expected_name, expected_email, check_partner=False, should_create=False):
@@ -88,12 +90,13 @@ class TestBase(TransactionCase):
     def test_00_res_partner_name_create(self):
         res_partner = self.env['res.partner']
         parse = res_partner._parse_partner_name
-        for text, name, mail in SAMPLES:
-            self.assertEqual((name, mail.lower()), parse(text))
-            partner_id, dummy = res_partner.name_create(text)
-            partner = res_partner.browse(partner_id)
-            self.assertEqual(name or mail.lower(), partner.name)
-            self.assertEqual(mail.lower() or False, partner.email)
+        for text, expected_name, expected_mail in SAMPLES:
+            with self.subTest(text=text):
+                self.assertEqual((expected_name, expected_mail.lower()), parse(text))
+                partner_id, dummy = res_partner.name_create(text)
+                partner = res_partner.browse(partner_id)
+                self.assertEqual(expected_name or expected_mail.lower(), partner.name)
+                self.assertEqual(expected_mail.lower() or False, partner.email)
 
         # name_create supports default_email fallback
         partner = self.env['res.partner'].browse(
diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py
index 5806e258f6b318e2aa9a9ca52b511c9dee6dcc16..f17264ed00698d9bd5154fbe59c4369cd496aed9 100644
--- a/odoo/addons/base/tests/test_expression.py
+++ b/odoo/addons/base/tests/test_expression.py
@@ -1086,7 +1086,7 @@ class TestQueries(TransactionCase):
                     )
                 )
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             Model.search(domain)
 
@@ -1098,7 +1098,7 @@ class TestQueries(TransactionCase):
             SELECT "res_partner".id
             FROM "res_partner"
             WHERE (("res_partner"."active" = %s) AND ("res_partner"."name"::text LIKE %s))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             Model.search([('name', 'like', 'foo')])
 
@@ -1201,7 +1201,7 @@ class TestMany2one(TransactionCase):
             SELECT "res_partner".id
             FROM "res_partner"
             WHERE ("res_partner"."company_id" = %s)
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('company_id', '=', self.company.id)])
 
@@ -1213,7 +1213,7 @@ class TestMany2one(TransactionCase):
                 FROM "res_company"
                 WHERE ("res_company"."name"::text like %s)
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('company_id.name', 'like', self.company.name)])
 
@@ -1229,7 +1229,7 @@ class TestMany2one(TransactionCase):
                     WHERE ("res_partner"."name"::text LIKE %s)
                 ))
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)])
 
@@ -1245,7 +1245,7 @@ class TestMany2one(TransactionCase):
                 FROM "res_country"
                 WHERE ("res_country"."code"::text LIKE %s)
             )))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([
                 '|',
@@ -1264,7 +1264,7 @@ class TestMany2one(TransactionCase):
                 FROM "res_company"
                 WHERE ("res_company"."name"::text like %s)
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             company_ids = self.company._search([('name', 'like', self.company.name)], order='id')
             self.Partner.search([('company_id', 'in', company_ids)])
@@ -1280,7 +1280,7 @@ class TestMany2one(TransactionCase):
                 ORDER BY "res_company"."id"
                 LIMIT 1
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             company_ids = self.company._search([('name', 'like', self.company.name)], order='id', limit=1)
             self.Partner.search([('company_id', 'in', company_ids)])
@@ -1297,7 +1297,7 @@ class TestMany2one(TransactionCase):
             LEFT JOIN "res_company" AS "res_partner__company_id" ON
                 ("res_partner"."company_id" = "res_partner__company_id"."id")
             WHERE ("res_partner__company_id"."name"::text LIKE %s)
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('company_id.name', 'like', self.company.name)])
 
@@ -1311,7 +1311,7 @@ class TestMany2one(TransactionCase):
                 FROM "res_partner"
                 WHERE ("res_partner"."name"::text LIKE %s)
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)])
 
@@ -1330,7 +1330,7 @@ class TestMany2one(TransactionCase):
                     ("res_company"."partner_id" = "res_company__partner_id"."id")
                 WHERE ("res_company__partner_id"."name"::text LIKE %s)
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)])
 
@@ -1347,7 +1347,7 @@ class TestMany2one(TransactionCase):
             LEFT JOIN "res_partner" AS "res_partner__company_id__partner_id" ON
                 ("res_partner__company_id"."partner_id" = "res_partner__company_id__partner_id"."id")
             WHERE ("res_partner__company_id__partner_id"."name"::text LIKE %s)
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('company_id.partner_id.name', 'like', self.company.name)])
 
@@ -1369,7 +1369,7 @@ class TestMany2one(TransactionCase):
                 ("res_partner"."company_id" = "res_partner__company_id"."id")
             WHERE (("res_partner__company_id"."name"::text LIKE %s)
                 OR ("res_partner__country_id"."code"::text LIKE %s))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([
                 '|',
@@ -1388,7 +1388,7 @@ class TestMany2one(TransactionCase):
                 FROM "res_company"
                 WHERE ("res_company"."name"::text LIKE %s)
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('company_id', 'like', self.company.name)])
 
@@ -1417,7 +1417,7 @@ class TestOne2many(TransactionCase):
             WHERE ("res_partner"."id" IN (
                 SELECT "partner_id" FROM "res_partner_bank" WHERE "id" IN %s
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('bank_ids', 'in', self.partner.bank_ids.ids)])
 
@@ -1429,7 +1429,7 @@ class TestOne2many(TransactionCase):
                 FROM "res_partner_bank"
                 WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s)
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('bank_ids.sanitized_acc_number', 'like', '12')])
 
@@ -1445,7 +1445,7 @@ class TestOne2many(TransactionCase):
                     WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s)
                 )) AND "res_partner"."parent_id" IS NOT NULL
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('child_ids.bank_ids.sanitized_acc_number', 'like', '12')])
 
@@ -1462,7 +1462,7 @@ class TestOne2many(TransactionCase):
             WHERE ("res_partner"."id" IN (
                 SELECT "partner_id" FROM "res_partner_bank" WHERE "id" IN %s
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('bank_ids', 'in', self.partner.bank_ids.ids)])
 
@@ -1474,7 +1474,7 @@ class TestOne2many(TransactionCase):
                 FROM "res_partner_bank"
                 WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s)
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('bank_ids.sanitized_acc_number', 'like', '12')])
 
@@ -1490,7 +1490,7 @@ class TestOne2many(TransactionCase):
                 FROM "res_partner_bank"
                 WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s)
             )))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([
                 ('bank_ids.sanitized_acc_number', 'like', '12'),
@@ -1509,7 +1509,7 @@ class TestOne2many(TransactionCase):
                     WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s)
                 )) AND ("res_partner"."active" = %s))
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('child_ids.bank_ids.sanitized_acc_number', 'like', '12')])
 
@@ -1539,7 +1539,7 @@ class TestOne2many(TransactionCase):
                     ("res_partner"."name" != %s) OR "res_partner"."name" IS NULL
                 ))
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('child_ids.bank_ids.id', 'in', self.partner.bank_ids.ids)])
 
@@ -1565,7 +1565,7 @@ class TestOne2many(TransactionCase):
                     "res_partner"."active" = %s
                 ))
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('child_ids.state_id.country_id.code', 'like', 'US')])
 
@@ -1580,7 +1580,7 @@ class TestOne2many(TransactionCase):
                 FROM "res_partner_bank"
                 WHERE ("res_partner_bank"."sanitized_acc_number"::text LIKE %s)
             ))
-            ORDER BY "res_partner"."display_name"
+            ORDER BY "res_partner"."display_name"asc,"res_partner"."id"desc
         ''']):
             self.Partner.search([('bank_ids', 'like', '12')])
 
diff --git a/odoo/addons/base/tests/test_mail.py b/odoo/addons/base/tests/test_mail.py
index 01009f5d3c3b015e708a98295b42e5e793d7a212..3df1576325c9cad5ceeebd8f789345c8b47047dc 100644
--- a/odoo/addons/base/tests/test_mail.py
+++ b/odoo/addons/base/tests/test_mail.py
@@ -8,10 +8,11 @@ import email.message
 import re
 import threading
 
+from odoo.tests import tagged
 from odoo.tests.common import BaseCase, SavepointCase, TransactionCase
 from odoo.tools import (
     is_html_empty, html_sanitize, append_content_to_html, plaintext2html,
-    email_split,
+    email_normalize, email_split, email_split_and_format,
     misc, formataddr,
     prepend_html_content,
 )
@@ -388,10 +389,67 @@ class TestHtmlTools(BaseCase):
         self.assertEqual(result, "<html><body><div>test</div><div>test</div></body></html>")
 
 
+@tagged('mail_tools')
 class TestEmailTools(BaseCase):
     """ Test some of our generic utility functions for emails """
 
+    def test_email_normalize(self):
+        """ Test 'email_normalize'. Note that it is built on 'email_split' so
+        some use cases are already managed in 'test_email_split(_and_format)'
+        hence having more specific test cases here about normalization itself. """
+        format_name = 'My Super Prénom'
+        format_name_ascii = '=?utf-8?b?TXkgU3VwZXIgUHLDqW5vbQ==?='
+        sources = [
+            '"Super Déboulonneur" <deboulonneur@example.com>',  # formatted
+            'Déboulonneur deboulonneur@example.com',  # wrong formatting
+            'deboulonneur@example.com Déboulonneur',  # wrong formatting (happens, alas)
+            '"Super Déboulonneur" <DEBOULONNEUR@example.com>, "Super Déboulonneur 2" <deboulonneur2@EXAMPLE.com>',  # multi + case
+            ' Déboulonneur deboulonneur@example.com déboulonneur deboulonneur2@example.com',  # wrong formatting + wrong multi
+            '"Déboulonneur 😊" <deboulonneur.😊@example.com>',  # unicode in name and email left-part
+            '"Déboulonneur" <déboulonneur@examplé.com>',  # utf-8
+            '"Déboulonneur" <DéBoulonneur@Examplé.com>',  # utf-8
+        ]
+        expected_list = [
+            'deboulonneur@example.com',
+            'deboulonneur@example.com',
+            'deboulonneur@example.comdéboulonneur',
+            False,
+            '@example.com',  # funny
+            'deboulonneur.😊@example.com',
+            'déboulonneur@examplé.com',
+            'DéBoulonneur@examplé.com',
+        ]
+        expected_fmt_utf8_list = [
+            f'"{format_name}" <deboulonneur@example.com>',
+            f'"{format_name}" <deboulonneur@example.com>',
+            f'"{format_name}" <deboulonneur@example.comdéboulonneur>',
+            f'"{format_name}" <@>',
+            f'"{format_name}" <@example.com>',
+            f'"{format_name}" <deboulonneur.😊@example.com>',
+            f'"{format_name}" <déboulonneur@examplé.com>',
+            f'"{format_name}" <DéBoulonneur@examplé.com>',
+        ]
+        expected_fmt_ascii_list = [
+            f'{format_name_ascii} <deboulonneur@example.com>',
+            f'{format_name_ascii} <deboulonneur@example.com>',
+            f'{format_name_ascii} <deboulonneur@example.xn--comdboulonneur-ekb>',
+            f'{format_name_ascii} <@>',
+            f'{format_name_ascii} <@example.com>',
+            f'{format_name_ascii} <deboulonneur.😊@example.com>',
+            f'{format_name_ascii} <déboulonneur@xn--exampl-gva.com>',
+            f'{format_name_ascii} <DéBoulonneur@xn--exampl-gva.com>',
+        ]
+        for source, expected, expected_utf8_fmt, expected_ascii_fmt in zip(sources, expected_list, expected_fmt_utf8_list, expected_fmt_ascii_list):
+            with self.subTest(source=source):
+                self.assertEqual(email_normalize(source, force_single=True), expected)
+                # standard usage of formataddr
+                self.assertEqual(formataddr((format_name, (expected or '')), charset='utf-8'), expected_utf8_fmt)
+                # check using INDA at format time, using ascii charset as done when
+                # sending emails (see extract_rfc2822_addresses)
+                self.assertEqual(formataddr((format_name, (expected or '')), charset='ascii'), expected_ascii_fmt)
+
     def test_email_split(self):
+        """ Test 'email_split' """
         cases = [
             ("John <12345@gmail.com>", ['12345@gmail.com']),  # regular form
             ("d@x; 1@2", ['d@x', '1@2']),  # semi-colon + extra space
@@ -402,6 +460,51 @@ class TestEmailTools(BaseCase):
         for text, expected in cases:
             self.assertEqual(email_split(text), expected, 'email_split is broken')
 
+    def test_email_split_and_format(self):
+        """ Test 'email_split_and_format', notably in case of multi encapsulation
+        or multi emails. """
+        sources = [
+            'deboulonneur@example.com',
+            '"Super Déboulonneur" <deboulonneur@example.com>',  # formatted
+            # wrong formatting
+            'Déboulonneur <deboulonneur@example.com',  # with a final typo
+            'Déboulonneur deboulonneur@example.com',  # wrong formatting
+            'deboulonneur@example.com Déboulonneur',  # wrong formatting (happens, alas)
+            # multi
+            'Déboulonneur, deboulonneur@example.com',  # multi-like with errors
+            'deboulonneur@example.com, deboulonneur2@example.com',  # multi
+            ' Déboulonneur deboulonneur@example.com déboulonneur deboulonneur2@example.com',  # wrong formatting + wrong multi
+            # format / misc
+            '"Déboulonneur" <"Déboulonneur Encapsulated" <deboulonneur@example.com>>',  # double formatting
+            '"Super Déboulonneur" <deboulonneur@example.com>, "Super Déboulonneur 2" <deboulonneur2@example.com>',
+            '"Super Déboulonneur" <deboulonneur@example.com>, wrong, ',
+            '"Déboulonneur 😊" <deboulonneur@example.com>',  # unicode in name
+            '"Déboulonneur 😊" <deboulonneur.😊@example.com>',  # unicode in name and email left-part
+            '"Déboulonneur" <déboulonneur@examplé.com>',  # utf-8
+        ]
+        expected_list = [
+            ['deboulonneur@example.com'],
+            ['"Super Déboulonneur" <deboulonneur@example.com>'],
+            # wrong formatting
+            ['"Déboulonneur" <deboulonneur@example.com>'],
+            ['"Déboulonneur" <deboulonneur@example.com>'],  # extra part correctly considered as a name
+            ['deboulonneur@example.comDéboulonneur'],  # concatenated, not sure why
+            # multi
+            ['deboulonneur@example.com'],
+            ['deboulonneur@example.com', 'deboulonneur2@example.com'],
+            ['@example.com'],  # funny one
+            # format / misc
+            ['deboulonneur@example.com'],
+            ['"Super Déboulonneur" <deboulonneur@example.com>', '"Super Déboulonneur 2" <deboulonneur2@example.com>'],
+            ['"Super Déboulonneur" <deboulonneur@example.com>'],
+            ['"Déboulonneur 😊" <deboulonneur@example.com>'],
+            ['"Déboulonneur 😊" <deboulonneur.😊@example.com>'],
+            ['"Déboulonneur" <déboulonneur@examplé.com>'],
+        ]
+        for source, expected in zip(sources, expected_list):
+            with self.subTest(source=source):
+                self.assertEqual(email_split_and_format(source), expected)
+
     def test_email_formataddr(self):
         email = 'joe@example.com'
         email_idna = 'joe@examplé.com'
diff --git a/odoo/addons/base/tests/test_res_partner.py b/odoo/addons/base/tests/test_res_partner.py
index 28e6f172f9565a9d010bd9fb6201bb7591fffc5d..3160db404fae627b506e5975ee2884f78a5b7a17 100644
--- a/odoo/addons/base/tests/test_res_partner.py
+++ b/odoo/addons/base/tests/test_res_partner.py
@@ -4,10 +4,89 @@
 from odoo.tests import Form
 from odoo.tests.common import TransactionCase
 from odoo.exceptions import AccessError, UserError
+from odoo.tests import tagged
 
 
+@tagged('res_partner')
 class TestPartner(TransactionCase):
 
+    def test_email_formatted(self):
+        """ Test various combinations of name / email, notably to check result
+        of email_formatted field. """
+        # multi create
+        new_partners = self.env['res.partner'].create([{
+            'name': "Vlad the Impaler",
+            'email': f'vlad.the.impaler.{idx:02d}@example.com',
+        } for idx in range(2)])
+        self.assertEqual(
+            sorted(new_partners.mapped('email_formatted')),
+            sorted([f'"Vlad the Impaler" <vlad.the.impaler.{idx:02d}@example.com>' for idx in range(2)]),
+            'Email formatted should be "name" <email>'
+        )
+
+        # test name_create with formatting / multi emails
+        for source, (exp_name, exp_email, exp_email_formatted) in [
+            (
+                'Balázs <vlad.the.negociator@example.com>, vlad.the.impaler@example.com',
+                ("Balázs", "vlad.the.negociator@example.com", '"Balázs" <vlad.the.negociator@example.com>')
+            ),
+            (
+                'Balázs <vlad.the.impaler@example.com>',
+                ("Balázs", "vlad.the.impaler@example.com", '"Balázs" <vlad.the.impaler@example.com>')
+            ),
+        ]:
+            with self.subTest(source=source):
+                new_partner_id = self.env['res.partner'].name_create(source)[0]
+                new_partner = self.env['res.partner'].browse(new_partner_id)
+                self.assertEqual(new_partner.name, exp_name)
+                self.assertEqual(new_partner.email, exp_email)
+                self.assertEqual(
+                    new_partner.email_formatted, exp_email_formatted,
+                    'Name_create should take first found email'
+                )
+
+        # check name updates
+        for source, exp_email_formatted in [
+            ('Vlad the Impaler', '"Vlad the Impaler" <vlad.the.impaler@example.com>'),
+            ('Balázs', '"Balázs" <vlad.the.impaler@example.com>'),
+            ('Balázs <email.in.name@example.com>', '"Balázs <email.in.name@example.com>" <vlad.the.impaler@example.com>'),
+        ]:
+            with self.subTest(source=source):
+                new_partner.write({'name': source})
+                self.assertEqual(new_partner.email_formatted, exp_email_formatted)
+
+        # check email updates
+        new_partner.write({'name': 'Balázs'})
+        for source, exp_email_formatted in [
+            # encapsulated email
+            (
+                "Vlad the Impaler <vlad.the.impaler@example.com>",
+                '"Balázs" <vlad.the.impaler@example.com>'
+            ), (
+                '"Balázs" <balazs@adam.hu>',
+                '"Balázs" <balazs@adam.hu>'
+            ),
+            # multi email
+            (
+                "vlad.the.impaler@example.com, vlad.the.dragon@example.com",
+                '"Balázs" <vlad.the.impaler@example.com,vlad.the.dragon@example.com>'
+            ), (
+                "vlad.the.impaler.com, vlad.the.dragon@example.com",
+                '"Balázs" <vlad.the.dragon@example.com>'
+            ), (
+                'vlad.the.impaler.com, "Vlad the Dragon" <vlad.the.dragon@example.com>',
+                '"Balázs" <vlad.the.dragon@example.com>'
+            ),
+            # falsy emails
+            (False, False),
+            ('', False),
+            (' ', '"Balázs" <@ >'),
+            ('notanemail', '"Balázs" <@notanemail>'),
+        ]:
+            with self.subTest(source=source):
+                new_partner.write({'email': source})
+                self.assertEqual(new_partner.email_formatted, exp_email_formatted)
+
     def test_name_search(self):
         """ Check name_search on partner, especially with domain based on auto_join
         user_ids field. Check specific SQL of name_search correctly handle joined tables. """
diff --git a/odoo/tools/mail.py b/odoo/tools/mail.py
index 3562b1b726c327268e9914cee0dafd41957e236d..77983c8f71acab003b499d4641861afe8be18d15 100644
--- a/odoo/tools/mail.py
+++ b/odoo/tools/mail.py
@@ -459,7 +459,6 @@ mail_header_msgid_re = re.compile('<[^<>]+>')
 
 email_addr_escapes_re = re.compile(r'[\\"]')
 
-
 def generate_tracking_message_id(res_id):
     """Returns a string that can be used in the Message-ID RFC822 header field
 
@@ -515,14 +514,35 @@ def email_send(email_from, email_to, subject, body, email_cc=None, email_bcc=Non
 
 def email_split_tuples(text):
     """ Return a list of (name, email) addresse tuples found in ``text``"""
+    def _parse_based_on_spaces(pair):
+        """ With input 'name email@domain.com' (missing quotes for a formatting)
+        getaddresses returns ('', 'name email@domain.com). This when having no
+        name and an email a fallback to enhance parsing is to redo a getaddresses
+        by replacing spaces by commas. The new email will be split into sub pairs
+        allowing to find the email and name parts, allowing to make a new name /
+        email pair. Emails should not contain spaces thus this is coherent with
+        email formation. """
+        name, email = pair
+        if not name and email and ' ' in email:
+            inside_pairs = getaddresses([email.replace(' ', ',')])
+            name_parts, found_email = [], False
+            for pair in inside_pairs:
+                if pair[1] and '@' not in pair[1]:
+                    name_parts.append(pair[1])
+                if pair[1] and '@' in pair[1]:
+                    found_email = pair[1]
+            name, email = (' '.join(name_parts), found_email) if found_email else (name, email)
+        return (name, email)
+
     if not text:
         return []
-    return [(addr[0], addr[1]) for addr in getaddresses([text])
-                # getaddresses() returns '' when email parsing fails, and
-                # sometimes returns emails without at least '@'. The '@'
-                # is strictly required in RFC2822's `addr-spec`.
-                if addr[1]
-                if '@' in addr[1]]
+    return list(map(_parse_based_on_spaces, [
+        (addr[0], addr[1]) for addr in getaddresses([text])
+        # getaddresses() returns '' when email parsing fails, and
+        # sometimes returns emails without at least '@'. The '@'
+        # is strictly required in RFC2822's `addr-spec`.
+        if addr[1] and '@' in addr[1]
+    ]))
 
 def email_split(text):
     """ Return a list of the email addresses found in ``text`` """
@@ -537,20 +557,61 @@ def email_split_and_format(text):
         return []
     return [formataddr((name, email)) for (name, email) in email_split_tuples(text)]
 
-def email_normalize(text):
-    """ Sanitize and standardize email address entries.
-        A normalized email is considered as :
-        - having a left part + @ + a right part (the domain can be without '.something')
-        - being lower case
-        - having no name before the address. Typically, having no 'Name <>'
-        Ex:
-        - Possible Input Email : 'Name <NaMe@DoMaIn.CoM>'
-        - Normalized Output Email : 'name@domain.com'
+def email_normalize(text, force_single=True):
+    """ Sanitize and standardize email address entries. As of rfc5322 section
+    3.4.1 local-part is case-sensitive. However most main providers do consider
+    the local-part as case insensitive. With the introduction of smtp-utf8
+    within odoo, this assumption is certain to fall short for international
+    emails. We now consider that
+
+      * if local part is ascii: normalize still 'lower' ;
+      * else: use as it, SMTP-UF8 is made for non-ascii local parts;
+
+    Concerning domain part of the address, as of v14 international domain (IDNA)
+    are handled fine. The domain is always lowercase, lowering it is fine as it
+    is probably an error. With the introduction of IDNA, there is an encoding
+    that allow non-ascii characters to be encoded to ascii ones, using 'idna.encode'.
+
+    A normalized email is considered as :
+    - having a left part + @ + a right part (the domain can be without '.something')
+    - having no name before the address. Typically, having no 'Name <>'
+    Ex:
+    - Possible Input Email : 'Name <NaMe@DoMaIn.CoM>'
+    - Normalized Output Email : 'name@domain.com'
+
+    :param boolean force_single: if True, text should contain a single email
+      (default behavior in stable 14+). If more than one email is found no
+      normalized email is returned. If False the first found candidate is used
+      e.g. if email is 'tony@e.com, "Tony2" <tony2@e.com>', result is either
+      False (force_single=True), either 'tony@e.com' (force_single=False).
     """
     emails = email_split(text)
-    if not emails or len(emails) != 1:
+    if not emails or (len(emails) != 1 and force_single):
         return False
-    return emails[0].lower()
+
+    local_part, at, domain = emails[0].rpartition('@')
+    try:
+        local_part.encode('ascii')
+    except UnicodeEncodeError:
+        pass
+    else:
+        local_part = local_part.lower()
+
+    return local_part + at + domain.lower()
+
+def email_normalize_all(text):
+    """ Tool method allowing to extract email addresses from a text input and returning
+    normalized version of all found emails. If no email is found, a void list
+    is returned.
+
+    e.g. if email is 'tony@e.com, "Tony2" <tony2@e.com' returned result is ['tony@e.com, tony2@e.com']
+
+    :return list: list of normalized emails found in text
+    """
+    if not text:
+        return []
+    emails = email_split(text)
+    return list(filter(None, [email_normalize(email) for email in emails]))
 
 def email_escape_char(email_address):
     """ Escape problematic characters in the given email address string"""