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.
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 :
| Champ | Rôle |
|---|---|
subject | Le sujet de l'email (c'est aussi le _rec_name de la campagne) |
body_html | Le corps, rendu par destinataire (placeholders) |
preview | Le pré-en-tête (preheader) affiché par les clients mail |
mailing_model_real | Le modèle cible réel : mailing.contact, res.partner, crm.lead… |
contact_list_ids | Les listes de diffusion (mailing.list) ciblées |
mailing_domain | Le filtre appliqué au modèle cible pour calculer les destinataires |
email_from | L'expéditeur — soumis à une contrainte (voir plus bas) |
mail_server_id | Le serveur sortant à utiliser pour cette campagne |
state | L'état de la campagne : draft → in_queue → sending → done |
schedule_type / schedule_date | Envoi immédiat ou programmé à une date |
use_exclusion_list | Respecter 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— forceschedule_type='now'puis appelleaction_put_in_queue: envoi dès que possible.action_put_in_queue— passe l'état àin_queueet 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 endraftet videschedule_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.
sending pendant l'envoi, puis en done une fois la file de destinataires épuisée.
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é.
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 avectools.split_everyet on traite lot après lot. - La création en
sudo: lesmail.mailsont créés en super-utilisateur carmail.mailest 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 sesmail.notification— la même trace par destinataire que l'envoi unitaire, à l'échelle. - Le commit par lot : hors mode test,
auto_commit=Truevalide 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.
mail.batch_size (50 par défaut). Chaque lot crée ses mail.mail et mail.notification, envoie, puis valide la transaction — rendant la progression durable et la reprise sûre.
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.mailacheminé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_recipientssoustrait les destinataires déjà tracés. La reprise ne sert que le reliquat. - Confondre
mailing.mailingetmailing.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 enexception, Odoo supprime leursmail.mailet 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.