# Part of Odoo. See LICENSE file for full copyright and licensing details.
from __future__ import annotations

import contextlib
import logging
import re
import textwrap
from binascii import Error as binascii_error
from collections import defaultdict
from lxml import html
from typing import Self

from odoo import _, api, fields, models, modules, tools
from odoo.exceptions import AccessError, MissingError
from odoo.fields import Command, Domain
from odoo.tools import clean_context, groupby, SQL
from odoo.tools.misc import OrderedSet
from odoo.addons.base.models.ir_attachment import condition_values
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)

MAX_COMODELS_FOR_DOMAIN = 5
SHARE_DOMAIN = Domain("is_internal", "=", False) & Domain("subtype_id.internal", "=", False)


def exists_in_cache(records, *, hint_field=''):
    """Like Model.exists(), but checks the cache when possible and avoids a
    call to `Model.exists()`."""
    # see test_record_unlinked_orphan_activities
    # TODO once cache pollution is solved, move this into Model.fetch()
    try:
        if hint_field:
            records.sudo().mapped(hint_field)
            return records
        for field in records._fields.values():
            if (
                field.store and field.column_type
                and field.prefetch is True
                and records._has_field_access(field, 'read')
            ):
                records.sudo().mapped(field.name)
                return records
    except MissingError:
        pass
    return records.exists()


def _find_allowed_doc_ids(env, model_ids, operation):
    """ Filter out communication records (messages, activities) that user cannot
    read due to missing document access.

    :param dict model_ids: dictionary giving messages IDs per model / doc ids {
        'document_model_name': {
            document_id_1: set(message IDs),
            document_id_2: set(message IDs),
        },
        [...]
    }

    :return: set of allowed message IDs to read, based on document check
    :rtype: set
    """
    allowed_ids = set()
    for doc_model, doc_dict in model_ids.items():
        documents = exists_in_cache(env[doc_model].browse(doc_dict))
        for document_domain, operation_res_ids in documents._mail_get_operation_for_mail_message_operation(operation):
            if not documents:
                break
            records = documents.sudo().filtered_domain(document_domain).with_env(env)
            documents -= records
            accessible_doc_ids = records._filtered_access(operation_res_ids)._ids
            for document_id in accessible_doc_ids:
                allowed_ids.update(doc_dict[document_id])
    return allowed_ids


