Se rendre au contenu

Déboguer la file d'envoi en Odoo 19 — mail.mail, états, exceptions et relance

Série Tech-Email · Article 13/14 · Parcours Infrastructure
6 juin 2026 par
Déboguer la file d'envoi en Odoo 19 — mail.mail, états, exceptions et relance
B.Mustapha
| Aucun commentaire pour l'instant

« L'email n'est pas parti. » Derrière cette phrase banale se cache un objet bien précis : un enregistrement mail.mail resté dans un état qui n'est pas sent. Toute la série a décrit comment un message est composé, rendu, adressé, tracé — mais entre le moment où Odoo décide d'envoyer et celui où le serveur distant accepte le courrier, il y a une file d'attente technique. Quand quelque chose cloche, c'est là, dans mail.mail, que se lit la cause exacte. Cet article apprend à la lire et à la débloquer.

L'angle est résolument opérationnel : comprendre la machine à états d'un mail.mail, savoir où le cron va le chercher, ce que send() en fait, et — surtout — quel champ consulter en premier quand un envoi échoue. Maîtriser cette couche, c'est cesser de fouiller les journaux serveur à l'aveugle pour aller droit à l'enregistrement qui porte la réponse.

Prérequis lecteur. Le mode debug actif (le menu Paramètres techniques → Emails liste les mail.mail), et la lecture des articles sur le transport SMTP et la traçabilité. On suppose acquis qu'un email part par un serveur sortant choisi selon son from_filter.

1. mail.mail, la file technique

Il faut distinguer deux objets que l'on confond souvent. Le mail.message est le contenu persistant — ce qui s'affiche dans le Chatter. Le mail.mail, lui, est l'email sortant concret, l'enveloppe qui doit être remise à un serveur SMTP. Un message peut donner lieu à plusieurs mail.mail (un par destinataire email), et c'est ce dernier qui porte l'état d'expédition.

Le lien avec l'article précédent est direct : la mailing.trace d'une campagne reflète le sort d'un mail.mail (via mail_mail_id). Un mail.mail en échec produit une trace en erreur. Mais la trace est un résumé analytique ; le mail.mail, c'est la réalité technique — avec, en clair, le message d'erreur du serveur. Pour déboguer, on remonte toujours de la trace vers le mail.mail.

2. Anatomie d'un mail.mail

Quelques champs concentrent tout l'intérêt pour le débogage. Le premier est l'état :

# mail/models/mail_mail.py — la machine à états
state = fields.Selection([
    ('outgoing', 'Outgoing'),          # en file, à envoyer
    ('sent', 'Sent'),                  # parti avec succès
    ('received', 'Received'),          # email entrant (passerelle)
    ('exception', 'Delivery Failed'),  # échec d'envoi
    ('cancel', 'Cancelled'),           # annulé manuellement
], 'Status', readonly=True, copy=False, default='outgoing')

Un email naît outgoing. S'il part, il devient sent ; s'il échoue, exception ; s'il est annulé, cancel. L'état est en lecture seule : on ne le modifie pas à la main dans la base, mais via les méthodes dédiées (section 7). Autour de cet état, trois champs cruciaux :

