DEV Community

Harena Sarobidy RAMALANJAONA
Harena Sarobidy RAMALANJAONA

Posted on

Odoo 15 – Multiple mails sent on order confirmation but wrong order lines rendered in templates

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
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

** 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>
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
rkingkong profile image
Rodolfo Kong

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())

used_programs = self.env['coupon.program']
for tmpl in product_tmpl_ids:
    used_programs |= programs.get_the_right_one(tmpl)

for program in used_programs:
    line_ids = self._distribution_line_ids_for_program(program)
    template = program.email_template_id or self.env.ref(
        'sol_don_website.mail_template_distribution_tracking',
        raise_if_not_found=True
    )

    # Let Odoo do the rendering + mail.mail creation
    template.with_context(
        distribution_line_ids=line_ids,
        distribution_program_id=program.id,
    ).send_mail(
        self.id,
        force_send=True,
        raise_exception=False,
        email_values={
            'email_to': self.partner_id.email_formatted,
            'email_from': self.company_id.email_formatted,
            'author_id': self.env.user.partner_id.id,
        },
    )`
Enter fullscreen mode Exit fullscreen mode

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.