Ad – 728×90
🔧 Backend Development

Odoo Email Templates – mail.template and Dynamic Emails

Odoo's email system is built around mail.template records — reusable, data-driven email definitions that support Jinja2 expressions to personalise subject lines, body content, and recipients based on the record being processed. Combined with mail.thread, which provides the chatter for tracking all communications on a record, Odoo gives you a complete outbound email framework you can use from module code, scheduled actions, and server actions alike.

⏱️ 22 min 🎯 Intermediate 📅 Updated 2026

📋 What You Will Learn

  • How Odoo processes and sends emails
  • Defining an email template with mail.template in XML
  • Using Jinja2 syntax for dynamic content
  • Sending email from Python with send_mail()
  • Attaching QWeb PDF reports to emails
  • Using message_post() for chatter messages

How Odoo Sends Emails

Odoo's email flow has two layers:

  1. mail.template: A template record holds the subject, body (Jinja2 HTML), sender, and recipient expressions. When you call template.send_mail(record_id), Odoo renders the template against that specific record and creates a mail.mail record.
  2. mail.mail / ir.mail_server: A background scheduler (or immediate send if force_send=True) picks up mail.mail records and sends them via the configured outgoing mail server (SMTP). The sent message is also logged in the record's chatter if the model has mail.thread.

Creating an Email Template (XML)

Define mail.template records in a data file — typically under data/email_templates.xml. Use noupdate="1" so that administrators can customise the template from the UI without your module overwriting their changes on upgrade:

XML
<odoo>
    <data noupdate="1">

        <record id="email_template_overdue_notice" model="mail.template">
            <field name="name">Library: Overdue Notice</field>

            <!-- The model this template is for -->
            <field name="model_id" ref="model_library_loan"/>

            <!-- Subject with Jinja2 expression -->
            <field name="subject">
                Overdue Notice: ${object.book_id.title or 'Your Loan'} is Past Due
            </field>

            <!-- Sender — can be static or dynamic -->
            <field name="email_from">${user.email or 'library@example.com'}</field>

            <!-- Recipient — from the related partner -->
            <field name="email_to">${object.borrower_id.email}</field>

            <!-- Copy to library manager -->
            <!-- <field name="email_cc">library-manager@example.com</field> -->

            <!-- Reply-To address -->
            <field name="reply_to">library@example.com</field>

            <!-- Automatically log the sent message in the record's chatter -->
            <field name="auto_delete">False</field>

            <!-- HTML body with Jinja2 -->
            <field name="body_html"><![CDATA[
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
    <h2 style="color: #714B67;">Library Overdue Notice</h2>
    <p>Dear ${object.borrower_id.name or 'Library Member'},</p>
    <p>
        Our records show that the following book is overdue:
    </p>
    <table style="border-collapse: collapse; width: 100%;">
        <tr style="background: #f5f5f5;">
            <td style="padding: 8px; border: 1px solid #ddd;"><strong>Book Title</strong></td>
            <td style="padding: 8px; border: 1px solid #ddd;">${object.book_id.title}</td>
        </tr>
        <tr>
            <td style="padding: 8px; border: 1px solid #ddd;"><strong>Due Date</strong></td>
            <td style="padding: 8px; border: 1px solid #ddd;">${object.due_date}</td>
        </tr>
        <tr style="background: #f5f5f5;">
            <td style="padding: 8px; border: 1px solid #ddd;"><strong>Days Overdue</strong></td>
            <td style="padding: 8px; border: 1px solid #ddd; color: #dc3545;">
                ${object.days_overdue} day(s)
            </td>
        </tr>
    </table>

    % if object.days_overdue > 14:
    <p style="color: #dc3545;">
        <strong>Your borrowing privileges may be suspended if the book is not returned promptly.</strong>
    </p>
    % endif

    <p>Please return the book to the library at your earliest convenience.</p>
    <p>Thank you,<br/>The Library Team</p>
</div>
            ]]></field>
        </record>

    </data>
</odoo>
Ad – 336×280

Dynamic Content with Jinja2

