Les articles précédents ont câblé le transport (SMTP), les adresses techniques, la passerelle entrante, puis le système d'abonnés qui décide qui reçoit quoi. Reste la question du contenu : comment Odoo transforme un modèle d'email figé — « Bonjour {{ nom }}, votre commande {{ référence }}… » — en un message personnalisé pour chaque destinataire ? La réponse est le modèle mail.template et son moteur de rendu, où se cache l'un des pièges les plus tenaces du framework : un même template utilise deux langages de gabarit différents selon le champ.
Cet article décortique mail.template en Odoo 19 : sa structure, ses deux moteurs de rendu (inline_template et qweb), le contexte d'évaluation, la génération par le code via _generate_template, l'envoi par send_mail, et le garde-fou de sécurité sur les expressions dynamiques. Il occupe la couche Template du panorama ouvert par l'article HUB sur les 4 couches mail et prolonge le mécanisme de notification décrit dans l'article sur mail.thread et les followers.
t-out / t-if / t-foreach). Les emails de notification s'appuyant sur un serveur SMTP configuré, la lecture des articles sur le transport est un plus mais n'est pas indispensable ici.
1. Pourquoi un modèle d'email
Coder en dur le corps d'un email dans une méthode Python est une fausse économie : le texte devient impossible à modifier sans intervention d'un développeur, intraduisible proprement, et illisible dès que la mise en forme HTML s'en mêle. Le modèle mail.template sépare le contenu (qu'un fonctionnel peut éditer) de la logique d'envoi (qui reste dans le code). C'est la couche Template : un gabarit paramétrable, rendu à la volée sur un enregistrement précis, qui produit le message final confié ensuite au transport.
Concrètement, un mail.template est rattaché à un modèle (model_id) — sale.order, res.partner, account.move… — et sait se rendre sur n'importe quel enregistrement de ce modèle. Le même gabarit « Confirmation de commande » produit un email différent pour chaque commande, en y injectant le client, le montant, les lignes, dans la langue du destinataire.
2. Anatomie de mail.template
Le modèle hérite de deux mixins : mail.render.mixin (toute la machinerie de rendu, détaillée dans l'article suivant) et template.reset.mixin (réinitialisation aux valeurs du module). Ses champs se répartissent entre ciblage, contenu, destinataires et options d'envoi.
| Champ | Rôle | Moteur de rendu |
|---|---|---|
model_id / model | Modèle cible du rendu (« Applies to ») | — |
subject | Sujet de l'email | inline_template |
body_html | Corps HTML du message | qweb |
email_from | Expéditeur (défaut : alias/email de l'auteur) | inline_template |
email_to / partner_to / email_cc | Destinataires (emails / ids partners / copie) | inline_template |
use_default_to | Utiliser les destinataires par défaut du record | — |
attachment_ids | Pièces jointes statiques | — |
report_template_ids | Rapports PDF générés dynamiquement à l'envoi | — |
mail_server_id | Serveur SMTP sortant préféré | — |
scheduled_date | Envoi différé (expression dynamique possible) | inline_template |
email_layout_xmlid | Layout de notification encapsulant l'email | — |
auto_delete | Supprimer le mail.mail après envoi | — |
lang | Langue de rendu (ex. {{ object.partner_id.lang }}) | inline_template |
Cette répartition des moteurs n'est pas anecdotique : elle est la source de la confusion la plus fréquente sur les templates Odoo, qu'on aborde immédiatement.
3. Le piège central : deux moteurs de rendu
Un mail.template n'utilise pas un langage de gabarit unique. Il en utilise deux, choisis champ par champ via l'attribut render_engine de chaque champ. Dans la définition du modèle, le corps est déclaré explicitement en QWeb :
# mail/models/mail_template.py (Odoo 19) — extrait de la définition des champs
subject = fields.Char('Subject', translate=True) # moteur par défaut : inline_template
body_html = fields.Html(
'Body',
render_engine='qweb', # ← le CORPS est rendu en QWeb
render_options={'post_process': True},
sanitize='email_outgoing',
)
Les conséquences sont nettes :
- Le sujet et les destinataires (
subject,email_to,partner_to,email_from) utilisent le moteurinline_template: la syntaxe à double accolade{{ object.partner_id.name }}, avec des blocs{% ... %}pour la logique. - Le corps (
body_html) utilise le moteurqweb: la syntaxe QWeb complète —<t t-out="object.name"/>,<t t-if="...">,<t t-foreach="object.order_line" t-as="line">.
<t t-out="..."/> dans le sujet ne fonctionne pas (le sujet attend {{ }}). Écrire {{ object.name }} dans le corps ne fonctionne pas non plus (le corps attend t-out). C'est la confusion n°1 sur les templates Odoo : le langage dépend du champ.
<!-- SUJET (moteur inline_template) : double accolade -->
Votre commande {{ object.name }} est confirmée
<!-- CORPS body_html (moteur qweb) : balises t-* -->
<p>Bonjour <t t-out="object.partner_id.name"/>,</p>
<p>Votre commande <strong><t t-out="object.name"/></strong>
d'un montant de <t t-out="format_amount(object.amount_total, object.currency_id)"/>
est confirmée.</p>
<t t-if="object.order_line">
<ul>
<li t-foreach="object.order_line" t-as="line">
<t t-out="line.product_id.display_name"/> — <t t-out="line.product_uom_qty"/>
</li>
</ul>
</t>
Un troisième moteur existe, qweb_view, qui pointe vers une vue QWeb (ir.ui.view) au lieu d'un texte stocké — réservé aux cas avancés où le corps mérite d'être un template à part entière, versionné dans un module.
inline_template (double accolade) pour le sujet et les destinataires, qweb (balises t-*) pour le corps HTML.
4. Le contexte d'évaluation
Quel que soit le moteur, le rendu dispose du même jeu de variables, fourni par _render_eval_context. La variable reine est object : le record sur lequel le template est rendu. À ses côtés, quelques outils pratiques.
| Variable | Contenu |
|---|---|
object | Le record cible (ex. la sale.order rendue) — point d'entrée vers tous ses champs |
user | L'utilisateur courant (res.users) |
ctx | Le contexte courant (dictionnaire) |
format_date / format_datetime / format_time | Formatage localisé des dates/heures |
format_amount / format_duration | Formatage monétaire (montant + devise) et de durée |
format_addr | Construction d'une adresse email « Nom <email> » |
env | L'environnement ORM complet — requêtes arbitraires possibles (d'où le garde-fou de sécurité, section 8) |
Le contexte expose également quelques utilitaires (is_html_empty, slug) ainsi qu'un sous-ensemble de fonctions Python sûres. La variable env mérite une attention particulière : elle ouvre tout l'ORM ({{ env['res.partner'].search(...) }}), ce qui explique pourquoi l'édition des templates dynamiques est protégée par un groupe dédié — on y revient en section 8.
Ces outils évitent les pièges classiques : afficher une date au format de la langue du destinataire, ou un montant avec le bon séparateur et le bon symbole de devise. Dans le corps, cela donne <t t-out="format_amount(object.amount_total, object.currency_id)"/> ; dans le sujet, {{ format_date(object.date_order) }}.
5. Rendre un template par le code
La méthode centrale est _generate_template(res_ids, render_fields). Elle rend les champs demandés du template sur une liste d'enregistrements et renvoie un dictionnaire {res_id: {champ: valeur_rendue}}. Son intelligence tient à plusieurs traitements spécialisés sous le capot.
# À exécuter dans odoo-bin shell (mode debug)
template = env.ref('sale.email_template_edi_sale') # template de confirmation de commande
order = env['sale.order'].browse(ORDER_ID)
# Rendre les champs voulus sur ce record :
values = template._generate_template(
order.ids,
['subject', 'body_html', 'email_to', 'partner_to', 'scheduled_date'],
)
rendered = values[order.id]
print(rendered['subject']) # sujet rendu (inline_template)
print(rendered['body_html']) # corps HTML rendu (qweb)
Deux points méritent attention. D'abord, le rendu est groupé par langue : la méthode _classify_per_lang répartit les enregistrements selon la langue de leur destinataire, de sorte qu'un même appel produise des sujets et des corps traduits correctement pour chaque public. Ensuite, certains champs ne passent pas par le rendu de texte simple : les destinataires (email_to, partner_to, email_cc), la date programmée, les valeurs statiques (serveur SMTP, layout, auto_delete) et les pièces jointes sont calculés par des sous-méthodes dédiées. Demander report_template_ids déclenche par exemple la génération des PDF et leur ajout, encodés en base64, sous une clé attachments.
6. Envoyer l'email
Le rendu produit des valeurs ; l'envoi en fait un message. La méthode send_mail(res_id, …) rend le template sur le record et crée un mail.mail prêt à partir.
# Envoi via la FILE (recommandé) : le mail part au prochain passage du cron mail queue
mail_id = template.send_mail(order.id) # retourne un int : l'id du mail.mail créé
# Envoi IMMÉDIAT (synchrone) : à réserver aux cas ponctuels, jamais en masse
mail_id = template.send_mail(order.id, force_send=True)
# Surcharger ponctuellement le mail généré :
template.send_mail(order.id, email_values={'email_cc': 'archive@example.com'})
Le choix force_send est structurant. Par défaut (force_send=False), le mail.mail est déposé dans la file d'envoi et expédié par le cron de la queue — c'est le mode recommandé, surtout en volume, car il découple la transaction métier de la latence SMTP. Avec force_send=True, l'envoi est synchrone : utile pour un test ou un envoi unitaire, à proscrire dans une boucle sur de nombreux enregistrements, où il bloquerait la transaction et multiplierait les connexions au relais.
Côté droits, send_mail n'est pas un passe-droit : _send_check_access vérifie un accès en lecture sur les enregistrements cibles. On ne peut envoyer un template que sur des documents que l'on a le droit de lire — un garde-fou contre l'exfiltration de données via un template envoyé sur des records inaccessibles.
mail.mail ; déposé dans la file, il est expédié par le cron de la queue vers le serveur SMTP de la couche transport.
7. Pièces jointes, PDF dynamiques et langue
Un template gère deux natures de pièces jointes. Les pièces statiques (attachment_ids) sont des ir.attachment identiques pour tous les destinataires — une plaquette commerciale, des conditions générales. Les rapports dynamiques (report_template_ids, des ir.actions.report) sont au contraire générés à l'envoi, sur le record courant : la facture PDF d'une commande, le bon de livraison. C'est ce mécanisme qui attache automatiquement le PDF de facture à l'email de confirmation, sans intervention.
La langue mérite une mention particulière. Le champ lang du template — typiquement {{ object.partner_id.lang }} — détermine la langue de rendu de chaque enregistrement. Couplé au regroupement _classify_per_lang, il garantit qu'un envoi en lot produit un sujet, un corps et même des PDF rendus dans la langue propre à chaque destinataire. Enfin, le champ scheduled_date accepte une expression dynamique : un email peut être programmé pour partir à une date calculée à partir du record, et non à une date figée.
8. Sécurité et pièges
Parce qu'un template évalue des expressions sur des records, il est un vecteur d'injection potentiel. Odoo encadre cela par un groupe dédié, « Mail Template Editor » (mail.group_mail_template_editor). Un utilisateur qui n'en est pas membre et qui tente d'enregistrer un template contenant des expressions jugées « sensibles » se voit opposer une AccessError : la méthode _has_unsafe_expression inspecte chaque champ (analyse du code QWeb pour le corps, des expressions pour l'inline), et _check_access_right_dynamic_template bloque l'enregistrement.
sudo(). La tentation, face à cette AccessError, est d'enregistrer le template en sudo() pour « passer outre ». C'est exactement ce que le garde-fou cherche à empêcher : un template dynamique posé sans contrôle est une porte d'entrée vers l'exécution de code arbitraire. La bonne réponse est d'attribuer le groupe éditeur à l'utilisateur habilité, pas de neutraliser la vérification.
Les autres pièges récurrents :
- Confondre les deux moteurs. Le piège n°1, répété ici car il coûte des heures :
{{ }}dans le sujet et les destinataires,t-outdans le corps. Jamais l'inverse. t-escdans le corps. En Odoo 19, le corps QWeb utiliset-out;t-escappartient au passé et doit être proscrit (règle générale QWeb v19).force_send=Trueen boucle. Bloque la transaction et sature le relais. En volume, toujours laisser la file faire son travail.- Oublier la langue. Sans
langrenseigné, tous les destinataires reçoivent le rendu dans la langue par défaut — un client anglophone recevant un email en français passe mal. - Pièce statique vs rapport dynamique. Pour joindre une facture PDF propre à chaque commande, c'est
report_template_ids, pasattachment_ids(qui resterait identique pour tous).
À retenir : mail.template est la fabrique de contenu de l'email Odoo. Sa subtilité tient à ses deux moteurs — la double accolade pour les champs simples, QWeb pour le corps —, à son rendu par langue et à son garde-fou de sécurité. Bien compris, il transforme un gabarit unique en autant d'emails personnalisés ; mal compris, il produit des placeholders littéraux dans les sujets et des notes de débogage chez les clients.
L'article suivant plongera sous le capot du rendu : le mixin mail.render.mixin, le détail des placeholders, la méthode _render_field, le sous-rendu et la gestion fine des langues — la mécanique qui fait fonctionner tout ce que cet article a survolé.
Voir aussi dans ce parcours Infrastructure
mail.thread — followers, sous-types et notifications
À qui partent les emails rendus par un template, et selon quels sous-types.
Lire l'article →Architecture mail Odoo — les 4 couches
Le HUB de la série : ici, on a détaillé la couche Template.
Lire l'article →Série Tech-Email — Article 8/14 — Parcours Infrastructure emailing Odoo 19.
Articles complémentaires
Email templates et mail.thread — envoi auto depuis create/write
Déclencher l'envoi d'un template depuis le cycle de vie ORM.
Lire l'article →Configurer Brevo SMTP — ir.mail_server & from_filter
Le serveur sortant qui expédie le mail.mail produit par le template.
Lire l'article →Source officielle : Odoo 19 — Mixins (mail.render.mixin).