How Odoo Sends Emails
Odoo's email pipeline has multiple layers. Understanding each layer helps you debug delivery issues and customize notification behavior:
- Message creation:
message_post()ormail.mail.create() - Notification generation: Based on followers and subtypes
- Email rendering: QWeb templates + mail layout
- Mail queue:
mail.mailrecords in outgoing state - SMTP sending: Cron job processes the queue via the outgoing mail server
Outgoing Mail Server
Configure at Settings > Technical > Outgoing Mail Servers:
# Key settings
SMTP Server: smtp.gmail.com (or your provider)
SMTP Port: 587 (TLS) or 465 (SSL)
Username: [email protected]
Password: app-specific password
Connection Security: TLS (STARTTLS)Odoo selects the outgoing mail server based on the email_from domain. If no server matches, it uses the first available server. If no server is configured, emails queue but never send.
Email Templates
Email templates (mail.template) are QWeb-rendered email bodies:
<record id="email_template_order_confirm" model="mail.template">
<field name="name">Order Confirmation</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="email_from">{{ object.company_id.email }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="subject">Order {{ object.name }} Confirmed</field>
<field name="body_html" type="html">
<p>Dear {{ object.partner_id.name }},</p>
<p>Your order <strong>{{ object.name }}</strong> has been confirmed.</p>
<table>
<tr t-foreach="object.order_line" t-as="line">
<td>{{ line.product_id.name }}</td>
<td>{{ line.product_uom_qty }}</td>
<td>{{ format_amount(line.price_subtotal, object.currency_id) }}</td>
</tr>
</table>
<p>Total: {{ format_amount(object.amount_total, object.currency_id) }}</p>
</field>
<field name="auto_delete">True</field>
</record>Sending Emails Programmatically
Via Email Template
template = self.env.ref('my_module.email_template_order_confirm')
template.send_mail(record.id, force_send=True)
# force_send=True: sends immediately (bypasses queue)
# force_send=False (default): adds to mail queue for cron processingVia mail.mail Directly
mail = self.env['mail.mail'].sudo().create({
'subject': 'Direct Email',
'email_from': '[email protected]',
'email_to': '[email protected]',
'body_html': '<p>Hello, this is a direct email.</p>',
'auto_delete': True,
})
mail.send()Via message_post (Chatter)
# Posts in chatter AND notifies followers via email
record.message_post(
body='Your request has been processed.',
message_type='comment',
subtype_xmlid='mail.mt_comment',
)Notification Preferences
Each user has a notification preference that controls how they receive messages:
| Preference | Behavior |
|---|---|
| Handle by Emails | Receives full email for each notification |
| Handle in Odoo | Sees notifications only in the Odoo inbox (no email) |
Set via user preferences or programmatically:
user.notification_type = 'email' # or 'inbox'The Mail Queue
Emails are stored as mail.mail records. The mail cron job processes them:
# Check mail queue
pending = self.env['mail.mail'].search([
('state', '=', 'outgoing')
])
# Force process queue
self.env['mail.mail'].process_email_queue()
# Check failed emails
failed = self.env['mail.mail'].search([
('state', '=', 'exception')
])
for mail in failed:
print(f'{mail.subject}: {mail.failure_reason}')Mail states: outgoing (queued), sent (delivered to SMTP), received (acknowledged), exception (failed), cancel (cancelled).
Template Rendering Context
Email templates have access to these variables in Jinja expressions:
object: The record being emaileduser: Current userctx: Context dictionaryformat_amount(amount, currency): Currency formattingformat_date(date, format, lang): Date formattingformat_datetime(dt, format, lang): Datetime formatting
Debugging Email Delivery
Check SMTP Configuration
# Test SMTP connection
server = self.env['ir.mail_server'].search([], limit=1)
try:
smtp = server.connect()
print('SMTP connection successful')
smtp.quit()
except Exception as e:
print(f'SMTP error: {e}')Enable Mail Logging
# In odoo.conf
log_handler = odoo.addons.mail:DEBUGCommon Issues and Fixes
| Problem | Cause | Fix |
|---|---|---|
| Emails stuck in outgoing | Mail cron not running | Activate the "Email Queue Manager" cron |
| Exception state | SMTP authentication failed | Check server credentials, use app passwords |
| No emails at all | No outgoing mail server | Configure SMTP server in settings |
| Emails in spam | Missing SPF/DKIM | Add DNS records for the sending domain |
| Template not rendering | Missing record context | Ensure template model matches the record model |
Auto-Delete and Cleanup
Set auto_delete=True on templates to clean up sent emails from the database. Without this, the mail.mail table grows indefinitely:
<field name="auto_delete">True</field>Best Practices
- Always configure an outgoing mail server — test it with the built-in test button
- Use
force_send=Trueonly for critical emails — queued sending is more reliable - Set
auto_delete=Trueon transactional templates to prevent database bloat - Use SPF, DKIM, and DMARC DNS records for the sending domain
- Monitor the mail queue for stuck or failed emails
- Use
mail.mt_notesubtype for internal messages to avoid spamming followers