Email templates use a Jinja2-like syntax for expressions. The key variable is object — the record being processed. Other variables include user (current user), ctx (context dict), and format_date (date formatting helper).

SyntaxPurposeExample
${expression}Output a Python expression value${object.name}, ${object.partner_id.email}
% if condition: ... % endifConditional block% if object.days_overdue > 7:
% for item in iterable: ... % endforLoop% for book in object.book_ids:
${object.field | safe}Render HTML content without escapingFor fields containing HTML markup
Jinja2 (email body)
<!-- Access related record fields -->
<p>Dear ${object.borrower_id.name},</p>
<p>Your loan of <strong>${object.book_id.title}</strong> is due on ${object.due_date}.</p>

<!-- Conditional block -->
% if object.borrower_id.country_id:
<p>Country: ${object.borrower_id.country_id.name}</p>
% endif

<!-- Format dates nicely -->
<p>Due: ${format_date(object.due_date, date_format='MMMM d, yyyy')}</p>

<!-- Loop over a One2many -->
% for loan_line in object.line_ids:
<li>${loan_line.book_id.title} — due ${loan_line.due_date}</li>
% endfor

<!-- Access current user info -->
<p>Contact: ${user.name} <${user.email}></p>

Sending Email from Python

The most common way to send an email from Python code is to look up the template by XML ID and call send_mail():

Python
from odoo import models, api
import logging

_logger = logging.getLogger(__name__)

class LibraryLoan(models.Model):
    _name = 'library.loan'

    def action_send_overdue_notice(self):
        """Send overdue notice email to borrower for each selected loan."""
        template = self.env.ref(
            'library_management.email_template_overdue_notice',
            raise_if_not_found=False,
        )
        if not template:
            _logger.warning("Overdue notice template not found")
            return

        for loan in self:
            if not loan.borrower_id.email:
                _logger.warning("Loan %d: borrower has no email address, skipping", loan.id)
                continue

            # force_send=True: send immediately instead of queuing
            # raise_exception=False: don't crash if SMTP fails
            template.send_mail(
                loan.id,
                force_send=True,
                raise_exception=False,
            )
            _logger.info("Sent overdue notice for loan %d to %s", loan.id, loan.borrower_id.email)

    @api.model
    def _cron_send_overdue_notifications(self):
        """Called by the scheduled action daily."""
        overdue = self.search([
            ('state', '=', 'overdue'),
            ('notification_sent', '=', False),
        ])
        overdue.action_send_overdue_notice()
        overdue.write({'notification_sent': True})
💡
force_send=True vs queue

Without force_send=True, send_mail() creates a mail.mail record and lets the email scheduler send it in the background. With force_send=True, the email is dispatched immediately in the current transaction. Use force_send=True for user-triggered sends where the user expects immediate confirmation; let the queue handle batch/cron sends for better performance.

Email from Automated Action

You can wire an email template directly to an automated action (ir.base.automation) without writing any Python code — Odoo handles the template rendering and sending automatically:

XML
<odoo>
    <record id="automation_send_overdue_email" model="ir.base.automation">
        <field name="name">Library: Email on Overdue</field>
        <field name="model_id" ref="model_library_loan"/>
        <field name="trigger">on_write</field>
        <field name="filter_pre_domain">[('state','!=','overdue')]</field>
        <field name="filter_domain">[('state','=','overdue')]</field>

        <!-- Use a server action with state=code to send the email -->
        <field name="action_server_ids" eval="[(0, 0, {
            'name': 'Send Overdue Notice',
            'model_id': ref('model_library_loan'),
            'state': 'email',
            'template_id': ref('email_template_overdue_notice'),
        })]"/>
    </record>
</odoo>

Attachments and Reports

You can automatically attach a QWeb PDF report to an email template using report_template_ids. Odoo renders the report for the relevant record and attaches the PDF to the outgoing email:

XML
<record id="email_template_overdue_notice" model="mail.template">
    <!-- ... other fields ... -->

    <!-- Attach the loan receipt PDF to this email -->
    <field name="report_template_ids" eval="[(4, ref('action_report_library_loan_receipt'))]"/>
