# Part of Odoo. See LICENSE file for full copyright and licensing details.

import logging
import re
import textwrap
from binascii import Error as binascii_error
from collections import defaultdict

from odoo import _, api, fields, models, modules, tools
from odoo.exceptions import AccessError
from odoo.osv import expression
from odoo.tools import clean_context, format_list, groupby, SQL
from odoo.tools.misc import OrderedSet
from odoo.addons.mail.tools.discuss import Store

_logger = logging.getLogger(__name__)
_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])(?: data-filename="([^"]*)")?', re.I)


class Message(models.Model):
    """ Message model (from notifications to user input).

    Note:: State management / Error codes / Failure types summary

    * mail.notification
      * notification_status
        'ready', 'sent', 'bounce', 'exception', 'canceled'
      * notification_type
        'inbox', 'email', 'sms' (SMS addon), 'snail' (snailmail addon)
      * failure_type
        # generic
        unknown,
        # mail
        "mail_email_invalid", "mail_smtp", "mail_email_missing",
        "mail_from_invalid", "mail_from_missing",
        # sms (SMS addon)
        'sms_number_missing', 'sms_number_format', 'sms_credit',
        'sms_server', 'sms_acc'
        # snailmail (snailmail addon)
        'sn_credit', 'sn_trial', 'sn_price', 'sn_fields',
        'sn_format', 'sn_error'

    * mail.mail
      * state
        'outgoing', 'sent', 'received', 'exception', 'cancel'
      * failure_reason: text

    * sms.sms (SMS addon)
      * state
        'outgoing', 'sent', 'error', 'canceled'
      * error_code
        'sms_number_missing', 'sms_number_format', 'sms_credit',
        'sms_server', 'sms_acc',
        # mass mode specific codes
        'sms_blacklist', 'sms_duplicate'

    * snailmail.letter (snailmail addon)
      * state
        'pending', 'sent', 'error', 'canceled'
      * error_code
        'CREDIT_ERROR', 'TRIAL_ERROR', 'NO_PRICE_AVAILABLE', 'FORMAT_ERROR',
        'UNKNOWN_ERROR',

    See ``mailing.trace`` model in mass_mailing application for mailing trace
    information.
    """
    _name = 'mail.message'
    _inherit = ["bus.listener.mixin"]
    _description = 'Message'
    _order = 'id desc'
    _rec_name = 'record_name'

    @api.model
    def default_get(self, fields):
        res = super(Message, self).default_get(fields)
        missing_author = 'author_id' in fields and 'author_id' not in res
        missing_email_from = 'email_from' in fields and 'email_from' not in res
        if missing_author or missing_email_from:
            author_id, email_from = self.env['mail.thread']._message_compute_author(res.get('author_id'), res.get('email_from'), raise_on_email=False)
            if missing_email_from:
                res['email_from'] = email_from
            if missing_author:
                res['author_id'] = author_id
        return res

    # content
    subject = fields.Char('Subject')
    date = fields.Datetime('Date', default=fields.Datetime.now)
    body = fields.Html('Contents', default='', sanitize_style=True)
    preview = fields.Char(
        'Preview', compute='_compute_preview',
        help='The text-only beginning of the body used as email preview.')
    link_preview_ids = fields.One2many(
        'mail.link.preview', 'message_id', string='Link Previews',
        groups="base.group_erp_manager")
    reaction_ids = fields.One2many(
        'mail.message.reaction', 'message_id', string="Reactions",
        groups="base.group_system")
    # Attachments are linked to a document through model / res_id and to the message through this field.
    attachment_ids = fields.Many2many(
        'ir.attachment', 'message_attachment_rel',
        'message_id', 'attachment_id',
        string='Attachments')
    parent_id = fields.Many2one(
        'mail.message', 'Parent Message', index='btree_not_null', ondelete='set null')
    child_ids = fields.One2many('mail.message', 'parent_id', 'Child Messages')
    # related document
    model = fields.Char('Related Document Model')
    res_id = fields.Many2oneReference('Related Document ID', model_field='model')
    record_name = fields.Char('Message Record Name') # display_name of the related document
    record_alias_domain_id = fields.Many2one('mail.alias.domain', 'Alias Domain', ondelete='set null')
    record_company_id = fields.Many2one('res.company', 'Company', ondelete='set null')
    # characteristics
    message_type = fields.Selection([
        ('email', 'Incoming Email'),
        ('comment', 'Comment'),
        ('email_outgoing', 'Outgoing Email'),
        ('notification', 'System notification'),
        ('auto_comment', 'Automated Targeted Notification'),
        ('user_notification', 'User Specific Notification')],
        'Type', required=True, default='comment',
        help="Used to categorize message generator"
             "\n'email': generated by an incoming email e.g. mailgateway"
             "\n'comment': generated by user input e.g. through discuss or composer"
             "\n'email_outgoing': generated by a mailing"
             "\n'notification': generated by system e.g. tracking messages"
             "\n'auto_comment': generated by automated notification mechanism e.g. acknowledgment"
             "\n'user_notification': generated for a specific recipient"
        )
    subtype_id = fields.Many2one('mail.message.subtype', 'Subtype', ondelete='set null', index=True)
    mail_activity_type_id = fields.Many2one(
        'mail.activity.type', 'Mail Activity Type',
        index='btree_not_null', ondelete='set null')
    is_internal = fields.Boolean('Employee Only', help='Hide to public / portal users, independently from subtype configuration.')
    # origin
    email_from = fields.Char('From', help="Email address of the sender. This field is set when no matching partner is found and replaces the author_id field in the chatter.")
    author_id = fields.Many2one(
        'res.partner', 'Author', index=True, ondelete='set null',
        help="Author of the message. If not set, email_from may hold an email address that did not match any partner.")
    author_avatar = fields.Binary("Author's avatar", related='author_id.avatar_128', depends=['author_id'], readonly=False)
    author_guest_id = fields.Many2one(string="Guest", comodel_name='mail.guest')
    is_current_user_or_guest_author = fields.Boolean(compute='_compute_is_current_user_or_guest_author')
    # recipients: include inactive partners (they may have been archived after
    # the message was sent, but they should remain visible in the relation)
    partner_ids = fields.Many2many('res.partner', string='Recipients', context={'active_test': False})
    # list of partner having a notification. Caution: list may change over time because of notif gc cron.
    # mainly usefull for testing
    notified_partner_ids = fields.Many2many(
        'res.partner', 'mail_notification', string='Partners with Need Action',
        context={'active_test': False}, depends=['notification_ids'], copy=False)
    needaction = fields.Boolean(
        'Need Action', compute='_compute_needaction', search='_search_needaction')
    has_error = fields.Boolean(
        'Has error', compute='_compute_has_error', search='_search_has_error')
    # notifications
    notification_ids = fields.One2many(
        'mail.notification', 'mail_message_id', 'Notifications',
        auto_join=True, copy=False, depends=['notified_partner_ids'])
    # user interface
    starred_partner_ids = fields.Many2many(
        'res.partner', 'mail_message_res_partner_starred_rel', string='Favorited By')
    pinned_at = fields.Datetime('Pinned', help='Datetime at which the message has been pinned')
    starred = fields.Boolean(
        'Starred', compute='_compute_starred', search='_search_starred', compute_sudo=False,
        help='Current user has a starred notification linked to this message')
    # tracking
    tracking_value_ids = fields.One2many(
        'mail.tracking.value', 'mail_message_id',
        string='Tracking values',
        groups="base.group_system",
        help='Tracked values are stored in a separate model. This field allow to reconstruct '
             'the tracking and to generate statistics on the model.')
    # mail gateway
    reply_to_force_new = fields.Boolean(
        'No threading for answers',
        help='If true, answers do not go in the original document discussion thread. Instead, it will check for the reply_to in tracking message-id and redirected accordingly. This has an impact on the generated message-id.')
    message_id = fields.Char('Message-Id', help='Message unique identifier', index='btree', readonly=True, copy=False)
    reply_to = fields.Char('Reply-To', help='Reply email address. Setting the reply_to bypasses the automatic thread creation.')
    mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server')
    # send notification information (for resend / reschedule)
    email_layout_xmlid = fields.Char('Layout', copy=False)  # xml id of layout
    email_add_signature = fields.Boolean(default=True)
    # `test_adv_activity`, `test_adv_activity_full`, `test_message_assignation_inbox`,...
    # By setting an inverse for mail.mail_message_id, the number of SQL queries done by `modified` is reduced.
    # 'mail.mail' inherits from `mail.message`: `_inherits = {'mail.message': 'mail_message_id'}`
    # Therefore, when changing a field on `mail.message`, this triggers the modification of the same field on `mail.mail`
    # By setting up the inverse one2many, we avoid to have to do a search to find the mails linked to the `mail.message`
    # as the cache value for this inverse one2many is up-to-date.
    # Besides for new messages, and messages never sending emails, there was no mail, and it was searching for nothing.
    mail_ids = fields.One2many('mail.mail', 'mail_message_id', string='Mails', groups="base.group_system")

    @api.depends('body')
    def _compute_preview(self):
        """ Returns an un-formatted version of the message body. Output is capped
        at 100 chars with a ' [...]' suffix if applicable. It is the longest
        known mail client preview length (Outlook 2013)."""
        for message in self:
            plaintext_ct = tools.mail.html_to_inner_content(message.body)
            message.preview = textwrap.shorten(plaintext_ct, 190)

    @api.depends('author_id', 'author_guest_id')
    @api.depends_context('guest', 'uid')
    def _compute_is_current_user_or_guest_author(self):
        user = self.env.user
        guest = self.env['mail.guest']._get_guest_from_context()
        for message in self:
            if not user._is_public() and (message.author_id and message.author_id == user.partner_id):
                message.is_current_user_or_guest_author = True
            elif message.author_guest_id and message.author_guest_id == guest:
                message.is_current_user_or_guest_author = True
            else:
                message.is_current_user_or_guest_author = False

    def _compute_needaction(self):
        """ Need action on a mail.message = notified on my channel """
        my_messages = self.env['mail.notification'].sudo().search([
            ('mail_message_id', 'in', self.ids),
            ('res_partner_id', '=', self.env.user.partner_id.id),
            ('is_read', '=', False)]).mapped('mail_message_id')
        for message in self:
            message.needaction = message in my_messages

    @api.model
    def _search_needaction(self, operator, operand):
        is_read = False if operator == '=' and operand else True
        notification_ids = self.env['mail.notification']._search([('res_partner_id', '=', self.env.user.partner_id.id), ('is_read', '=', is_read)])
        return [('notification_ids', 'in', notification_ids)]

    def _compute_has_error(self):
        error_from_notification = self.env['mail.notification'].sudo().search([
            ('mail_message_id', 'in', self.ids),
            ('notification_status', 'in', ('bounce', 'exception'))]).mapped('mail_message_id')
        for message in self:
            message.has_error = message in error_from_notification

    def _search_has_error(self, operator, operand):
        if operator == '=' and operand:
            return [('notification_ids.notification_status', 'in', ('bounce', 'exception'))]
        return ['!', ('notification_ids.notification_status', 'in', ('bounce', 'exception'))]  # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False"

    @api.depends('starred_partner_ids')
    @api.depends_context('uid')
    def _compute_starred(self):
        """ Compute if the message is starred by the current user. """
        # TDE FIXME: use SQL
        starred = self.sudo().filtered(lambda msg: self.env.user.partner_id in msg.starred_partner_ids)
        for message in self:
            message.starred = message in starred

    @api.model
    def _search_starred(self, operator, operand):
        if operator == '=' and operand:
            return [('starred_partner_ids', 'in', [self.env.user.partner_id.id])]
        return [('starred_partner_ids', 'not in', [self.env.user.partner_id.id])]

    # ------------------------------------------------------
    # CRUD / ORM
    # ------------------------------------------------------

    def init(self):
        self._cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
        if not self._cr.fetchone():
            self._cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
        self._cr.execute("""CREATE INDEX IF NOT EXISTS mail_message_model_res_id_id_idx ON mail_message (model, res_id, id)""")

    @api.model
    def _search(self, domain, offset=0, limit=None, order=None):
        """ Override that adds specific access rights of mail.message, to remove
        ids uid could not see according to our custom rules. Please refer to
        _check_access() for more details about those rules.

        Non employees users see only message with subtype (aka do not see
        internal logs).

        After having received ids of a classic search, keep only:
        - if author_id == pid, uid is the author, OR
        - uid belongs to a notified channel, OR
        - uid is in the specified recipients, OR
        - uid has a notification on the message
        - otherwise: remove the id
        """
        # Rules do not apply to administrator
        if self.env.is_superuser():
            return super()._search(domain, offset, limit, order)

        # Non-employee see only messages with a subtype and not internal
        if not self.env.user._is_internal():
            domain = self._get_search_domain_share() + domain

        # make the search query with the default rules
        query = super()._search(domain, offset, limit, order)

        # retrieve matching records and determine which ones are truly accessible
        self.flush_model(['model', 'res_id', 'author_id', 'message_type', 'partner_ids'])
        self.env['mail.notification'].flush_model(['mail_message_id', 'res_partner_id'])

        pid = self.env.user.partner_id.id
        ids = []
        allowed_ids = set()
        model_ids = defaultdict(lambda: defaultdict(set))

        rel_alias = query.make_alias(self._table, 'partner_ids')
        query.add_join("LEFT JOIN", rel_alias, 'mail_message_res_partner_rel', SQL(
            "%s = %s AND %s = %s",
            SQL.identifier(self._table, 'id'),
            SQL.identifier(rel_alias, 'mail_message_id'),
            SQL.identifier(rel_alias, 'res_partner_id'),
            pid,
        ))
        notif_alias = query.make_alias(self._table, 'notification_ids')
        query.add_join("LEFT JOIN", notif_alias, 'mail_notification', SQL(
            "%s = %s AND %s = %s",
            SQL.identifier(self._table, 'id'),
            SQL.identifier(notif_alias, 'mail_message_id'),
            SQL.identifier(notif_alias, 'res_partner_id'),
            pid,
        ))
        self.env.cr.execute(query.select(
            SQL.identifier(self._table, 'id'),
            SQL.identifier(self._table, 'model'),
            SQL.identifier(self._table, 'res_id'),
            SQL.identifier(self._table, 'author_id'),
            SQL.identifier(self._table, 'message_type'),
            SQL(
                "COALESCE(%s, %s)",
                SQL.identifier(rel_alias, 'res_partner_id'),
                SQL.identifier(notif_alias, 'res_partner_id'),
            ),
        ))
        for id_, model, res_id, author_id, message_type, partner_id in self.env.cr.fetchall():
            ids.append(id_)
            if author_id == pid:
                allowed_ids.add(id_)
            elif partner_id == pid:
                allowed_ids.add(id_)
            elif model and res_id and message_type != 'user_notification':
                model_ids[model][res_id].add(id_)

        allowed_ids.update(self._find_allowed_doc_ids(model_ids))
        allowed = self.browse(id_ for id_ in ids if id_ in allowed_ids)
        return allowed._as_query(order)

    @api.model
    def _find_allowed_model_wise(self, doc_model, doc_dict):
        doc_ids = list(doc_dict)
        allowed_doc_ids = self.env[doc_model].with_context(active_test=False).search([('id', 'in', doc_ids)]).ids
        return set([message_id for allowed_doc_id in allowed_doc_ids for message_id in doc_dict[allowed_doc_id]])

    @api.model
    def _find_allowed_doc_ids(self, model_ids):
        IrModelAccess = self.env['ir.model.access']
        allowed_ids = set()
        for doc_model, doc_dict in model_ids.items():
            if not IrModelAccess.check(doc_model, 'read', False):
                continue
            allowed_ids |= self._find_allowed_model_wise(doc_model, doc_dict)
        return allowed_ids

    def _check_access(self, operation: str) -> tuple | None:
        """ Access rules of mail.message:
            - read: if
                - author_id == pid, uid is the author OR
                - create_uid == uid, uid is the creator OR
                - uid is in the recipients (partner_ids) OR
                - uid has been notified (needaction) OR
                - uid have read access to the related document if model, res_id
                - otherwise: raise
            - create: if
                - no model, no res_id (private message) OR
                - pid in message_follower_ids if model, res_id OR
                - uid can read the parent OR
                - uid have write or create access on the related document if model, res_id, OR
                - otherwise: raise
            - write: if
                - author_id == pid, uid is the author, OR
                - uid is in the recipients (partner_ids) OR
                - uid has write or create access on the related document if model, res_id
                - otherwise: raise
            - unlink: if
                - uid has write or create access on the related document
                - otherwise: raise

        Specific case: non employee users see only messages with subtype (aka do
        not see internal logs).
        """
        result = super()._check_access(operation)
        if not self:
            return result

        # discard forbidden records, and check remaining ones
        messages = self - result[0] if result else self
        if messages and (forbidden := messages._get_forbidden_access(operation)):
            if result:
                result = (result[0] + forbidden, result[1])
            else:
                result = (forbidden, lambda: forbidden._make_access_error(operation))
        return result

    def _get_forbidden_access(self, operation: str) -> api.Self:
        """ Return the subset of ``self`` that does not satisfy the specific
        conditions for messages.
        """
        forbidden = self.browse()

        # Non employees see only messages with a subtype (aka, not internal logs)
        if not self.env.user._is_internal():
            rows = self.env.execute_query(SQL(
                ''' SELECT message.id
                    FROM "mail_message" AS message
                    LEFT JOIN "mail_message_subtype" as subtype ON message.subtype_id = subtype.id
                    WHERE message.id = ANY (%s)
                        AND message.message_type = 'comment'
                        AND (message.is_internal IS TRUE OR message.subtype_id IS NULL OR subtype.internal IS TRUE)
                ''',
                self.ids,
            ))
            if rows:
                internal = self.browse(id_ for [id_] in rows)
                forbidden += internal
                self -= internal  # noqa: PLW0642
            if not self:
                return forbidden

        # Read the value of messages in order to determine their accessibility.
        # The values are put in 'messages_to_check', and entries are popped
        # once we know they are accessible. At the end, the remaining entries
        # are the invalid ones.
        self.flush_recordset(['model', 'res_id', 'author_id', 'create_uid', 'parent_id', 'message_type', 'partner_ids'])
        self.env['mail.notification'].flush_model(['mail_message_id', 'res_partner_id'])

        if operation in ('read', 'write'):
            query = SQL(
                """ SELECT m.id, m.model, m.res_id, m.author_id, m.create_uid, m.parent_id,
                        bool_or(partner_rel.res_partner_id IS NOT NULL OR needaction_rel.res_partner_id IS NOT NULL) AS notified,
                        m.message_type
                    FROM "mail_message" m
                    LEFT JOIN "mail_message_res_partner_rel" partner_rel
                        ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %(pid)s
                    LEFT JOIN "mail_notification" needaction_rel
                        ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %(pid)s
                    WHERE m.id = ANY(%(ids)s)
                    GROUP BY m.id
                """,
                pid=self.env.user.partner_id.id, ids=self.ids,
            )
        elif operation in ('create', 'unlink'):
            query = SQL(
                """ SELECT id, model, res_id, author_id, parent_id, message_type
                    FROM "mail_message"
                    WHERE id = ANY(%s)
                """, self.ids,
            )
        else:
            raise ValueError(_('Wrong operation name (%s)', operation))

        # trick: messages_to_check doesn't contain missing records from messages
        messages_to_check = {
            values['id']: values
            for values in self.env.execute_query_dict(query)
        }

        # Author condition (READ, WRITE, CREATE (private))
        partner_id = self.env.user.partner_id.id
        if operation == 'read':
            for mid, message in list(messages_to_check.items()):
                if (message.get('author_id') == partner_id
                        or message.get('create_uid') == self.env.uid):
                    messages_to_check.pop(mid)
        elif operation == 'write':
            for mid, message in list(messages_to_check.items()):
                if message.get('author_id') == partner_id:
                    messages_to_check.pop(mid)
        elif operation == 'create':
            for mid, message in list(messages_to_check.items()):
                if not self.is_thread_message(message):
                    messages_to_check.pop(mid)

        if not messages_to_check:
            return forbidden

        # Recipients condition, for read and write (partner_ids)
        # keep on top, usefull for systray notifications
        if operation in ('read', 'write'):
            for mid, message in list(messages_to_check.items()):
                if message.get('notified'):
                    messages_to_check.pop(mid)
            if not messages_to_check:
                return forbidden

        # CRUD: Access rights related to the document
        # {document_model_name: {document_id: message_ids}}
        model_docid_msgids = defaultdict(lambda: defaultdict(list))
        for mid, message in messages_to_check.items():
            if (message.get('model') and message.get('res_id') and
                    message.get('message_type') != 'user_notification'):
                model_docid_msgids[message['model']][message['res_id']].append(mid)

        for model, docid_msgids in model_docid_msgids.items():
            documents = self.env[model].browse(docid_msgids)
            if hasattr(documents, '_get_mail_message_access'):
                doc_operation = documents._get_mail_message_access(docid_msgids, operation)  # why not giving model here?
            else:
                doc_operation = self.env['mail.thread']._get_mail_message_access(docid_msgids, operation, model_name=model)
            doc_result = documents._check_access(doc_operation)
            forbidden_doc_ids = set(doc_result[0]._ids) if doc_result else set()
            for doc_id, msg_ids in docid_msgids.items():
                if doc_id not in forbidden_doc_ids:
                    for mid in msg_ids:
                        messages_to_check.pop(mid)

        if not messages_to_check:
            return forbidden

        # Parent condition, for create (check for received notifications for the created message parent)
        if operation == 'create':
            parent_ids_msg_ids = defaultdict(list)
            for mid, message in messages_to_check.items():
                if message.get('parent_id'):
                    parent_ids_msg_ids[message['parent_id']].append(mid)
            if parent_ids_msg_ids:
                query = SQL(
                    """ SELECT m.id
                        FROM "mail_message" m
                        JOIN "mail_message_res_partner_rel" partner_rel
                            ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %s
                        WHERE m.id = ANY(%s) """,
                    self.env.user.partner_id.id, list(parent_ids_msg_ids),
                )
                for [parent_id] in self.env.execute_query(query):
                    for mid in parent_ids_msg_ids[parent_id]:
                        messages_to_check.pop(mid)

            if not messages_to_check:
                return forbidden

            # Recipients condition for create (message_follower_ids)
            for model, docid_msgids in model_docid_msgids.items():
                domain = [
                    ('res_model', '=', model),
                    ('res_id', 'in', list(docid_msgids)),
                    ('partner_id', '=', self.env.user.partner_id.id),
                ]
                followers = self.env['mail.followers'].sudo().search_fetch(domain, ['res_id'])
                for follower in followers:
                    for mid in docid_msgids[follower.res_id]:
                        messages_to_check.pop(mid)

            if not messages_to_check:
                return forbidden

        forbidden += self.browse(messages_to_check)
        return forbidden

    def _make_access_error(self, operation: str) -> AccessError:
        return AccessError(_(
            "The requested operation cannot be completed due to security restrictions. "
            "Please contact your system administrator.\n\n"
            "(Document type: %(type)s, Operation: %(operation)s)\n\n"
            "Records: %(records)s, User: %(user)s",
            type=self._description,
            operation=operation,
            records=self.ids[:6],
            user=self.env.uid,
        ))

    @api.model
    def _get_with_access(self, message_id, operation, **kwargs):
        """Return the message with the given id if it exists and if the current
        user can access it for the given operation."""
        message = self.browse(message_id).exists()
        if not message:
            return message

        if self.env.user._is_public() and self.env["mail.guest"]._get_guest_from_context():
            # Don't check_access_rights for public user with a guest, as the rules are
            # incorrect due to historically having no reason to allow operations on messages to
            # public user before the introduction of guests. Even with ignoring the rights,
            # check_access_rule and its sub methods are already covering all the cases properly.
            if not message.sudo(False)._get_forbidden_access(operation):
                return message
        else:
            if message.sudo(False).has_access(operation):
                return message

        if message.model and message.res_id:
            mode = self.env[message.model]._get_mail_message_access([message.res_id], operation)
            if self.env[message.model]._get_thread_with_access(message.res_id, mode, **kwargs):
                return message

        return self.browse()

    @api.model_create_multi
    def create(self, values_list):
        tracking_values_list = []
        for values in values_list:
            if 'email_from' not in values:  # needed to compute reply_to
                _author_id, email_from = self.env['mail.thread']._message_compute_author(values.get('author_id'), email_from=None, raise_on_email=False)
                values['email_from'] = email_from
            if not values.get('message_id'):
                values['message_id'] = self._get_message_id(values)
            if 'reply_to' not in values:
                values['reply_to'] = self._get_reply_to(values)
            if 'record_name' not in values and 'default_record_name' not in self.env.context:
                values['record_name'] = self._get_record_name(values)

            if not values.get('attachment_ids'):
                values['attachment_ids'] = []
            # extract base64 images
            if 'body' in values:
                Attachments = self.env['ir.attachment'].with_context(clean_context(self._context))
                data_to_url = {}
                def base64_to_boundary(match):
                    key = match.group(2)
                    if not data_to_url.get(key):
                        name = match.group(4) if match.group(4) else 'image%s' % len(data_to_url)
                        try:
                            attachment = Attachments.create({
                                'name': name,
                                'datas': match.group(2),
                                'res_model': values.get('model'),
                                'res_id': values.get('res_id'),
                            })
                        except binascii_error:
                            _logger.warning("Impossible to create an attachment out of badly formated base64 embedded image. Image has been removed.")
                            return match.group(3)  # group(3) is the url ending single/double quote matched by the regexp
                        else:
                            attachment.generate_access_token()
                            values['attachment_ids'].append((4, attachment.id))
                            data_to_url[key] = ['/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token), name]
                    return '%s%s alt="%s"' % (data_to_url[key][0], match.group(3), data_to_url[key][1])
                values['body'] = _image_dataurl.sub(base64_to_boundary, values['body'] or '')

            # delegate creation of tracking after the create as sudo to avoid access rights issues
            tracking_values_list.append(values.pop('tracking_value_ids', False))

        messages = super(Message, self).create(values_list)

        # link back attachments to records, to filter out attachments linked to
        # the same records as the message (considered as ok if message is ok)
        # and check rights on other documents
        attachments_tocheck = self.env['ir.attachment']
        doc_to_attachment_ids = defaultdict(set)
        if all(isinstance(command, int) or command[0] in (4, 6)
               for values in values_list
               for command in values['attachment_ids']):
            for values in values_list:
                message_attachment_ids = set()
                for command in values['attachment_ids']:
                    if isinstance(command, int):
                        message_attachment_ids.add(command)
                    elif command[0] == 6:
                        message_attachment_ids |= set(command[2])
                    else:  # command[0] == 4:
                        message_attachment_ids.add(command[1])
                if message_attachment_ids:
                    key = (values.get('model'), values.get('res_id'))
                    doc_to_attachment_ids[key] |= message_attachment_ids

            attachment_ids_all = {
                attachment_id
                for doc_attachment_ids in doc_to_attachment_ids
                for attachment_id in doc_attachment_ids
            }
            AttachmentSudo = self.env['ir.attachment'].sudo().with_prefetch(list(attachment_ids_all))
            for (model, res_id), doc_attachment_ids in doc_to_attachment_ids.items():
                # check only attachments belonging to another model, access already
                # checked on message for other attachments
                attachments_tocheck += AttachmentSudo.browse(doc_attachment_ids).filtered(
                    lambda att: att.res_model != model or att.res_id != res_id
                ).sudo(False)
        else:
            attachments_tocheck = messages.attachment_ids  # fallback on read if any unknown command
        if attachments_tocheck:
            attachments_tocheck.check('read')

        for message, values, tracking_values_cmd in zip(messages, values_list, tracking_values_list):
            if tracking_values_cmd:
                vals_lst = [dict(cmd[2], mail_message_id=message.id) for cmd in tracking_values_cmd if len(cmd) == 3 and cmd[0] == 0]
                other_cmd = [cmd for cmd in tracking_values_cmd if len(cmd) != 3 or cmd[0] != 0]
                if vals_lst:
                    self.env['mail.tracking.value'].sudo().create(vals_lst)
                if other_cmd:
                    message.sudo().write({'tracking_value_ids': tracking_values_cmd})

            if message.is_thread_message(values):
                message._invalidate_documents(values.get('model'), values.get('res_id'))

        return messages

    def read(self, fields=None, load='_classic_read'):
        """ Override to explicitely call check_access(), that is not called
            by the ORM. It instead directly fetches ir.rules and apply them. """
        self.check_access('read')
        return super(Message, self).read(fields=fields, load=load)

    def fetch(self, field_names):
        # This freaky hack is aimed at reading data without the overhead of
        # checking that "self" is accessible, which is already done above in
        # methods read() and _search(). It reproduces the existing behavior
        # before the introduction of method fetch(), where the low-lever
        # reading method _read() did not enforce any actual permission.
        self = self.sudo()
        return super().fetch(field_names)

    def write(self, vals):
        record_changed = 'model' in vals or 'res_id' in vals
        if record_changed and not self.env.is_system():
            raise AccessError(_("Only administrators can modify 'model' and 'res_id' fields."))
        if record_changed or 'message_type' in vals:
            self._invalidate_documents()
        res = super(Message, self).write(vals)
        if vals.get('attachment_ids'):
            for mail in self:
                mail.attachment_ids.check(mode='read')
        if 'notification_ids' in vals or record_changed:
            self._invalidate_documents()
        return res

    def unlink(self):
        # cascade-delete attachments that are directly attached to the message (should only happen
        # for mail.messages that act as parent for a standalone mail.mail record).
        # the cache of the related document doesn't need to be invalidate (see @_invalidate_documents)
        # because the unlink method invalidates the whole cache anyway
        if not self:
            return True
        self.check_access('unlink')
        self.mapped('attachment_ids').filtered(
            lambda attach: attach.res_model == self._name and (attach.res_id in self.ids or attach.res_id == 0)
        ).unlink()
        messages_by_partner = defaultdict(lambda: self.env['mail.message'])
        partners_with_user = self.partner_ids.filtered('user_ids')
        for elem in self:
            for partner in (
                elem.partner_ids & partners_with_user | elem.notification_ids.author_id
            ):
                messages_by_partner[partner] |= elem
        # Notify front-end of messages deletion for partners having a user
        for partner, messages in messages_by_partner.items():
            partner._bus_send("mail.message/delete", {"message_ids": messages.ids})
        return super(Message, self).unlink()

    def export_data(self, fields_to_export):
        if not self.env.is_admin():
            raise AccessError(_("Only administrators are allowed to export mail message"))

        return super(Message, self).export_data(fields_to_export)

    # ------------------------------------------------------
    # ACTIONS
    # ----------------------------------------------------

    def action_open_document(self):
        """ Opens the related record based on the model and ID """
        self.ensure_one()
        return {
            'res_id': self.res_id,
            'res_model': self.model,
            'target': 'current',
            'type': 'ir.actions.act_window',
            'view_mode': 'form',
        }

    # ------------------------------------------------------
    # DISCUSS API
    # ------------------------------------------------------

    @api.model
    def mark_all_as_read(self, domain=None):
        # not really efficient method: it does one db request for the
        # search, and one for each message in the result set is_read to True in the
        # current notifications from the relation.
        notif_domain = [
            ('res_partner_id', '=', self.env.user.partner_id.id),
            ('is_read', '=', False)]
        if domain:
            messages = self.search(domain)
            messages.set_message_done()
            return messages.ids

        notifications = self.env['mail.notification'].sudo().search_fetch(notif_domain, ['mail_message_id'])
        notifications.write({'is_read': True})

        self.env.user._bus_send(
            "mail.message/mark_as_read",
            {
                "message_ids": notifications.mail_message_id.ids,
                "needaction_inbox_counter": self.env.user.partner_id._get_needaction_count(),
            },
        )

    def set_message_done(self):
        """ Remove the needaction from messages for the current partner. """
        partner_id = self.env.user.partner_id
        notifications = self.env['mail.notification'].sudo().search_fetch([
            ('mail_message_id', 'in', self.ids),
            ('res_partner_id', '=', partner_id.id),
            ('is_read', '=', False),
        ], ['mail_message_id'])
        if not notifications:
            return
        notifications.write({'is_read': True})
        # notifies changes in messages through the bus.
        self.env.user._bus_send(
            "mail.message/mark_as_read",
            {
                "message_ids": notifications.mail_message_id.ids,
                "needaction_inbox_counter": self.env.user.partner_id._get_needaction_count(),
            },
        )

    @api.model
    def unstar_all(self):
        """ Unstar messages for the current partner. """
        partner = self.env.user.partner_id
        starred_messages = self.search([('starred_partner_ids', 'in', partner.id)])
        partner.starred_message_ids -= starred_messages
        self.env.user._bus_send(
            "mail.message/toggle_star", {"message_ids": starred_messages.ids, "starred": False}
        )

    def toggle_message_starred(self):
        """ Toggle messages as (un)starred. Technically, the notifications related
            to uid are set to (un)starred.
        """
        self.ensure_one()
        # a user should always be able to star a message they can read
        self.check_access('read')
        starred = not self.starred
        partner = self.env.user.partner_id
        if starred:
            partner.starred_message_ids |= self
        else:
            partner.starred_message_ids -= self
        self.env.user._bus_send(
            "mail.message/toggle_star", {"message_ids": [self.id], "starred": starred}
        )
        return Store(self, {"starred": self.starred}).get_result()

    def _message_reaction(self, content, action, partner, guest, store: Store = None):
        self.ensure_one()
        # search for existing reaction
        domain = [
            ("message_id", "=", self.id),
            ("partner_id", "=", partner.id),
            ("guest_id", "=", guest.id),
            ("content", "=", content),
        ]
        reaction = self.env["mail.message.reaction"].search(domain)
        # create/unlink reaction if necessary
        if action == "add" and not reaction:
            create_values = {
                "message_id": self.id,
                "content": content,
                "partner_id": partner.id,
                "guest_id": guest.id,
            }
            self.env["mail.message.reaction"].create(create_values)
        if action == "remove" and reaction:
            reaction.unlink()
        if store:
            # fill the store to use for non logged in portal users in mail_message_reaction()
            self._reaction_group_to_store(store, content)
        # send the reaction group to bus for logged in users
        self._bus_send_reaction_group(content)

    def _bus_send_reaction_group(self, content):
        store = Store()
        self._reaction_group_to_store(store, content)
        self._bus_send_store(store)

    def _reaction_group_to_store(self, store: Store, content):
        group_domain = [("message_id", "=", self.id), ("content", "=", content)]
        reactions = self.env["mail.message.reaction"].search(group_domain)
        reaction_group = (
            Store.many(reactions, "ADD")
            if reactions
            else [("DELETE", {"message": self.id, "content": content})]
        )
        store.add(self, {"reactions": reaction_group})

    # ------------------------------------------------------
    # MESSAGE READ / FETCH / FAILURE API
    # ------------------------------------------------------

    def _records_by_model_name(self):
        ids_by_model = defaultdict(OrderedSet)
        prefetch_ids_by_model = defaultdict(OrderedSet)
        prefetch_messages = self | self.browse(self._prefetch_ids)
        for message in prefetch_messages.filtered(lambda m: m.model and m.res_id):
            target = ids_by_model if message in self else prefetch_ids_by_model
            target[message.model].add(message.res_id)
        return {
            model_name: self.env[model_name]
            .browse(ids)
            .with_prefetch(tuple(ids_by_model[model_name] | prefetch_ids_by_model[model_name]))
            for model_name, ids in ids_by_model.items()
        }

    def _record_by_message(self):
        records_by_model_name = self._records_by_model_name()
        return {
            message: self.env[message.model]
            .browse(message.res_id)
            .with_prefetch(records_by_model_name[message.model]._prefetch_ids)
            for message in self.filtered(lambda m: m.model and m.res_id)
        }

    def _to_store(
        self,
        store: Store,
        /,
        *,
        fields=None,
        format_reply=True,
        msg_vals=None,
        for_current_user=False,
        add_followers=False,
        followers=None,
    ):
        """Add the messages to the given store.

        :param format_reply: if True, also get data about the parent message if it exists.
            Only makes sense for discuss channel.

        :param msg_vals: dictionary of values used to create the message. If
          given it may be used to access values related to ``message`` without
          accessing it directly. It lessens query count in some optimized use
          cases by avoiding access message content in db;

        :param for_current_user: if True, get extra fields only relevant to the current user.
            When this param is set, the result should not be broadcasted to other users!

        :param add_followers: if True, also add followers of the current user for each thread of
            each message. Only applicable if ``for_current_user`` is also True.

        :param followers: if given, use this pre-computed list of followers instead of fetching
            them. It lessen query count in some optimized use cases.
            Only applicable if ``add_followers`` is True.
        """
        if fields is None:
            fields = [
                "body",
                "create_date",
                "date",
                "message_type",
                "model",  # keep for iOS app
                "pinned_at",
                "res_id",  # keep for iOS app
                "subject",
                "write_date",
            ]
        com_id = self.env["ir.model.data"]._xmlid_to_res_id("mail.mt_comment")
        note_id = self.env["ir.model.data"]._xmlid_to_res_id("mail.mt_note")
        # fetch scheduled notifications once, only if msg_vals is not given to
        # avoid useless queries when notifying Inbox right after a message_post
        scheduled_dt_by_msg_id = {}
        if msg_vals:
            scheduled_dt_by_msg_id = {msg.id: msg_vals.get("scheduled_date") for msg in self}
        elif self:
            schedulers = (
                self.env["mail.message.schedule"]
                .sudo()
                .search([("mail_message_id", "in", self.ids)])
            )
            for scheduler in schedulers:
                scheduled_dt_by_msg_id[scheduler.mail_message_id.id] = scheduler.scheduled_datetime
        record_by_message = self._record_by_message()
        records = record_by_message.values()
        non_channel_records = filter(lambda record: record._name != "discuss.channel", records)
        if for_current_user and add_followers and non_channel_records:
            if followers is None:
                domain = expression.OR(
                    [("res_model", "=", model), ("res_id", "in", [r.id for r in records])]
                    for model, records in groupby(non_channel_records, key=lambda r: r._name)
                )
                domain = expression.AND(
                    [domain, [("partner_id", "=", self.env.user.partner_id.id)]]
                )
                # sudo: mail.followers - reading followers of current partner
                followers = self.env["mail.followers"].sudo().search(domain)
            follower_by_record_and_partner = {
                (
                    self.env[follower.res_model].browse(follower.res_id),
                    follower.partner_id,
                ): follower
                for follower in followers
            }
        for record in records:
            thread_data = {}
            if record._name != "discuss.channel":
                # sudo: mail.thread - if mentionned in a non accessible thread, name is allowed
                thread_data["name"] = record.sudo().display_name
            if self.env[record._name]._original_module:
                thread_data["module_icon"] = modules.module.get_module_icon(
                    self.env[record._name]._original_module
                )
            if for_current_user and add_followers:
                thread_data["selfFollower"] = Store.one(
                    follower_by_record_and_partner.get((record, self.env.user.partner_id)),
                    fields={"is_active": True, "partner": []},
                )
            store.add(record, thread_data, as_thread=True)
        for message in self:
            # model, res_id, record_name need to be kept for mobile app as iOS app cannot be updated
            data = message._read_format(fields, load=False)[0]
            record = record_by_message.get(message)
            if record:
                # sudo: if mentionned in a non accessible thread, user should be able to see the name
                record_name = record.sudo().display_name
                default_subject = record_name
                if hasattr(record, "_message_compute_subject"):
                    # sudo: if mentionned in a non accessible thread, user should be able to see the subject
                    default_subject = record.sudo()._message_compute_subject()
            else:
                record_name = False
                default_subject = False
            data["default_subject"] = default_subject
            vals = {
                # sudo: mail.message - reading attachments on accessible message is allowed
                "attachment_ids": Store.many(message.sudo().attachment_ids.sorted("id")),
                # sudo: mail.message - reading link preview on accessible message is allowed
                "linkPreviews": Store.many(
                    message.sudo().link_preview_ids.filtered(lambda l: not l.is_hidden)
                ),
                # sudo: mail.message - reading reactions on accessible message is allowed
                "reactions": Store.many(message.sudo().reaction_ids),
                "record_name": record_name,  # keep for iOS app
                "is_note": message.subtype_id.id == note_id,
                "is_discussion": message.subtype_id.id == com_id,
                # sudo: mail.message.subtype - reading description on accessible message is allowed
                "subtype_description": message.subtype_id.sudo().description,
                # sudo: res.partner: reading limited data of recipients is acceptable
                "recipients": Store.many(message.sudo().partner_ids, fields=["avatar_128", "name"]),
                "scheduledDatetime": scheduled_dt_by_msg_id.get(message.id, False),
                "thread": Store.one(record, as_thread=True, only_id=True),
            }
            if self.env.user._is_internal():
                vals["notifications"] = Store.many(message.notification_ids._filtered_for_web_client())
            if for_current_user:
                # sudo: mail.message - filtering allowed tracking values
                displayed_tracking_ids = message.sudo().tracking_value_ids._filter_has_field_access(
                    self.env
                )
                if record and hasattr(record, "_track_filter_for_display"):
                    displayed_tracking_ids = record._track_filter_for_display(
                        displayed_tracking_ids
                    )
                # sudo: mail.message - checking whether there is a notification for the current user is acceptable
                notifications_partners = message.sudo().notification_ids.filtered(
                    lambda n: not n.is_read
                ).res_partner_id
                vals["needaction"] = (
                    not self.env.user._is_public()
                    and self.env.user.partner_id in notifications_partners
                )
                vals["starred"] = message.starred
                vals["trackingValues"] = displayed_tracking_ids._tracking_value_format()
            data.update(vals)
            store.add(message, data)
        # sudo: mail.message: access to author is allowed
        self.sudo()._author_to_store(store)
        # Add extras at the end to guarantee order in result. In particular, the parent message
        # needs to be after the current message (client code assuming the first received message is
        # the one just posted for example, and not the message being replied to).
        self._extras_to_store(store, format_reply=format_reply)

    def _author_to_store(self, store: Store):
        for message in self:
            data = {
                "author": False,
                "email_from": message.email_from,
            }
            # sudo: mail.message: access to author is allowed
            if guest_author := message.sudo().author_guest_id:
                data["author"] = Store.one(guest_author, fields=["avatar_128", "name"])
            # sudo: mail.message: access to author is allowed
            elif author := message.sudo().author_id:
                data["author"] = Store.one(
                    author, fields=["avatar_128", "is_company", "name", "user"]
                )
            store.add(message, data)

    def _extras_to_store(self, store: Store, format_reply):
        pass

    @api.model
    def _message_fetch(self, domain, search_term=None, before=None, after=None, around=None, limit=30):
        res = {}
        if search_term:
            # we replace every space by a % to avoid hard spacing matching
            search_term = search_term.replace(" ", "%")
            domain = expression.AND([domain, expression.OR([
                # sudo: access to attachment is allowed if you have access to the parent model
                [("attachment_ids", "in", self.env["ir.attachment"].sudo()._search([("name", "ilike", search_term)]))],
                [("body", "ilike", search_term)],
                [("subject", "ilike", search_term)],
                [("subtype_id.description", "ilike", search_term)],
            ])])
            domain = expression.AND([domain, [("message_type", "not in", ["user_notification", "notification"])]])
            res["count"] = self.search_count(domain)
        if around is not None:
            messages_before = self.search(domain=[*domain, ('id', '<=', around)], limit=limit // 2, order="id DESC")
            messages_after = self.search(domain=[*domain, ('id', '>', around)], limit=limit // 2, order='id ASC')
            return {**res, "messages": (messages_after + messages_before).sorted('id', reverse=True)}
        if before:
            domain = expression.AND([domain, [('id', '<', before)]])
        if after:
            domain = expression.AND([domain, [('id', '>', after)]])
        res["messages"] = self.search(domain, limit=limit, order='id ASC' if after else 'id DESC')
        if after:
            res["messages"] = res["messages"].sorted('id', reverse=True)
        return res

    def _message_notifications_to_store(self, store: Store):
        """Returns the current messages and their corresponding notifications in
        the format expected by the web client.

        Notifications hold the information about each recipient of a message: if
        the message was successfully sent or if an exception or bounce occurred.
        """
        for message in self:
            message_data = {
                "author": Store.one(message.author_id, only_id=True),
                "date": message.date,
                "message_type": message.message_type,
                "body": message.body,
                "notifications": Store.many(message.notification_ids._filtered_for_web_client()),
                "thread": (
                    Store.one(
                        self.env[message.model].browse(message.res_id) if message.model else False,
                        as_thread=True,
                        fields=["modelName", "name" if message.model == "discuss.channel" else "display_name"],
                    )
                ),
            }
            store.add(message, message_data)

    def _notify_message_notification_update(self):
        """Send bus notifications to update status of notifications in the web
        client. Purpose is to send the updated status per author."""
        messages = self.env['mail.message']
        record_by_message = self._record_by_message()
        for message in self:
            # Check if user has access to the record before displaying a notification about it.
            # In case the user switches from one company to another, it might happen that they don't
            # have access to the record related to the notification. In this case, we skip it.
            # YTI FIXME: check allowed_company_ids if necessary
            if record := record_by_message.get(message):
                if record.has_access('read'):
                    messages += message
        messages_per_partner = defaultdict(lambda: self.env['mail.message'])
        for message in messages:
            if not self.env.user._is_public():
                messages_per_partner[self.env.user.partner_id] |= message
            if message.author_id and not any(user._is_public() for user in message.author_id.with_context(active_test=False).user_ids):
                messages_per_partner[message.author_id] |= message
        for partner, messages in messages_per_partner.items():
            store = Store()
            messages._message_notifications_to_store(store)
            partner._bus_send_store(store)

    def _bus_channel(self):
        return self.env.user._bus_channel()

    # ------------------------------------------------------
    # TOOLS
    # ------------------------------------------------------

    def _cleanup_side_records(self):
        """ Clean related data: notifications, stars, ... to avoid lingering
        notifications / unreachable counters with void messages notably. """
        outdated_starred_partners = self.starred_partner_ids.sorted("id")
        self.write({
            'starred_partner_ids': [(5, 0, 0)],
            'notification_ids': [(5, 0, 0)],
        })
        if outdated_starred_partners:
            # sudo: bus.bus: reading non-sensitive last id
            bus_last_id = self.env["bus.bus"].sudo()._bus_last_id()
            self.env.cr.execute("""
                SELECT res_partner_id, count(*)
                  FROM mail_message_res_partner_starred_rel
                 WHERE res_partner_id IN %s
              GROUP BY res_partner_id
              ORDER BY res_partner_id
            """, [tuple(outdated_starred_partners.ids)])
            star_count_by_partner_id = dict(self.env.cr.fetchall())
            for partner in outdated_starred_partners:
                partner._bus_send_store(
                    "mail.thread",
                    {
                        "counter": star_count_by_partner_id.get(partner.id, 0),
                        "counter_bus_id": bus_last_id,
                        "id": "starred",
                        "messages": Store.many(self, "DELETE", only_id=True),
                        "model": "mail.box",
                    },
                )

    def _filter_empty(self):
        """ Return subset of "void" messages """
        return self.filtered(
            lambda msg:
                (not msg.body or tools.is_html_empty(msg.body)) and
                (not msg.subtype_id or not msg.subtype_id.description) and
                not msg.attachment_ids and
                not msg.tracking_value_ids
        )

    @api.model
    def _get_record_name(self, values):
        """ Return the related document name, using display_name. It is done using
            SUPERUSER_ID, to be sure to have the record name correctly stored. """
        model = values.get('model', self.env.context.get('default_model'))
        res_id = values.get('res_id', self.env.context.get('default_res_id'))
        if not model or not res_id or model not in self.env:
            return False
        return self.env[model].sudo().browse(res_id).display_name

    @api.model
    def _get_reply_to(self, values):
        """ Return a specific reply_to for the document """
        model = values.get('model', self._context.get('default_model'))
        res_id = values.get('res_id', self._context.get('default_res_id')) or False
        email_from = values.get('email_from')
        message_type = values.get('message_type')
        records = None
        if self.is_thread_message({'model': model, 'res_id': res_id, 'message_type': message_type}):
            records = self.env[model].browse([res_id])
        else:
            records = self.env[model] if model else self.env['mail.thread']
        return records.sudo()._notify_get_reply_to(default=email_from)[res_id]

    @api.model
    def _get_message_id(self, values):
        if values.get('reply_to_force_new', False) is True:
            message_id = tools.mail.generate_tracking_message_id('reply_to')
        elif self.is_thread_message(values):
            message_id = tools.mail.generate_tracking_message_id('%(res_id)s-%(model)s' % values)
        else:
            message_id = tools.mail.generate_tracking_message_id('private')
        return message_id

    def is_thread_message(self, vals=None):
        if vals:
            res_id = vals.get('res_id')
            model = vals.get('model')
            message_type = vals.get('message_type')
        else:
            self.ensure_one()
            res_id = self.res_id
            model = self.model
            message_type = self.message_type
        return res_id and model and message_type != 'user_notification'

    def _invalidate_documents(self, model=None, res_id=None):
        """ Invalidate the cache of the documents followed by ``self``. """
        fnames = ['message_ids', 'message_needaction', 'message_needaction_counter']
        self.flush_recordset(['model', 'res_id'])
        for record in self:
            model = model or record.model
            res_id = res_id or record.res_id
            if model in self.pool and issubclass(self.pool[model], self.pool['mail.thread']):
                self.env[model].browse(res_id).invalidate_recordset(fnames)

    def _get_search_domain_share(self):
        return ['&', '&', ('is_internal', '=', False), ('subtype_id', '!=', False), ('subtype_id.internal', '=', False)]
