Se rendre au contenu

mail.thread en Odoo 19 — followers, sous-types et notifications du Chatter

Série Tech-Email · Article 7/14 · Parcours Infrastructure
6 juin 2026 par
mail.thread en Odoo 19 — followers, sous-types et notifications du Chatter
B.Mustapha
| Aucun commentaire pour l'instant

Les articles précédents ont câblé la plomberie de l'email Odoo : le relais SMTP qui envoie, le from_filter qui aligne, les adresses techniques de mail.alias.domain, la passerelle fetchmail.server qui ramène les réponses. Une question reste ouverte, et c'est la plus importante côté métier : qui reçoit une notification quand il se passe quelque chose sur un enregistrement ? La réponse tient dans le mixin mail.thread et son système d'abonnés — les followers.

Cet article décortique le mécanisme du Chatter en Odoo 19 : le modèle mail.followers, les sous-types (mail.message.subtype) qui servent de filtres de notification, le piège central de message_post, l'API message_subscribe et l'abonnement automatique. Il s'appuie sur l'article HUB sur les 4 couches mail (ici, on est dans la couche Message) et prolonge la passerelle entrante de l'article fetchmail.server.

Prérequis lecteur. Une instance Odoo 19 (CE ou EE) en mode debug, un accès admin, et des notions d'ORM (un modèle qui hérite de mail.thread, un champ Many2one avec tracking=True). Aucun module externe n'est requis : mail.thread, mail.followers et les sous-types système font partie du module mail de base.

1. Abonné n'est pas destinataire

