> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Odoo: Send a Silent Internal Copy of Every Confirmed Booking (and Why the Customer Got It Instead)
- URL: https://blog.sudowl.com/odoo-automated-action-hidden-booking-copy-email/
- Published: 2026-10-16T08:15:00.000Z
- Updated: 2026-10-16T12:26:41.000Z
- Description: An Odoo automation rule + code server action that mails an internal copy of each confirmed rental, and the use_default_to trap that mails the customer.
- Author: Lukas
- Tags: business-ai

A small business I run some of the IT for rents out office space by the day, and the bookings live in Odoo Online (Rental app, so every booking is a `sale.order` with `is_rental_order = True`). The ask was very normal: "every time a booking gets confirmed, send a copy to bookkeeping, the owner and me. The customer should not notice anything."

Sounds like ten minutes of clicking. It took longer, because Odoo has three different ways of "sending an email" and two of them involve the customer, plus the templates had a translation trap I didn't see coming.

**TL;DR:** Make a dedicated `mail.template` on `sale.order` with fixed internal addresses in `email_to`, and set **`use_default_to = False`** and **`partner_to` empty**, otherwise Odoo happily sends it to the customer. Trigger it from an automation rule ("on create and edit", before-domain `state != 'sale'`, domain `state = 'sale'` and `is_rental_order = True`) with a **code** server action that calls `template.send_mail(rec.id, force_send=True)`. Don't use the "Send Email" action type, it posts on the order and notifies the followers, and that includes the customer. And if your database runs in a non-English language, edit translatable template fields **with that language in the context**.

## The three ways to "send an email" in Odoo

1. **Server action type "Send Email"** (the built-in one). It renders a template and *posts it as a message on the record*. That means it shows up in the chatter, and followers of the record get notified. On a sales order the customer is a follower. Exactly what we don't want.
2. **"Send by Email" button on the order.** Opens the composer with the customer as recipient. Manual, and customer-facing by design.
3. **`mail.template.send_mail()`** from code. Renders the template and creates a `mail.mail` directly, without posting on the record. Recipients come only from the template. This is the one.

## The template

Create a template on the Sales Order model. In the UI that's Settings → Technical → Email Templates (developer mode). The fields that matter:

| Field                                 | Value                                                     |
| ------------------------------------- | --------------------------------------------------------- |
| Model                                 | Sales Order (sale.order)                                  |
| Subject                               | Booking confirmed: {{ object.name }}                      |
| To (email\_to)                        | office@example.com,bookkeeping@example.com,it@example.com |
| Default recipients (use\_default\_to) | **off**                                                   |
| To (Partners) (partner\_to)           | **empty**                                                 |
| Auto Delete                           | on                                                        |

Body (QWeb):

```html
<div>
  <p>New confirmed booking <strong><t t-out="object.name"/></strong></p>
  <p>Customer: <t t-out="object.partner_id.name"/><br/>
     Total: <t t-out="format_amount(object.amount_total, object.currency_id)"/></p>
  <ul>
    <t t-foreach="object.order_line" t-as="line">
      <li><t t-out="line.name"/></li>
    </t>
  </ul>
</div>

```

## Gotcha: use\_default\_to sends it to the customer

This is the big one. With `use_default_to` on, Odoo ignores your carefully typed `email_to` and uses the record's default recipients. For a sales order that's the customer, so your "internal copy" lands in the customer's inbox instead.

Both have to be off/empty: `use_default_to = False` **and** `partner_to = False`. Then the only recipients are the ones in `email_to`.

`auto_delete` keeps the sent `mail.mail` records from piling up. You still see the mail in the outgoing log while it's queued, it just doesn't stay around forever.

## The server action (code)

Settings → Technical → Server Actions → new, model Sales Order, type **Execute Code**:

```python
template = env['mail.template'].browse(TEMPLATE_ID)  # the id of the template above
for rec in records:
    template.send_mail(rec.id, force_send=True)

```

`force_send=True` sends immediately instead of waiting for the mail queue cron. Without it the copy shows up whenever the queue runs next, which is fine too, but people tend to test by confirming an order and staring at their inbox.

Side note on permissions: the admin account I was using had sales admin and access-rights management, but not full "Settings" (system) rights. In the UI, creating a code server action was blocked for that user. Via the API, the same user could create it. I'm not going to argue whether that's intended. It just means: if the UI won't let you, check what rights you actually have before assuming the feature doesn't exist on Odoo Online.

## The automation rule

Settings → Technical → Automation Rules (needs the Automation Rules / `base_automation` module):

- **Model:** Sales Order
- **Trigger:** On create and edit
- **Before Update Domain:** `[('state', '!=', 'sale')]`
- **Apply on (domain):** `[('state', '=', 'sale'), ('is_rental_order', '=', True)]`
- **Action:** the code server action from above