ChampRôle
failure_typeLe type d'échec, typé : mail_smtp (connexion serveur), mail_email_invalid, mail_email_missing, mail_from_invalid/mail_from_missing, mail_spam, mail_bl (blacklisté), mail_optout, mail_dup, unknown
failure_reasonLe champ de diagnostic. Texte libre : « l'exception renvoyée par le serveur mail, stockée pour faciliter le débogage »
scheduled_dateDate UTC avant laquelle la file n'enverra pas (sinon : dès que possible)
auto_deleteSupprime le mail.mail après envoi réussi (économie d'espace — mais les sent « disparaissent »)
Le bon réflexe. Devant un email qui n'est pas parti, la première chose à lire n'est pas le journal du serveur : c'est failure_reason sur le mail.mail en exception. Odoo y recopie l'erreur SMTP brute. Neuf fois sur dix, le diagnostic est là, en une ligne.

3. Le cron « Email Queue Manager »

Les emails ne partent pas au moment où on clique : ils sont mis en file, puis expédiés par un cron planifié. Celui-ci s'appelle Mail: Email Queue Manager et son code tient en une ligne :

<!-- mail/data/ir_cron_data.xml -->
<record id="ir_cron_mail_scheduler_action" model="ir.cron">
    <field name="name">Mail: Email Queue Manager</field>
    <field name="code">model.process_email_queue(batch_size=1000)</field>
</record>
# mail/models/mail_mail.py — process_email_queue (cœur)
@api.model
def process_email_queue(self, email_ids=(), batch_size=1000):
    domain = ['&', ('state', '=', 'outgoing'),
              '|', ('scheduled_date', '=', False),
                   ('scheduled_date', '<=', datetime.datetime.utcnow())]
    batch_size = int(self.env['ir.config_parameter'].sudo()
                     .get_param('mail.mail.queue.batch.size', batch_size)) or batch_size
    send_ids = self.search(domain, limit=batch_size).ids
    ...
    auto_commit = not modules.module.current_test
    self.browse(send_ids).send(auto_commit=auto_commit, post_send_callback=post_send_callback)

Ce cron s'exécute par défaut toutes les heures (interval_number=1, interval_type=hours) : un email mis en file peut donc attendre jusqu'à un cycle avant de partir, sauf déclenchement immédiat. Le domaine est limpide : il prend les mail.mail en état outgoing dont la scheduled_date est nulle ou échue. La taille de lot est plafonnée (paramètre système mail.mail.queue.batch.size, défaut 1000) pour borner le temps d'exécution. Et le commentaire du code est explicite : l'envoi « commit après chaque message — ce n'est pas transactionnel ». Un email parti reste parti, même si le suivant plante.

4. send() : l'expédition

La méthode send() est le moteur d'expédition. Sa première intelligence : regrouper les mails par configuration d'envoi avant d'ouvrir une session SMTP, pour ne pas reconnecter à chaque message.

# mail/models/mail_mail.py — send() (cœur)
for mail_server_id, alias_domain_id, smtp_from, batch_ids in self._split_by_mail_configuration():
    try:
        smtp_session = self.env['ir.mail_server']._connect__(
            mail_server_id=mail_server_id, smtp_from=smtp_from)
    except Exception as exc:
        if raise_exception:
            raise MailDeliveryException(_('Unable to connect to SMTP Server'), exc)
        else:
            batch = self.browse(batch_ids)
            batch.write({'state': 'exception',
                         'failure_reason': tools.exception_to_unicode(exc)})
            batch._postprocess_sent_message(success_pids=[], success_emails=[],
                                            failure_type="mail_smtp")
    else:
        self.browse(batch_ids)._send(auto_commit=auto_commit, smtp_session=smtp_session, ...)
    finally:
        if smtp_session: smtp_session.quit()

_split_by_mail_configuration répartit les mails par serveur sortant, domaine d'alias et adresse from — c'est ici que le from_filter vu plus tôt dans la série désigne quel ir.mail_server traite chaque groupe. Puis Odoo ouvre une session SMTP par groupe et envoie le lot.

Point décisif pour le débogage : si la connexion SMTP elle-même échoue, tout le lot bascule en exception, avec failure_type='mail_smtp' et l'exception recopiée dans failure_reason. Un parc de mails en mail_smtp ne pointe pas un destinataire fautif mais une configuration de serveur sortant cassée (identifiants, port, TLS). En mode raise_exception=True, l'erreur remonte au lieu d'être stockée — utile pour un test manuel en console.

5. _send() : le résultat, mail par mail

Une fois la session ouverte, _send() envoie chaque mail et écrit son sort :

# mail/models/mail_mail.py — _send() (extrait du résultat)
if res:   # le serveur a renvoyé un message_id → parti au moins une fois
    mail.write({'state': 'sent', 'message_id': res,
                'failure_type': False, 'failure_reason': False})
else:
    mail_vals = {}
    if failure_reason: mail_vals['failure_reason'] = failure_reason
    if failure_type:   mail_vals['failure_type'] = failure_type
    if mail_vals: mail.write(mail_vals)
mail._postprocess_sent_message(success_pids, success_emails, failure_type, failure_reason)

Le succès est binaire : le serveur a renvoyé un message_id → l'état passe sent, et les champs d'échec sont remis à zéro (un renvoi réussi efface l'erreur précédente). L'extrait ci-dessus est la branche d'échec partiel (certains destinataires acceptés, d'autres invalides). Sur un échec franc, c'est le bloc except Exception de _send() qui prend la main et écrit explicitement state='exception' en plus de failure_reason/failure_type — c'est ce chemin qui produit la plupart des exception visibles dans la file. Une nuance importante de robustesse : les erreurs graves et transitoiresMemoryError, erreur PostgreSQL, session SMTP brutalement coupée — ne marquent pas le mail en échec : elles remontent, la transaction est annulée, et le mail reste outgoing pour être re-tenté au prochain passage du cron. On ne « brûle » pas un email sur une panne passagère.

Piège auto_delete. Le post-traitement supprime le mail.mail si auto_delete est vrai et l'envoi réussi — d'où des sent introuvables (c'est voulu, pour l'espace disque). Subtilité : les échecs mail_email_invalid/mail_email_missing sont aussi supprimés (rien à déboguer côté serveur), mais les autres erreurs conservent le mail, précisément pour qu'on puisse lire failure_reason.

6. Le throttle des serveurs personnels

Tous les serveurs sortants ne se valent pas : un ir.mail_server « personnel » (rattaché à un utilisateur via owner_user_id) est soumis à un quota. Odoo applique alors un limiteur de débit qui étale l'envoi dans le temps plutôt que de saturer le fournisseur.

# mail/models/mail_mail.py — étalement au-delà du quota (extrait)
MAX_SEND = mail_server._get_personal_mail_servers_limit()
...
if owner_limit_count >= MAX_SEND:
    server_limit_minute += timedelta(minutes=1)
mail.scheduled_date = server_limit_minute        # repoussé dans le futur
...
self.env.ref('mail.ir_cron_mail_scheduler_action')._trigger(
    min(to_delay.mapped('scheduled_date')) + timedelta(seconds=59))

Au-delà de MAX_SEND envois, les mails excédentaires voient leur scheduled_date repoussée minute après minute, et le cron est re-programmé en conséquence. Conséquence concrète au débogage : un mail « bloqué » en outgoing avec une scheduled_date dans le futur n'est pas en panne — il attend son tour. Inutile de le forcer ; il partira à l'heure prévue.

7. Déboguer et relancer

Comprendre les états, c'est savoir agir dessus. Odoo expose des méthodes simples — toutes accessibles depuis la liste des emails en mode debug, ou en console :

# Remettre en file un email en échec
def mark_outgoing(self):
    return self.write({'state': 'outgoing'})

# Annuler définitivement
def cancel(self):
    return self.write({'state': 'cancel'})

# Le bouton « Réessayer » : ne relance que les échecs
def action_retry(self):
    self.filtered(lambda mail: mail.state == 'exception').mark_outgoing()

En pratique, la boîte à outils est la suivante :

  • Relancer les échecs : action_retry() (bouton « Réessayer ») ne touche que les exception et les remet outgoing — le cron les reprendra. Pour forcer tout de suite : action_send_and_close() ou send().
  • Diagnostiquer avant de relancer : lire failure_reason. Relancer sans corriger la cause (mauvais from_filter, serveur down) ne fera que reproduire l'exception.
  • Forcer l'erreur : en console, mail.send(raise_exception=True) fait remonter l'exception au lieu de la stocker — idéal pour voir la trace complète.
  • Annuler : cancel() sort un mail de la file sans l'envoyer (campagne erronée, test).
  • Filtrer la file : dans Paramètres techniques → Emails, filtrer par état exception donne la liste des problèmes ; grouper par failure_type sépare un souci de serveur (mail_smtp) d'adresses fautives.

8. Pièges récurrents

  • Fouiller les logs avant le mail. failure_reason porte l'erreur SMTP exacte. C'est le point de départ, pas le journal serveur.
  • Croire un sent perdu. Si auto_delete est actif, les emails réussis sont supprimés après envoi. L'absence d'enregistrement ne signifie pas l'absence d'envoi.
  • Forcer un mail en scheduled_date future. Il n'est pas bloqué : il attend (throttle ou programmation). Le cron le prendra à l'heure dite.
  • Relancer sans corriger. action_retry sur un mail_smtp dont le serveur est toujours cassé reproduira l'exception. Corriger la cause d'abord.
  • Attendre une file transactionnelle. Le cron commit par message : un lot peut être partiellement parti. C'est par design, et cohérent avec la reprise au passage suivant.
  • Confondre mail.message et mail.mail. Supprimer un mail.mail non-notification cascade jusqu'à son mail.message parent — attention en nettoyage manuel.

À retenir : mail.mail est la vérité du terrain de l'envoi. Sa machine à états dit où en est chaque email ; failure_reason dit pourquoi un échec ; le cron Email Queue Manager dit quand il repartira ; et une poignée de méthodes — action_retry, mark_outgoing, cancel, send(raise_exception=True) — suffisent à reprendre la main. Déboguer un email en Odoo, ce n'est pas deviner : c'est lire le bon enregistrement.

Un acteur est apparu à chaque étape de cette mécanique sans jamais être expliqué : le cron lui-même. Comment Odoo planifie-t-il ces tâches d'arrière-plan, comment les déclenche-t-on, et — pour un module d'emailing fiable — comment les teste-t-on ? Ce sera le dernier volet de la série.

Voir aussi dans ce parcours Infrastructure

Traçabilité d'une campagne — mailing.trace

L'analytique qui reflète le sort de chaque mail.mail d'une campagne.

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

Le serveur sortant que send() choisit, et la source des erreurs mail_smtp.

Lire l'article →

Série Tech-Email — Article 13/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 file d'envoi dans le flux complet.

Lire l'article →
Diffusion de masse — mailing.mailing

La campagne qui remplit la file et propose action_retry_failed.

Lire l'article →

Source officielle : Odoo 19 — Email Communication.

Se connecter pour laisser un commentaire.
Traçabilité d'une campagne en Odoo 19 — mailing.trace, ouvertures, clics et rebonds
Série Tech-Email · Article 12/14 · Parcours Infrastructure