Se rendre au contenu

Planifier et tester l'emailing en Odoo 19 — ir.cron, déclencheurs et MockEmail

Série Tech-Email · Article 14/14 · Parcours Infrastructure
6 juin 2026 par
Planifier et tester l'emailing en Odoo 19 — ir.cron, déclencheurs et MockEmail
B.Mustapha
| Aucun commentaire pour l'instant

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.

Prérequis lecteur. Le mode debug (les crons sont sous Paramètres techniques → Automatisation → Actions planifiées), des notions de tests Odoo (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.

Champv19Conséquence
numbercallsupprimé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.
doallsuppriméPlus de rattrapage automatique des exécutions manquées.
failure_countajoutéNombre d'échecs consécutifs, remis à zéro au premier succès.
first_failure_dateajouté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.

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é).

Le piège à connaître. En test, aucun cron ne tourne. La file 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.

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 : _trigger pour réveiller une file sans attendre ; method_direct_trigger pour le débogage manuel.
  • Tests : hériter de MailCase, envelopper dans mock_mail_gateway, vider la file à la main (process_email_queue), vérifier avec assertSentEmail et assertMailMail.
  • 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.

Se connecter pour laisser un commentaire.
Déboguer la file d'envoi en Odoo 19 — mail.mail, états, exceptions et relance
Série Tech-Email · Article 13/14 · Parcours Infrastructure