---
title: "openemail.templates"
description: "Every method in this namespace: its signature, its parameters, what it returns and an example."
url: "https://openemail.uk/docs/python/reference/templates"
area: "Python"
category: "Reference"
---

# openemail.templates

Every method in this namespace: its signature, its parameters, what it returns and an example.

## Methods

Bodies stored once and sent many times, with versions, previews and typed props.

### `templates.list()`

List templates, most recently updated first

```python
def list(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    status: TemplateStatus | None = None,
    search: str | None = None,
    sort: TemplateSort | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[TemplateResource]
```

Returns one page of the workspace's templates, ordered by `updatedAt` descending unless `sort=` asks for another order. Each row carries the published version's `subject`, `engine`, `slots` and `props`, so you can see what a send needs without fetching every template. A template with nothing published omits those four keys and reports `publishedVersion` as `None`.

Paging is keyset on `updatedAt`, and the cursor is opaque: it holds the `sort` and where the last template on the page sat in it, so a template deleted while you page never breaks the walk. Editing a template moves it to the front, so a template changed while you page can show up twice. Pass `nextCursor` back as `cursor=` while `hasMore` is `True`, or let `list_all` or `iterate` walk the pages for you.

`status=` narrows to one template status. `'draft'` is the status of a template created without `publish` and never published or activated since. It does not mean "has unpublished edits": check `latestVersion` against `publishedVersion` for that.

Scopes: `templates:read`.

**Parameters**