La confusion la plus fréquente oppose deux notions que le Chatter manipule en permanence. Un destinataire est un partner explicitement visé par un message donné (le partner_ids d'un message_post). Un abonné — un follower — est un partner qui a choisi (ou que le système a abonné) de suivre durablement un enregistrement : il recevra une notification à chaque nouveau message d'un type qu'il suit, sans être nommé individuellement à chaque fois.

C'est précisément ce qui ferme la boucle ouverte par les articles précédents. La couche transport (SMTP) sait comment envoyer ; la passerelle entrante sait ramener les réponses. Mais c'est le système d'abonnés qui décide vers qui part chaque notification — l'auteur d'un devis, le responsable d'un ticket, le client concerné. Sans mail.thread et ses followers, un ERP enverrait des emails dans le vide : la mécanique d'envoi serait là, le carnet d'adresses des intéressés, non.

Hériter de mail.thread sur un modèle lui ajoute le Chatter (le fil de messages), la table des abonnés et toute la machinerie de notification. La plupart des modèles métier d'Odoo — sale.order, crm.lead, project.task — en héritent. La suite décrit ce qui se passe sous ce mixin.

2. Anatomie de mail.followers

Un abonnement est un enregistrement du modèle mail.followers. Sa structure est volontairement minimale : qui suit, quoi, et avec quels filtres.

ChampRôle
res_modelNom du modèle suivi (Char, pas de clé étrangère — choix de performance)
res_idIdentifiant de l'enregistrement suivi (Many2oneReference)
partner_idLe partner abonné (ondelete='cascade', requis)
subtype_idsLes sous-types suivis = filtres décidant quels messages déclenchent une notification
name / email / is_activeChamps related sur le partner (lecture seule)

Deux détails de conception méritent attention. D'abord, le modèle porte une contrainte d'unicité exprimée en syntaxe Odoo 19 — un attribut de classe, et non l'ancien _sql_constraints :

# mail/models/mail_followers.py (Odoo 19)
class MailFollowers(models.Model):
    _name = 'mail.followers'
    _log_access = False            # pas de create_date/write_uid : table technique

    res_model  = fields.Char('Related Document Model Name', required=True, index=True)
    res_id     = fields.Many2oneReference('Related Document ID', index=True,
                                          model_field='res_model')
    partner_id = fields.Many2one('res.partner', ondelete='cascade', required=True)
    subtype_ids = fields.Many2many('mail.message.subtype')

    # Un partner ne peut suivre un enregistrement qu'UNE seule fois :
    _mail_followers_res_partner_res_model_id_uniq = models.Constraint(
        'unique(res_model,res_id,partner_id)',
        "Error, a partner cannot follow twice the same object.",
    )

Cette contrainte garantit une ligne d'abonnement par couple (document, partner) : suivre deux fois le même enregistrement est impossible. C'est ce qui permet à l'API d'abonnement de raisonner en termes de « politique d'écrasement » plutôt que de risquer des doublons — on y revient en section 5.

Ensuite, mail.followers ne référence plus que des partners : en Odoo 19, les canaux de discussion (channels) suivent une mécanique distincte et ne figurent pas dans cette table. Un follower est toujours un res.partner — un utilisateur interne, un contact client, un fournisseur. Enfin, comme suivre un document influe sur les droits d'accès, toute écriture sur mail.followers invalide le cache des documents concernés.

3. Les sous-types : le cœur du filtrage

Si l'abonnement était binaire — on suit, on est notifié de tout —, le Chatter serait inutilisable : chaque note interne, chaque changement d'étape, chaque pièce jointe inonderait la boîte de tous les abonnés. Les sous-types (mail.message.subtype) résolvent ce problème. Chaque message porte un sous-type ; un abonné n'est notifié que des messages dont le sous-type figure dans ses subtype_ids. C'est un système d'abonnement par canal thématique.

Le modèle mail.message.subtype définit ces filtres. Ses champs les plus structurants :

ChampRôle
internalMessage visible des seuls employés (membres du groupe utilisateur interne) — exclu des clients et du portail
defaultSous-type activé par défaut quand un partner s'abonne (défaut True)
res_modelModèle ciblé ; False = s'applique à tous les modèles
parent_idSous-type parent, utilisé pour l'abonnement automatique en cascade (ex. projet → tâche)
relation_fieldChamp reliant le modèle enfant au parent pour cette cascade
hiddenMasque le sous-type dans les options de suivi de l'interface

Le module mail livre trois sous-types système, déclarés noupdate="1" et qu'il faut connaître par cœur :

XML IDLibelléPropriétésUsage
mail.mt_commentDiscussionsdefault=True, non internalLe « Envoyer un message » du Chatter — notifie les abonnés
mail.mt_noteNotedefault=False, internal=TrueLe « Log note » — note interne, ne notifie pas les abonnés externes
mail.mt_activitiesActivitiesdefault=False, internal=TrueMessages liés aux activités planifiées

La distinction mt_comment / mt_note est l'axe de tout le système. Discussions est le sous-type externe par défaut — celui que chaque nouvel abonné suit, et celui qui déclenche les notifications email (le pont vers la couche SMTP des articles sur les relais). Note, marqué internal et default=False, sert aux échanges internes que les clients ne doivent jamais voir.

4. Le piège central : message_post() ne « discute » pas

Voici l'erreur que commet presque tout développeur Odoo débutant. On veut « écrire un message visible dans le Chatter et notifier les abonnés », on appelle record.message_post(body="…")… et personne n'est notifié. La raison est dans une seule ligne du cœur de message_post : quand aucun sous-type n'est fourni, le sous-type par défaut est mail.mt_note.

# mail/models/mail_thread.py — message_post() (extrait)
if subtype_xmlid:
    subtype_id = self.env['ir.model.data']._xmlid_to_res_id(subtype_xmlid)
if not subtype_id:
    # défaut = NOTE, pas Discussion → message interne, aucun abonné notifié
    subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')

Autrement dit, un message_post nu produit une note interne : visible des employés dans le Chatter, mais qui ne déclenche aucune notification vers les abonnés (et reste invisible des clients, puisque mt_note est internal). Pour réellement « discuter » — notifier les followers et, le cas échéant, leur envoyer un email —, il faut explicitement viser le sous-type Discussions :

# À exécuter dans odoo-bin shell sur un enregistrement mail.thread (ex. un sale.order)
order = env['sale.order'].browse(ORDER_ID)

# ❌ Note interne silencieuse — AUCUN abonné notifié :
order.message_post(body="Relance interne à traiter")

# ✅ Discussion — notifie les abonnés du sous-type mt_comment (+ email) :
order.message_post(
    body="Bonjour, votre commande est confirmée.",
    subtype_xmlid='mail.mt_comment',
    message_type='comment',
)
À retenir. message_post() sans subtype_xmlid = note interne, pas une discussion. Pour notifier les abonnés, passer explicitement subtype_xmlid='mail.mt_comment'. C'est la cause numéro un des « notifications qui ne partent jamais » dans le code custom.

Le pendant existe dans l'interface : le bouton Envoyer un message du Chatter utilise mt_comment (et notifie), tandis que Log note utilise mt_note (note interne). Le code doit choisir consciemment le même axe.

5. S'abonner et se désabonner par le code

L'API publique d'abonnement est message_subscribe(partner_ids, subtype_ids=None). Son premier rôle est de vérifier les droits d'accès avant toute écriture, et la règle est asymétrique :

  • S'abonner soi-même (le seul partner visé est celui de l'utilisateur courant) : un simple check_access('read') suffit — pouvoir lire le document autorise à le suivre. En cas d'échec, la méthode renvoie False sans lever d'exception.
  • Abonner autrui : un check_access('write') est exigé — on ne s'autorise à imposer un suivi à un tiers que si l'on peut modifier le document.

