Skip to content

Dynamic Email Templates with QWeb in Odoo: Complete Guide

DeployMonkey Team · March 23, 2026 11 min read

Email Templates in Odoo

Odoo's email system is built on mail.template records that use QWeb for rendering. Unlike simple placeholder substitution, QWeb gives you full programming power: loops, conditionals, formatting filters, and access to any related record. This guide covers everything from basic templates to advanced dynamic emails.

Creating a Basic Mail Template

Mail templates are defined in XML data files:

<record id="email_template_maintenance_approved" model="mail.template">
  <field name="name">Maintenance: Request Approved</field>
  <field name="model_id" ref="model_maintenance_request"/>
  <field name="email_from">{{ (object.company_id.email or user.email) }}</field>
  <field name="email_to">{{ object.requester_id.email }}</field>
  <field name="subject">Request #{{ object.name }} Approved</field>
  <field name="body_html" type="html">
    <p>Dear {{ object.requester_id.name }},</p>
    <p>Your maintenance request <strong>{{ object.name }}</strong>
       has been approved by {{ object.approved_by.name }}.</p>
    <p>Scheduled date: {{ format_date(object.scheduled_date) }}</p>
  </field>
</record>

The object variable refers to the record being emailed. You have access to all relational fields, computed fields, and helper functions.

QWeb Expressions and Filters

Inside {{ }} blocks you can use any Python expression. Odoo provides several helper functions:

  • format_date(value, format=False, lang_code=False) — formats a date according to the user's locale
  • format_datetime(value, tz=False, format=False) — formats datetime with timezone
  • format_amount(amount, currency) — formats monetary values with currency symbol
  • format_duration(value) — converts float hours to human-readable duration
<p>Amount due: {{ format_amount(object.amount_total, object.currency_id) }}</p>
<p>Due date: {{ format_date(object.date_due) }}</p>
<p>Duration: {{ format_duration(object.planned_hours) }}</p>

Conditional Content

Use QWeb directives for conditional sections. The t-if, t-elif, and t-else directives work inside email templates:

<t t-if="object.priority == '3'">
  <p style="color: red; font-weight: bold;">URGENT REQUEST</p>
</t>
<t t-elif="object.priority == '2'">
  <p style="color: orange;">High priority request</p>
</t>
<t t-else="">
  <p>Standard priority request</p>
</t>

<t t-if="object.attachment_ids">
  <p>{{ len(object.attachment_ids) }} file(s) attached.</p>
</t>

Loops for Line Items

Use t-foreach to iterate over One2many or Many2many fields:

<table style="width: 100%; border-collapse: collapse;">
  <thead>
    <tr>
      <th style="border: 1px solid #ddd; padding: 8px;">Task</th>
      <th style="border: 1px solid #ddd; padding: 8px;">Assigned To</th>
      <th style="border: 1px solid #ddd; padding: 8px;">Status</th>
    </tr>
  </thead>
  <tbody>
    <t t-foreach="object.task_ids" t-as="task">
      <tr>
        <td style="border: 1px solid #ddd; padding: 8px;">{{ task.name }}</td>
        <td style="border: 1px solid #ddd; padding: 8px;">{{ task.user_id.name or 'Unassigned' }}</td>
        <td style="border: 1px solid #ddd; padding: 8px;">{{ task.state }}</td>
      </tr>
    </t>
  </tbody>
</table>

Inline CSS for Email Clients

Email clients strip <style> tags, so always use inline CSS. Common patterns:

<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
  <div style="background-color: #875A7B; padding: 20px; text-align: center;">
    <h1 style="color: white; margin: 0;">{{ object.company_id.name }}</h1>
  </div>
  <div style="padding: 20px; background: #ffffff;">
    <!-- content here -->
  </div>
  <div style="padding: 10px; text-align: center; color: #666; font-size: 12px;">
    <p>{{ object.company_id.name }} | {{ object.company_id.phone }}</p>
  </div>
</div>

Dynamic Attachments

Attach reports or static files to email templates:

<record id="email_template_with_report" model="mail.template">
  <field name="name">Order Confirmation with PDF</field>
  <field name="model_id" ref="sale.model_sale_order"/>
  <field name="report_template_id" ref="sale.action_report_saleorder"/>
  <field name="report_name">Order_{{ object.name }}</field>
  <!-- ... -->
</record>

The report_template_id automatically generates and attaches a PDF report. The report_name field controls the filename.

Multi-Language Templates

Odoo translates email templates based on the recipient's language:

<field name="lang">{{ object.partner_id.lang }}</field>

When lang is set, Odoo renders the template in the recipient's language. All translatable strings in the template body are looked up in the translation table. To make strings translatable, export and import PO files for your module.

Sending Templates Programmatically

Trigger email sending from Python code:

# Send immediately
template = self.env.ref('module.email_template_id')
template.send_mail(record.id, force_send=True)

# Queue for sending (recommended for bulk)
template.send_mail(record.id, force_send=False)

# Send with extra values
template.with_context(
    custom_value='Hello'
).send_mail(record.id)

# Access custom context in template:
# {{ ctx.get('custom_value', '') }}

Scheduled Digest Emails

Combine cron jobs with email templates for digest-style emails:

def _cron_send_weekly_digest(self):
    records = self.search([
        ('state', '=', 'done'),
        ('completion_date', '>=',
         fields.Date.today() - timedelta(days=7)),
    ])
    if records:
        template = self.env.ref('module.weekly_digest_template')
        # Group by manager and send one email per manager
        for manager, group in groupby(
                records, key=lambda r: r.manager_id):
            template.with_context(
                digest_records=group
            ).send_mail(manager.id)

Debugging Email Templates

Common issues and fixes:

  • Empty fields: Always use {{ object.field or '' }} to avoid rendering False
  • Missing related records: Check with t-if before accessing relational fields
  • Encoding issues: Use Markup() for raw HTML injection (rare, be careful of XSS)
  • Testing: Use the Preview button in Settings > Email Templates, or call template._render_field('body_html', [record.id])