Hello everyone, is there any Odoo developer here?
I’m facing an issue on Odoo 15 (Website Sale) related to automatic email sending after order confirmation.
This worked correctly before, but the issue appeared after introducing multiple email templates, one per rule.
Functional overview (minimal)
Sale order is confirmed from the website
After confirmation:
One standard order email is sent
Additional emails are sent depending on product rules
Each rule uses a different mail.template
Each email should display only the order lines related to that rule
Controller used on order confirmation
@http.route(['/shop/confirmation'], type='http', auth="public", website=True, sitemap=False)ute(['/shop/confirmation'], type='http', auth="public", website=True, sitemap=False)
def shop_payment_confirmation(self, **post):
""" Automatically confirm an order from website """
if 'valid_rules' in post and not bool(int(post['valid_rules'])):
return
purchase_capability, msg = request.env.user.partner_id.check_purchase_capability(
request.website.company_id
)
if msg:
return
sale_order_id = request.session.get('sale_last_order_id') or 0
order = request.env['sale.order'].sudo().browse(sale_order_id)
if order.exists():
if order.state not in ('draft', 'sent'):
return request.redirect('/shop/cart')
for line in order.order_line.filtered('product_packaging_id'):
max_qty = line.product_packaging_id.virtual_available_qty
if max_qty <= 0:
return request.redirect('/shop/confirm_order')
order.action_confirm()
order.action_create_activity()
request.website.sale_reset()
res = super(WebsiteSale, self).shop_payment_confirmation(**post)
# send mail if there is at least one product for distribution
so_line_with_distribution_id = order.order_line.filtered(
lambda r: r.product_id.product_tmpl_id.is_for_distribution
)
if so_line_with_distribution_id:
product_tmpl_ids = so_line_with_distribution_id.mapped('product_id').mapped('product_tmpl_id')
order._send_distribution_mail(product_tmpl_ids)
return res
Mail sending logic
def get_coupon_domain(self):
return [('promo_code_usage', '=', 'distribution')]
def _send_distribution_mail(self, product_tmpl_ids):
self.ensure_one()
coupon_program_ids = self.code_promo_program_id.search(self.get_coupon_domain())
email_values = list()
used_coupon_ids = self.env['coupon.program']
for product_tmpl_id in product_tmpl_ids:
used_coupon_ids |= coupon_program_ids.get_the_right_one(product_tmpl_id)
for coupon_program_id in used_coupon_ids:
email_values.append(self._get_mail_values(coupon_program_id))
mail_ids = self.env['mail.mail'].sudo().create(email_values)
mail_ids.send(raise_exception=False)
Mail values generation
def _get_mail_values(self, coupon_program_id):
template_id = (
coupon_program_id.email_template_id
or self.env.ref(
'sol_don_website.mail_template_distribution_tracking',
raise_if_not_found=True
)
)
email_values = {
'email_to': self.partner_id.email_formatted,
'email_from': self.company_id.email_formatted,
'author_id': self.env.user.partner_id.id,
'subject': _('Distribution condition %s') % (self.name,),
'body_html': template_id._render_field(
'body_html',
[self.id],
compute_lang=True,
post_process=True
)[self.id],
'recipient_ids': self.message_follower_ids.mapped('partner_id')
+ self.partner_invoice_id
}
return email_values
Coupon program model
class CouponProgram(models.Model):
_inherit = "coupon.program"
promo_code_usage = fields.Selection(
selection_add=[('distribution', 'Distribution condition')]
)
distribution_msg = fields.Text('Distribution condition body')
email_template_id = fields.Many2one(
'mail.template',
'Mail template',
domain=[('model_id', '=', 'sale.order')]
)
def get_the_right_one(self, product_tmpl_ids):
product_ids = product_tmpl_ids.mapped('id')
for program in self:
domain = ast.literal_eval(
program.rule_products_domain
) if program.rule_products_domain else []
if any(
i['id'] in product_ids
for i in product_tmpl_ids.search_read(domain, ['id'])
if i
):
return program
return self - self
** Extract of the Mail template**
<t t-foreach="
object.order_line.filtered(
lambda r: r.product_id.product_tmpl_id.is_for_distribution
)
" t-as="so_line_id">
<tr>
<td>
<span t-field="so_line_id.product_id.default_code"/>
</td>
<td>
<span t-field="so_line_id.name"/>
</td>
<td>
<span t-field="so_line_id.price_subtotal"/>
</td>
</tr>
</t>
Observed issue
When:
A sale order contains products matching multiple coupon programs
Each coupon program has its own mail template
Multiple emails are generated during the same confirmation
Then:
All emails are sent successfully
-
But the rendered order lines are incorrect:
- Only one order line is displayed
- Usually the last added line
- Lines appear in templates where they should not belong
There are:
No errors
No tracebacks
_get_mail_values() is called correctly
_render_field() returns HTML
But the final email content is wrong
Question
Is this a known behavior or limitation in Odoo 15 mail.template rendering when:
Sending multiple emails
Same model (sale.order)
Same res_id
Different templates
Same HTTP request / transaction
What is the recommended approach to isolate data per email in this case?
Thanks in advance for any insight. You can also see this post in the Odoo forum here: Odoo 15 – Multiple mails sent on order confirmation but wrong order lines rendered in templates. Unfortunately the odoo forum is unavailable but you can still view it when you are logged in
Top comments (1)
it’s not a limitation of “same model/res_id different template” per se; it’s a side effect of rendering + post-processing + bulk send without isolating context. The fix is render/send via mail.template.send_mail() and pass the per-rule line IDs via context, then filter in QWeb by those IDs.
What you’re seeing is very consistent with Odoo’s mail rendering pipeline when you “manually” pre-render body_html and then bulk-create/send mail.mail in the same request.
Two key points:
1) Your template has no per-rule isolation
Right now the template loop is:
<t t-foreach="object.order_line.filtered(lambda r: r.product_id.product_tmpl_id.is_for_distribution)" t-as="so_line_id">
That filter is global to the order. It does not depend on the coupon program/rule at all. So even if you pick different templates per rule, you’re still rendering “distribution lines in the order”, not “lines matching this program’s domain”.
So the correct design is: compute the line IDs that belong to the specific coupon_program_id, pass them to the template (context), and filter by those IDs in the template.
2) Rendering multiple emails in one transaction can reuse/collide with internal render/post-process caches
In Odoo 15, template rendering/post-processing (tracking links, layout, translations, safe_eval/qweb evaluation, etc.) uses caches at multiple layers. If you call _render_field(..., post_process=True) repeatedly in the same request for the same (model, res_id) you can end up with “bleed” effects depending on how the render context and post-processing cache are keyed.
This is exactly why the recommended approach is not “render body_html yourself + create mail.mail + send”, but instead: let mail.template generate and create the message per send (it builds the right rendering context and avoids a bunch of subtle collisions). The generic advice you’ll see is “use template.send_mail()” .
Recommended approach (robust + isolates data)
A) Compute the lines per program and pass them via context
Example (IDs are safest to pass; don’t pass recordsets through context if you can avoid it):
`def _send_distribution_mail(self, product_tmpl_ids):
self.ensure_one()
programs = self.env['coupon.program'].search(self.get_coupon_domain())
And implement _distribution_line_ids_for_program(program) however you decide (usually: parse program.rule_products_domain and match to order lines).
B) Use those IDs in the QWeb template
QWeb supports expressions in t-foreach , so filter by context-provided IDs:
<t t-set="line_ids" t-value="ctx.get('distribution_line_ids', [])"/><t t-foreach="object.order_line.filtered(lambda l: l.id in line_ids)" t-as="so_line_id">
<tr>
<td><span t-field="so_line_id.product_id.default_code"/></td>
<td><span t-field="so_line_id.name"/></td>
<td><span t-field="so_line_id.price_subtotal"/></td>
</tr>
</t>
This gives you true per-email isolation: each send uses its own context payload and renders only its own line set.
Why this fixes the “only last line shows” symptom
Even if your current code “renders HTML”, the moment you batch-create multiple mail.mail and call send() on a recordset, you’re going through a pipeline that can:
re-apply layout / tracking post-processing,
normalize/sanitize html,
and in some cases re-evaluate parts with a shared environment/context.
When your template logic depends on dynamic record evaluation (filtered(lambda ...), t-field, etc.), those internal caches/context reuse can manifest as “it always looks like the last evaluation won”.
Using template.send_mail() per program avoids a lot of those edge cases because:
it generates a message per call with the correct render context,
and it’s the code path Odoo itself expects you to use for repeated template sends .
Minimal change if you insist on keeping mail.mail.create(...)
If you absolutely want to keep your “create many then send” approach, at least do both:
pass per-program line IDs in context: template.with_context(distribution_line_ids=...)
set post_process=False in _render_field and handle post-processing differently (because post_process=True is where link/layout processing and some caching effects show up)
…but honestly, send_mail() per template is the cleanest and most “Odoo-native” fix.