Lorsqu'on abonne d'autres partners, la méthode filtre au passage les adresses inactives (partners archivés) avant de les inscrire. Le comportement vis-à-vis des sous-types dépend ensuite de l'argument subtype_ids :

# mail/models/mail_thread.py — _message_subscribe (cœur logique)
if not subtype_ids:
    # Sans sous-types précisés : sous-types par défaut, on N'ÉCRASE PAS
    # un abonné déjà présent (politique 'skip').
    self.env['mail.followers']._insert_followers(
        self._name, self.ids, partner_ids, subtypes=None,
        check_existing=True, existing_policy='skip')
else:
    # Sous-types imposés : on REMPLACE les sous-types de l'abonné ('replace').
    self.env['mail.followers']._insert_followers(
        self._name, self.ids, partner_ids,
        subtypes={pid: subtype_ids for pid in partner_ids},
        check_existing=True, existing_policy='replace')

En pratique, l'appel reste simple. Mais il est utile de connaître les quatre politiques d'écrasement (existing_policy) que la couche basse _add_followers sait appliquer, car elles expliquent toutes les nuances de comportement :

PolitiqueEffet sur un abonné déjà existant
skipIgnoré, ses sous-types ne sont pas touchés (cas de l'abonnement simple)
forceSupprimé puis recréé avec les sous-types fournis
replaceSous-types remplacés (ajout des nouveaux + retrait des anciens)
updateAjout des sous-types manquants uniquement — jamais de retrait
# Exemples — odoo-bin shell
lead = env['crm.lead'].browse(LEAD_ID)
partner = env['res.partner'].browse(PARTNER_ID)

# S'abonner soi-même (sous-types par défaut, ne touche rien si déjà suiveur) :
lead.message_subscribe(partner_ids=[env.user.partner_id.id])

# Abonner un tiers en lui imposant uniquement le sous-type Discussions :
mt_comment = env.ref('mail.mt_comment').id
lead.message_subscribe(partner_ids=[partner.id], subtype_ids=[mt_comment])

# Désabonner ce tiers :
lead.message_unsubscribe(partner_ids=[partner.id])

Le désabonnement (message_unsubscribe) applique une asymétrie analogue, mais plus permissive pour les employés. Un utilisateur interne qui se retire lui-même n'a besoin d'aucun contrôle d'accès : Odoo l'autorise explicitement à se désabonner d'un document de sa boîte sans vérification. Un utilisateur portail ou public qui se retire lui-même doit, lui, disposer d'un droit de lecture. Et retirer un tiers exige dans tous les cas un droit d'écriture. La méthode supprime alors les lignes mail.followers correspondantes.

6. L'abonnement automatique

La plupart des abonnements ne sont jamais posés à la main : Odoo les crée automatiquement via _message_auto_subscribe, déclenché quand certains champs trackés changent à la création ou à la modification d'un enregistrement. Deux mécanismes coexistent.

Le responsable. Si le modèle possède un champ user_id (Many2one vers res.users) marqué tracking=True, le nouveau responsable assigné est abonné et notifié via le template mail.message_user_assigned (« You have been assigned to … »). C'est le comportement standard : changer le responsable d'un ticket ou d'une tâche l'abonne et le prévient. Ce comportement s'étend en surchargeant _message_auto_subscribe_followers :

# Comportement par défaut (mail.thread) : abonne + notifie le nouveau responsable
def _message_auto_subscribe_followers(self, updated_values, default_subtype_ids):
    field = self._fields.get('user_id')
    user_id = updated_values.get('user_id')
    if field and user_id and field.comodel_name == 'res.users' \
            and getattr(field, 'tracking', False):
        user = self.env['res.users'].sudo().browse(user_id)
        try:  # optimiste : on tente de lire sans exists() préalable
            if user.active:
                return [(user.partner_id.id, default_subtype_ids,
                         'mail.message_user_assigned' if user != self.env.user else False)]
        except Exception:
            pass
    return []

La propagation parent → enfant. Le second mécanisme s'appuie sur le champ parent_id des sous-types. Suivre un document parent (un projet) avec certains sous-types peut abonner automatiquement aux documents enfants (les tâches) via le relation_field. C'est ainsi qu'un membre suivant un projet est notifié de l'activité de ses tâches sans s'abonner à chacune. La résolution est mise en cache (_get_auto_subscription_subtypes), d'où l'invalidation du cache à chaque create/write/unlink d'un sous-type.

Ce câblage explique pourquoi le simple fait de déclarer tracking=True sur un champ relationnel a des effets en cascade sur les notifications. Le suivi de champ lui-même — comment Odoo détecte et journalise les changements de valeur — sera détaillé dans un article dédié de la série.

7. Le cloisonnement client, garde-fou de confidentialité

Un risque guette tout ERP ouvert sur l'extérieur : qu'une note interne — un commentaire d'équipe, une remarque sur la solvabilité d'un client — parte par email au client concerné. Odoo s'en prémunit au niveau même de l'attribution des sous-types. Lors de l'abonnement par défaut, la couche _add_default_followers distingue les clients des employés :

# mail/models/mail_followers.py — _add_default_followers (extrait)
default, _internal, external = self.env['mail.message.subtype'].default_subtypes(res_model)
# customer_ids = partners 'partner_share' (clients, utilisateurs portail)
p_stypes = dict(
    (pid, external.ids if pid in customer_ids else default.ids)
    for pid in partner_ids
)

La règle est nette : un partner partner_share=True — un client, un contact sans compte interne, un utilisateur portail — ne se voit attribuer que les sous-types externes, jamais les internal. Concrètement, un client abonné à une commande suit mt_comment (Discussions) mais jamais mt_note (Note). Une note interne reste donc structurellement invisible pour lui, même s'il est follower du document.

Conséquence pratique. Le cloisonnement repose entièrement sur le flag internal des sous-types. Un sous-type métier custom destiné à des échanges confidentiels doit être déclaré internal=True ; à défaut, un client abonné pourrait recevoir ces messages. C'est un point à vérifier systématiquement lors de la création de sous-types personnalisés.

8. Récapitulatif et pièges

Le système d'abonnés de mail.thread tient en quelques règles, mais chacune est une source d'erreur classique :

  • Abonné ≠ destinataire. Un follower suit durablement un document ; un destinataire est visé ponctuellement par un message. Les deux notions cohabitent dans message_post.
  • message_post() nu = note interne. Le piège n°1 : sans subtype_xmlid='mail.mt_comment', aucun abonné n'est notifié. Toujours choisir le sous-type consciemment.
  • Le filtrage passe par les sous-types. Un abonné n'est notifié que des sous-types présents dans ses subtype_ids. Un sous-type custom mal configuré (oublié dans les défauts) ne notifiera personne.
  • internal protège les clients. Tout sous-type confidentiel doit être internal=True, sinon un client follower peut le recevoir.
  • L'abonnement auto suit le tracking. Le responsable (user_id tracké) est abonné et notifié automatiquement ; déclarer tracking=True a des effets de bord sur les followers.
  • Une seule ligne par (doc, partner). La contrainte d'unicité interdit les doublons : on ne « ré-abonne » pas, on ajuste les sous-types via les politiques skip/replace/update.

À retenir : mail.thread est le carnet d'adresses vivant de chaque enregistrement. Il décide qui reçoit quoi, filtre par sous-type, et cloisonne interne et externe. Les couches transport (SMTP) et passerelle (fetchmail) ne sont que les bras de ce cerveau : c'est ici, dans les followers et leurs sous-types, que se joue la pertinence — et la confidentialité — de chaque notification.

L'article suivant restera dans le module mail pour la fabrique de contenu des emails : le modèle mail.template et son rendu QWeb — comment un même gabarit produit un email personnalisé pour chaque destinataire, et le piège de ses deux moteurs de rendu.

Voir aussi dans ce parcours Infrastructure

fetchmail.server — relève IMAP, POP3 et routage entrant

La passerelle qui ramène les réponses des abonnés dans le bon fil de discussion.

Lire l'article →
Architecture mail Odoo — les 4 couches

Le HUB de la série : ici, on a détaillé la couche Message et ses notifications.

Lire l'article →

Série Tech-Email — Article 7/14 — Parcours Infrastructure emailing Odoo 19.

Articles complémentaires

Email templates et mail.thread — envoi auto depuis create/write

Comment déclencher des notifications mail.thread depuis le cycle de vie ORM.

Lire l'article →
Relais SMTP alternatifs — Mailjet, SES, Microsoft 365

La couche transport qui achemine l'email déclenché par mt_comment.

Lire l'article →

Source officielle : Odoo 19 — Mixins (mail.thread).

Se connecter pour laisser un commentaire.
fetchmail.server en Odoo 19 — relève IMAP, POP3 et routage des emails entrants
Série Tech-Email · Article 6/14 · Parcours Infrastructure