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 localeformat_datetime(value, tz=False, format=False)— formats datetime with timezoneformat_amount(amount, currency)— formats monetary values with currency symbolformat_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 renderingFalse - Missing related records: Check with
t-ifbefore 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])