# Send from a template

A template is a subject and a body kept by Lessly Mail, with named placeholders you fill in at send time. You write it once, publish it, and from then on your sending code carries only a template id and a handful of values.

Templates are managed from your product's authenticated session, not with a sending key from a client app. In the examples below, `...` stands for the Mail API base URL that session uses. The one exception is sending an email from a template, which is an ordinary send and goes to the public sending endpoint.

## Create a template

An agent calls [`mail_template_create`](/reference/mcp-tools/mail_template_create); over REST it is `POST /mail/templates` on the [Mail API](/reference/openapi/mail).

```bash
curl -X POST ".../templates" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order receipt",
    "subject": "Receipt for order {{order_id}}",
    "html": "<p>Hi {{FIRST_NAME}}, thanks for order {{order_id}}.</p>",
    "variables": [
      { "name": "order_id", "type": "string" }
    ]
  }'
```

```json
{
  "data": {
    "id": "3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c",
    "name": "Order receipt",
    "subject": "Receipt for order {{order_id}}",
    "html": "<p>Hi {{FIRST_NAME}}, thanks for order {{order_id}}.</p>",
    "text": null,
    "variables": [
      { "name": "order_id", "type": "string", "optional": false }
    ],
    "status": "draft",
    "createdAt": "2026-08-02T09:00:00.000Z",
    "updatedAt": "2026-08-02T09:00:00.000Z"
  },
  "success": true
}
```

`name`, `subject` and `html` are required and must not be empty. `text` is optional: supply it to control the plain-text part yourself, or leave it out and it is derived from the rendered HTML. A new template is always created as a `draft`.

The rest of the operations:

- [`mail_template_list`](/reference/mcp-tools/mail_template_list)
- [`mail_template_get`](/reference/mcp-tools/mail_template_get)
- [`mail_template_update`](/reference/mcp-tools/mail_template_update)
- [`mail_template_publish`](/reference/mcp-tools/mail_template_publish)
- [`mail_template_delete`](/reference/mcp-tools/mail_template_delete)

## Declare the variables

A template declares its variables up front. Each declaration has a `name`, a `type`, an `optional` flag and an optional `fallback`:

```json
{ "name": "plan", "type": "string", "optional": true, "fallback": "Free" }
```

A template may declare **at most 20 variables**. Names must be unique within the template and must match `[A-Za-z_][A-Za-z0-9_]*` — a letter or underscore first, then letters, digits and underscores.

`type` is one of `string`, `number` or `boolean`. It documents what the variable is meant to hold, and the values you may pass at send time are strings, numbers and booleans. The type does not change rendering: whatever you supply is written into the message as text, so `true` renders as `true` and `42` renders as `42`.

`optional` and `fallback` decide what happens when you pass no value:

| Declaration | With no value supplied |
|---|---|
| Has a `fallback` | The fallback is used, whatever `optional` says. |
| `optional: true`, no fallback | Renders as an empty string. |
| Neither | The send is rejected. |

Missing values are reported together, so one round trip tells you everything that is absent:

```json
{ "name": "invalid_template_variables",
  "message": "invalid_template_variables: missing order_id, total",
  "missing": ["order_id", "total"] }
```

### Reserved names

Four names are reserved and a template may not declare them.

| Name | Filled with |
|---|---|
| `FIRST_NAME` | the contact's `first_name` property |
| `LAST_NAME` | the contact's `last_name` property |
| `EMAIL` | the contact's email address |
| `UNSUBSCRIBE_URL` | that contact's personal unsubscribe link |

They are filled in for you when a message goes out to a list of contacts, one value per contact. In a direct send there is no contact, so you supply them yourself alongside your own variables if the template uses them. A reserved name never fails a send: with no value it renders as an empty string. Declaring one is rejected when the template is created or updated, with `'FIRST_NAME' is a reserved field`.

## Write the placeholders

A placeholder is a name in double braces, with or without spaces inside them: `{{order_id}}` and `{{ order_id }}` are the same thing. Placeholders work in the subject, in the HTML body and in the plain-text body.

Only plain names are recognised. Anything else in braces — a conditional, a loop, an expression — is not a placeholder and stays in the message exactly as written. A placeholder whose name the template does not declare, and which is not a reserved name, renders as an empty string.

Values are escaped for the place they land in:

| Where | Escaping |
|---|---|
| HTML body | `&`, `<`, `>`, `"` and `'` are replaced by their HTML entities, so a value can never introduce markup. `Ben & Jerry's` renders as `Ben &amp; Jerry&#39;s` in the source and reads as `Ben & Jerry's` in the mail client. |
| Subject | Not HTML-escaped — a subject line is not markup. Line breaks inside a value are collapsed to a single space, so a value can never split the subject or add a header. |
| Plain-text body | Values are inserted literally, whether you wrote the text part yourself or it was derived from the HTML. |

## Publish it

A template is either `draft` or `published`, and only a published template can be sent. An agent calls [`mail_template_publish`](/reference/mcp-tools/mail_template_publish); over REST, `POST /mail/templates/:id/publish`.

```bash
curl -X POST ".../templates/3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c/publish"
```

The response is the template with `status` now `published`. Publishing is idempotent: publishing an already-published template changes nothing and returns it as it stands.

Only a draft can be edited. `PATCH` on a published template is refused:

```json
{ "name": "template_not_editable",
  "message": "template 3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c is published and cannot be edited" }
```

So a published template is a fixed thing your sending code can rely on. To change what a message says, create a new template — typically a copy with the new wording — publish it, and point your code at the new id. A template can be deleted in either state.

## Send from it

Replace the inline body with a `template` object naming a published template and the values for its variables.

```bash
curl -X POST "https://public.lessly.dev/$PRODUCT_ID/mail/emails" \
  -H "X-Api-Key: $LESSLY_MAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@mail.acme.com>",
    "to": "customer@example.com",
    "template": {
      "id": "3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c",
      "variables": { "order_id": "4711", "FIRST_NAME": "Dana" }
    }
  }'
```

The subject comes from the template, so `template` is mutually exclusive with `subject`, `html` and `text`; sending both arms in one request is rejected. `variables` may be omitted when the template needs none. Everything else about the request — recipients, `reply_to`, `headers`, `tags`, `scheduled_at` — works exactly as it does for an inline send; see [send a message](/ship/mail/sending).

An id that names no template of yours, or one that is still a draft, is a `404`.

## What a template send freezes

The template is rendered while your send request is being handled, and the resulting subject and bodies are stored with that message. Two things follow.

A message already created is final. It is not re-rendered later, so it is unaffected by anything you do to the template afterwards — including deleting the template. What went out is what the template said at the moment of the send, and your record of that message keeps showing exactly that. The same holds for a scheduled send, even one set for weeks ahead: its content is fixed when you create it, not when it goes out.

This is why publishing is one-way and published templates cannot be edited. Correcting a live template would otherwise change what some messages say and not others, depending on when each one was created. For a receipt that is the whole point: publishing a new template instead keeps every message traceable to the exact wording it carried.

## Next steps

- [Send a message](/ship/mail/sending): the recipients, headers and scheduling a template send shares with an inline one.
- [Send from TypeScript](/ship/mail/sdk): manage templates and send from them with `@lessly/mail`.
- [Receive delivery events](/ship/mail/webhooks): find out what happened to what you sent.
- [How system email works](/ship/mail): where templates sit in the wider flow.
