Ce que tu vas apprendre
mail.template
Déclarer un template en XML avec sujet, destinataire et corps HTML.
Variables QWeb inline
{{ object.field }} et <t t-out="..."/>.
send_mail
Envoi programmatique depuis create, write ou une action.
mail.thread
Le mixin qui transforme ton modèle en conversation trackée.
Prérequis
1. mail.thread — pourquoi c'est central
Hériter mail.thread ajoute gratuitement à ton modèle :
- Un chatter (zone de messages sur la form).
- La gestion des followers (
message_follower_ids, abonnements auto). - L'historique de modifications des champs avec
tracking=True. - L'alias email entrant : répondre à un mail crée/met à jour le record.
- La méthode
message_post()pour poster un message par code.
Le mixin mail.activity.mixin ajoute par-dessus la gestion des
activités (rappels, to-do, planning).
class HelpdeskTicket(models.Model):
_name = 'helpdesk.ticket'
_inherit = [
'mail.thread',
'mail.activity.mixin',
]
_description = 'Ticket helpdesk'
# Le `tracking=True` fait apparaître les modifications dans le chatter
state = fields.Selection([...], tracking=True)
priority = fields.Selection([...], tracking=True)
user_id = fields.Many2one('res.users', tracking=True)
Sans mail.thread, aucun des patterns ci-dessous ne fonctionne. C'est le
prérequis technique unique.
2. Déclarer un mail.template en XML
On crée data/mail_templates.xml avec un premier template : accusé de
réception à la création du ticket.
<record id="mail_template_ticket_opened" model="mail.template">
<field name="name">Helpdesk : ticket ouvert</field>
<field name="model_id" ref="model_helpdesk_ticket"/>
<field name="subject">[{{ object.reference }}] {{ object.name }} — ticket enregistré</field>
<field name="email_from">{{ (object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="auto_delete" eval="True"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="body_html" type="html">
<div style="margin:0;font-family:Arial,sans-serif;font-size:14px;color:#212529;">
<p>Bonjour <t t-out="object.partner_id.name or ''"/>,</p>
<p>Votre demande a bien été enregistrée. Voici un récapitulatif :</p>
<table style="border-collapse:collapse;width:100%;margin:16px 0;">
<tr>
<td style="background:#f6f8fa;padding:8px;width:30%;"><strong>Référence</strong></td>
<td style="padding:8px;border-bottom:1px solid #dee2e6;">
<t t-out="object.reference or ''"/>
</td>
</tr>
<!-- ...autres lignes... -->
</table>
<p>Nous vous tiendrons informé dès la résolution. Vous pouvez répondre directement
à cet email — votre réponse sera automatiquement ajoutée au ticket.</p>
<p style="margin-top:24px;">Cordialement,<br/>
<strong>L'équipe support OdooSkills</strong></p>
</div>
</field>
</record>
mail.template côté backend. Odoo surligne les variables
en orange — pratique pour repérer d'un coup d'œil les interpolations.Champs à connaître :
| Champ | Rôle |
|---|---|
model_id |
Le modèle cible. ref="model_helpdesk_ticket" pointe vers
l'external ID généré automatiquement pour chaque models.Model. |
subject / body_html |
Interpolés à l'envoi avec le moteur QWeb inline. |
email_from |
Expression Python : ici on prend l'email de l'agent assigné, sinon celui de l'utilisateur courant. Fallback élégant. |
partner_to |
ID(s) de partenaires destinataires — typiquement le client. Pour plusieurs :
{{ ','.join(str(p.id) for p in object.follower_partner_ids) }}. |
auto_delete |
True = nettoie le record mail.mail après envoi.
Évite d'encombrer la table. |
lang |
Langue de rendu — crucial si ton template a des traductions ou si le
lang du client diffère de celui de l'agent. |
3. QWeb inline — les deux syntaxes
Dans mail.template, deux syntaxes cohabitent :
3.1 {{ expression }} — pour les champs "simples"
Utilisée dans subject, email_from, partner_to,
lang, scheduled_date. Expression Python évaluée avec
object (le record cible) et user (l'utilisateur qui déclenche).
# Exemples valides
{{ object.reference }}
{{ object.partner_id.lang or 'fr_FR' }}
{{ (object.user_id.email_formatted or user.email_formatted) }}
{{ object.deadline.strftime('%d/%m/%Y') if object.deadline else '' }}
3.2 <t t-out="..."/> — pour le corps HTML
Dans body_html, on utilise la syntaxe QWeb classique (comme dans les
rapports QWeb vus en T20).
<t t-out="object.partner_id.name or ''"/>
<t t-if="object.deadline">
Échéance : <t t-out="object.deadline"/>
</t>
<t t-foreach="object.tag_ids" t-as="tag">
<span class="badge"><t t-out="tag.name"/></span>
</t>
{{ }} dans le body aussi ? Techniquement, ça
marche dans certains cas mais l'écosystème Odoo pousse vers <t t-out/>
pour le HTML — plus lisible, meilleure intégration avec les outils d'édition visuels,
et gestion propre des directives t-if, t-foreach.
4. Envoi automatique depuis create et write
Le template seul n'envoie rien : il faut le déclencher. Pattern le plus
courant : hook sur create et write.
Code complet de la surcharge create :
@api.model_create_multi
def create(self, vals_list):
# 1. Génération de la référence (comme en T15)
for vals in vals_list:
if not vals.get('reference'):
vals['reference'] = self.env['ir.sequence'].sudo().next_by_code(
'helpdesk.ticket'
) or '/'
# 2. Création réelle
tickets = super().create(vals_list)
# 3. Email d'accusé de réception — UN par ticket créé
for ticket in tickets:
ticket.message_post(body=f"Ticket créé — {ticket.reference}")
if ticket.partner_id.email and not self.env.context.get('skip_mail'):
template = self.env.ref(
'odooskills_helpdesk.mail_template_ticket_opened',
raise_if_not_found=False,
)
if template:
template.send_mail(ticket.id, force_send=False)
return tickets
Et la surcharge write qui détecte le passage à done :
def write(self, vals):
# ...protection d'état (voir T15)...
# Qui passe à 'done' au cours de ce write ?
newly_resolved = self.env['helpdesk.ticket']
if vals.get('state') == 'done':
vals.setdefault('resolved_at', fields.Datetime.now())
newly_resolved = self.filtered(lambda t: t.state != 'done')
result = super().write(vals)
# Email de résolution uniquement aux tickets qui viennent de passer à done
if newly_resolved and not self.env.context.get('skip_mail'):
template = self.env.ref(
'odooskills_helpdesk.mail_template_ticket_resolved',
raise_if_not_found=False,
)
if template:
for ticket in newly_resolved:
if ticket.partner_id.email:
template.send_mail(ticket.id, force_send=False)
return result
Trois points à noter :
newly_resolvedcalculé AVANT lesuper().write()— sinon tous les tickets sont déjà à done, etfiltered(lambda t: t.state != 'done')renvoie un recordset vide.self.env.context.get('skip_mail')— un bypass pour les imports en masse, les tests, les migrations. Les appelants passent.with_context(skip_mail=True)pour désactiver.raise_if_not_found=False— si le template est supprimé par un admin, on ne crash pas. Défensif.
5. send_mail vs message_post vs message_post_with_source
| Méthode | Usage | Crée un mail.mail ? | Post dans chatter ? |
|---|---|---|---|
template.send_mail(id) |
Email sortant à un destinataire externe. | ✅ oui, queued puis envoyé par le cron | ✅ oui, comme message |
record.message_post(body=...) |
Note interne ou message sans email. | ❌ non | ✅ oui |
record.message_post_with_source('template_xml_id') |
Alternative moderne à send_mail — rend le template via QWeb
puis poste en message. Préféré en v18+. |
✅ oui | ✅ oui |
template.send_mail(id) reste le plus simple
et le plus courant. message_post_with_source est plus élégant quand tu veux
passer des paramètres extra (subtype, partners additionnels, pièces jointes) tout en
profitant du template.
6. Bonus — alias email entrant
Avec mail.thread, un pattern magique : configurer un alias
support@mondomaine.com qui crée automatiquement un ticket
à chaque email reçu, et ajoute les réponses au bon ticket via le
Message-ID/In-Reply-To.
class HelpdeskTicket(models.Model):
_name = 'helpdesk.ticket'
_inherit = ['mail.thread', 'mail.activity.mixin']
@api.model
def message_new(self, msg_dict, custom_values=None):
"""Appelé par le système d'alias pour créer un ticket depuis un email."""
custom_values = dict(custom_values or {})
custom_values.setdefault('name', msg_dict.get('subject') or 'Nouveau ticket')
custom_values.setdefault('channel', 'email')
return super().message_new(msg_dict, custom_values)
def message_update(self, msg_dict, update_vals=None):
"""Appelé quand une réponse à un email existant arrive."""
if self.state == 'done':
# Réponse après résolution : on rouvre automatiquement
self.with_context(skip_mail=True).write({'state': 'in_progress'})
return super().message_update(msg_dict, update_vals)
Il reste à configurer un alias côté Paramètres > Techniques > Alias email
pointant vers helpdesk.ticket. Ça sort du cadre de cet article, mais
sache que tout est déjà prêt côté code — c'est juste une config.
Les pièges à connaître
- Oublier le mixin
mail.thread→ pas de chatter, pas demessage_post, pas de tracking.send_mailcrash. force_send=Truedans un hookcreateouwrite→ bloque la transaction le temps du SMTP. Pire : si le SMTP timeout, toute la création rollback. Toujoursforce_send=Falseen hook et laisser le cron gérer.- Calculer
newly_resolvedAPRÈSsuper().write()→ filtrage toujours vide. Filtrer avant. - Oublier
auto_delete→ la tablemail_mailenfle avec le temps, audit DB douloureux à long terme. - Utiliser
{{ }}dansbody_html— marche parfois, mais le moteur et l'éditeur visuel préfèrent<t t-out/>. - Ne pas prévoir de
skip_maildans le contexte → impossible de faire un import en masse sans spammer. Toujours exposer un bypass. - Dépendre d'un email obligatoire —
if ticket.partner_id.email:protège contre les tickets créés sans contact ou sans email renseigné.
Voir aussi dans cette série
_inherit, mixins
create, write hooks
QWeb, external_layout
Prochain article — T22
Place aux actions serveur automatisées :
ir.actions.server, ir.cron, déclencheurs auto
(base.automation), et cas pratique d'escalade SLA automatique.