Un acteur a tiré les ficelles de toute cette série sans jamais monter sur scène : le cron. C'est lui qui vide la file d'envoi, lui qui réveille une campagne mise en attente, lui qui étale les lots dans le temps. Et une fois la mécanique comprise, reste la question qui sépare un module d'emailing bricolé d'un module fiable : comment le tester — sans jamais envoyer un vrai email ? Ce dernier article de la série répond aux deux : le modèle ir.cron, et l'outillage de test du mail en Odoo 19.
Deux sujets, une même finalité : l'industrialisation. Planifier proprement les tâches d'arrière-plan, et garantir par des tests automatisés que les emails partent — ou échouent — comme prévu. C'est ce qui transforme le code qui « marche sur ma machine » en code qu'on déploie sans crainte.
TransactionCase), et la lecture des articles sur la file mail.mail et la diffusion de masse.
1. Le cron, acteur de l'ombre
Tout au long de cette série, une même phrase est revenue : « le cron s'en charge ». Le cron Email Queue Manager draine la file des mail.mail ; le mass-mailing « déclenche le cron » pour partir ; le throttle des serveurs personnels « re-programme le cron ». Derrière ces formules se cache un seul modèle : ir.cron, le planificateur de tâches d'Odoo.
Un cron n'a rien de magique. C'est simplement une action serveur exécutée périodiquement, en arrière-plan, par un processus dédié. Comprendre sa structure, c'est savoir pourquoi un envoi part « dans l'heure » plutôt que tout de suite, comment le forcer, et — au moment d'écrire un module — comment planifier ses propres traitements différés.
2. Anatomie d'un ir.cron
Premier fait structurant : un cron est une action serveur. Le modèle ir.cron hérite de ir.actions.server par délégation, et seul l'état code est supporté — un cron exécute du code Python.
# base/models/ir_cron.py — l'essentiel du modèle
class IrCron(models.Model):
_name = 'ir.cron'
_inherits = {'ir.actions.server': 'ir_actions_server_id'} # un cron EST une action serveur
active = fields.Boolean(default=True)
interval_number = fields.Integer(default=1, required=True)
interval_type = fields.Selection([
('minutes', 'Minutes'), ('hours', 'Hours'),
('days', 'Days'), ('weeks', 'Weeks'), ('months', 'Months'),
], default='months', required=True) # pas de granularité « secondes »
nextcall = fields.Datetime(required=True, default=fields.Datetime.now)
lastcall = fields.Datetime() # passée au job via context['lastcall']
priority = fields.Integer(default=5) # 0 = haute priorité, 10 = basse
La planification tient en deux champs : interval_number + interval_type définissent la périodicité (« tous les 1 heures »), et nextcall porte la prochaine échéance. Après chaque exécution, nextcall est avancé d'un intervalle et lastcall enregistre le dernier passage réussi — cette dernière date étant fournie au code du job dans son contexte, ce qui permet un traitement incrémental (« tout ce qui a changé depuis lastcall »).
La cohérence est garantie, en syntaxe Odoo 19, par un attribut de classe models.Constraint — non l'ancien _sql_constraints :
_check_strictly_positive_interval = models.Constraint(
'CHECK(interval_number > 0)',
"The interval number must be a strictly positive number.")
3. Ce qui change en v19
Quiconque a écrit des crons en Odoo 16, 17 ou 18 doit connaître deux suppressions et deux ajouts — sous peine d'erreurs à la mise à jour.
| Champ | v19 | Conséquence |
|---|---|---|
numbercall | ❌ supprimé | Plus de « exécuter N fois puis s'arrêter ». Un cron actif tourne indéfiniment ; pour un one-shot, on le désactive dans le code. |
doall | ❌ supprimé | Plus de rattrapage automatique des exécutions manquées. |
failure_count | ✅ ajouté | Nombre d'échecs consécutifs, remis à zéro au premier succès. |
first_failure_date | ✅ ajouté | Date du premier échec d'une série, remise à zéro au succès. |
Les deux suppressions sont des pièges de migration classiques : un fichier de données XML qui définit encore numbercall ou doall échouera. Les deux ajouts, eux, traduisent une orientation v19 vers la résilience : Odoo trace désormais les défaillances répétées d'un cron, et peut désactiver automatiquement un job qui échoue en boucle (via le mécanisme de progression de la section 5) — au lieu de marteler une opération vouée à planter.
4. Déclencher un cron : trois voies
Un cron ne s'exécute pas seulement « à l'heure dite ». Il existe trois déclencheurs, et les distinguer évite bien des confusions au débogage.
# 1) AUTOMATIQUE — le worker cron réveille les jobs dont nextcall <= maintenant.
# Après exécution : nextcall += interval, lastcall = maintenant.
# 2) MANUEL — le bouton « Lancer manuellement »
def method_direct_trigger(self):
self.ensure_one()
self.browse().check_access('write')
job = self._acquire_one_job(self.env.cr, self.id, include_not_ready=True)
if not job:
raise UserError(self.env._("Job '%s' already executing", self.name))
self._process_job(self.env.cr, job) # thread courant, nouveau curseur
# 3) HORS-BANDE — réveiller le cron sans attendre nextcall
cron._trigger(at=fields.Datetime.now()) # crée un ir.cron.trigger, précision 1 min
Le premier est le fonctionnement normal. Le deuxième, method_direct_trigger, est le bouton « Lancer manuellement » : il exécute le job immédiatement dans le thread courant (mais sur un nouveau curseur, comme le ferait le scheduler), et lève une erreur si le job tourne déjà. C'est l'outil de débogage par excellence pour vider une file sur-le-champ.
Le troisième, _trigger, est le plus intéressant pour comprendre la série : il planifie une exécution hors-bande, indépendamment de nextcall, en créant un enregistrement ir.cron.trigger. C'est exactement ce que font le mass-mailing (quand on lance une campagne) et le throttle des mail.mail : plutôt que d'attendre le prochain cycle horaire, ils « réveillent » le cron pour que la file parte sans délai. Voilà pourquoi une campagne lancée ne patiente pas une heure entière.
_trigger (hors-bande) — ce dernier étant le mécanisme par lequel le mass-mailing et la file mail.mail partent sans attendre.
5. La progression des longs jobs
Un envoi de masse peut occuper le cron longtemps. Pour ne pas exécuter un job « en aveugle », Odoo 19 expose une API de progression que les traitements longs appellent au fil de l'eau :
# base/models/ir_cron.py — remonter la progression au superviseur cron
self.env['ir.cron']._commit_progress(processed=1, remaining=n, deactivate=False)
# ⚠️ _notify_progress existe encore mais est @api.deprecated depuis la v19 :
# utiliser _commit_progress à la place.
Cet appel — déjà croisé dans la file mail.mail et la boucle de lots du mass-mailing — permet au superviseur de connaître l'avancement, de committer les progrès, et au besoin de désactiver un job (deactivate=True) qui n'a plus rien à faire ou qui échoue en série. À noter pour une base v19 : l'ancien _notify_progress est désormais marqué @api.deprecated au profit de _commit_progress — un détail qui compte si l'on reprend du code de module antérieur. Couplé à failure_count, ce mécanisme forme le socle de résilience v19 : un cron n'est plus une boîte noire qui tourne ou plante, mais un job observable et auto-régulé. Les modèles auxiliaires ir.cron.trigger et ir.cron.progress matérialisent respectivement les déclenchements hors-bande et l'état d'avancement.
6. Tester l'emailing sans rien envoyer
Passons à l'autre versant de l'industrialisation. Un module qui envoie des emails pose un problème de test évident : on ne veut surtout pas envoyer de vrais messages pendant la suite de tests. Odoo résout cela avec un outillage dédié dans mail.tests.common : la classe MockEmail (et sa combinaison MailCase = TransactionCase + MockEmail + BusCase) qui intercepte le gateway d'envoi.
# Hériter de MailCase, puis envelopper l'envoi dans mock_mail_gateway
from odoo.addons.mail.tests.common import MailCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestCampaign(MailCase):
def test_send_captures_outgoing(self):
with self.mock_mail_gateway(): # SMTP mocké : RIEN ne part
self.mailing.action_send_mail()
# la file ne se vide pas seule en test : on l'exécute à la main
self.env['mail.mail'].process_email_queue()
# tout ce qui « serait parti » est collecté :
# self._mails → dicts des emails sortants (email_to, subject, body…)
# self._new_mails → recordset des mail.mail créés dans le bloc
self.assertEqual(len(self._new_mails), 3)
Sous le with self.mock_mail_gateway(), Odoo remplace la connexion SMTP et instrumente mail.mail. Aucun octet ne quitte la machine. En échange, deux collecteurs se remplissent : self._mails, la liste des emails sortants tels qu'ils auraient été construits (avec email_from, email_to, subject, body…), et self._new_mails, le recordset des mail.mail créés pendant le bloc. On dispose ainsi de la matière exacte pour vérifier le contenu et le statut de chaque envoi.
7. Les assertions, et le piège du cron absent
Deux assertions de haut niveau évitent d'inspecter les collecteurs à la main :
# Vérifier un email RÉELLEMENT sorti par le gateway
self.assertSentEmail(
author=self.user_sender.partner_id,
recipients=[self.partner_client],
subject="Votre commande",
body_content="Merci pour votre achat", # le corps contient ce fragment
)
# Vérifier les mail.mail créés et leur état (sent / exception / …)
self.assertMailMail(
recipients=self.partner_client,
status='sent',
)
assertSentEmail contrôle un email passé par la passerelle sortante (sujet, destinataires, fragment de corps, reply_to, references…). assertMailMail vérifie l'existence des enregistrements mail.mail et leur state — c'est le pont avec l'article sur la file d'envoi : on teste qu'un message a bien atterri en sent (ou en exception pour un cas d'échec simulé).
mail.mail ne se vide pas toute seule : il faut appeler explicitement process_email_queue() (ou mail.send()) pour déclencher l'envoi. De plus, auto_commit vaut not current_test : en test, l'envoi ne commit pas, ce qui garantit un rollback propre entre les cas et permet de tout inspecter dans la même transaction. Pour un envoi programmé (scheduled_date future), figer le temps ou poser une date passée pour rendre le mail éligible.
mock_mail_gateway intercepte SMTP et mail.mail, remplit self._mails / self._new_mails, et assertSentEmail / assertMailMail vérifient le résultat — sans qu'aucun email ne parte vraiment.
8. Industrialiser — et clôture de la série
Planifier et tester sont les deux gestes qui rendent un module d'emailing déployable en confiance. Une checklist de fiabilité, en clôture :
- Crons : intervalle réaliste, pas de
numbercall/doall(v19), traitements longs instrumentés avec_commit_progress, jobs idempotents (rejouables sans dégât). - Déclenchement :
_triggerpour réveiller une file sans attendre ;method_direct_triggerpour le débogage manuel. - Tests : hériter de
MailCase, envelopper dansmock_mail_gateway, vider la file à la main (process_email_queue), vérifier avecassertSentEmailetassertMailMail. - Cas d'échec : tester aussi les
exception(mauvais expéditeur, destinataire vide), pas seulement le chemin heureux.
Cette série a parcouru l'infrastructure mail d'Odoo 19 de bout en bout : l'authentification et le transport (DNS, DKIM/SPF/DMARC, ir.mail_server, relais transactionnel, mail.alias.domain, réception fetchmail) ; le cœur applicatif (mail.thread et ses followers, mail.template, mail.render.mixin, les notifications et Discuss) ; la diffusion de masse et son suivi (mailing.mailing, mailing.trace, la file mail.mail) ; et enfin, ici, l'industrialisation (ir.cron et les tests). Un même fil les relie : en Odoo, un email n'est jamais un mystère. Chaque étape — du paquet DNS au statut d'une trace, jusqu'à l'assertion d'un test — est un objet qu'on peut lire, configurer et vérifier.
C'est précisément ce qui distingue une plateforme mature : non pas que rien ne casse, mais que tout soit observable. Savoir où regarder, savoir quoi tester — voilà la compétence que cette série visait à transmettre. La mécanique est désormais entre des mains qui savent la lire.
Voir aussi dans ce parcours Infrastructure
Déboguer la file d'envoi — mail.mail
La file que le cron draine et que les tests exercent via process_email_queue.
Lire l'article →Architecture mail Odoo — les 4 couches
Le HUB de la série : la vue d'ensemble que ce dernier article referme.
Lire l'article →Série Tech-Email — Article 14/14 (dernier) — Parcours Infrastructure emailing Odoo 19.
Articles complémentaires
Diffusion de masse — mailing.mailing
La campagne qui déclenche le cron via _trigger pour vider sa file.
Lire l'article →Configurer Brevo SMTP — ir.mail_server & from_filter
Le transport que mock_smtplib remplace en test pour ne rien envoyer.
Lire l'article →Source officielle : Odoo 19 — Testing modules.