Se rendre au contenu

mail.template en Odoo 19 — rendu QWeb, placeholders et envoi d'emails

Série Tech-Email · Article 8/14 · Parcours Infrastructure
6 juin 2026 par
mail.template en Odoo 19 — rendu QWeb, placeholders et envoi d'emails
B.Mustapha
| Aucun commentaire pour l'instant

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.

Prérequis lecteur. Une instance Odoo 19 (CE ou EE) en mode debug, un accès admin, et des bases de QWeb (le langage de gabarit d'Odoo, 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.

ChampRôleMoteur de rendu
model_id / modelModèle cible du rendu (« Applies to »)
subjectSujet de l'emailinline_template
body_htmlCorps HTML du messageqweb
email_fromExpéditeur (défaut : alias/email de l'auteur)inline_template
email_to / partner_to / email_ccDestinataires (emails / ids partners / copie)inline_template
use_default_toUtiliser les destinataires par défaut du record
attachment_idsPièces jointes statiques
report_template_idsRapports PDF générés dynamiquement à l'envoi
mail_server_idServeur SMTP sortant préféré
scheduled_dateEnvoi différé (expression dynamique possible)inline_template
email_layout_xmlidLayout de notification encapsulant l'email
auto_deleteSupprimer le mail.mail après envoi
langLangue 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 moteur inline_template : la syntaxe à double accolade {{ object.partner_id.name }}, avec des blocs {% ... %} pour la logique.
  • Le corps (body_html) utilise le moteur qweb : la syntaxe QWeb complète — <t t-out="object.name"/>, <t t-if="...">, <t t-foreach="object.order_line" t-as="line">.
À retenir. Écrire <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.

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.

VariableContenu
objectLe record cible (ex. la sale.order rendue) — point d'entrée vers tous ses champs
userL'utilisateur courant (res.users)
ctxLe contexte courant (dictionnaire)
format_date / format_datetime / format_timeFormatage localisé des dates/heures
format_amount / format_durationFormatage monétaire (montant + devise) et de durée
format_addrConstruction d'une adresse email « Nom <email> »
envL'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.

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.

⚠️ Ne pas contourner par 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-out dans le corps. Jamais l'inverse.
  • t-esc dans le corps. En Odoo 19, le corps QWeb utilise t-out ; t-esc appartient au passé et doit être proscrit (règle générale QWeb v19).
  • force_send=True en boucle. Bloque la transaction et sature le relais. En volume, toujours laisser la file faire son travail.
  • Oublier la langue. Sans lang renseigné, 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, pas attachment_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).

Se connecter pour laisser un commentaire.
mail.thread en Odoo 19 — followers, sous-types et notifications du Chatter
Série Tech-Email · Article 7/14 · Parcours Infrastructure