Se rendre au contenu

Email templates et mail.thread en Odoo 19 : envoi automatique depuis create et write

Bloc 5 · Rapports et automatisations — Article 2/4 Email templates et mail.thread en Odoo 19 Déclarer des modèles d'email avec variables QWeb inline…
26 avril 2026 par
Email templates et mail.thread en Odoo 19 : envoi automatique depuis create et write
B.Mustapha

Bloc 5 · Rapports et automatisations — Article 2/4

Email templates et mail.thread en Odoo 19

Déclarer des modèles d'email avec variables QWeb inline, déclencher l'envoi automatique depuis create/write, et laisser le chatter capturer les réponses pour enrichir le ticket. Le tout avec mail.template et les mixins natifs.

~14 minutes de lecture

Chatter ticket avec email auto rendu — variables QWeb interpolées
Objectif — à la création d'un ticket, le client reçoit ce mail automatiquement. Variables QWeb interpolées (référence, sujet, catégorie), envoi non-bloquant, trace complète dans le chatter.

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

  • Module odooskills_helpdesk v19.0.1.12.0 (T20).
  • helpdesk.ticket hérite de mail.thread + mail.activity.mixin (ajouté en T13).
  • Un serveur SMTP configuré (Paramètres > Technique > Outgoing Mail Servers) pour l'envoi réel. Sans ça, les emails s'empilent dans la file mail.mail (statut "outgoing").

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>
Formulaire mail.template avec variables QWeb surlignées en orange
La form 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 :

ChampRô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>
Pourquoi pas {{ }} 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.

create() nouveau ticket partner_id.email ? env.ref(template) .send_mail(id, force_send=False) mail.mail queued + message_post dans chatter cron envoie dans la minute Flux d'envoi automatique force_send=True force l'envoi SMTP immédiat (bloquant — à éviter dans les hooks).

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_resolved calculé AVANT le super().write() — sinon tous les tickets sont déjà à done, et filtered(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éthodeUsageCré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
En pratiquetemplate.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

  1. Oublier le mixin mail.thread → pas de chatter, pas de message_post, pas de tracking. send_mail crash.
  2. force_send=True dans un hook create ou write → bloque la transaction le temps du SMTP. Pire : si le SMTP timeout, toute la création rollback. Toujours force_send=False en hook et laisser le cron gérer.
  3. Calculer newly_resolved APRÈS super().write() → filtrage toujours vide. Filtrer avant.
  4. Oublier auto_delete → la table mail_mail enfle avec le temps, audit DB douloureux à long terme.
  5. Utiliser {{ }} dans body_html — marche parfois, mais le moteur et l'éditeur visuel préfèrent <t t-out/>.
  6. Ne pas prévoir de skip_mail dans le contexte → impossible de faire un import en masse sans spammer. Toujours exposer un bypass.
  7. Dépendre d'un email obligatoireif ticket.partner_id.email: protège contre les tickets créés sans contact ou sans email renseigné.

Voir aussi dans cette série

T15 — Méthodes de modèle

create, write hooks

T20 — Rapports QWeb PDF

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.

Télécharger le guide technique Odoo 19 (PDF gratuit)
Rapports QWeb PDF en Odoo 19 : ir.actions.report, external_layout et wkhtmltopdf
Bloc 5 · Rapports et automatisations — Article 1/4 Rapports QWeb PDF en Odoo 19 Générer une fiche PDF imprimable depuis n'importe quel modèle — avec ir.actions.