class MailMessage(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",
        "mail_spam"
        # 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 = 'subject'
    _access_domain_heavy = True

    @api.model
    def default_get(self, fields):
        res = super().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'))
            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.')
    linked_message_ids = fields.Many2many("mail.message", compute="_compute_linked_message_ids")
    message_link_preview_ids = fields.One2many(
        "mail.message.link.preview", "message_id", 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', bypass_search_access=True)
    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', compute='_compute_record_name', store=False)
    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'),
        # somehow generated by system but with specific meaning / computation
        ('auto_comment', 'Automated Targeted Notification'),
        ('out_of_office', 'Out-of-office Message'),
        ('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})
    # email recipients of incoming emails: comma separated list of emails (not necessarily normalized)
    incoming_email_to = fields.Text('Emails To')
    incoming_email_cc = fields.Char('Emails Cc')
    # email recipients of outgoing emails: comma separated list of emails (not necessarily normalized)
    outgoing_email_to = fields.Char('emails To')
    # 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',
        bypass_search_access=True, copy=False, depends=['notified_partner_ids'])
    # user interface
    bookmarked_partner_ids = fields.Many2many(
        'res.partner', 'mail_message_res_partner_bookmarked_rel', string='Bookmarked By')
    pinned_at = fields.Datetime('Pinned', help='Datetime at which the message has been pinned')
    is_bookmarked = fields.Boolean(
        "Bookmarked",
        compute="_compute_is_bookmarked",
        search="_search_is_bookmarked",
        help="Current user has bookmarked 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.')
    # polls
    ended_poll_ids = fields.One2many('mail.poll', 'end_message_id')
    started_poll_ids = fields.One2many('mail.poll', 'start_message_id')
    has_poll = fields.Boolean(compute='_compute_has_poll', compute_sudo=True)
    # 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")

    _model_res_id_idx = models.Index("(model, res_id)")
    _model_res_id_id_idx = models.Index("(model, res_id, id)")

    @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_context("uid")
    @api.depends("body")
    def _compute_linked_message_ids(self):
        """ Compute the linked messages from the body of the message."""
        message_ids_by_message = defaultdict(list)
        for message in self._filtered_access('read'):
            if tools.is_html_empty(message.body):
                continue
            str_ids = html.fromstring(message.body).xpath(
                "//a[contains(@class, 'o_message_redirect') and @data-oe-model='mail.message']/@data-oe-id",
            )
            for str_id in str_ids:
                with contextlib.suppress(ValueError, TypeError):
                    message_ids_by_message[message].append(int(str_id))
        mids = [mid for mids in message_ids_by_message.values() for mid in mids]
        if not mids:
            self.linked_message_ids = self.env["mail.message"]
            return
        # Remove any potential sudo from the env as linked messages are user input, returning them
        # as sudo could lead to users being able to read any arbitrary message through this feature.
        # Only allowed messages for the current user are acceptable.
        linked_messages = self.sudo(False).search(Domain("id", "in", mids))
        for message in self:
            message.linked_message_ids = linked_messages.filtered(
                lambda m, message=message: m.id in message_ids_by_message[message],
            )

    @api.depends('model', 'res_id')
    def _compute_record_name(self):
        free = self.filtered(lambda m: not m.model or not m.res_id or m.model not in self.env)
        free.record_name = False
        # sudo here, as it behaves like a m2o -> can read message, can read name_get
        for message, record in (self - free)._record_by_message().items():
            try:
                message.record_name = record.sudo().display_name
            except MissingError:
                message.record_name = False

    @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):
        if operator not in ('in', 'not in'):
            return NotImplemented
        is_read = operator == 'not in'
        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 != 'in':
            return NotImplemented
        return [('notification_ids.notification_status', 'in', ('bounce', 'exception'))]

    @api.depends("bookmarked_partner_ids")
    @api.depends_context("uid")
    def _compute_is_bookmarked(self):
        """ Compute if the message is bookmarked by the current user. """
        # TDE FIXME: use SQL
        bookmarked = (
            self.sudo()
            .filtered(lambda msg: self.env.user.partner_id in msg.bookmarked_partner_ids)
            .sudo(False)
        )
        for message in self:
            message.is_bookmarked = message in bookmarked

    @api.model
    def _search_is_bookmarked(self, operator, operand):
        if operator != 'in':
            return NotImplemented
        return [('bookmarked_partner_ids', 'in', self.env.user.partner_id.ids)]

    @api.depends('started_poll_ids', 'ended_poll_ids')
    def _compute_has_poll(self):
        for message in self:
            message.has_poll = message.started_poll_ids or message.ended_poll_ids
    # ------------------------------------------------------
    # CRUD / ORM
    # ------------------------------------------------------

    @api.model
    def _search(self, domain, offset=0, limit=None, order=None, *, bypass_access=False, **kwargs):
        """ Override that adds specific access rights of mail.message, to remove
        ids uid could not see according to our custom rules. Please refer to
        :meth:`_check_access` for more details about those rules.
        """
        domain = Domain(domain).optimize(self)
        if self.env.su or bypass_access or domain.is_false():
            return super()._search(domain, offset, limit, order, bypass_access=True, **kwargs)
        if self.env.context.get('_generating_sql_for_fields'):
            raise ValueError("Cannot generate SQL for whole mail.message")

        # Non-employee see only messages with a subtype and not internal
        domain = self._get_search_domain_share() & domain
        domain = domain.optimize_dynamic(self)

        # search by ids
        if condition_values(self, 'id', domain) is not None:
            query = super()._search(domain, order=order, **kwargs)
            records = self.browse()._filter_accessible_from_query(query, 'read')
            if offset > 0:
                records = records[offset:]
            if limit is not None:
                records = records[:limit]
            return records._as_query(ordered=bool(order))

        # searching for all messages or a subset of models
        res_model_names = condition_values(self, 'model', domain) or ()
        if not (0 < len(res_model_names) <= MAX_COMODELS_FOR_DOMAIN):
            query = super()._search(domain, offset, limit, order, **kwargs)
            records = self.browse()._filter_accessible_from_query(query, 'read')
            return records._as_query(ordered=bool(order))

        # search by model and res_id
        model_codomains = Domain.FALSE  # (model = a & res_id in ...) | (model = b & ...)
        env = self.with_context(active_test=False).env
        for res_model_name in res_model_names:
            if res_model_name not in env:
                continue
            comodel = env[res_model_name]
            codomain = Domain('model', '=', comodel._name)
            comodel_res_ids = condition_values(self, 'res_id', domain.map_conditions(
                lambda cond: codomain & cond if cond.field_expr == 'model' else cond
            ))
            # For each model, build a query with accessible comodel ids.
            # Start with a false domain and at each step:
            # 1. domain_operation is the remaining records
            # 2. update the remaining to remove currently handled records
            # 3. add to comodel_domain, the records with their access rule
            # Then: add known ids for simpler query and optimize with sudo
            # (because the rules are applied with sudo permissions) and search.
            comodel_domain = Domain.FALSE
            comodel_domain_remaining = Domain.TRUE
            for domain_operation, doc_operation in comodel._mail_get_operation_for_mail_message_operation('read'):
                domain_operation, comodel_domain_remaining = (
                    comodel_domain_remaining & domain_operation,
                    comodel_domain_remaining & ~domain_operation,
                )
                if not comodel.has_access(doc_operation):
                    continue
                if doc_operation == 'read':
                    comodel_rule = Domain.TRUE  # covered by the search below
                else:
                    comodel_rule = self.env['ir.rule']._compute_domain(comodel._name, doc_operation)
                comodel_domain |= (domain_operation & comodel_rule)
            if comodel_res_ids is not None:
                comodel_domain &= Domain('id', 'in', comodel_res_ids)
            comodel_domain = comodel_domain.optimize_full(comodel.sudo())
            query = comodel._search(comodel_domain)
            if query.is_empty():
                continue
            if query.where_clause:
                codomain &= Domain('res_id', 'any!', query)
            model_codomains |= codomain

        partner = self.env.user.partner_id
        domain &= Domain.OR((
            Domain('author_id', '=', partner.id),
            Domain('create_uid', '=', self.env.uid),
            # force an IN condition with a list of values
            Domain('partner_ids', 'any!', partner._as_query()),
            Domain('notified_partner_ids', 'any!', partner._as_query()),
            # User_notification notified relevant partners, hence covered by
            # 'partner_ids' domain part (which is why it is ok to exclude them
            # complete from records-based domain).
            model_codomains & Domain('message_type', '!=', 'user_notification'),
        ))

        return super()._search(domain, offset, limit, order, **kwargs)

    def _get_search_domain_share(self):
        if self.env.user._is_internal():
            return Domain.TRUE
        return SHARE_DOMAIN

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

        Access on related document is custom custom per model. The rule only
        applies for messages that are not user notifications.

        Global restriction: non employee users cannot see internal messages (aka logs):
        'is_internal' flag on message, 'internal' flag on subtype.
        """
        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

        query = self.sudo()._search(self._get_search_domain_share() & Domain('id', 'in', messages.ids), active_test=False)
        accessible_messages = messages._filter_accessible_from_query(query, operation)
        if messages != accessible_messages:
            forbidden = messages - accessible_messages
            if result:
                result = (result[0] + forbidden, result[1])
            else:
                result = (forbidden, lambda: forbidden._make_access_error(operation))
        return result

    def _filter_accessible_from_query(self, query: models.Query, operation: str) -> Self:
        """ Return the subset of ``self`` that satisfies the specific conditions
        for messages. Flush current recordset or the model on empty self.
        """
        assert query._model._name == self._name
        if query.is_empty():
            return self.browse()

        # 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.
        if self:
            self.flush_recordset(['model', 'res_id', 'author_id', 'create_uid', 'parent_id', 'message_type', 'partner_ids'])
        else:
            self.flush_model(['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'])
        pid = self.env.user.partner_id.id

        # Non internal users see only non-private messages
        table = query.table
        if operation in ('read', 'write'):
            id_sql = table.id
            query.groupby = id_sql
            # notified: partner_ids or needaction
            query.add_join('LEFT JOIN', 'partner_rel', 'mail_message_res_partner_rel',
                SQL('partner_rel.mail_message_id = %s AND partner_rel.res_partner_id = %s', id_sql, pid))
            query.add_join('LEFT JOIN', 'needaction_rel', 'mail_notification',
                SQL('needaction_rel.mail_message_id = %s AND needaction_rel.res_partner_id = %s', id_sql, pid))
            query = query.select(*(
                table[fname]
                for fname in ('id', 'model', 'res_id', 'author_id', 'parent_id', 'message_type', 'create_uid')
            ), SQL('bool_or(partner_rel.res_partner_id IS NOT NULL OR needaction_rel.res_partner_id IS NOT NULL) AS notified'))
        elif operation in ('create', 'unlink'):
            query = query.select(*(
                table[fname]
                for fname in ('id', 'model', 'res_id', 'author_id', 'parent_id', 'message_type')
            ))
        else:
            raise ValueError(_('Wrong operation name (%s)', operation))
        # skip flush which is already done
        self.env.cr.execute(query)
        messages_to_check = {
            values['id']: values
            for values in self.env.cr.dictfetchall()
        }
        accessible = self.browse(messages_to_check)
        if not messages_to_check:
            return accessible

        # Author condition (READ, WRITE, CREATE (private))
        if operation == 'read':
            uid = self.env.uid
            messages_to_check = {
                mid: message
                for mid, message in messages_to_check.items()
                if message['author_id'] != pid
                and message['create_uid'] != uid
            }
        elif operation == 'write':
            messages_to_check = {
                mid: message
                for mid, message in messages_to_check.items()
                if message['author_id'] != pid
            }
        elif operation == 'create':
            messages_to_check = {
                mid: message
                for mid, message in messages_to_check.items()
                if message['model'] and message['res_id']
                if message['message_type'] != 'user_notification'
            }

        if not messages_to_check:
            return accessible

        # Recipients condition, for read and write (partner_ids)
        # keep on top, useful for systray notifications
        if operation in ('read', 'write'):
            messages_to_check = {
                mid: message
                for mid, message in messages_to_check.items()
                if not message['notified']
            }
            if not messages_to_check:
                return accessible

        # 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 (
                (model := message['model'])
                and (res_id := message['res_id'])
                and message['message_type'] != 'user_notification'
            ):
                model_docid_msgids[model][res_id].append(mid)

        for mid in _find_allowed_doc_ids(self.env, model_docid_msgids, operation):
            messages_to_check.pop(mid)

        if not messages_to_check:
            return accessible

        # 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) """,
                    pid, 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 accessible

        return accessible - self.browse(messages_to_check)

    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,
        ))

    def _get_with_access(self, mode="read", **kwargs):
        if not self:
            return self
        self.ensure_one()
        if self.env.user._is_admin():
            return self
        # sanity check on kwargs
        allowed_params = self.env[self.sudo().model or 'mail.thread']._get_allowed_access_params()
        if invalid := (set((kwargs or {}).keys()) - allowed_params):
            _logger.warning("Invalid parameters to _get_with_access: %s", invalid)
        if self.sudo(False).has_access(mode):
            return self
        if self.model and self.res_id:
            thread_su = self.env[self.model].browse(self.res_id).sudo()
            for domain, access_mode in thread_su._mail_get_operation_for_mail_message_operation(mode):
                if thread_su.filtered_domain(domain):
                    if self.env[self.model]._get_thread_with_access(self.res_id, mode=access_mode, **kwargs):
                        return self
                    break
        return self.browse()

    @api.model_create_multi
    def create(self, vals_list):
        tracking_values_list = []
        for values in vals_list:
            if not (self.env.su or self.env.user.has_group('base.group_user')):
                values.pop('author_id', None)
                values.pop('email_from', None)
                self = self.with_context({k: v for k, v in self.env.context.items() if k not in ['default_author_id', 'default_email_from']})  # noqa: PLW0642
            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)
                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 not values.get('attachment_ids', True):
                # pop empty values
                del values['attachment_ids']
            # extract base64 images
            if 'body' in values:
                Attachments = self.env['ir.attachment'].with_context(clean_context(self.env.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()
                            attachments = values.setdefault('attachment_ids', [])
                            attachments.append((4, attachment.id))
                            data_to_url[key] = ['/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token), name, attachment.id]
                    # data-attachment-id helps identify image attachments that are already inserted in the body
                    # this is notably used to avoid displaying them twice in the chatter
                    return f'{data_to_url[key][0]}{match.group(3)} alt="{data_to_url[key][1]}" data-attachment-id="{data_to_url[key][2]}"'
                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().create(vals_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 vals_list
               for command in values.get('attachment_ids', ())):
            for values in vals_list:
                message_attachment_ids = set()
                for command in values.get('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_access('read')

        for message, values, tracking_values_cmd in zip(messages, vals_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})

        messages.filtered(lambda msg: msg._is_thread_message() and msg.message_type != 'user_notification')._invalidate_documents()

        return messages

    def write(self, vals):
        if not (self.env.su or self.env.user.has_group('base.group_user')):
            vals.pop('author_id', None)
            vals.pop('email_from', None)
        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().write(vals)
        if vals.get('attachment_ids'):
            self.attachment_ids.check_access('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().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().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(),
            },
        )
        return notifications.mail_message_id.ids

    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(),
            },
        )

    def mark_as_unread(self):
        """Sets the needaction on the messages for the current partner"""
        notifications = self.env["mail.notification"].search([
            ("mail_message_id", "in", self.ids),
            ("res_partner_id", "=", self.env.user.partner_id.id),
            ("notification_type", "=", "inbox"),
        ])
        if not notifications:
            return
        notifications.write({"is_read": False, "read_date": False})
        store = Store().add(
            notifications.mail_message_id,
            "_store_message_fields",
            fields_params={"inbox_fields": True},
        )
        self.env.user._bus_send(
            "mail.message/mark_as_unread",
            {"message_ids": notifications.mail_message_id.ids, "store_data": store.get_result()},
        )

    @api.model
    def _message_fetch(self, domain, *, thread=None, search_term=None, is_notification=None, before=None, after=None, around=None, limit=30):
        res = {}
        domain = Domain(True if domain is None else domain)
        if thread:
            domain &= (
                Domain("res_id", "=", thread.id)
                & Domain("model", "=", thread._name)
                & Domain("message_type", "!=", "user_notification")
            )
        if is_notification is True:
            domain &= Domain("message_type", "=", "notification")
        elif is_notification is False:
            domain &= Domain("message_type", "!=", "notification")
        if search_term:
            # we replace every space by a % to avoid hard spacing matching
            search_term = search_term.replace(" ", "%")
            message_domain = Domain.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)]))],
                # sudo: res.partner - allow searching by author name
                [("author_id", "in", self.env["res.partner"].sudo()._search([("name", "ilike", search_term)]))],
                # sudo: mail.guest - allow searching by guest name
                [("author_guest_id", "in", self.env["mail.guest"].sudo()._search([("name", "ilike", search_term)]))],
                [("body", "ilike", search_term)],
                [("subject", "ilike", search_term)],
                [("subtype_id.description", "ilike", search_term)],
            ])
            if thread and is_notification is not False:
                tracking_value_domain = (
                    Domain("mail_message_id.res_id", "=", thread.id)
                    & Domain("mail_message_id.model", "=", thread._name)
                    & self._get_tracking_values_domain(search_term)
                )
                # sudo: mail.tracking.value - searching allowed tracking values for acessible records
                tracking_values = self.env["mail.tracking.value"].sudo().search(tracking_value_domain)
                accessible_tracking_value_ids = tracking_values._filter_has_field_access(self.env)
                message_domain |= Domain("id", "in", accessible_tracking_value_ids.mail_message_id.ids)
            domain &= message_domain
        if search_term or is_notification is not None:
            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 &= Domain('id', '<', before)
        if after:
            domain &= 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 _get_tracking_values_domain(self, search_term):
        """Get the domain to search for tracking values."""
        numeric_term = None
        # try to convert the search term to a number
        with contextlib.suppress(ValueError, TypeError):
            numeric_term = float(search_term)
        domain = Domain.OR(
            Domain(field_name, "ilike", search_term)
            for field_name in (
                "old_value_char",
                "new_value_char",
                "old_value_text",
                "new_value_text",
                "old_value_datetime",
                "new_value_datetime",
                "field_id.name",
                "field_id.field_description",
            )
        )
        if numeric_term:
            epsilon = 1e-9  # small epsilon to allow for floating point precision
            domain |= Domain.OR(
                Domain(field_name, ">=", numeric_term - epsilon)
                & Domain(field_name, "<=", numeric_term + epsilon)
                for field_name in ("old_value_float", "new_value_float")
            )
            if numeric_term.is_integer():
                domain |= Domain.OR(
                    Domain(field_name, "=", int(numeric_term))
                    for field_name in ("old_value_integer", "new_value_integer")
                )
        return domain

    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()
            store.add(self, "_store_reaction_group_fields", fields_params={"content": 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(bus_channel=self._bus_channel())
        store.add(self, "_store_reaction_group_fields", fields_params={"content": content})
        store.bus_send()

    def _store_reaction_group_fields(self, res: Store.FieldList, *, content):
        group_domain = [("message_id", "in", self.ids), ("content", "=", content)]
        reactions = self.env["mail.message.reaction"].search(group_domain)
        reactions_by_message = reactions.grouped("message_id")
        res.many(
            "reactions",
            [],
            mode="ADD",
            predicate=lambda m: m in reactions_by_message,
            value=reactions_by_message.get,
        )
        res.attr(
            "reactions",
            [("DELETE", {"message": self.id, "content": content})],
            predicate=lambda m: m not in reactions_by_message,
        )

    # ------------------------------------------------------
    # STORE / NOTIFICATIONS
    # ------------------------------------------------------

    def _store_message_fields(
        self,
        res: Store.FieldList,
        *,
        format_reply=True,
        msg_vals=False,
        inbox_fields=False,
        followers=None,
    ):
        """
        :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 inbox_fields: if True, also add inbox fields: followers of the current target for
            each thread of each message as well as module icon and priority fields.
            Only applicable if ``res.target`` is a specific user.

        :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 ``inbox_fields`` is True.
        """
        # sudo: mail.message - reading attachments on accessible message is allowed
        res.many(
            "attachment_ids",
            "_store_attachment_fields",
            sort="id",
            dynamic_fields="_store_attachment_dynamic_fields",
            sudo=True,
        )
        # sudo: mail.message: access to author_guest_id is allowed
        res.one("author_guest_id", "_store_avatar_fields", sudo=True)
        # sudo: mail.message: access to author_id is allowed
        res.one(
            "author_id",
            lambda res: (
                res.attr("is_company"),
                res.one("main_user_id", ["partner_id", "share"]),
                res.from_method("_store_avatar_fields"),
            ),
            dynamic_fields="_store_partner_name_dynamic_fields",
            sudo=True,
        )
        res.extend(["body", "create_date", "date"])
        res.attr(
            "email_from",
            predicate=lambda m: res.is_for_internal_users()
            or (not m.author_id and not m.author_guest_id),
        )
        # keep "model" for iOS app
        res.extend(["incoming_email_cc", "incoming_email_to", "message_type", "model"])
        # sudo: res.partner: reading limited data of recipients is acceptable
        res.many(
            "partner_ids",
            lambda res: res.from_method("_store_avatar_fields"),
            dynamic_fields="_store_partner_name_dynamic_fields",
            sort="id",
            sudo=True,
        )
        res.attr("pinned_at")
        # sudo: mail.message - reading reactions on accessible message is allowed
        res.many("reactions", [], value=lambda m: m.sudo().reaction_ids)
        res.attr(
            "reply_to",
            predicate=lambda m: res.is_for_internal_users()
            or (not m.author_id and not m.author_guest_id)
        )
        # keep "record_name" and "res_id" for iOS app
        res.extend(["record_name", "res_id"])
        # sudo - mail.poll: reading poll of accessible message is allowed.
        res.many(
            "started_poll_ids",
            "_store_poll_fields",
            fields_params={"with_start_message_id": False},
            predicate=lambda m: m.has_poll,
            sudo=True,
        )
        res.many("ended_poll_ids", "_store_poll_fields", predicate=lambda m: m.has_poll, sudo=True)
        res.attr("subject")
        # sudo: mail.message.subtype - reading subtype on accessible message is allowed
        res.one("subtype_id", ["description"], sudo=True)
        res.attr("write_date")
        self._store_linked_messages_fields(res)
        self._store_message_link_previews_fields(res)
        if res.is_for_internal_users():
            # sudo - mail.notification: internal users can access notifications.
            res.many(
                "notification_ids",
                "_store_notification_fields",
                sudo=True,
            )

        # 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 = defaultdict(bool)
        if msg_vals:
            scheduled_dt_by_msg = {m: msg_vals.get("scheduled_date", False) for m 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[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)
        target_user = res.target_user()
        follower_by_record_and_partner = defaultdict(self.env["mail.followers"].browse)
        if target_user and inbox_fields and non_channel_records:
            if followers is None:
                domain = Domain.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 &= Domain("partner_id", "=", target_user.partner_id.id)
                # sudo: mail.followers - reading followers of current partner
                followers = self.env["mail.followers"].sudo().search(domain)
            for follower in followers:
                follower_by_record_and_partner[
                    self.env[follower.res_model].browse(follower.res_id),
                    follower.partner_id,
                ] = follower
        res.one(
            "thread",
            lambda res: (
                # sudo: mail.thread - if mentionned in a non accessible thread, name is allowed
                res.attr("display_name", sudo=True),
                res.attr("has_mail_thread", lambda t: isinstance(t, self.env.registry["mail.thread"])),
                res.attr(
                    "module_icon",
                    lambda t: modules.module.get_module_icon(t._original_module),
                    predicate=lambda t: inbox_fields and t._original_module,
                ),
                res.one(
                    "selfFollower",
                    ["is_active", "partner_id"],
                    predicate=lambda t: target_user and inbox_fields and non_channel_records,
                    value=lambda t: follower_by_record_and_partner[t, target_user.partner_id],
                ),
                res.attr(
                    "priority",
                    value=lambda t: t[t._priority_field],
                    predicate=lambda t: inbox_fields and hasattr(t, "_priority_field"),
                    sudo=True,
                ),
                res.attr(
                    "priority_definition",
                    predicate=lambda t: inbox_fields and hasattr(t, "_priority_field"),
                    value=lambda t: t.fields_get([t._priority_field], ["selection"])[t._priority_field]["selection"],
                ),
            ),
            as_thread=True,
            value=record_by_message.get,
        )
        if res.is_for_current_user():
            res.append("is_bookmarked")

        def default_subject(message):
            if record := record_by_message.get(message):
                if hasattr(record, "_message_compute_subject"):
                    # sudo: if mentionned in a non accessible thread, user should be able to see the subject
                    return record.sudo()._message_compute_subject()
                return message.record_name
            return False

        res.attr("default_subject", default_subject)
        res.attr("scheduledDatetime", lambda m: scheduled_dt_by_msg[m])
        res.attr(
            "incoming_email_cc",
            lambda message: tools.mail.email_split_tuples(message.incoming_email_cc),
            predicate=lambda message: message.incoming_email_cc,
        )
        res.attr(
            "incoming_email_to",
            lambda message: tools.mail.email_split_tuples(message.incoming_email_to),
            predicate=lambda message: message.incoming_email_to,
        )
        if res.is_for_current_user():

            def needaction(message):
                # sudo: mail.message - checking whether there is a notification for the current user is acceptable
                return not message.env.user._is_public() and bool(
                    message.sudo().notification_ids.filtered(
                        lambda n: not n.is_read
                        and n.res_partner_id == message.env.user.partner_id,
                    ),
                )

            res.attr("needaction", needaction)

            def tracking_values(message):
                # sudo: mail.message - filtering allowed tracking values
                trackings = message.sudo().tracking_value_ids._filter_has_field_access(message.env)
                record = record_by_message.get(message)
                if record and hasattr(record, "_track_filter_for_display"):
                    trackings = record._track_filter_for_display(trackings)
                return trackings._tracking_value_format()

            res.attr("trackingValues", tracking_values)
        # 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._store_extra_fields(res, format_reply=format_reply)

    def _to_store(self, store: Store, res: Store.FieldList):
        store.add_records_fields(res)

    def _store_message_link_previews_fields(self, res: Store.FieldList):
        res.many(
            "message_link_preview_ids",
            "_store_message_link_preview_fields",
            value=lambda m: m.sudo().message_link_preview_ids.filtered(
                lambda message_link_preview: not message_link_preview.is_hidden,
            ),
            sort=lambda message_link_preview: (
                message_link_preview.sequence,
                message_link_preview.id,
            ),
        )

    def _store_partner_name_dynamic_fields(self, res: Store.FieldList):
        res.attr("name")

    def _store_attachment_dynamic_fields(self, attachment_res: Store.FieldList):
        if attachment_res.is_for_current_user() and self.is_current_user_or_guest_author:
            attachment_res.from_method("_store_ownership_fields")

    def _store_linked_messages_fields(self, res: Store.FieldList):
        """Add the messages that are referenced by the current message's body to the given store.
        This method should only return message data that are not sensitive to be broadcasted to
        other users, as it doesn't check res.target by simplicity and the target might not
        necessarily have permission to read the linked messages."""
        rec_by_m = self.linked_message_ids._record_by_message()
        res.many(
            "linked_message_ids",
            lambda res: (
                res.extend(["model", "res_id"]),
                # sudo: mail.thread - reading record name of accessible message is acceptable
                res.one("thread", ["display_name"], value=rec_by_m.get, as_thread=True, sudo=True),
            ),
            only_data=True,
        )

    def _store_extra_fields(self, res: Store.FieldList, *, format_reply):
        pass

    def _store_notification_fields(self, res: Store.FieldList):
        """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.
        """
        res.extend(["author_id", "author_guest_id", "body", "date", "message_type"])
        res.many(
            "notification_ids",
            "_store_notification_fields",
            value=lambda m: m.notification_ids._filtered_for_web_client(),
        )
        res.one(
            "thread",
            lambda res: (
                res.attr(
                    "modelName",
                    lambda thread: thread.env["ir.model"]._get(thread._name).display_name,
                ),
                res.attr("display_name"),
            ),
            as_thread=True,
        )

    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):
                try:
                    if record.has_access('read'):
                        _dummy = record.display_name  # access anything to make sure record exists
                        messages += message
                except (MissingError):
                    # record has been removed from db without cascading notif -> avoid crash at least
                    continue
        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():
            if user := partner.main_user_id:
                store = Store(bus_channel=user)
                user_messages = messages.with_user(user)._filtered_access('read')
                store.add(user_messages, "_store_notification_fields")
                store.bus_send()

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

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

    def _filter_empty(self):
        """ Return subset of "void" messages """
        return self.filtered(lambda message: message._is_empty())

    def _is_empty(self):
        self.ensure_one()
        return (
            (not self.body or tools.is_html_empty(self.body))
            and (not self.subtype_id or not self.subtype_id.description)
            and not self.attachment_ids
            and not (
                self.has_field_access(self._fields["tracking_value_ids"], "read")
                and self.tracking_value_ids
            )
            and not self.has_poll
        )

    @api.model
    def _get_reply_to(self, values):
        """ Return a specific reply_to for the document """
        author_id = values.get('author_id')
        model = values.get('model', self.env.context.get('default_model'))
        res_id = values.get('res_id', self.env.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(vals={'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, author_id=author_id)[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(vals=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=False, thread=None):
        """ Tool method to compute thread validity in notification methods.

        Thread message has a model and a res_id.
        """
        vals = vals or {}
        res_model = vals['model'] if 'model' in vals else thread._name if thread else self.model
        res_id = vals['res_id'] if 'res_id' in vals else thread.ids[0] if thread and thread.ids else self.res_id
        return bool(res_id) if (res_model and res_model != 'mail.thread') else False

    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 _records_by_model_name(self):
        ids_by_model = defaultdict(OrderedSet)
        prefetch_ids_by_model = defaultdict(OrderedSet)
        prefetch_messages = self | self.browse(self._prefetch_ids)
        # prefetch read in sudo because we may have access to only part of the prefetch
        for message in prefetch_messages.sudo().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 | 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)
        }
