Se rendre au contenu

Diffusion de masse en Odoo 19 — mailing.mailing, file d'envoi et traçabilité

Série Tech-Email · Article 11/14 · Parcours Infrastructure
6 juin 2026 par
Diffusion de masse en Odoo 19 — mailing.mailing, file d'envoi et traçabilité
B.Mustapha
| Aucun commentaire pour l'instant

Jusqu'ici, la série a suivi un message unique : un document, un public de followers, un rendu, un transport, une notification par destinataire. Mais comment Odoo passe-t-il à l'échelle d'une campagne — un même contenu adressé à des milliers de contacts, avec une file d'envoi, une reprise après incident et un suivi par destinataire ? C'est le rôle du modèle mailing.mailing, la brique de diffusion de masse, et c'est ce qu'examine cet article.

L'angle est volontairement technique : pas l'éditeur visuel de campagnes, mais ce qui se passe sous le bouton « Envoyer ». La state machine de la campagne, le cron qui vide la file, la garantie qu'aucun destinataire n'est contacté deux fois, l'envoi par lots qui encaisse le volume, et le modèle mailing.trace qui consigne chaque envoi. La diffusion de masse ne réinvente pas le moteur d'email : elle réutilise le rendu et le transport vus précédemment, en y ajoutant l'orchestration du volume.

Prérequis lecteur. Une instance Odoo 19 avec le module mass_mailing installé, le mode debug actif, et la lecture des articles de la série sur le transport SMTP et les notifications. On suppose acquis qu'un email est porté par un mail.mail et acheminé par un serveur sortant.

1. Du mail unitaire à la campagne

Envoyer un email à un destinataire est immédiat : on crée un mail.mail, on l'envoie. Envoyer le même contenu à dix mille contacts soulève des problèmes d'une autre nature. Que se passe-t-il si le serveur tombe au 4 000e envoi ? Comment éviter de re-contacter les 4 000 premiers à la reprise ? Comment ne pas saturer le relais SMTP d'un coup ? Comment savoir, après coup, qui a reçu, qui a ouvert, qui a rebondi ?

Le modèle mailing.mailing répond à ces questions en encapsulant une campagne dans un objet doté d'un état, d'une file d'attente pilotée par un cron, d'un envoi par lots et d'une traçabilité par destinataire. Sa valeur ajoutée n'est pas l'envoi lui-même — délégué aux couches déjà décrites — mais l'orchestration qui rend cet envoi fiable à grande échelle.

2. Anatomie de mailing.mailing

Le modèle hérite de mail.thread (la campagne est elle-même un document suivi) et porte les champs qui définissent quoi envoyer et à qui. Les principaux :

ChampRôle
subjectLe sujet de l'email (c'est aussi le _rec_name de la campagne)
body_htmlLe corps, rendu par destinataire (placeholders)
previewLe pré-en-tête (preheader) affiché par les clients mail
mailing_model_realLe modèle cible réel : mailing.contact, res.partner, crm.lead
contact_list_idsLes listes de diffusion (mailing.list) ciblées
mailing_domainLe filtre appliqué au modèle cible pour calculer les destinataires
email_fromL'expéditeur — soumis à une contrainte (voir plus bas)
mail_server_idLe serveur sortant à utiliser pour cette campagne
stateL'état de la campagne : draftin_queuesendingdone
schedule_type / schedule_dateEnvoi immédiat ou programmé à une date
use_exclusion_listRespecter la liste noire (blacklist) au moment de l'envoi

Un point de syntaxe Odoo 19 mérite l'attention : la cohérence de l'expéditeur est garantie par une contrainte exprimée en attribut de classe — models.Constraint, et non l'ancien _sql_constraints :

# mass_mailing/models/mailing.py (Odoo 19)
# Un mailing de type 'mail' exige un expéditeur :
_email_from = models.Constraint(
    "CHECK(email_from IS NOT NULL OR mailing_type != 'mail')",
    "email from is required for mailing"
)

La cible d'une campagne se définit donc sur deux axes complémentaires : un modèle (sur quel type d'enregistrement on tire les destinataires) et un domaine (quel sous-ensemble). Les listes de diffusion sont un cas particulier où le modèle est mailing.contact et le domaine restreint aux contacts des listes choisies. C'est ce couple modèle + domaine qui, au moment de l'envoi, produira la liste réelle des destinataires.

3. La state machine de la campagne

Une campagne traverse quatre états, déclarés avec tracking=True (chaque transition est journalisée dans le Chatter de la campagne) :

