L'article précédent a présenté mail.template et son curieux dédoublement : la double accolade pour le sujet, QWeb pour le corps. Mais qui exécute réellement ce rendu ? Quel composant transforme {{ object.partner_id.name }} en « Dupont » pour une commande et en « Martin » pour une autre, dans la langue de chacun, sans jamais ouvrir une faille d'exécution de code ? La réponse est un mixin discret mais central : mail.render.mixin, le moteur de rendu partagé par tout ce qu'Odoo personnalise — templates, mailings, descriptions de sous-types.
Cet article ouvre le capot du rendu en Odoo 19 : le point d'entrée _render_field, le dispatcher _render_template, les trois moteurs (inline_template, qweb, qweb_view), la syntaxe exacte des placeholders, le mécanisme de sécurité « à deux vitesses » et la résolution des langues. Il prolonge directement l'article sur mail.template et s'inscrit dans la couche Template de l'architecture mail à 4 couches.
t-out, t-if) et la lecture de l'article sur mail.template. Cet article descend d'un cran : il s'adresse au développeur qui veut comprendre — ou déboguer — ce qui se passe entre un gabarit et la chaîne finale.
1. Un moteur de rendu mutualisé
Plutôt que de redévelopper un rendu de gabarit dans chaque modèle qui en a besoin, Odoo le concentre dans un mixin : mail.render.mixin. C'est lui dont hérite mail.template, mais aussi mailing.mailing (les campagnes de masse), les descriptions de mail.message.subtype, et tout modèle qui veut produire du texte personnalisé à partir d'un gabarit et d'un enregistrement. Le principe est constant : prendre un texte-gabarit, un modèle et une liste d'identifiants, et renvoyer un dictionnaire {res_id: texte rendu}.
Centraliser ce rendu n'a pas qu'un intérêt de réutilisation : cela permet d'y concentrer les garde-fous de sécurité, la gestion des langues et le post-traitement des liens, une fois pour toutes. Quand on comprend ce mixin, on comprend du même coup comment se rendent les emails transactionnels, les campagnes marketing et les notifications d'assignation.
2. Le point d'entrée : _render_field
La méthode que les modèles appellent en pratique est _render_field(field, res_ids). Elle rend un champ du gabarit — body_html ou subject sur un mail.template — sur une liste d'enregistrements. Son intelligence tient en deux gestes.
D'abord, elle déduit le moteur du champ lui-même. Chaque champ peut porter un attribut render_engine ; _render_field le lit et l'applique :
# mail/models/mail_render_mixin.py — _render_field (extrait)
f = self._fields[field]
if hasattr(f, 'render_engine') and f.render_engine:
engine = f.render_engine # body_html → 'qweb' ; Char → 'inline_template'
render_options = options.copy() if options else {}
if hasattr(f, 'render_options') and f.render_options:
render_options = {**f.render_options, **render_options} # ex. {'post_process': True}
C'est pourquoi, dans l'article précédent, aucun appel n'avait à préciser « rends ce champ en QWeb » : l'information vit sur la définition du champ body_html (render_engine='qweb'), et le mixin la récupère. Ensuite, _render_field résout la langue de chaque enregistrement — soit en la calculant (compute_lang), soit à partir d'une correspondance fournie, soit en la forçant — avant de déléguer au dispatcher.
3. Le dispatcher : _render_template
Au centre se trouve _render_template(template_src, model, res_ids, engine, options). Avant tout rendu, il valide rigoureusement ses entrées — un garde-fou contre les appels malformés :
res_idsdoit être une liste ou un tuple (jamais un identifiant nu), sinon uneValueErrorexplicite.enginedoit appartenir à{'inline_template', 'qweb', 'qweb_view'}.- Les
optionsdoivent être un sous-ensemble de{'post_process', 'preserve_comments'}— toute clé inconnue est rejetée.
Une fois validé, il aiguille vers le bon moteur, puis applique le post-traitement si post_process est demandé. La structure est limpide :
# _render_template — aiguillage (extrait)
if engine == 'qweb_view':
rendered = self._render_template_qweb_view(template_src, model, res_ids, ...)
elif engine == 'qweb':
rendered = self._render_template_qweb(template_src, model, res_ids, ...)
else:
rendered = self._render_template_inline_template(template_src, model, res_ids, ...)
if options.get('post_process'): # liens locaux → absolus
rendered = self._render_template_postprocess(model, rendered)
return rendered
_render_field récupère le moteur depuis le champ et résout la langue ; _render_template valide puis aiguille vers l'un des trois moteurs.
4. Le moteur inline_template et ses placeholders
C'est le moteur par défaut, celui du sujet et des destinataires. Sa syntaxe est minimale, fondée sur la double accolade. La définition exacte du motif vit dans les outils de rendu d'Odoo :
# odoo/tools/rendering_tools.py
INLINE_TEMPLATE_REGEX = re.compile(r"\{\{(.+?)(\|\|\|\s*(.*?))?\}\}")
# → {{ expression ||| valeur_par_défaut }}
Chaque placeholder est donc une expression, suivie d'une valeur de repli optionnelle introduite par |||. La valeur par défaut s'affiche lorsque l'expression est vide — un garde-fou élégant contre les « Bonjour , » disgracieux quand un nom manque.
<!-- Sujet (inline_template) avec valeur de repli -->
Bonjour {{ object.partner_id.name ||| client }}, votre devis {{ object.name }}
<!-- Si le partenaire n'a pas de nom, « client » s'affiche à la place. -->
En interne, parse_inline_template découpe le texte en une suite de triplets (texte, expression, défaut) que le moteur réassemble en injectant la valeur de chaque expression. La variable reine reste object (le record), complétée par les outils de formatage vus dans l'article précédent (format_date, format_amount…).
5. Les moteurs qweb et qweb_view
Le moteur qweb rend le corps HTML (body_html). Techniquement, le texte est enveloppé dans un <div> temporaire — pour tolérer plusieurs nœuds racines —, passé au moteur ir.qweb, puis l'emballage est retiré du résultat. Il accepte la pleine puissance de QWeb : conditions, boucles, sous-templates.
<!-- Corps body_html (moteur qweb) -->
<p>Bonjour <t t-out="object.partner_id.name"/>,</p>
<t t-if="object.state == 'sale'">
<p>Votre commande est confirmée. Détail :</p>
<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>
Le troisième moteur, qweb_view, ne rend pas un texte stocké en base mais une vue QWeb (ir.ui.view) référencée par son identifiant. C'est le choix des corps d'emails que l'on veut versionner dans un module, sous contrôle de code, plutôt que d'éditer en base — typiquement pour des templates structurants livrés avec une application.
6. Le cœur de la sécurité : un rendu à deux vitesses
Voici la mécanique la plus importante du mixin, et la plus mal comprise. Rendre un gabarit revient à évaluer des expressions sur des records ; mal encadré, cela autoriserait l'exécution de code arbitraire. Odoo résout cela par une stratégie à deux vitesses, appliquée par chaque moteur avant tout rendu.
Les deux moteurs dynamiques — inline_template et qweb — commencent par tester si le gabarit contient une expression « non sûre ». Le résultat décide de la voie empruntée :
- Expression sûre — uniquement des chemins de champs autorisés, comme
object.partner_id.name. Le moteur bascule en mode statique (_render_template_inline_template_regexou_render_template_qweb_regex) : aucuneval, une simple traversée d'attributs. Ce rendu est inoffensif, donc accessible à tout le monde. - Expression non sûre — appel de fonction, accès à
env, code Python. Le moteur appelle alors le vrai moteur d'évaluation, mais réservé aux utilisateurs habilités : administrateur, membre du groupemail.group_mail_template_editor, ou rendu explicitement « non restreint ». À défaut, uneAccessErrorest levée.
# _render_template_inline_template (extrait) — la bascule du moteur inline
if not self._has_unsafe_expression_template_inline_template(str(template_txt), model):
# expression sûre → mode statique, PAS de safe_eval, accessible à tous
return self._render_template_inline_template_regex(str(template_txt), model, res_ids)
# expression sensible → moteur réel, mais réservé aux éditeurs habilités
if (not self._unrestricted_rendering and not self.env.is_admin()
and not self.env.user.has_group('mail.group_mail_template_editor')):
raise AccessError(_('Only members of … group are allowed to edit templates '
'containing sensible placeholders'))
Une nuance d'implémentation mérite d'être connue pour le débogage : les deux moteurs n'imposent pas la restriction de la même façon. Le moteur inline_template lève l'AccessError avant de rendre, comme ci-dessus. Le moteur qweb, lui, ne pré-bloque pas : il transmet un drapeau (raise_on_forbidden_code_for_model) au moteur ir.qweb, qui refuse le code interdit en cours de rendu ; l'erreur remonte alors sous forme de QWebError encapsulant une PermissionError, convertie en AccessError. Le symptôme est le même (un éditeur non habilité ne peut pas enregistrer une expression sensible), mais l'endroit où l'erreur surgit diffère. Quant au moteur qweb_view, il n'applique pas ce contrôle d'expression : un corps versionné dans un module relève de la responsabilité du développeur qui le livre, sous revue de code, et non d'un filtrage à l'exécution.
{{ object.name }}, <t t-out="object.partner_id.email"/> — sont des chemins de champs, donc rendus sans eval, par tous les utilisateurs. Le groupe éditeur n'est requis que pour les expressions contenant du code. La tentation de tout passer en sudo() pour « éviter l'erreur » revient à désactiver précisément le garde-fou qui empêche l'injection : la bonne réponse est d'écrire des placeholders simples, ou d'attribuer le groupe à l'auteur habilité.
eval pour un chemin de champ, évaluation réelle et gated par le groupe éditeur pour du code.
7. La résolution des langues
Un envoi en lot mélange des destinataires de langues différentes. Le mixin gère cela en deux temps. _render_lang(res_ids) détermine la langue de chaque enregistrement : si le gabarit définit un champ lang — typiquement {{ object.partner_id.lang }} —, cette expression est rendue par record ; sinon, la langue est celle du partenaire destinataire — ou False si aucun partenaire n'est trouvé, auquel cas le gabarit est rendu sans contexte de langue (langue par défaut du système).
Ensuite, _classify_per_lang(res_ids) regroupe les enregistrements par langue et renvoie, pour chacune, le gabarit contextualisé dans cette langue (self.with_context(lang=lang)) accompagné du sous-ensemble d'identifiants concernés. Conséquence : le rendu s'effectue sur la version traduite du gabarit, garantissant qu'un même envoi produise des sujets, des corps — et même des PDF — dans la langue propre à chaque public. En prévisualisation, la clé de contexte template_preview_lang force une langue choisie.
8. Post-traitement, options et pièges
Une fois le texte rendu, un dernier passage peut s'imposer. L'option post_process — portée par défaut par body_html — déclenche _render_template_postprocess, qui transforme les liens locaux en liens absolus : un /shop/produit-1 devient https://exemple.com/shop/produit-1, grâce à l'URL de base du record. Sans ce traitement, les liens d'un email pointeraient vers nulle part une fois ouverts hors de l'application. La seconde option valide, preserve_comments, conserve les commentaires HTML — utile pour le code conditionnel propre à certains clients de messagerie.
Les pièges récurrents autour du rendu :
- Mélanger les syntaxes.
{{ }}appartient àinline_template(sujet, destinataires) ;t-outà QWeb (corps). Le moteur étant déduit du champ, écrire la mauvaise syntaxe produit un placeholder littéral, non un rendu. - Contourner l'
AccessErrorparsudo(). L'erreur signale une expression sensible ; la neutraliser rouvre la faille. Préférer un placeholder simple (mode statique) ou le groupe éditeur. - Oublier
post_process. Sur un corps custom rendu hors du flux standard, des liens relatifs non convertis cassent à l'ouverture de l'email. - Passer un id nu à
_render_template. La méthode exige une liste d'identifiants ; un entier seul lève uneValueError. - Ignorer la langue. Sans champ
lang, le rendu retombe sur la langue du partenaire ou la langue par défaut — à vérifier pour un public multilingue.
À retenir : mail.render.mixin est le moteur silencieux derrière chaque email personnalisé d'Odoo. Il choisit le moteur depuis le champ, valide ses entrées, rend en mode statique sûr tant que les expressions restent de simples chemins de champs, réserve l'évaluation de code aux éditeurs habilités, et résout les langues par lot. Comprendre cette mécanique, c'est savoir déboguer un placeholder qui ne se rend pas, une AccessError inattendue, ou un lien d'email cassé — et écrire des gabarits qui restent à la fois personnalisés et sûrs.
L'article suivant quittera la fabrique de contenu pour le canal moderne : les notifications et l'inbox Discuss, où l'on verra comment un même message peut atterrir dans une boîte interne ou dans un email selon le destinataire — la suite logique du système d'abonnés et du rendu décrits jusqu'ici.
Voir aussi dans ce parcours Infrastructure
mail.template — rendu QWeb et envoi d'emails
La surface dont ce mixin est le moteur : structure du template et deux langages de gabarit.
Lire l'article →mail.thread — followers, sous-types et notifications
À qui partent les messages rendus, et selon quels sous-types.
Lire l'article →Série Tech-Email — Article 9/14 — Parcours Infrastructure emailing Odoo 19.
Articles complémentaires
Architecture mail Odoo — les 4 couches
Le HUB de la série : où se situe le rendu dans le flux global.
Lire l'article →Configurer Brevo SMTP — ir.mail_server & from_filter
Le transport qui expédie l'email une fois son contenu rendu.
Lire l'article →Source officielle : Odoo 19 — Mixins (mail.render.mixin).