- `limit` (`int`): Rows per page, a whole number from 1 to 100. The server defaults to 25.
- `cursor` (`str`): The `nextCursor` from the previous page. Never build one yourself.
- `status` (`TemplateStatus`): Restricts the page to `'draft'`, `'active'` or `'archived'` templates. Leave it out for all of them.
- `search` (`str`): Matches the name, the slug, the description, the published version's subject and the id, each word loosely, with close spellings when nothing matches exactly, as a substring. `%` and `_` are taken literally.
- `sort` (`TemplateSort`): The order: `'updated-newest'` (the default), `'updated-oldest'`, `'created-newest'`, `'created-oldest'`, `'name'` or `'name-reversed'`. The cursor follows whichever you asked for.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[TemplateResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each template has `id`, `name`, `slug`, `description`, `status`, `publishedVersion`, `latestVersion`, `createdAt` and `updatedAt`, plus `subject`, `engine`, `slots` and `props` when a version is published, so read those four with `.get()`.

**Example**

```python
from openemail import openemail

page = openemail.templates.list(status='active', limit=50)

for template in page['items']:
    print(template['slug'], template['publishedVersion'], template['latestVersion'])

if page['hasMore'] and page['nextCursor']:
    following = openemail.templates.list(status='active', limit=50, cursor=page['nextCursor'])
    print(len(following['items']))
```

**Notes**

- A cursor is only valid under the sort it was handed out with. Keep `sort` and `search` the same while you page, which `list_all` and `iterate` do for you.
- A cursor this list did not hand out, or one handed out under another `sort`, raises a 400 `invalid_cursor` rather than returning an empty page. Start again without a cursor.
- `publishedVersion` lower than `latestVersion` means the body was edited after the last publish, and live sends still use the published version.
- `publishedVersion` is `None` for a template that has never been published, here and on `get`, `create` and `update` alike.

Also available in: API [`GET /templates`](https://openemail.uk/docs/api/reference/templates#get-templates); TypeScript [`templates.list()`](https://openemail.uk/docs/sdk/reference/templates#list); Ruby [`templates.list`](https://openemail.uk/docs/ruby/reference/templates#list); CLI [`openemail templates list`](https://openemail.uk/docs/cli/reference/templates#templates-list).

### `templates.list_all()`

Collect every template into one list

```python
def list_all(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    status: TemplateStatus | None = None,
    search: str | None = None,
    sort: TemplateSort | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[TemplateResource]
```

Walks every page of the template list and returns all of them as one list, most recently updated first. It follows `nextCursor` until `hasMore` is `False`, so the number of requests is the template count divided by the page size.

A workspace holds at most 200 templates, so passing `limit=100` normally finishes in two requests. `status=` collects only one status. Paging is keyset on `updatedAt`, so a template edited by someone else during the walk can appear twice: de-duplicate on `id` if other writers are active.

Scopes: `templates:read`.

**Parameters**

- `limit` (`int`): Page size for each request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the first page.
- `status` (`TemplateStatus`): Collects only `'draft'`, `'active'` or `'archived'` templates.
- `search` (`str`): Matches the name, the slug, the description, the published version's subject and the id, each word loosely.
- `sort` (`TemplateSort`): The order to walk in: `'updated-newest'` (the default), `'updated-oldest'`, `'created-newest'`, `'created-oldest'`, `'name'` or `'name-reversed'`.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[TemplateResource]` holding every matching template, each with `id`, `slug`, `status`, `publishedVersion` and `latestVersion`, plus the published `subject`, `engine`, `slots` and `props` where one exists.

**Example**

```python
from openemail import openemail

templates = openemail.templates.list_all(limit=100)

unpublished = [
    template['slug'] for template in templates if template['publishedVersion'] is None
]
print(unpublished)
```

**Notes**

- Each page is a separate request. If one fails the call raises, and the pages already fetched are discarded.
- Archived templates count towards the 200 limit, and only `delete` frees a place.
- Use `iterate` when you only need the first match, since it stops fetching once you break.
- `timeout=` applies to each page request on its own, not to the whole walk.

Also available in: API [`GET /templates`](https://openemail.uk/docs/api/reference/templates#get-templates); TypeScript [`templates.listAll()`](https://openemail.uk/docs/sdk/reference/templates#listAll); Ruby [`templates.list_all`](https://openemail.uk/docs/ruby/reference/templates#listAll).

### `templates.iterate()`

Stream templates one at a time

```python
def iterate(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    status: TemplateStatus | None = None,
    search: str | None = None,
    sort: TemplateSort | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[TemplateResource]
```

Returns a generator that yields templates one at a time and requests the next page only after the current one is drained. Nothing is fetched until you start consuming it, and breaking out of the loop stops further requests, so this is the cheapest way to find one template by a property the API cannot filter on.

The walk follows `nextCursor` and ends when `hasMore` is `False`, when a page arrives without a `nextCursor`, or when the server repeats a cursor. Order is most recently updated first, so a template edited while you iterate can be yielded twice. Collect ids during the loop and act on them afterwards if you are also writing.

Scopes: `templates:read`.

**Parameters**

- `limit` (`int`): Page size per request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the first page.
- `status` (`TemplateStatus`): Yields only `'draft'`, `'active'` or `'archived'` templates.
- `search` (`str`): Matches the name, the slug, the description, the published version's subject and the id, each word loosely.
- `sort` (`TemplateSort`): The order to walk in: `'updated-newest'` (the default), `'updated-oldest'`, `'created-newest'`, `'created-oldest'`, `'name'` or `'name-reversed'`.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[TemplateResource]`, a generator yielding one template per step.

**Example**

```python
from openemail import openemail

for template in openemail.templates.iterate(status='archived'):
    if template['name'] == 'Order shipped':
        print(template['id'], template['slug'], template['latestVersion'])
        break
```

**Notes**

- A page that fails raises out of the `for` loop, after the templates of the earlier pages have been yielded.
- The generator is lazy, so an abandoned loop costs only the pages it consumed.

Also available in: API [`GET /templates`](https://openemail.uk/docs/api/reference/templates#get-templates); TypeScript [`templates.iterate()`](https://openemail.uk/docs/sdk/reference/templates#iterate); Ruby [`templates.iterate`](https://openemail.uk/docs/ruby/reference/templates#iterate).

### `templates.get()`

Read a template with its head version in full

```python
def get(
    id_or_slug: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateDetailResource
```

Fetches one template by `tpl_` id or by slug. Every templates method accepts either, and the two cannot collide because ids carry the `tpl_` prefix while a slug has no underscores. Pin the slug in code: it is derived once at creation and never changes when the template is renamed.

`latest` is the head version, meaning the current draft, or the published version when nothing has been edited since. This is the only read that returns the body: `document` for the `blocks` engine, and `html` for the `html` engine exactly as submitted, before sanitising. The top level `subject`, `engine`, `slots` and `props` describe the published version, which is what a send uses, so they differ from `latest` while somebody has unpublished edits.

When nothing has been published yet, `publishedVersion` is `None` and the top level `subject`, `engine`, `slots` and `props` are absent. Read the draft from `latest` instead. Use `list` or `list_versions` to find out whether a template can actually be sent.

Scopes: `templates:read`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateDetailResource`: the `TemplateResource` keys plus `latest`, a `TemplateVersionResource` with `id`, `version`, `state`, `engine`, `subject`, `slots`, `props`, `document`, `html`, `publishedAt` and `createdAt`.

**Example**

```python
from openemail import openemail

template = openemail.templates.get('order-shipped')

latest = template['latest']
print(template['id'], template['publishedVersion'], latest['version'], latest['state'])

required = [prop['key'] for prop in template.get('props', []) if prop['required']]
print(required)
```

**Notes**

- A missing template raises a 404 `resource_not_found`, whether it never existed or belongs to another workspace, so `is_not_found` is the check to make.
- Read required props from the top level `props`, not from a preview. A preview tolerates a missing required prop and a send refuses it.
- `latest['html']` is the markup as you posted it. What goes out is the sanitised copy compiled at publish, which `preview` shows.

Also available in: API [`GET /templates/{id}`](https://openemail.uk/docs/api/reference/templates#get-templates-id); TypeScript [`templates.get()`](https://openemail.uk/docs/sdk/reference/templates#get); Ruby [`templates.get`](https://openemail.uk/docs/ruby/reference/templates#get); CLI [`openemail templates get`](https://openemail.uk/docs/cli/reference/templates#templates-get).

### `templates.create()`

Create a template and its first version

```python
def create(
    body: TemplateCreate,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateResource
```

Creates the template together with version 1. Without `'publish': True` that version is a draft, and a draft cannot be sent: `send` raises a 422 `template_not_published` until somebody calls `publish`. With `'publish': True` the body is compiled straight away and the template starts `'active'`, so a body that fails to render is refused here instead of later.

The engine follows what you send. Passing `html` selects the `html` engine, anything else is `blocks`. A `blocks` template stores a `document` whose `body` is a tree of `@react-email/components` nodes (`Section`, `Row`, `Column`, `Container`, `Text`, `Heading`, `Button`, `Link`, `Img`, `Hr`, `Markdown`, `CodeBlock` and `CodeInline`), validated on the way in with a ceiling of 500 nodes nested 8 deep. An `html` template stores markup you rendered yourself, with whatever tool you like, and it is sanitised when the version is compiled.

`{{key}}` placeholders are filled from declared slots and props. A slot has a default and belongs to whoever edits the template. A prop is supplied by the sender and can be `required`. Keys start with a letter and continue with letters, digits or underscores, and one key cannot be both. In a `blocks` template an undeclared placeholder raises a 422 `invalid_template` naming its path. In `html` markup, any placeholder used inside an attribute such as `href` or `src` must be declared with kind `'url'` or `'image'`, or compiling fails.

Scopes: `templates:write`.

**Parameters**

- `body['name']` (`str`, required): Display name, 1 to 100 characters after trimming and unique per workspace.
- `body['slug']` (`str`): Stable handle of lowercase letters, digits and hyphens, at most 64 characters. Derived from `name` when omitted.
- `body['description']` (`str | None`): Free text note, at most 500 characters.
- `body['publish']` (`bool`): Compiles and publishes version 1 immediately. Defaults to `False`, which leaves a draft.
- `body['starter']` (`str`): A starter slug from `list_starters`, which seeds the subject and the body. Anything you send yourself wins over the starter, and an unknown slug is a 404.
- `body['engine']` (`TemplateEngine`): `'blocks'` or `'html'`. Inferred from whether `html` is present.
- `body['subject']` (`str`): Subject line with optional `{{key}}` placeholders, at most 998 characters and free of line breaks.
- `body['document']` (`TemplateDocument`): The `blocks` body as a dict: `body` (the block tree) plus the page settings that travel with it, `preview`, `tailwind`, `fonts` and `style`. Defaults to an empty body.
- `body['html']` (`str`): Pre-rendered markup for the `html` engine. Required for it, at most 1,000,000 characters.
- `body['slots']` (`list[TemplateCreateSlotsItem]`): Up to 100 editor filled values, each a dict with `key`, optional `label`, `kind` (default `'text'`) and `default` (default empty string).
- `body['props']` (`list[TemplateCreatePropsItem]`): Up to 100 sender supplied values, each a dict with `key`, optional `label`, `kind` (default `'text'`), `required` (default `False`) and `default` (default `None`).
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateResource` with the new `id` and `slug`, `status` of `'draft'` or `'active'`, `latestVersion` of 1, and, when it was published, version 1's `subject`, `engine`, `slots` and `props`.

**Example**

```python
from openemail import openemail

template = openemail.templates.create(
    {
        'name': 'Order shipped',
        'subject': 'Order {{order_id}} is on its way',
        'html': '<p>Hi {{customer}}, <a href="{{tracking_url}}">track {{order_id}}</a>.</p>',
        'props': [
            {'key': 'order_id', 'required': True},
            {'key': 'customer', 'default': 'there'},
            {'key': 'tracking_url', 'kind': 'url', 'required': True},
        ],
        'publish': True,
    }
)

print(template['id'], template['slug'], template['status'])
```

**Notes**

- Without `publish`, the response has `publishedVersion` set to `None` and leaves out `subject`, `engine`, `slots` and `props`, because nothing is published yet.
- A duplicate name is a 409 `template_name_taken` and a duplicate slug is a 409 `template_slug_taken`, both scoped to the workspace.
- A workspace holds at most 200 templates, archived ones included. Creating one more raises a 422 `workspace_limit_reached`.
- Not retried by the SDK, since there is no idempotency key on this route. After a lost response, `get` the slug before trying again.

Also available in: API [`POST /templates`](https://openemail.uk/docs/api/reference/templates#post-templates); TypeScript [`templates.create()`](https://openemail.uk/docs/sdk/reference/templates#create); Ruby [`templates.create`](https://openemail.uk/docs/ruby/reference/templates#create); CLI [`openemail templates create`](https://openemail.uk/docs/cli/reference/templates#templates-create).

### `templates.update()`

Edit template metadata or its draft body

```python
def update(
    id_or_slug: str,
    patch: TemplatePatch,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateDetailResource
```

Changes a template in place. `name`, `description` and `status` are metadata and never create a version. Sending any of `subject`, `document`, `html`, `slots`, `props` or `engine` is a body edit: if the head version is still a draft it is overwritten, and if the head is published a new draft numbered one higher is minted. Body keys you leave out keep the head's values, so a patch carrying only `subject` keeps the existing document and declarations, while `slots` or `props` replace the whole list.

Nothing here changes what a live send resolves to. Sends keep using the published version until you call `publish`, which is what makes it safe to edit a template in production. The response's `latest` is the version this call wrote into.

Pass `expectedVersion` with the `latestVersion` you read before editing. If another writer has moved the head since, the call raises a 409 `version_conflict` instead of overwriting their change. Without it the last write wins. Setting `'status': 'archived'` is the reversible alternative to `delete`. An archived template refuses to send until it is active again, while `preview` still renders it.

Scopes: `templates:write`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `patch['name']` (`str`): Replacement name, 1 to 100 characters and unique per workspace. The slug does not follow it.
- `patch['slug']` (`str`): Replacement slug of lowercase letters, digits and hyphens, at most 64 characters. Anything pinning the old slug starts getting 404s, and taking another template's slug is a 409 `template_slug_taken`.
- `patch['description']` (`str | None`): Replacement note of at most 500 characters, or `None` to clear it.
- `patch['status']` (`Literal['active', 'archived']`): `'archived'` archives the template and `'active'` reactivates it. It cannot be set back to `'draft'`.
- `patch['expectedVersion']` (`int`): The head version number you edited from. A mismatch is a 409 `version_conflict` and nothing is written.
- `patch['engine']` (`TemplateEngine`): Switches between `'blocks'` and `'html'`. Switching to `'html'` needs `html` supplied or already stored.
- `patch['subject']` (`str`): Replacement subject, at most 998 characters and free of line breaks.
- `patch['document']` (`TemplateDocument`): The `blocks` body as a dict: `body` (the block tree) plus the page settings that travel with it, `preview`, `tailwind`, `fonts` and `style`. It replaces the whole document, so read the current one before rebuilding part of it. Revalidated in full.
- `patch['html']` (`str`): Replacement markup for the `html` engine, at most 1,000,000 characters.
- `patch['slots']` (`list[TemplatePatchSlotsItem]`): Complete replacement slot list.
- `patch['props']` (`list[TemplatePatchPropsItem]`): Complete replacement prop list.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateDetailResource` with the saved metadata, the published version's keys at the top level, and `latest` set to the version this edit wrote into, including its `document` or `html`.

**Example**

```python
from openemail import openemail

current = openemail.templates.get('order-shipped')

updated = openemail.templates.update(
    'order-shipped',
    {
        'subject': 'Your order {{order_id}} has shipped',
        'expectedVersion': current['latestVersion'],
    },
)

print(updated['latest']['version'], updated['latest']['state'])

openemail.templates.publish('order-shipped')
```

**Notes**

- Not retried by the SDK. With `expectedVersion` set, repeating the call yourself after a lost response is a 409 if the first attempt minted a version.
- An archived template refuses to send with 422 `template_archived`. `publish` sets `status` back to `'active'`, so publishing an archived template reactivates it.
- The merged body is validated as a whole, so an edit can be refused with 422 `invalid_template` for what it does to keys you did not send.
- Renaming onto another template's name is a 409 `template_name_taken`.

Also available in: API [`PATCH /templates/{id}`](https://openemail.uk/docs/api/reference/templates#patch-templates-id); TypeScript [`templates.update()`](https://openemail.uk/docs/sdk/reference/templates#update); Ruby [`templates.update`](https://openemail.uk/docs/ruby/reference/templates#update); CLI [`openemail templates update`](https://openemail.uk/docs/cli/reference/templates#templates-update).

### `templates.duplicate()`

Copy a template into a new one

```python
def duplicate(
    id_or_slug: str,
    body: TemplateDuplicate | None = None,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateDetailResource
```

Creates a new template from the HEAD version of an existing one: the same subject, body, slots and props, and the source's description. The copy starts at version 1 as a DRAFT, whatever the source had published, so a copy is never sendable by accident. Publish it when you mean to.

The copy is a separate template with its own id and slug. Nothing links it back to the source, so editing either one afterwards leaves the other alone. This is the safe way to try a redesign of a template that is sending in production.

The body is optional, and so is `name` inside it. Left out, the source name is reused, and because a name is unique per workspace the server appends a number until one is free, up to twenty attempts. Sending a name that is already taken behaves the same way, so a script that copies nightly keeps working.

Scopes: `templates:write`.

**Parameters**

- `id_or_slug` (`str`, required): The template to copy, by `tpl_` id or slug.
- `body['name']` (`str`): The name for the copy, 1 to 100 characters. Omit it to reuse the source name with a number appended.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateDetailResource` for the NEW template, with `latest` set to its version 1 draft, including the copied `document` or `html`. The status code is 201.

**Example**

```python
from openemail import openemail

copy = openemail.templates.duplicate('order-shipped', {'name': 'Order shipped, new design'})

print(copy['id'], copy['slug'], copy['latestVersion'], copy['publishedVersion'])

openemail.templates.update(copy['id'], {'subject': 'Your order is on the way'})
```

**Notes**

- The copy takes the source's HEAD, which is the unpublished draft when there is one. Duplicate after publishing if you want the live body.
- Not retried by the SDK. Calling it twice makes two copies, each with its own numbered name.
- A workspace at its 200 template limit raises a 422 `workspace_limit_reached`, and nothing is copied.
- Every name from the base to the base plus 20 being taken is a 409 `template_name_taken`.

Also available in: API [`POST /templates/{id}/duplicate`](https://openemail.uk/docs/api/reference/templates#post-templates-id-duplicate); TypeScript [`templates.duplicate()`](https://openemail.uk/docs/sdk/reference/templates#duplicate); Ruby [`templates.duplicate`](https://openemail.uk/docs/ruby/reference/templates#duplicate); CLI [`openemail templates duplicate`](https://openemail.uk/docs/cli/reference/templates#templates-duplicate).

### `templates.replace_content()`

Swap a template's design for a starter or another template's

```python
def replace_content(
    id_or_slug: str,
    body: TemplateContentSource,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateDetailResource
```

Replaces the whole body of a template with a starter design or with another template's body, keeping the template's own identity: its id, slug, name, description and status do not move, and neither does what a live send resolves to.

The write lands exactly where an `update` to the body lands. A draft head is overwritten in place, and a published head mints version N+1 as a draft. Sends keep resolving the published version until you publish, so this is safe to call on a template that is sending.

Name exactly one source. `starter` takes a slug from `list_starters`, and `fromTemplateId` takes another template in the same workspace, whose published version is copied when it has one and whose draft is copied otherwise. Sending both, neither, or the target itself is a 422. A source in another workspace is a 404, like everything else here.

This discards the body it replaces. A version that was published is still in the version list and can be restored, but an unpublished draft body is gone.

Scopes: `templates:write`.

**Parameters**

- `id_or_slug` (`str`, required): The template whose design is being replaced, by `tpl_` id or slug.
- `body['starter']` (`str`): A starter slug from `list_starters`. Mutually exclusive with `fromTemplateId`.
- `body['fromTemplateId']` (`str`): Another template in this workspace to borrow the design from, by id or slug. Mutually exclusive with `starter`.
- `body['expectedVersion']` (`int`): The head version you read before replacing. A mismatch is a 409 `version_conflict` and nothing is written.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateDetailResource` with the unchanged metadata and `latest` set to the version this call wrote into, carrying the new `document` or `html`.

**Example**

```python
from openemail import openemail

current = openemail.templates.get('order-shipped')

replaced = openemail.templates.replace_content(
    'order-shipped',
    {'starter': 'order-shipped', 'expectedVersion': current['latestVersion']},
)

latest = replaced['latest']
print(latest['version'], latest['state'], [prop['key'] for prop in latest['props']])
```

**Notes**

- The new body brings the source's slots and props with it, replacing the target's declarations entirely. A send that passed the old props may start failing with `unknown_template_prop`, so read the response before publishing.
- A template cannot replace its own design: that is a 422 `invalid_template` on `fromTemplateId`.
- Not retried by the SDK. With `expectedVersion` set, a repeat after a lost response is a 409 if the first attempt landed.

Also available in: API [`POST /templates/{id}/content`](https://openemail.uk/docs/api/reference/templates#post-templates-id-content); TypeScript [`templates.replaceContent()`](https://openemail.uk/docs/sdk/reference/templates#replaceContent); Ruby [`templates.replace_content`](https://openemail.uk/docs/ruby/reference/templates#replaceContent); CLI [`openemail templates replace-content`](https://openemail.uk/docs/cli/reference/templates#templates-replace-content).

### `templates.delete()`

Delete a template and every version

```python
def delete(
    id_or_slug: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> DeletedTemplateResource
```

Permanently removes the template and all of its versions. There is no undo. Mail already accepted is unaffected, because each send stores the body it rendered, but any integration still sending against this id or slug starts receiving 404s.

If you might need the template again, archive it with `update(id_or_slug, {'status': 'archived'})` instead. The response is a tombstone rather than an empty body, so a log line can record exactly what was removed.

Scopes: `templates:write`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`DeletedTemplateResource`, the dict `{'object': 'template', 'id': ..., 'deleted': True}`, where `id` is always the `tpl_` id, even when you passed a slug.

**Example**

```python
from openemail import OpenEmailApiError, openemail

try:
    deleted = openemail.templates.delete('order-shipped')
    print(f'Deleted {deleted["id"]}')
except OpenEmailApiError as error:
    if not error.is_conflict:
        raise
    print('A scheduled broadcast still uses this template')
```

**Notes**

- Deleting frees both the name and the slug. A template created later with the same slug gets a new `tpl_` id.
- Archived templates still count towards the 200 template limit, so deleting is the only way to make room.
- Refused with a 409 `template_in_use` while a scheduled or queued broadcast still names the template. Cancel the broadcast or wait until it starts sending.
- Not retried by the SDK. Repeating a delete that already succeeded raises a 404.

Also available in: API [`DELETE /templates/{id}`](https://openemail.uk/docs/api/reference/templates#delete-templates-id); TypeScript [`templates.delete()`](https://openemail.uk/docs/sdk/reference/templates#delete); Ruby [`templates.delete`](https://openemail.uk/docs/ruby/reference/templates#delete); CLI [`openemail templates delete`](https://openemail.uk/docs/cli/reference/templates#templates-delete).

### `templates.list_versions()`

List one page of a template's versions, newest first

```python
def list_versions(
    id_or_slug: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[TemplateVersionResource]
```

Returns one page of a template's versions, newest first. Every publish adds a version, so a template edited for a long time holds many: follow `nextCursor` while `hasMore` is `True` to reach version 1, or let `list_all_versions` and `iterate_versions` do that walk. At most one version is a draft and it is always the highest number. Every version below it has been published, and the highest published version is the one unpinned sends resolve to.

Bodies are left out, so `document` and `html` are absent on every row. What each version declares (`subject`, `slots` and `props`) is present, which makes this the call for checking what pinning a `version` on `send` would commit you to.

Pages are keyset on the version number, so a version deleted while you walk never breaks the walk: the next page starts at the first version below the cursor.

Scopes: `templates:read`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `limit` (`int`): Versions per page, a whole number from 1 to 100. The server defaults to 25.
- `cursor` (`str`): The `nextCursor` of the previous page, passed back as it came. It is opaque, so never build one yourself.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[TemplateVersionResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `id` (a `tplv_` id), `templateId`, `version`, `state`, `engine`, `subject`, `slots`, `props`, `publishedAt` and `createdAt`.

**Example**

```python
from openemail import openemail

page = openemail.templates.list_versions('order-shipped', limit=10)

live = next((version for version in page['items'] if version['state'] == 'published'), None)

if live is not None:
    print(live['version'], [prop['key'] for prop in live['props'] if prop['required']])

print(page['hasMore'], page['nextCursor'])
```

**Notes**

- An empty first page is impossible for an existing template, since version 1 is created with it. No `'published'` row anywhere in the walk means the template cannot be sent yet.
- `get` returns only the head body. `get_version` returns any one version's body, and passing the same number as `version` to `preview` renders it.
- A cursor this list did not hand out raises a 400 `invalid_cursor`, and a template id or slug that names nothing is a 404.

Also available in: API [`GET /templates/{id}/versions`](https://openemail.uk/docs/api/reference/templates#get-templates-id-versions); TypeScript [`templates.listVersions()`](https://openemail.uk/docs/sdk/reference/templates#listVersions); Ruby [`templates.list_versions`](https://openemail.uk/docs/ruby/reference/templates#listVersions); CLI [`openemail templates list-versions`](https://openemail.uk/docs/cli/reference/templates#templates-list-versions).

### `templates.list_all_versions()`

Collect every version of a template into one list

```python
def list_all_versions(
    id_or_slug: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[TemplateVersionResource]
```

Walks every page of a template's versions and returns all of them as one list, newest first. It follows `nextCursor` until `hasMore` is `False`, one request per page.

A version published during the walk is newer than its first page and is not included. A version deleted during the walk is simply missing from the result.

Scopes: `templates:read`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `limit` (`int`): Page size for each request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest version.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[TemplateVersionResource]` holding every version of the template, newest first.

**Example**

```python
from openemail import openemail

versions = openemail.templates.list_all_versions('order-shipped', limit=100)

published = [version for version in versions if version['state'] == 'published']
print(f'{len(published)} of {len(versions)} versions were published')
```

**Notes**

- If any page fails the call raises, and the versions already fetched are discarded.
- Use `iterate_versions` to stop early, for example at the first published version.
- `timeout=` applies to each page request on its own, not to the whole walk.

Also available in: API [`GET /templates/{id}/versions`](https://openemail.uk/docs/api/reference/templates#get-templates-id-versions); TypeScript [`templates.listAllVersions()`](https://openemail.uk/docs/sdk/reference/templates#listAllVersions); Ruby [`templates.list_all_versions`](https://openemail.uk/docs/ruby/reference/templates#listAllVersions).

### `templates.iterate_versions()`

Stream a template's versions one at a time, newest first

```python
def iterate_versions(
    id_or_slug: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[TemplateVersionResource]
```

Returns a generator over a template's versions that yields them one at a time and fetches the next page only when the current one is drained. Nothing is requested until you consume it, and breaking out of the loop stops further requests.

The walk ends when `hasMore` is `False`, when a page arrives without a `nextCursor`, or when the server repeats a cursor.

Scopes: `templates:read`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `limit` (`int`): Page size per request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest version.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[TemplateVersionResource]`, a generator yielding one version per step.

**Example**

```python
from openemail import openemail

for version in openemail.templates.iterate_versions('order-shipped'):
    if version['state'] == 'published':
        print('Live is version', version['version'])
        break
```

**Notes**

- A page that fails raises out of the `for` loop, after the versions of the earlier pages have been yielded.

Also available in: API [`GET /templates/{id}/versions`](https://openemail.uk/docs/api/reference/templates#get-templates-id-versions); TypeScript [`templates.iterateVersions()`](https://openemail.uk/docs/sdk/reference/templates#iterateVersions); Ruby [`templates.iterate_versions`](https://openemail.uk/docs/ruby/reference/templates#iterateVersions).

### `templates.get_version()`

Read one version of a template, body included

```python
def get_version(
    id_or_slug: str,
    version: int,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateVersionResource
```

One frozen revision with its body. `document` carries the block tree for the `blocks` engine and `html` carries the submitted markup for the `html` engine, alongside the `subject`, `slots` and `props` that were declared at the time.

This is what `list_versions` leaves out. It is the only way to read an old version without changing anything: `restore_version` also shows you the body, but it moves the head to get there, so reading version 3 that way would cost you your draft.

Reach for it to diff a regression against the revision that worked, to lift a block out of a design you have since replaced, or to record what a campaign actually said.

Scopes: `templates:read`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `version` (`int`, required): The version number to read, as `list_versions` reports it. Not a `tplv_` id.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateVersionResource` with `document` and `html` populated, plus `id`, `templateId`, `version`, `state`, `engine`, `subject`, `slots`, `props`, `publishedAt` and `createdAt`. Only one of `document` and `html` holds anything, the other is `None`, and which it is follows `engine`.

**Example**

```python
import difflib

from openemail import openemail

old = openemail.templates.get_version('order-shipped', 3)
head = openemail.templates.get('order-shipped')

before = (old.get('html') or '').splitlines()
after = (head['latest'].get('html') or '').splitlines()

for line in difflib.unified_diff(before, after, 'version 3', 'head', lineterm=''):
    print(line)
```

**Notes**

- A number nobody published, or one that was deleted, raises a 404 `template_version_not_found`. Numbers are never reused, so a deleted one stays gone.
- `html` is the markup exactly as it was submitted, not what went out: sanitising happens at publish, against the compiled copy.
- Nothing is rendered here. Pass the same number as `version` to `preview` to see it with values substituted.
- Reading a version does not touch the draft. `restore_version` is still the call that brings an old body back.

Also available in: API [`GET /templates/{id}/versions/{version}`](https://openemail.uk/docs/api/reference/templates#get-templates-id-versions-version); TypeScript [`templates.getVersion()`](https://openemail.uk/docs/sdk/reference/templates#getVersion); Ruby [`templates.get_version`](https://openemail.uk/docs/ruby/reference/templates#getVersion); CLI [`openemail templates get-version`](https://openemail.uk/docs/cli/reference/templates#templates-get-version).

### `templates.publish()`

Publish the draft so sends resolve to it

```python
def publish(
    id_or_slug: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateVersionResource
```

Freezes the head version and makes it the one unpinned sends resolve to. Compiling happens here: a `blocks` tree is rendered through react-email and `html` markup is sanitised, so a body that does not render fails with 422 `invalid_template` for the person publishing rather than for a recipient. Nothing is published when that happens.

The call is idempotent. If the head is already the published version it comes back unchanged, so a deploy script can publish on every run. Publishing also sets the template's `status` to `'active'`, which reactivates an archived template.

Only the head can be published, and there is no call to republish an older version. To keep production on an earlier version while a new one is prepared, pin that `version` on `send`. The response is the published version with the updated parent attached as `template`.

Scopes: `templates:write`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateVersionResource` for the head with `state` set to `'published'` and `publishedAt` set, without `document` or `html`, plus `template`, the parent `TemplateResource` showing the new `publishedVersion` and `status`.

**Example**

```python
from openemail import openemail

published = openemail.templates.publish('order-shipped')

print(published['version'], published['publishedAt'])

template = published.get('template')

if template is not None:
    print(template['publishedVersion'], template['status'])
```

**Notes**

- The SDK retries this call on network errors and retryable statuses, which is safe because publishing twice lands in the same place.
- The server answers 201 even when nothing changed.
- Sends that pin an earlier `version` are unaffected. Only unpinned sends move to the new version.
- An `html` placeholder used inside an attribute with kind `'text'` fails here with `param` set to `props.<key>`.

Also available in: API [`POST /templates/{id}/versions`](https://openemail.uk/docs/api/reference/templates#post-templates-id-versions); TypeScript [`templates.publish()`](https://openemail.uk/docs/sdk/reference/templates#publish); Ruby [`templates.publish`](https://openemail.uk/docs/ruby/reference/templates#publish); CLI [`openemail templates publish`](https://openemail.uk/docs/cli/reference/templates#templates-publish).

### `templates.restore_version()`

Bring an older version's body back as the draft

```python
def restore_version(
    id_or_slug: str,
    version: int,
    body: TemplateRestoreInput | None = None,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateRestoredResource
```

Copies an older version's subject, body, slots and props forward into the head, which is how you undo a design you regret. Nothing is rolled back in place: the old version stays where it is in the list and the restored copy becomes the current draft.

Where it lands follows the usual rule. A published head mints version N+1 as a draft, and a draft head is overwritten, so restoring twice does not pile up versions. Live sends do not move until you publish, so a restore is reversible until then: restore something else, or publish to commit.

Restoring the head itself is a 422, because there is nothing to bring back. An unknown version is a 404. `expectedVersion` makes the call safe against a concurrent editor, the same as on `update`, and the body can be left out when you do not need it.

Scopes: `templates:write`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `version` (`int`, required): The version whose body you want back, as `list_versions` reports it.
- `body['expectedVersion']` (`int`): The head version you read before restoring. A mismatch is a 409 `version_conflict` and nothing is written.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateRestoredResource`: a `TemplateDetailResource` whose `latest` is the version this call wrote, plus `restoredFrom`, the version number it was copied from.

**Example**

```python
from openemail import openemail

current = openemail.templates.get('order-shipped')

restored = openemail.templates.restore_version(
    'order-shipped', 2, {'expectedVersion': current['latestVersion']}
)

print(restored['restoredFrom'], restored['latest']['version'], restored['latest']['state'])

openemail.templates.publish('order-shipped')
```

**Notes**

- It does not publish. Until you call `publish`, sends keep resolving whatever was live before.
- The restored body brings the old version's slots and props with it, so a send passing newer props may start failing with `unknown_template_prop`.
- Restoring the current head is a 422 `invalid_template`, not a no-op.
- Not retried by the SDK, because a repeat can mint a second version.

Also available in: API [`POST /templates/{id}/versions/{version}/restore`](https://openemail.uk/docs/api/reference/templates#post-templates-id-versions-version-restore); TypeScript [`templates.restoreVersion()`](https://openemail.uk/docs/sdk/reference/templates#restoreVersion); Ruby [`templates.restore_version`](https://openemail.uk/docs/ruby/reference/templates#restoreVersion); CLI [`openemail templates restore-version`](https://openemail.uk/docs/cli/reference/templates#templates-restore-version).

### `templates.delete_version()`

Delete one version of a template

```python
def delete_version(
    id_or_slug: str,
    version: int,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> DeletedTemplateVersionResource
```

Removes a single revision and leaves the template itself alone. It is for tidying a long version list, not for changing what sends.

Three versions cannot be deleted, and each refusal is a 422 rather than a silent success. The LIVE version cannot, because sends resolve it. The HEAD cannot, because that is the one being edited, and restoring an older version first is how you move off it. The only version a template has cannot either, because a template with no versions could not be read at all, so delete the template instead.

Everything else is fair game. Mail already sent from a deleted version is untouched, since a send stores the body it rendered, but a send that pins a deleted `version` starts failing with `template_version_not_found`.

Scopes: `templates:write`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `version` (`int`, required): The version number to delete, as `list_versions` reports it.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`DeletedTemplateVersionResource`, the dict `{'object': 'template_version', 'templateId': ..., 'version': ..., 'deleted': True}`.

**Example**

```python
from openemail import OpenEmailApiError, openemail

try:
    deleted = openemail.templates.delete_version('order-shipped', 2)
    print(f'Deleted version {deleted["version"]} of {deleted["templateId"]}')
except OpenEmailApiError as error:
    if error.code != 'template_version_not_deletable':
        raise
    print('Version 2 is live or the head, so it stays')
```

**Notes**

- The live version raises a 422 `template_version_not_deletable`. Publish another version first, then delete it.
- The head is refused with the same code. Call `restore_version` with an older version, which makes a new head, and the old head becomes deletable.
- Version numbers are never reused: deleting version 3 does not free the number.
- Not retried by the SDK. Repeating a delete that succeeded raises a 404.

Also available in: API [`DELETE /templates/{id}/versions/{version}`](https://openemail.uk/docs/api/reference/templates#delete-templates-id-versions-version); TypeScript [`templates.deleteVersion()`](https://openemail.uk/docs/sdk/reference/templates#deleteVersion); Ruby [`templates.delete_version`](https://openemail.uk/docs/ruby/reference/templates#deleteVersion); CLI [`openemail templates delete-version`](https://openemail.uk/docs/cli/reference/templates#templates-delete-version).

### `templates.list_starters()`

List the built-in starter designs

```python
def list_starters(
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[TemplateStarterResource]
```

The starter designs the web editor offers, as a plain list. A starter is a ready-made block document with a subject and its declared slots and props, and it is the same catalogue the console shows, so an integration and a person building a template by hand start from the same place.

Bodies are left out here. `slug` is the handle to pass as `starter` to `create` or `replace_content`, and `get_starter` returns one in full with its block tree and a rendered preview.

Starters are static: they are part of the product rather than workspace data, so this answer is the same for every key and changes only when a release adds one.

Scopes: `templates:read`.

**Parameters**

- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[TemplateStarterResource]`, each with `slug`, `name`, `description`, `category` (`'account'`, `'commerce'`, `'notify'` or `'marketing'`), `subject`, and the `slots` and `props` it declares.

**Example**

```python
from openemail import openemail

starters = openemail.templates.list_starters()

for starter in starters:
    print(starter['category'], starter['slug'], [prop['key'] for prop in starter['props']])
```

**Notes**

- Not paginated, and there is no cursor. The catalogue is small.
- A starter's props are what the template gets on creation. They are yours to change afterwards with `update`.

Also available in: API [`GET /templates/starters`](https://openemail.uk/docs/api/reference/templates#get-templates-starters); TypeScript [`templates.listStarters()`](https://openemail.uk/docs/sdk/reference/templates#listStarters); Ruby [`templates.list_starters`](https://openemail.uk/docs/ruby/reference/templates#listStarters); CLI [`openemail templates list-starters`](https://openemail.uk/docs/cli/reference/templates#templates-list-starters).

### `templates.get_starter()`

Retrieve one starter design, body and preview included

```python
def get_starter(
    slug: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateStarterDetailResource
```

One starter in full: everything the list carries, plus `document`, the block tree itself, and `preview`, the starter rendered to HTML with each undefaulted prop left visible as `{{key}}`.

The preview is what the console shows in its starter picker, so a client can display the same thing without rendering anything itself. The document is there so you can seed a template from a starter and edit the tree before creating it, rather than creating from the starter and patching afterwards. Send the starter's `slots` and `props` along with the edited `document`, because `create` refuses a placeholder the template does not declare.

An unknown slug is a 404. Pass the slug exactly as `list_starters` reports it.

Scopes: `templates:read`.

**Parameters**

- `slug` (`str`, required): A starter slug from `list_starters`, such as `welcome`.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateStarterDetailResource`: the starter's metadata with `document` (the block tree, ready to send as `document` on `create`) and `preview` (rendered HTML).

**Example**

```python
from pathlib import Path

from openemail import openemail

starter = openemail.templates.get_starter('welcome')

Path('welcome-preview.html').write_text(starter['preview'])

print(starter['subject'], [prop['key'] for prop in starter['props'] if prop['required']])
```

**Notes**

- Passing `'starter': 'welcome'` to `create` does the same seeding server side, declarations included, and is one call instead of two.
- The preview is rendered once per process and cached, so it costs nothing to ask for it repeatedly.

Also available in: API [`GET /templates/starters/{slug}`](https://openemail.uk/docs/api/reference/templates#get-templates-starters-slug); TypeScript [`templates.getStarter()`](https://openemail.uk/docs/sdk/reference/templates#getStarter); Ruby [`templates.get_starter`](https://openemail.uk/docs/ruby/reference/templates#getStarter); CLI [`openemail templates get-starter`](https://openemail.uk/docs/cli/reference/templates#templates-get-starter).

### `templates.list_fonts()`

List the web fonts a template can load

```python
def list_fonts(
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[TemplateFontResource]
```

Every web font a template may load, as a plain list, in the order the web editor offers them. Each row names a `family`, the full CSS `stack` to write, the `fallback` a mail client shows when it cannot load the font, the `weight` range the file covers, and the `url` the file is served from.

The list is closed on purpose. A font file is fetched by the reader's mail client the moment the message is opened, so a font loaded from anywhere else would tell whoever runs that host when the message was read, whatever the workspace has open tracking set to. A template whose `webFont.url` is not the one listed here for its family is refused with 422 `invalid_template`.

Fonts are static: they are part of the product rather than workspace data, so this answer is the same for every key and changes only when a release adds one.

Scopes: `templates:read`.

**Parameters**

- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[TemplateFontResource]`, each a dict with `object` set to `'template_font'`, `family`, `fallback`, `stack`, `weight`, `format` and `url`, with `format` always `'woff2'`.

**Example**

```python
from openemail import openemail

fonts = openemail.templates.list_fonts()

for font in fonts:
    print(font['family'], font['weight'], font['stack'], font['url'])
```

**Notes**

- Not paginated, and there is no cursor. The catalogue is small.
- One template may load at most 8 web fonts. A family that is not listed still renders: leave `webFont` out and it falls back to `fallbackFontFamily`, which is what Gmail and Outlook on Windows do with every web font anyway.

Also available in: API [`GET /templates/fonts`](https://openemail.uk/docs/api/reference/templates#get-templates-fonts); TypeScript [`templates.listFonts()`](https://openemail.uk/docs/sdk/reference/templates#listFonts); Ruby [`templates.list_fonts`](https://openemail.uk/docs/ruby/reference/templates#listFonts); CLI [`openemail templates list-fonts`](https://openemail.uk/docs/cli/reference/templates#templates-list-fonts).

### `templates.render()`

Render a body that is not stored anywhere

```python
def render(
    body: TemplateRenderInput,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateRenderResource
```

Compiles and renders content you pass in, without creating a template or touching one. This is what the web editor calls while somebody types, and it is the call for checking a design in CI before it becomes a template.

Everything `create` accepts as content is accepted here: `document` for the blocks engine, `html` for the html engine, plus `subject`, `slots` and `props`. `values['props']` and `values['slots']` fill the placeholders, and anything left unfilled is rendered blank and reported in `warnings`, exactly as `preview` does for a stored template.

`'mark': True` leaves every placeholder visible as `{{key}}` instead of substituting it, which is how an editor shows an author what is a variable.

Scopes: `templates:read`.

**Parameters**

- `body['engine']` (`TemplateEngine`): Defaults to `'html'` when `html` is sent and `'blocks'` otherwise.
- `body['subject']` (`str`): The subject to render, at most 998 characters.
- `body['document']` (`TemplateDocument`): The `blocks` body for the `blocks` engine, validated in full: `body` plus `preview`, `tailwind`, `fonts` and `style`.
- `body['html']` (`str`): The markup for the `html` engine, at most 1,000,000 characters.
- `body['slots']` (`list[TemplateRenderInputSlotsItem]`): The slots this body declares.
- `body['props']` (`list[TemplateRenderInputPropsItem]`): The props this body declares.
- `body['values']['props']` (`dict[str, Any]`): Values for the declared props, keyed by prop key. A missing one renders blank and is reported.
- `body['values']['slots']` (`dict[str, Any]`): Values for the declared slots, keyed by slot key, overriding their defaults.
- `body['mark']` (`bool`): Leaves placeholders as `{{key}}` rather than substituting them.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateRenderResource` with `subject`, `html`, `text` and `warnings`, each warning a dict with `code` set to `'unfilled_placeholder'` and `key`. Nothing is stored and nothing is sent.

**Example**

```python
from openemail import openemail

rendered = openemail.templates.render(
    {
        'subject': 'Order {{order_id}} is on its way',
        'html': '<p>Hello {{customer}}, {{order_id}} left the warehouse.</p>',
        'props': [
            {'key': 'order_id', 'kind': 'text', 'required': True},
            {'key': 'customer', 'kind': 'text'},
        ],
        'values': {'props': {'order_id': 'A-3311', 'customer': 'Ada'}},
    }
)

print(rendered['subject'])
print([warning['key'] for warning in rendered['warnings']])
```

**Notes**

- Needs only `templates:read`, because nothing is written. A body that does not compile raises a 422 `invalid_template` naming the offending path.
- Lenient like `preview`: a required prop with no value is a warning here and a refusal on a send.
- Retried by the SDK on network errors and retryable statuses, since rendering has no side effects.

Also available in: API [`POST /templates/render`](https://openemail.uk/docs/api/reference/templates#post-templates-render); TypeScript [`templates.render()`](https://openemail.uk/docs/sdk/reference/templates#render); Ruby [`templates.render`](https://openemail.uk/docs/ruby/reference/templates#render); CLI [`openemail templates render`](https://openemail.uk/docs/cli/reference/templates#templates-render).

### `templates.preview()`

Render a template without sending it

```python
def preview(
    id_or_slug: str,
    body: TemplatePreviewInput | None = None,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplatePreviewResource
```

Renders a version with the values you pass and returns the subject, HTML and plain text a send with the same values would produce. Nothing is sent or recorded. Point CI at it so a broken template is caught by a test rather than by a customer.

It renders the published version unless `version` names another one, and unlike `send` it can render a draft, which is compiled on the fly. A template with nothing published needs an explicit `version`, otherwise the call raises a 422 `template_not_published`. Required props are relaxed here: a missing one renders its default or an empty string, and every placeholder that ends up blank is listed in `warnings` as `unfilled_placeholder`.

The other value checks still apply. A key the version does not declare is a 422 `unknown_template_prop` or `unknown_template_slot`, and a value that is not a string, number or boolean is a 422 `invalid_template_prop`. Values of kind `'url'` or `'image'` are parsed, and anything outside http, https, mailto, tel and cid renders as `#`. The body is optional, and leaving it out renders the published version with every default.

Scopes: `templates:read`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `body['version']` (`int`): Version to render, drafts included. Defaults to the published version.
- `body['props']` (`dict[str, Any]`): Values for declared props, keyed by prop key. Strings, numbers and booleans only.
- `body['slots']` (`dict[str, Any]`): Overrides for slot defaults, keyed by slot key.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplatePreviewResource` with `templateId`, the resolved `version`, the filled `subject`, `html` and `text`, and `warnings` as a list of dicts with `code` and `key`.

**Example**

```python
from openemail import openemail

preview = openemail.templates.preview(
    'order-shipped',
    {
        'props': {
            'order_id': 'AC-4192',
            'customer': 'Ada',
            'tracking_url': 'https://track.example.com/AC-4192',
        }
    },
)

for warning in preview['warnings']:
    print('Unfilled placeholder:', warning['key'])

print(preview['version'], preview['subject'])
```

**Notes**

- Needs only `templates:read`, so a CI key can preview without being able to edit or send.
- Check that `warnings` is empty. `send` refuses a missing required prop that `preview` only reports.
- A `version` that does not exist is a 404. Treat an unrecognised warning `code` as a warning too.
- The SDK retries it on network errors and retryable statuses, since rendering has no side effects.

Also available in: API [`POST /templates/{id}/preview`](https://openemail.uk/docs/api/reference/templates#post-templates-id-preview); TypeScript [`templates.preview()`](https://openemail.uk/docs/sdk/reference/templates#preview); Ruby [`templates.preview`](https://openemail.uk/docs/ruby/reference/templates#preview); CLI [`openemail templates preview`](https://openemail.uk/docs/cli/reference/templates#templates-preview).

### `templates.get_analytics()`

How one template has performed

```python
def get_analytics(
    id_or_slug: str,
    *,
    days: int | None = None,
    minutes: int | None = None,
    grain: TrackingGrain | None = None,
    offset_minutes: int | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateAnalyticsResource
```

The engagement report the console shows on a template: how many messages it rendered in the window, how many of those were tracked, how many were opened and clicked, and the same numbers broken down by day, by source and by version.

Rates are computed against what was TRACKED, not against everything sent, because a message sent with tracking off can never report an open and counting it would quietly lower every rate. `trackedForOpens` and `trackedForClicks` are the denominators, and they are in the response so you can recompute anything yourself.

`lifetime` ignores the window: it is every live send this template has ever made, the test sends counted separately, and the first and last time it sent. `recent` previews the twelve newest sends in the window with their own open and click counts, which is the fastest way to see whether a template that was just published is behaving. It is a preview, not the list: `list_sends` returns every send in the window, a page at a time.

Only live sends are in the windowed figures. A message sent with a test key counts in `lifetime['testSends']` and nowhere else.

Scopes: `templates:read`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `days` (`int`): How far back to look, 1 to 365. Defaults to 30. The window starts at the beginning of that day and ends now.
- `minutes` (`int`): The window in minutes, which wins over `days`. For the last hour of a send in flight.
- `grain` (`TrackingGrain`): How wide one `byDay` bucket is: `'day'`, `'hour'` or `'minute'`. The bucket keys change shape with it.
- `offset_minutes` (`int`): The reader's UTC offset in minutes, -840 to 840, so days are bucketed in their own timezone. Pass `time.localtime().tm_gmtoff // 60` for the local zone.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateAnalyticsResource` with `sends`, `matched`, `trackedForOpens`, `trackedForClicks`, `opened`, `clicked`, `openRate`, `clickRate`, `totalOpens`, `totalClicks`, the `bySource`, `byDay` and `byVersion` breakdowns, `recent` (a preview of the twelve newest sends, with every one of them a page at a time through `list_sends`) and `lifetime`.

**Example**

```python
from openemail import openemail

analytics = openemail.templates.get_analytics('order-shipped', days=7, grain='day')

print(analytics['sends'], analytics['openRate'], analytics['clickRate'])

for version in analytics['byVersion']:
    print(version['version'], version['sends'], version['opened'])
```

**Notes**

- `openRate` and `clickRate` are percentages to one decimal place, and are 0 rather than `None` when nothing was tracked.
- `matched` is how many renders were paired with a tracked message. A gap between `sends` and `matched` is sends that carried no tracking at all, not lost data.
- A template that has never sent answers with zeroes, not a 404. The 404 is for a template that does not exist.
- Retried by the SDK on network errors and retryable statuses, since it only reads.

Also available in: API [`GET /templates/{id}/analytics`](https://openemail.uk/docs/api/reference/templates#get-templates-id-analytics); TypeScript [`templates.getAnalytics()`](https://openemail.uk/docs/sdk/reference/templates#getAnalytics); Ruby [`templates.get_analytics`](https://openemail.uk/docs/ruby/reference/templates#getAnalytics); CLI [`openemail templates get-analytics`](https://openemail.uk/docs/cli/reference/templates#templates-get-analytics).

### `templates.list_sends()`

The individual messages a template sent

```python
def list_sends(
    id_or_slug: str,
    *,
    days: int | None = None,
    minutes: int | None = None,
    grain: TrackingGrain | None = None,
    offset_minutes: int | None = None,
    page: int | None = None,
    page_size: int | None = None,
    search: str | None = None,
    source: str | None = None,
    version: int | None = None,
    opened: bool | None = None,
    clicked: bool | None = None,
    tracked: bool | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TemplateSendsResource
```

One row per message this template rendered, newest first, with the subject as it went out, who it went to, and whether it was opened or clicked. It is the list behind the numbers `get_analytics` reports, and the place to answer "did this person get it".

Paging here is by page number rather than by cursor, because the console shows a table with a total, and `total` is the count matching the filters rather than the size of the page. Pages are 25 rows by default and at most 100.

The filters narrow by window (`days=` or `minutes=`), by `version=`, by `source=`, and by engagement: `opened=`, `clicked=` and `tracked=` each take a `bool`. `search=` matches the subject and the recipient addresses. A row whose message carried no tracking reports `matched` as `False` and zero counts, which is not the same as nobody opening it.

Scopes: `templates:read`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `days` (`int`): How far back to look, 1 to 365. Defaults to 30.
- `minutes` (`int`): The window in minutes, which wins over `days`.
- `grain` (`TrackingGrain`): Only floors the start of the window, so this list can cover the same window as `get_analytics`.
- `offset_minutes` (`int`): The reader's UTC offset in minutes, -840 to 840.
- `page` (`int`): Which page, from 1. Defaults to 1.
- `page_size` (`int`): Rows per page, 1 to 100. Defaults to 25.
- `search` (`str`): Matches the subject that went out and the recipient addresses.
- `source` (`str`): Only sends from one source, such as `'api'` or `'console'`.
- `version` (`int`): Only sends that rendered this version number.
- `opened` (`bool`): `True` for sends with at least one counted open, `False` for none.
- `clicked` (`bool`): `True` for sends with at least one counted click, `False` for none.
- `tracked` (`bool`): `True` for sends that carried tracking at all, `False` for the ones that could never report.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TemplateSendsResource`, a dict the SDK builds from the response with `items`, `total` (matching rows, not page size), `page` and `pageSize`. Each row has `id`, `version`, `source`, `subject`, `createdAt`, `recipients`, `matched`, `opens`, `clicks`, `openCount` and `clickCount`.

**Example**

```python
from openemail import openemail

sends = openemail.templates.list_sends(
    'order-shipped', days=7, opened=False, tracked=True, page_size=50
)

print(sends['total'], len(sends['items']))

for send in sends['items']:
    print(send['createdAt'], ', '.join(send['recipients']), send['openCount'])
```

**Notes**

- `opens` and `clicks` say whether the message ASKED to be tracked, and `openCount` and `clickCount` say what happened.
- Only live sends are listed. Test-key sends are counted in `lifetime['testSends']` of `get_analytics` and appear nowhere here.
- Page numbers are not stable while mail is going out, since a new send pushes rows down. Narrow the window rather than paging deep.

Also available in: API [`GET /templates/{id}/sends`](https://openemail.uk/docs/api/reference/templates#get-templates-id-sends); TypeScript [`templates.listSends()`](https://openemail.uk/docs/sdk/reference/templates#listSends); Ruby [`templates.list_sends`](https://openemail.uk/docs/ruby/reference/templates#listSends); CLI [`openemail templates list-sends`](https://openemail.uk/docs/cli/reference/templates#templates-list-sends).

### `templates.send()`

Send an email rendered from a template

```python
def send(
    id_or_slug: str,
    body: TemplateSend,
    *,
    idempotency_key: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> SentTemplateEmailResource
```

Resolves the published version, or the one `version` pins, fills its placeholders from `props` and `slots`, and queues the message. Values are checked strictly here. An undeclared key is a 422 `unknown_template_prop` or `unknown_template_slot`, a missing required prop is a 422 `missing_template_prop`, and a value that is not a string, number or boolean is a 422 `invalid_template_prop`, each with `param` set to `template.props.<key>`. A template with nothing published, or a pinned version that is still a draft, is a 422 `template_not_published`. No mail leaves when any of these fail.

Pin `version` in production code. Without it every send resolves whatever is published at that moment, which changes the morning somebody publishes a rewrite. `subject` replaces the version's subject for this message only and is used exactly as written, with no placeholder filling. `scheduledAt` takes a `datetime`, an ISO 8601 instant or an ISO 8601 duration such as `'PT30M'`, up to 365 days out, and cannot be combined with a non zero `cancellableForSeconds`.

The SDK attaches an `Idempotency-Key` generated once per call and reuses it on that call's retries, so a retry replays the original message instead of sending a second one. Pass `idempotency_key=` to deduplicate across processes and restarts, and derive it from what caused the send, never from a clock. A replay returns `replayed` set to `True` and the original message, while reusing a key with a different body raises a 422 `idempotency_key_reuse`.

Scopes: `templates:write`, `emails:send`.

**Parameters**

- `id_or_slug` (`str`, required): A `tpl_` id or the template's slug.
- `body['from']` (`RecipientInput`, required): Sender as `'dispatch@acme.com'`, `'Acme Dispatch <dispatch@acme.com>'` or `{'email': 'dispatch@acme.com', 'name': 'Acme Dispatch'}`. The key must be allowed to send as it, otherwise 403 `from_address_forbidden`.
- `body['to']` (`RecipientInput | list[RecipientInput]`, required): One recipient or a list of 1 to 50, each written like `from`. The SDK wraps a single value in a list.
- `body['cc']` (`RecipientInput | list[RecipientInput]`): Up to 50 copied recipients, one or a list.
- `body['bcc']` (`RecipientInput | list[RecipientInput]`): Up to 50 blind copied recipients, one or a list.
- `body['replyTo']` (`RecipientInput`): Sets the Reply-To header.
- `body['version']` (`int`): Published version to send. Defaults to the currently published one.
- `body['props']` (`dict[str, Any]`): Values for the declared props, keyed by prop key: strings, numbers or booleans.
- `body['slots']` (`dict[str, Any]`): Overrides for slot defaults, keyed by slot key.
- `body['subject']` (`str`): Replaces the version's subject for this message, sent verbatim, at most 998 characters.
- `body['scheduledAt']` (`datetime | str`): When to send: a `datetime`, an ISO 8601 instant or a duration such as `'PT2H'`. Must be in the future and at most 365 days out. A `datetime` without a time zone is read as local time.
- `body['cancellableForSeconds']` (`int`): Holds the message 0 to 900 seconds so it can still be cancelled. Defaults to 0 and is refused alongside `scheduledAt`.
- `body['tracking']` (`TrackingRequest`): A dict with `opens` and `clicks` switches for open and click tracking on this message.
- `body['tags']` (`dict[str, str]`): Your own labels for the send, a dict of strings with keys up to 64 and values up to 256 characters.
- `body['translate']` (`SendTranslateOptions`): Translates the rendered message into `to` before sending, with optional `from`, `includeOriginal` (default `True`) and `subject` (default `True`).
- `idempotency_key` (`str`): Your own key in place of the generated one: 1 to 255 characters of letters, digits, `_`, `.`, `:` or `-`.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`SentTemplateEmailResource`: the `EmailResource` keys such as `id`, `status`, `mode`, `from`, `subject`, `messageId`, `threadId`, `scheduledAt`, `cancellableUntil`, `tags` and `createdAt`, plus `replayed` and `template`, which holds the `id` you called with and the `version` that went out.

**Example**

```python
from openemail import openemail

sent = openemail.templates.send(
    'order-shipped',
    {
        'from': {'email': 'dispatch@acme.com', 'name': 'Acme Dispatch'},
        'to': 'ada@example.com',
        'version': 5,
        'props': {
            'order_id': 'AC-4192',
            'customer': 'Ada',
            'tracking_url': 'https://track.example.com/AC-4192',
        },
    },
    idempotency_key='order-shipped:AC-4192',
)

print(sent['id'], sent['status'], sent['template']['version'], sent['replayed'])
```

**Notes**

- A `from` on a domain that cannot sign mail yet is refused with 409 `domain_not_sendable`, the same as `emails.send`.
- Needs both `templates:write` and `emails:send`. A key that may send its own bodies still cannot send a stored template without the first.
- The replay check hashes the request together with the version it resolved to. An unpinned retry that lands after somebody publishes a new version is refused with `idempotency_key_reuse` rather than replayed, so pin `version` when you rely on replays.
- `template['id']` echoes the argument you passed, so it is the slug when you sent by slug. Call `get` if you need the `tpl_` id.
- An archived template is a 422 `template_archived`. An unknown template is a 404 `resource_not_found`, while a pinned `version` that does not exist is a 422 `template_version_not_found` with `param` set to `template.version`.

Also available in: API [`POST /templates/{id}/send`](https://openemail.uk/docs/api/reference/templates#post-templates-id-send); TypeScript [`templates.send()`](https://openemail.uk/docs/sdk/reference/templates#send); Ruby [`templates.send`](https://openemail.uk/docs/ruby/reference/templates#send); CLI [`openemail templates send`](https://openemail.uk/docs/cli/reference/templates#templates-send).

### `templates.list_images()`

List one page of template images

```python
def list_images(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[TemplateImageResource]
```

Returns one page of the images uploaded for templates, newest first: the library the template editor offers when you add an image. `list_all_images` collects every page and `iterate_images` walks them lazily.

Template images belong to the workspace: every template in it can use them, and they stay at their address after the template that first used them is deleted.

Scopes: `templates:read`.

**Parameters**

- `limit` (`int`): Page size, from 1 to 120. The server defaults to 24.
- `cursor` (`str`): The `nextCursor` of the previous page. Leave it out for the first page.
- `api_key` (`str`): Overrides the client's API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[TemplateImageResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `id`, `url` and `size`.

**Example**

```python
from openemail import openemail

page = openemail.templates.list_images(limit=10)

print([image['url'] for image in page['items']])
print(page['hasMore'], page['nextCursor'])
```

**Notes**

- Needs `templates:read`.

Also available in: API [`GET /templates/images`](https://openemail.uk/docs/api/reference/templates#get-templates-images); TypeScript [`templates.listImages()`](https://openemail.uk/docs/sdk/reference/templates#listImages); Ruby [`templates.list_images`](https://openemail.uk/docs/ruby/reference/templates#listImages); CLI [`openemail templates list-images`](https://openemail.uk/docs/cli/reference/templates#templates-list-images).

### `templates.list_all_images()`

Collect every template image into one list

```python
def list_all_images(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[TemplateImageResource]
```

Walks every page of `list_images` and returns every template image as one list, newest first. One request per page.

Scopes: `templates:read`.

**Parameters**

- `limit` (`int`): Page size for each request, from 1 to 120. The server defaults to 24.
- `cursor` (`str`): Starts the walk after this cursor instead of the first page.
- `api_key` (`str`): Overrides the client's API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[TemplateImageResource]` holding every image.

**Example**

```python
from openemail import openemail

images = openemail.templates.list_all_images()

total = sum(image['size'] for image in images)
print(f'{len(images)} images, {total / 1_000_000:.1f} MB')
```

**Notes**

- If any page fails the call raises, and the images already fetched are discarded.
- `timeout=` applies to each page request on its own, not to the whole walk.

Also available in: API [`GET /templates/images`](https://openemail.uk/docs/api/reference/templates#get-templates-images); TypeScript [`templates.listAllImages()`](https://openemail.uk/docs/sdk/reference/templates#listAllImages); Ruby [`templates.list_all_images`](https://openemail.uk/docs/ruby/reference/templates#listAllImages).

### `templates.iterate_images()`

Stream template images one at a time

```python
def iterate_images(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[TemplateImageResource]
```

Returns a generator that yields one template image at a time, newest first, and requests the next page only once the current one is drained.

Scopes: `templates:read`.

**Parameters**

- `limit` (`int`): Page size for each request, from 1 to 120. The server defaults to 24.
- `cursor` (`str`): Starts the walk after this cursor instead of the first page.
- `api_key` (`str`): Overrides the client's API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[TemplateImageResource]`, a generator yielding one image per step.

**Example**

```python
from openemail import openemail

for image in openemail.templates.iterate_images():
    if image['size'] > 1_000_000:
        print(image['id'], image['url'], image['size'])
```

**Notes**

- The generator is lazy, so an abandoned loop costs only the pages you consumed.

Also available in: API [`GET /templates/images`](https://openemail.uk/docs/api/reference/templates#get-templates-images); TypeScript [`templates.iterateImages()`](https://openemail.uk/docs/sdk/reference/templates#iterateImages); Ruby [`templates.iterate_images`](https://openemail.uk/docs/ruby/reference/templates#iterateImages).

### `templates.upload_image()`

Upload an image for templates

```python
def upload_image(
    data: RawBody,
    *,
    content_type: TemplateImageType | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> UploadedTemplateImageResource
```

Sends the image bytes as the request body and returns its public `url`, ready to put in a template. It is the upload of the template editor.

PNG, JPEG, WebP, GIF or SVG, up to 5 MB, fitted into 1200 by 1800 pixels and stored in a form every mail client shows. The type comes from `content_type=`. Bytes carry no type of their own, so without it the SDK sends `application/octet-stream`, which the server refuses with 422 `invalid_image`.

Template images belong to the workspace: every template in it can use them, and they stay at their address after the template that first used them is deleted.

Scopes: `templates:write`.

**Parameters**

- `data` (`RawBody`, required): The image as `bytes`, `bytearray` or `memoryview`, such as `Path('logo.png').read_bytes()`.
- `content_type` (`TemplateImageType`): `'image/png'`, `'image/jpeg'`, `'image/webp'`, `'image/gif'` or `'image/svg+xml'`. Pass it on every call, since the bytes alone carry no type.
- `api_key` (`str`): Overrides the client's API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`UploadedTemplateImageResource` with `id`, `url`, `size`, `width` and `height`.

**Example**

```python
from pathlib import Path

from openemail import openemail

image = openemail.templates.upload_image(
    Path('logo.png').read_bytes(), content_type='image/png'
)

print(image['url'], image['width'], image['height'])
```

**Notes**

- The SDK does not retry an upload, because a second one stores a second copy.
- An image of another type, one over 5 MB, or one that cannot be read is refused with 422 `invalid_image`. A busy image service answers 503 `image_busy` and a failed save 502 `image_not_stored`.

Also available in: API [`POST /templates/images`](https://openemail.uk/docs/api/reference/templates#post-templates-images); TypeScript [`templates.uploadImage()`](https://openemail.uk/docs/sdk/reference/templates#uploadImage); Ruby [`templates.upload_image`](https://openemail.uk/docs/ruby/reference/templates#uploadImage); CLI [`openemail templates upload-image`](https://openemail.uk/docs/cli/reference/templates#templates-upload-image).

### `templates.design()`

Design a new template from a brief

```python
def design(
    body: TemplateDesignInput,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> DesignedTemplateResource
```

A designer builds a new block template from a written brief, the way the assistant in the app does, and saves it, as a draft unless `publish` is `True`. It uses every block the editor has, with a palette, type and spacing, and the result opens in the visual editor fully editable.

Put everything the design must hold in `brief`: what it is for, the sections in order, the words, the colours and fonts, and which values change per recipient. `starter` builds on a starter design, and `imageFileIds` places up to ten uploaded or received images. It spends one AI action and can take up to a minute, which is longer than the client's default `timeout` of 30 seconds, so pass a larger `timeout=` on this call.

Scopes: `templates:write`.

**Parameters**

- `body['name']` (`str`, required): A short name for the template. When the name is taken, a free one is chosen and returned.
- `body['brief']` (`str`, required): Everything the design must hold and look like, up to 8,000 characters.
- `body['description']` (`str`): One line about what it is for.
- `body['starter']` (`str`): The slug of a starter design to build on, from `list_starters`.
- `body['imageFileIds']` (`list[str]`): Up to ten file ids of images to place in the design.
- `body['publish']` (`bool`): Publish it at once so it can be sent. Defaults to `False`.
- `api_key` (`str`): Overrides the client's API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`DesignedTemplateResource`: the template, plus `design` with the `subject`, the `notes` on what was adjusted and the `imageProblems`.

**Example**

```python
from openemail import openemail

template = openemail.templates.design(
    {
        'name': 'Spring launch',
        'brief': (
            'A launch email for our spring collection: a hero image, three product cards '
            'and a button to the shop. Green and cream, friendly tone.'
        ),
    },
    timeout=90,
)

print(template['id'], template['design']['subject'], template['design']['notes'])
```

**Notes**

- The SDK does not retry it, because a second call designs and saves a second template.
- A design that cannot be made valid raises a 422 `invalid_template`, a server with no model a 409 `ai_not_configured`, and a workspace out of AI actions a 429 `ai_quota_exceeded`.
- An image id the key cannot reach is left out and named in `design['imageProblems']`.

Also available in: API [`POST /templates/design`](https://openemail.uk/docs/api/reference/templates#post-templates-design); TypeScript [`templates.design()`](https://openemail.uk/docs/sdk/reference/templates#design); Ruby [`templates.design`](https://openemail.uk/docs/ruby/reference/templates#design); CLI [`openemail templates design`](https://openemail.uk/docs/cli/reference/templates#templates-design).

### `templates.redesign()`

Change a template’s design from instructions

```python
def redesign(
    id_or_slug: str,
    body: TemplateRedesignInput,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> DesignedTemplateResource
```

A designer applies written instructions to a block template and leaves everything else alone: restyle it, change colours, fonts or spacing, rewrite or translate its copy, add, move or remove sections, swap an image. The change lands in the draft, so live sends keep the published version until you publish.

A raw HTML template cannot be redesigned and is refused with 422 `invalid_template`. It spends one AI action and, like `design`, can take up to a minute, so pass a `timeout=` above the client's default of 30 seconds.

Scopes: `templates:write`.

**Parameters**

- `id_or_slug` (`str`, required): The template's id or slug.
- `body['instructions']` (`str`, required): What to change, with every detail: the words, colours and which section. Up to 8,000 characters.
- `body['imageFileIds']` (`list[str]`): Up to ten file ids of images to use.
- `body['expectedVersion']` (`int`): The version you based the change on. A newer one is refused with 409 `version_conflict`.
- `api_key` (`str`): Overrides the client's API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`DesignedTemplateResource`: the template, plus `design` with `changes` (blocks edited, added and removed), `draftVersion`, the `subject`, `notes` and `imageProblems`.

**Example**

```python
from openemail import openemail

template = openemail.templates.redesign(
    'spring-launch',
    {'instructions': 'Make the button orange and translate the copy into Spanish.'},
    timeout=90,
)

design = template['design']
print(design.get('changes'), design.get('draftVersion'))
```

**Notes**

- The SDK does not retry it, because each call is a fresh design pass.
- The same errors as `design`, plus 404 for a template that does not exist.

Also available in: API [`POST /templates/{id}/redesign`](https://openemail.uk/docs/api/reference/templates#post-templates-id-redesign); TypeScript [`templates.redesign()`](https://openemail.uk/docs/sdk/reference/templates#redesign); Ruby [`templates.redesign`](https://openemail.uk/docs/ruby/reference/templates#redesign); CLI [`openemail templates redesign`](https://openemail.uk/docs/cli/reference/templates#templates-redesign).