# mass_mailing/models/mailing.py — l'état de la campagne
state = fields.Selection(
    [('draft', 'Draft'), ('in_queue', 'In Queue'),
     ('sending', 'Sending'), ('done', 'Sent')],
    string='Status', default='draft', required=True,
    copy=False, tracking=True, group_expand=True)

Les transitions sont portées par des actions explicites. Lancer une campagne, ce n'est pas l'envoyer dans la foulée : c'est la mettre en file et laisser le cron s'en charger.

  • action_launch — force schedule_type='now' puis appelle action_put_in_queue : envoi dès que possible.
  • action_put_in_queue — passe l'état à in_queue et déclenche le cron d'envoi (cron._trigger) à la date voulue.
  • action_schedule — si une date future est fixée, met en file pour cette date ; sinon ouvre le dialogue de programmation.
  • action_cancel — repasse la campagne en draft et vide schedule_date / next_departure.
# action_put_in_queue : mise en file + réveil du cron
def action_put_in_queue(self):
    self.write({'state': 'in_queue'})
    cron = self.env.ref('mass_mailing.ir_cron_mass_mailing_queue')
    cron._trigger(
        schedule_date or fields.Datetime.now()
        for schedule_date in self.mapped('schedule_date')
    )

Ce découplage est délibéré : l'interface utilisateur rend la main immédiatement (la campagne est « en file »), et le travail lourd — potentiellement des milliers d'emails — s'exécute en arrière-plan, dans le cron, sans bloquer la session. L'état sending n'est pas posé par l'utilisateur : c'est le cron qui le pose quand il commence réellement à envoyer.

4. Le cron qui vide la file

Le moteur d'envoi est un cron, _process_mass_mailing_queue. À chaque passage, il sélectionne les campagnes prêtes à partir — celles en file dont la date d'envoi est échue ou immédiate — et les traite une par une.

# mass_mailing/models/mailing.py — le cron d'envoi (cœur)
@api.model
def _process_mass_mailing_queue(self):
    mass_mailings = self.search([
        ('state', 'in', ('in_queue', 'sending')),
        '|', ('schedule_date', '<', fields.Datetime.now()),
             ('schedule_date', '=', False)])
    for mass_mailing in mass_mailings:
        # applique le contexte (langue, société) du responsable de la campagne
        context_user = mass_mailing.user_id or mass_mailing.write_uid or self.env.user
        mass_mailing = mass_mailing.with_context(
            **self.env['res.users'].with_user(context_user).context_get())
        if len(mass_mailing._get_remaining_recipients()) > 0:
            mass_mailing.state = 'sending'
            mass_mailing._action_send_mail()
        else:
            mass_mailing.write({'state': 'done', 'sent_date': fields.Datetime.now(),
                                'kpi_mail_required': not mass_mailing.sent_date})

La logique est robuste par conception. Tant qu'il reste des destinataires à servir, la campagne est en sending et le cron envoie. Quand il n'en reste plus, elle bascule en done. Comme le cron sélectionne aussi les campagnes déjà en sending, une campagne interrompue (serveur redémarré, lot en échec) est automatiquement reprise au passage suivant : elle n'est jamais « coincée ». Tout repose sur la capacité à savoir, à tout instant, qui reste à contacter — la section suivante.

5. Idempotence : ne jamais contacter deux fois

Le cœur de la fiabilité tient en deux méthodes. _get_recipients calcule l'ensemble théorique des destinataires ; _get_remaining_recipients en retire ceux qui ont déjà été contactés pour cette campagne.

# _get_recipients : tous les destinataires (modèle cible + domaine)
def _get_recipients(self):
    mailing_domain = self._get_recipients_domain()
    res_ids = self.env[self.mailing_model_real].search(mailing_domain).ids
    return res_ids

# _get_remaining_recipients : ceux qu'on n'a PAS encore tracés
def _get_remaining_recipients(self):
    res_ids = self._get_recipients()
    trace_domain = (Domain('model', '=', self.mailing_model_real)
                    & Domain('res_id', 'in', res_ids)
                    & Domain('mass_mailing_id', '=', self.id))
    already_mailed = self.env['mailing.trace'].search_read(trace_domain, ['res_id'])
    done_res_ids = {record['res_id'] for record in already_mailed}
    return [rid for rid in res_ids if rid not in done_res_ids]