</record>

You can also attach files manually from Python using send_mail()'s context:

Python
def action_send_with_attachment(self):
    """Send email with a dynamically generated PDF attachment."""
    self.ensure_one()
    template = self.env.ref('library_management.email_template_overdue_notice')

    # Render the PDF and create an attachment
    report = self.env.ref('library_management.action_report_library_loan_receipt')
    pdf_content, _ = self.env['ir.actions.report']._render_qweb_pdf(
        report, res_ids=self.ids,
    )
    import base64
    attachment = self.env['ir.attachment'].create({
        'name': f'LoanReceipt-{self.name}.pdf',
        'type': 'binary',
        'datas': base64.b64encode(pdf_content),
        'mimetype': 'application/pdf',
    })

    # Send email with the attachment IDs in context
    template.with_context(attachment_ids=[attachment.id]).send_mail(
        self.id, force_send=True
    )

mail.thread Mixin — Chatter Messages

If your model inherits from mail.thread, you can post internal messages (notes) or external emails directly to the record's chatter using message_post(). These are logged on the record and visible to followers:

Python
class LibraryLoan(models.Model):
    _name = 'library.loan'
    _inherit = ['mail.thread', 'mail.activity.mixin']

    def action_mark_returned(self):
        self.ensure_one()
        self.write({'state': 'returned', 'return_date': fields.Date.today()})

        # Post an internal log note to the chatter (not sent as email)
        self.message_post(
            body=f"Book returned on {self.return_date} in {self.return_condition} condition.",
            message_type='comment',
            subtype_xmlid='mail.mt_note',  # 'note' = internal; 'comment' = external
        )

    def action_send_reminder(self):
        """Send an email message AND log it in the chatter."""
        self.ensure_one()
        # message_type='email' → sends to followers via email AND logs in chatter
        self.message_post(
            body=f"Reminder: your loan of '{self.book_id.title}' is due on {self.due_date}.",
            message_type='email',
            subject=f"Loan Reminder: {self.book_id.title}",
            partner_ids=[self.borrower_id.id],  # explicitly specify recipients
        )

Summary

📋 Key Points

  • Define email templates as mail.template records in XML with noupdate="1"
  • Use ${object.field_name} Jinja2 syntax for dynamic content; % if / % for for control flow
  • template.send_mail(record_id, force_send=True) sends the email immediately from Python
  • Without force_send=True, emails are queued and sent by the background mail scheduler
  • Attach QWeb PDF reports via report_template_ids on the template record
  • mail.thread mixin adds chatter; use message_post() for internal notes and outgoing emails
  • Always check that the recipient has an email address before calling send_mail()

FAQ

What is the difference between message_type='comment' and 'email' in message_post()? +

message_type='comment' creates an internal message that is logged in the chatter but not sent as an email (unless followers have notification settings enabled). message_type='email' sends the message as an actual email to the specified partner_ids and also logs it in the chatter. Use message_type='comment' with subtype_xmlid='mail.mt_note' for private internal notes that should never be emailed.

How do I preview an email template before sending? +

In developer mode, go to Settings > Technical > Email > Templates. Open your template and use the "Send Test Email" button to send a preview to yourself. Alternatively, you can use the "Preview" option in the compose dialog. From Python, you can render the body without sending using template._render_template(template.body_html, template.model, record.ids).

Can I send an email without defining a mail.template record? +

Yes. Use self.env['mail.mail'].create({...}).send() for a one-off email, or use message_post() on a mail.thread model with message_type='email' and partner_ids. For recurring, admin-editable, record-personalised emails, a mail.template record is strongly recommended because it's configurable from the UI without a code deploy.

Why does my email end up in the outbound queue instead of sending immediately? +

By default, send_mail() without force_send=True creates a mail.mail record in the "outgoing" state and lets the scheduler send it. The scheduler runs every minute. If emails are stuck in the queue (not "sent"), check your outgoing mail server configuration under Settings > Technical > Outgoing Mail Servers and look at the email's error message in Settings > Technical > Email > Emails.