The before/after domain pair is what makes it fire **once**. The rule only runs when the record goes from "not confirmed" to "confirmed rental". Without the before-domain, every later edit of a confirmed order (adding a note, changing a line) would send another copy. With only "On create", you'd miss orders that get created as quotations and confirmed later, which is basically all of them.

Filtering on `is_rental_order` keeps normal sales orders out of it, in case the business ever sells anything that isn't a rental.

## Doing it via JSON-RPC instead of clicking

I did most of this from a script on my laptop, because I wanted backups of every template body before touching it, and because of the permission thing above. Odoo Online exposes the external API at `/jsonrpc`. Minimal Node client, no dependencies (Node 18+ for `fetch`):

```javascript
const URL = 'https://example.odoo.com/jsonrpc';
const DB = 'example', USER = 'admin@example.com', KEY = process.env.ODOO_API_KEY;

async function rpc(service, method, ...args) {
  const r = await fetch(URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { service, method, args }, id: Date.now() }),
  });
  const j = await r.json();
  if (j.error) throw new Error(JSON.stringify(j.error.data?.message || j.error));
  return j.result;
}

const uid = await rpc('common', 'login', DB, USER, KEY);
const call = (model, method, args, kw = {}) =>
  rpc('object', 'execute_kw', DB, uid, KEY, model, method, args, kw);

// sanity check: the fields we care about on the template
console.log(await call('mail.template', 'read', [[TEMPLATE_ID]],
  { fields: ['name', 'email_to', 'use_default_to', 'partner_to', 'auto_delete'] }));

```

Use an API key (user preferences → Account Security), not your password, and keep it out of the script and out of git. I pass it as an env var and revoke it when I'm done.

## Gotcha: translatable fields and the language context

This one came up on a different part of the same job: adding an info block to the customer-facing quotation and confirmation templates.

The database had exactly one active language, German. `body_html` on `mail.template` is a **translatable** field. When you write to it over RPC without a language in the context, you write the **en\_US base value**. The mails get rendered in the customer's language, which is German, so they use the German translation, which you didn't touch. The write "succeeds", reading it back without context shows your change, and the real mail looks exactly like before.

Fix: read and write with the language in the context, every time:

```javascript
const ctx = { context: { lang: 'de_DE' } };

const [tpl] = await call('mail.template', 'read', [[TEMPLATE_ID]], { fields: ['body_html'], ...ctx });
// backup first
require('fs').writeFileSync(`body_${TEMPLATE_ID}.bak.html`, tpl.body_html);

const patched = tpl.body_html.replace(ANCHOR, INFO_BLOCK + ANCHOR);
await call('mail.template', 'write', [[TEMPLATE_ID], { body_html: patched }], ctx);

```

Second trap in those stock templates: a large part of the body sits inside a `t-if` for website orders. If you put your block in there, it only shows up for web shop orders. I anchored my block right before the salesperson-signature condition, which renders for every order. Then I checked the actual sent mail, not the template preview, before calling it done.

## Gotcha: default terms are only a default

Related, and still on my list: the company "terms and conditions" text (`invoice_terms`) is just the default for the order's `note` field. The bookings here come in from a website integration that writes its own `note` (room, date, name). That overwrites the default, so the terms block never makes it onto the PDF for web bookings. If you rely on that text being on every document, check an order that was created by your integration, not one you made by hand.

## Closing a stuck rental while I was at it

One reservation had been sitting in "Reserved" for two months, long after the stay was over. On a rental order line, the status follows the delivered/returned quantities. Setting `qty_delivered` and `qty_returned` to the booked quantity moves the order to "Returned". In the UI that's just Pickup and then Return, but if you're already in a script anyway, it's one write on the order line.

## Checklist

- Dedicated template, `email_to` internal only, `use_default_to` off, `partner_to` empty.
- Code server action with `send_mail(..., force_send=True)`, not "Send Email".
- Automation rule on create and edit with a before-domain, so it fires once.
- Translatable fields: always pass the language in the context, and test with a real sent mail.
- Back up every template body before writing to it.

If you're automating business mail flows in general, the same idempotency rules apply as with any cron job: see [the bug that uploaded the same file 153 times](/idempotency-cron-automation-duplicate-uploads/). And for receipts that only arrive as HTML mails, I wrote about [turning them into PDFs with headless Chromium](/html-email-receipts-to-pdf-headless-chromium/).

---

### Related posts

- [“I Barely Use It” — Where $100 of AI API Credit Actually Went](https://blog.diengdoh.com/ai-agent-api-costs-background-loops/?ref=blog.sudowl.com)
- [Turning HTML-Only Email Receipts into PDFs with Headless Chromium](https://blog.diengdoh.com/html-email-receipts-to-pdf-headless-chromium/?ref=blog.sudowl.com)
- [My AI Receipt Sorter Shifted Amounts by 2,000 Euros, So I Took the Numbers Away From It](https://blog.diengdoh.com/ai-receipt-matching-deterministic-parsing/?ref=blog.sudowl.com)