« 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.
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 :
| Champ | Rôle |
|---|---|
failure_type | Le 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_reason | Le champ de diagnostic. Texte libre : « l'exception renvoyée par le serveur mail, stockée pour faciliter le débogage » |
scheduled_date | Date UTC avant laquelle la file n'enverra pas (sinon : dès que possible) |
auto_delete | Supprime le mail.mail après envoi réussi (économie d'espace — mais les sent « disparaissent ») |
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.
mail.mail part de outgoing vers sent (succès) ou exception (échec). action_retry ramène un exception en file ; cancel l'arrête définitivement.
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 transitoires — MemoryError, 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.
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 lesexceptionet les remetoutgoing— le cron les reprendra. Pour forcer tout de suite :action_send_and_close()ousend(). - Diagnostiquer avant de relancer : lire
failure_reason. Relancer sans corriger la cause (mauvaisfrom_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
exceptiondonne la liste des problèmes ; grouper parfailure_typesépare un souci de serveur (mail_smtp) d'adresses fautives.
outgoing échus, les envoie par lots de 1000 (commit par mail), répartit en sent/exception ; le throttle repousse les excédents via scheduled_date, et action_retry réinjecte les échecs.
8. Pièges récurrents
- Fouiller les logs avant le mail.
failure_reasonporte l'erreur SMTP exacte. C'est le point de départ, pas le journal serveur. - Croire un
sentperdu. Siauto_deleteest 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_datefuture. Il n'est pas bloqué : il attend (throttle ou programmation). Le cron le prendra à l'heure dite. - Relancer sans corriger.
action_retrysur unmail_smtpdont 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.messageetmail.mail. Supprimer unmail.mailnon-notification cascade jusqu'à sonmail.messageparent — 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.