La garantie anti-doublon est là, dans une simple soustraction : on retire des destinataires théoriques tous ceux qui ont déjà une mailing.trace rattachée à cette campagne (mass_mailing_id). Conséquence directe : relancer le cron ne renvoie jamais ce qui est déjà parti. Un crash au milieu d'un envoi de masse n'est donc pas un drame — à la reprise, _get_remaining_recipients ne retourne que le reliquat, et l'envoi continue exactement où il s'était arrêté.

Pourquoi c'est crucial. Sans cette idempotence, toute reprise après incident re-bombarderait les destinataires déjà servis — au mieux une gêne, au pire un signal de spam qui dégrade la réputation du domaine d'envoi. La trace par destinataire n'est pas qu'un outil de reporting : c'est le verrou qui rend l'envoi de masse rejouable sans risque.

6. L'envoi délégué au composer

Vient le moment d'envoyer. Et ici, la diffusion de masse ne réécrit rien : elle confie le travail au composer de messages en mode mass_mail, qui sait rendre un corps par destinataire et produire les mail.mail.

# _action_send_mail (extrait) — délégation au composer en mode masse
mailing_res_ids = res_ids or mailing._get_remaining_recipients()
if not mailing_res_ids:
    raise UserError(_('There are no recipients selected.'))

composer_values = {
    'composition_mode': 'mass_mail',
    'model': mailing.mailing_model_real,
    'mass_mailing_id': mailing.id,
    'email_from': mailing.email_from,
    'mail_server_id': mailing.mail_server_id.id,
    'subject': mailing.subject,
    'body': mailing._prepend_preview(mailing.body_html or '', mailing.preview),
    'auto_delete': not mailing.keep_archives,
    'reply_to_force_new': mailing.reply_to_mode == 'new',
    'use_exclusion_list': mailing.use_exclusion_list,
}
composer = self.env['mail.compose.message'].with_context(
    active_ids=mailing_res_ids, default_composition_mode='mass_mail',
).create(composer_values)
composer._action_send_mail(auto_commit=not modules.module.current_test)

mailing.write({'state': 'done', 'sent_date': fields.Datetime.now(),
               'kpi_mail_required': not mailing.sent_date})

Plusieurs détails se lisent dans ces valeurs. Le mode mass_mail signale au composer qu'il rend un contenu personnalisé pour chaque destinataire. use_exclusion_list active le respect de la liste noire au moment du rendu. auto_delete détermine si les mail.mail sont purgés après envoi (sauf si la campagne demande à conserver les archives). Et si la liste de destinataires est vide, l'envoi lève un UserError('There are no recipients selected.') plutôt que de partir à vide.

Autrement dit, mailing.mailing est un orchestrateur, pas un moteur d'email : il prépare le contexte (liste, domaine, expéditeur, serveur) et délègue le rendu (gabarits QWeb) et le transport (SMTP) aux couches conçues pour cela.

7. La boucle par lots

Sous le composer, l'envoi de masse procède par lots. C'est ce qui permet d'encaisser des volumes importants sans saturer la mémoire ni le serveur sortant, tout en sécurisant la progression.

# mail/wizard/mail_compose_message.py — _action_send_mail_mass_mail (cœur)
batch_size = int(self.env['ir.config_parameter'].sudo().get_param('mail.batch_size')) \
             or self._batch_size or 50   # défaut : 50 destinataires par lot

for res_ids_iter in tools.split_every(batch_size, res_ids):
    vals = self._manage_mail_values(self._prepare_mail_values(res_ids_iter))
    iter_mails_sudo = self.env['mail.mail'].sudo().create(list(vals.values()))
    # une mail.notification par destinataire (cf. article #10)
    self.env['mail.notification'].create(
        self._generate_mail_notification_values(iter_mails_sudo))
    if self.force_send:
        iter_mails_sudo.send(auto_commit=auto_commit)
        continue
    if auto_commit is True:
        # commit après chaque lot : la progression est durable
        self.env['ir.cron']._notify_progress(done=..., remaining=...)
        self.env.cr.commit()

Trois mécanismes se combinent dans cette boucle :

  • La taille de lot est réglable par le paramètre système mail.batch_size (défaut 50). On découpe la liste avec tools.split_every et on traite lot après lot.
  • La création en sudo : les mail.mail sont créés en super-utilisateur car mail.mail est un modèle technique ; les droits d'accès réels sont vérifiés en amont, au moment de préparer les valeurs (_prepare_mail_values) en parcourant les enregistrements sources. Chaque lot crée aussi ses mail.notification — la même trace par destinataire que l'envoi unitaire, à l'échelle.
  • Le commit par lot : hors mode test, auto_commit=True valide la transaction après chaque lot et remonte la progression au cron (_notify_progress). C'est l'autre moitié de l'idempotence : un incident au lot N ne perd pas les lots 1…N-1, déjà commités et tracés.
