# Email templates

Stored templates are the per-recipient path. Their HTML is carried onto a
campaign and interpolated at send time, which is the only place `{{tokens}}`
resolve.

Branding — logo, colours, name, mailing address — is baked into a template's
HTML when the starter is seeded. It is **not** a runtime token.

## `GET /api/email-templates`

Every active template in the project, most recently updated first.

**Scope:** `templates:read`

| Parameter | In | Type | Notes |
| --- | --- | --- | --- |
| `projectId` | query | string | **Required.** |

```json
{
  "templates": [
    {
      "id": "tpl_…",
      "projectId": "acme-app",
      "name": "Monthly digest",
      "description": null,
      "category": "custom",
      "subjectEn": "What shipped in July",
      "htmlEn": "<html>…</html>",
      "textEn": "…",
      "subjectPt": null,
      "htmlPt": null,
      "textPt": null,
      "status": "active",
      "createdAt": "2026-07-02T11:00:00.000Z",
      "updatedAt": "2026-07-29T16:40:00.000Z"
    }
  ]
}
```

Archived templates are not returned.

## `POST /api/email-templates`

Create a template. English is canonical and required; Portuguese is optional
and falls back to English at render time.

**Scope:** `templates:write`

| Field | Type | Notes |
| --- | --- | --- |
| `projectId` | string | **Required.** |
| `name` | string | **Required.** |
| `en` | object | **Required.** `{ subject, html, text? }`. `text` falls back to stripped HTML. |
| `pt` | object | Optional `{ subject, html, text? }`. |
| `description` | string | |
| `category` | string | Defaults to `custom`. |

```bash
curl -X POST https://pharosbase.com/api/email-templates \
  -H "Authorization: Bearer $PHAROS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "acme-app",
    "name": "Monthly digest",
    "en": {
      "subject": "What shipped in July",
      "html": "<p>Hi {{contact.firstName|there}} — here is what changed.</p><p><a href=\"{{unsubscribeUrl}}\">Unsubscribe</a></p>"
    }
  }'
```

`201` returns the created `{ "template": { … } }` row. Invalid locale content
returns `400` with the offending locale named (`EN: …` / `PT: …`).

## `GET /api/email-templates/{id}`

One template in full.

**Scope:** `templates:read`

## `PATCH /api/email-templates/{id}`

Update in place — iterate on copy without minting a new template row for every
revision.

**Scope:** `templates:write`

| Field | Type | Notes |
| --- | --- | --- |
| `name` | string | |
| `description` | string | |
| `category` | string | |
| `en` | object | **Partial** — only the keys you pass change. |
| `pt` | object \| null | Partial. Pass `null` to drop the Portuguese localization entirely. |

## `DELETE /api/email-templates/{id}`

Archive the template. It stops appearing in `GET /api/email-templates`; sends
that already went out keep their recorded subject and template id.

**Scope:** `templates:write`

## Tokens

These resolve in stored template HTML at send time. Unknown tokens resolve to
an empty string rather than erroring.

| Token | Resolves to | Always |
| --- | --- | --- |
| `{{unsubscribeUrl}}` | Per-recipient one-click unsubscribe URL | Yes |
| `{{project.unsubscribeUrl}}` | Alias of `{{unsubscribeUrl}}` | Yes |
| `{{contact.email}}` | Recipient's email address | Yes |
| `{{contact.firstName}}` | First name; empty when unknown | Yes |
| `{{contact.lastName}}` | Last name; empty when unknown | Yes |
| `{{subject}}` | Campaign subject | Legacy |
| `{{body}}` | Campaign body | Legacy |
| `{{project.name}}` | Project name | Legacy |
| `{{project.logo}}` | Project logo URL | Legacy |
| `{{project.primaryColor}}` | Primary brand colour | Legacy |

The legacy tokens exist for older templates; branding is baked in at seed time,
so new templates should not reach for them.

A token may carry a fallback for when its value is empty or missing, written
`{{contact.firstName|there}}`. Values are HTML-escaped in the HTML part and
inserted raw in the plain-text part.

> Tokens do **not** resolve in the `content` body passed to
> [`POST /api/send`](/docs/api-campaigns#post-apisend) against a built-in React
> template. That copy is rendered literally, so `{{contact.email}}` written
> there ships as those exact characters. Per-recipient interpolation requires a
> stored template.

Every send needs a working unsubscribe path. Include `{{unsubscribeUrl}}` in
any template that goes to a list.