À savoir. Le commit par lot signifie qu'un envoi de masse n'est pas atomique : une partie des emails peut être partie avant une erreur. C'est voulu — on préfère un envoi partiel reprenable à un tout-ou-rien qui renverrait tout depuis le début. Le couple « trace + commit par lot » garantit que la reprise (section 5) repartira proprement du reliquat.

8. mailing.trace et pièges

À l'échelle de la masse, l'équivalent de la mail.notification de l'envoi unitaire s'appelle mailing.trace : une ligne par destinataire et par campagne, qui est à la fois le verrou d'idempotence (section 5) et le socle de toute l'analytique. Son champ trace_status suit le cycle de vie de chaque envoi :

# mass_mailing/models/mailing_trace.py — le statut par destinataire
trace_status = fields.Selection(selection=[
    ('outgoing', 'Outgoing'), ('process', 'Processing'),
    ('pending', 'Sent'),      ('sent', 'Delivered'),
    ('open', 'Opened'),       ('reply', 'Replied'),
    ('bounce', 'Bounced'),    ('error', 'Exception'),
    ('cancel', 'Cancelled')], string='Status', default='outgoing')

Le modèle porte aussi le document ciblé (model / res_id), l'adresse normalisée (email), les horodatages d'ouverture, de clic et de réponse, et — décisif pour le diagnostic — un failure_type typé : mail_bounce, mail_spam, mail_email_invalid, mail_bl (adresse blacklistée), mail_dup (doublon), mail_optout. C'est cette table que l'article suivant exploitera pour parler ouvertures, clics, rebonds et taux.

Les pièges récurrents de la diffusion de masse :

  • Croire que le mass-mailing a son propre moteur d'envoi. Il délègue au composer, qui produit des mail.mail acheminés par le SMTP. Déboguer un envoi de masse, c'est souvent déboguer le serveur sortant.
  • Penser qu'un re-déclenchement du cron renvoie tout. Faux : _get_remaining_recipients soustrait les destinataires déjà tracés. La reprise ne sert que le reliquat.
  • Confondre mailing.mailing et mailing.trace. La première est la campagne (un enregistrement) ; la seconde est l'accusé par destinataire (des milliers de lignes).
  • Attendre l'atomicité. Le commit par lot signifie qu'un envoi peut être partiel. C'est par design : repris proprement au passage de cron suivant.
  • Oublier action_retry_failed. Pour réémettre uniquement les emails en exception, Odoo supprime leurs mail.mail et traces en échec puis remet la campagne en file — inutile de tout relancer.

À retenir : mailing.mailing est l'orchestrateur du volume. Il encapsule une campagne dans une state machine, met l'envoi en file pour un cron, garantit par la trace qu'aucun destinataire n'est servi deux fois, découpe l'envoi en lots commités pour encaisser l'échelle, et délègue rendu et transport aux couches dédiées. Tout le reste — qui a ouvert, qui a cliqué, qui a rebondi — se lit dans mailing.trace.

L'article suivant s'y attardera précisément : comment Odoo capte les ouvertures, clics et rebonds, comment ces signaux remontent dans mailing.trace, et comment ils nourrissent les statistiques d'une campagne — la traçabilité de bout en bout.

Voir aussi dans ce parcours Infrastructure

Notifications et Discuss — mail.notification

L'accusé par destinataire à l'unité, que le mass-mailing produit par lots.

Lire l'article →
mail.template — rendu QWeb et envoi d'emails

Le moteur de gabarit que le composer mobilise pour rendre chaque email de la campagne.

Lire l'article →

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

Articles complémentaires

Architecture mail Odoo — les 4 couches

Le HUB de la série : la place de la diffusion de masse dans le flux complet.

Lire l'article →
Configurer Brevo SMTP — ir.mail_server & from_filter

Le relais transactionnel qui encaisse le volume d'une campagne.

Lire l'article →

Source officielle : Odoo 19 — Email Marketing.

Se connecter pour laisser un commentaire.
Notifications et Discuss en Odoo 19 — mail.notification, inbox et email
Série Tech-Email · Article 10/14 · Parcours Infrastructure