# Templates: MJML or HTML with Liquid

A template is an email's subject, body and optional plain-text body, saved as an immutable **version** (saving the same
content again makes no new version). `format: mjml` (MJML 5) compiles once on save (Liquid is kept for send time);
`format: html` is cleaned and wrapped in the brand kit (below). Every save runs the **checks** first: errors refuse it,
warnings are saved with the version. Examples: `examples/welcome/templates/`.

```yaml
brand: main
format: mjml
subject: "Welcome, {{ contact.first_name | default: 'there' }}"
body: |
  <mjml>
    <mj-head><mj-preview>Your account is ready</mj-preview></mj-head>
    <mj-body>
      <mj-section>
        <mj-column>
          <mj-text>Hi {{ contact.first_name | default: 'there' }},</mj-text>
          <mj-text>Thanks for joining {{ brand.name }}.</mj-text>
          <mj-button href="https://example.com/start">Get started</mj-button>
        </mj-column>
      </mj-section>
    </mj-body>
  </mjml>
textBody: null        # optional; derived from the HTML when null
utm: false            # optional; overrides the account's UTM setting for this template
```

## Importing a design (Claude Design, HTML, MJML, a web page)

`POST /v1/templates/import` (MCP `template_import`, `plainsend template import <file|url> --brand <b> --key <k>`, the
console's Content → Import) turns a design into an MJML template in the brand's kit:

- **In**: a Claude Design export `.zip` (its `index.html`, stylesheets and images; an `.mjml` file in a zip wins), an
  HTML file, MJML, or a public web page (`url`). JSON (`file: {name, content: <base64>}`, `html`, `mjml` or `url`) or
  multipart (`file`). Options: `brand` (needed when the workspace has several), `key` (else made from `name` or the
  title), `name`, `subject` (else the page title or first heading), `save: false` to only look. A zip whose page is
  built by JavaScript when it opens (a React app) is refused: export it as standalone HTML. PDFs are refused.
- **Converted**: styles are computed (stylesheets, specificity, `var()`, inheritance) and written inline; each block
  becomes a section named after its class (so UTMs say where a link sat); a flex or grid row of 2–4 items, or a table
  row of 2–4 cells, becomes columns (they stack on phones); 5 or more stack. Headings keep their level, a link styled
  as a button becomes `mj-button`. Removed: scripts, forms, frames (a YouTube embed becomes a thumbnail linking to the
  video), video (its poster, linking to it), SVG, animations, hover effects, pop-ups and fixed bars, site navigation
  and tracking pixels. Web fonts are kept with an email-safe fallback; background images become a background colour
  (a warning). With a brand kit, the design's header and footer are replaced by the kit's; colours and fonts equal to
  the kit's become brand tokens. MJML passes through as written.
- **Images** are copied into your asset store and served from `https://api.plainsend.app/a/<account>/<hash>.<ext>`;
  an image without alt text gets `[describe image]` and a warning.
- **Personalisation**: `[First name]`, `{{first_name}}`, `*|FNAME|*`, `%%FirstName%%` become
  `{{ contact.first_name | default: 'there' }}`; Mailchimp's `*|UNSUB|*`, `*|LIST:ADDRESS|*`, `*|LIST:COMPANY|*` become
  the unsubscribe link and the brand's address and name. An unknown property is an `unknown_property` error.
- **Out**: `{key, version, created, saved, subject, mjml, fields, changes, issues, previews: {desktop, mobile}, next}`.
  `fields` are what to edit next (headline, text, buttons with their links, images, repeated cards as one list, and
  personalisation); `changes` say what was changed, in plain English; `issues` are the checks below plus the import's
  own warnings. It is saved as a new version only when there are no errors; otherwise fix `mjml` and `PUT` it.

## Liquid

[Liquid](https://shopify.github.io/liquid/) with strict filters (an unknown filter is an error on save).

| Variable | Value |
|---|---|
| `contact.email`, `contact.first_name`, `contact.last_name`, `contact.phone`, `contact.country`, `contact.region`, `contact.locale`, `contact.id` | The contact's core fields (snake_case). |
| `contact.<property>` | A custom property from a source mapping's `properties`, or one already in your contacts' data, e.g. `contact.postcode`. |
| `event.<field>` | The triggering event's payload, in event-triggered journeys. |
| `brand.name`, `brand.key`, `brand.from_email`, `brand.from_name`, `brand.postal_address` | The sending brand. |
| `unsubscribe_url` | This message's unsubscribe link (one is always added in the footer anyway). |

Values are HTML-escaped in the body, not in the subject or text. Two rules are checked on every save (ADR-0005):

- **Every output has a fallback**: `{{ contact.first_name | default: 'there' }}`. Exempt: `contact.email`,
  `contact.id`, `brand.*`, `unsubscribe_url`, loop items, and properties marked `required: true` in their mapping
  (`properties: { plan: { from: Plan, required: true } }`). Use single quotes inside MJML attributes.
  `POST /v1/templates/<key>/suggest-fallbacks` (body: `properties: ["contact.first_name"]` and/or a draft's
  `subject`/`body`) proposes one per property type (first name → "there", numbers → 0) with the replacement text.
- **Every property is known**: an unknown one (`contact.frist_name`) is an error whose fix names the closest match.

## Conditions and loops (MJML)

Liquid control flow in MJML goes through two tags; never hand-write `<mj-raw>{% if %}` pairs.

```xml
<ps-if when="contact.lastVisit | days_ago > 180"> <mj-section>…</mj-section> </ps-if>
<ps-for each="product in event.items" limit="10"> <ps-module ref="product-row@1" name="{{ product.name }}"/> </ps-for>
```

- `when`: properties, strings ('pro'), numbers, `true`/`false`/`blank`/`empty`; `== != > < >= <= contains`; clauses
  joined by `and`/`or` (no parentheses); filters `days_ago`, `days_until`, `size`, `downcase`, `upcase`, `abs`.
- `each`: a list property (the item is `item` unless named); `limit` defaults to 10, at most 50.
- Any other `ps-*` tag is an error. In HTML templates use Liquid `{% if %}` directly.

## HTML templates

HTML (an import, a Claude Design export) is cleaned, then wrapped in the sending brand's kit like MJML. Removed, in
one warning: script, iframe, object, embed, forms and inputs, `on…=` attributes, `javascript:` links, external
stylesheets and `@import`, 1x1 or hidden tracking pixels. Kept: inline styles, tables, Outlook conditional comments,
Liquid. It is refused when unsafe structure remains (an unclosed `<script`, CSS `expression()`, a refresh redirect).

## Checks (the same list everywhere)

`POST /v1/templates/<key>/lint` (MCP `template_lint`, `plainsend template lint <file>`) runs every check without
saving; `PUT` (MCP `template_put`, `plainsend template publish <file>`) runs them and returns `issues`;
`GET /v1/templates/<key>/versions/<v>/issues` reads a version's. Each issue is
`{ field, line, rule, severity, message, fix }`; `line` is the line in that field.

| Rule | Severity | What |
|---|---|---|
| `liquid`, `mjml`, `unknown_tag`, `ps_if`, `ps_for`, `unclosed_tag`, block rules | error | the source does not parse or compile |
| `missing_fallback`, `unknown_property` | error | the ADR-0005 lint above |
| `unsafe_html` / `html_removed` | error / warning | HTML cleaning |
| `unsubscribe`, `postal_address` | error | the footer's unsubscribe link and address are missing or hidden as sent |
| `gmail_clip` | warning over 90 KB, error over 102 KB | Gmail clips the email (and its footer) |
| `img_alt` | warning | an image without alt text |
| `broken_link` | warning | a link answers 4xx/5xx or not at all (each URL once; Liquid links skipped) |
| `outlook_css` | warning | flex, grid or position, which Outlook ignores |
| `dark_mode_logo`, `dark_mode_text` | warning | a transparent logo without `logo.darkUrl`; pure black text |
| `subject_length`, `preview_text` | warning | over 60 characters; no `<mj-preview>` (or hidden preheader in HTML) |

## Added at send time (you cannot remove them)

- A footer with the brand name, postal address and an unsubscribe link, in the HTML and the text.
- `List-Unsubscribe` and `List-Unsubscribe-Post` (one-click) headers.
- Click tracking (every `http(s)` link is rewritten to a signed redirect) and an open pixel, unless the account turned
  tracking off.
- UTM parameters on every tracked link that has none: `utm_source=plainsend`, `utm_medium=email`,
  `utm_campaign=<journey key, else template key>`, `utm_content=<block key, section css-class or section-<n>, header,
  footer, or body>`. Off for the account with `PATCH /v1/tenant/settings {"utm": false}`, per template with `utm`.
  The links as sent (with their UTMs) are stored on the send (`GET /v1/sends/<id>` → `links`) and shown by
  `template_preview`.

## Check before sending

- `template_preview` renders exactly what a contact (or sample data) would receive. Nothing is sent.
- `template_test_send` sends one test through the normal eligibility check: in a sandbox or test account only verified
  recipients receive it; anyone else is skipped with a reason (`contact_get` shows it).
- `template_lint` before saving; errors on save list every issue with its field, line and fix.
- `POST /v1/templates/<key>/preview` with `draft: {brand, subject, body, format}` renders an unsaved source in its
  brand's kit (`version: 0`; errors refuse it like a publish). The console's builder previews with it.
- `GET /v1/properties` lists every property a template may use (`contact.*` with its type, and whether it is
  required, so needs no fallback).

## Brand kits: one template, any brand's look

Each brand can have a **brand kit** (`PUT /v1/brandkits/<brand>`, MCP `brandkit_put`). Templates hold content only;
the renderer wraps an MJML template in its sending brand's kit, so the same template renders in any brand's kit
(`template_preview` with `brand` shows it in another brand's kit; a journey sending as another brand uses that kit).

```yaml
colors: { primary: "#0e5e6f", accent: "#f2a541", text: "#1d2a30", background: "#f6f4ef" }   # dark-mode colours are generated
fonts:
  heading: { family: Fraunces, url: "https://fonts.googleapis.com/css2?family=Fraunces:wght@600&display=swap" }
  body: { family: Inter }                 # fallback chosen for you (Arial, Helvetica, sans-serif); or set `fallback`
logo: { url: "https://example.com/logo.png", darkUrl: "https://example.com/logo-white.png", alt: "Harbour Physio" }
button: { radius: 6, background: accent }   # primary | accent | a hex colour; text colour picked for contrast
layout: { width: 600, header: "<mj-section>…</mj-section>", footer: "<ps-module ref=\"social@2\"/>" }
footer: { text: "You're a patient of {{ brand.name }}.", preferencesUrl: "https://example.com/preferences" }
```

- The kit sets MJML defaults (`mj-text`, `mj-button`, fonts) and dark-mode CSS; use `mj-class="ps-heading"` for
  headings. Brand tokens resolve at compile: `{{ brand.color.primary }}`, `{{ brand.color.accent }}`,
  `{{ brand.font.heading }}`, `{{ brand.logo.url }}`, `{{ brand.button.radius }}`.
- The footer always carries the brand name, postal address and unsubscribe link; the kit only adds to it.
- Text must reach 4.5:1 on its background (light and dark) or the put is refused; pale brand colours are warnings.
- Before changing a kit, `POST /v1/brandkits/<brand>/plan` (MCP `brandkit_plan`) lists every template of the brand
  rendered before and after. `POST /v1/brandkits/from-url` (MCP `brandkit_from_url`) drafts a kit from a website;
  it saves nothing.
- `POST /v1/brands/<brand>/copy {toBrand, templates}` (MCP `brand_copy`) copies the kit and templates; each copy
  records where it came from.

## Shared blocks

A **block** (`PUT /v1/blocks/<key>`, MCP `block_put`) is an MJML fragment (sections) with declared fields:

```yaml
name: Book a visit button
fields: [{ key: label, default: "Book a visit" }, { key: url, type: url, required: true }]
mjml: <mj-section><mj-column><mj-button href="{{ field.url }}">{{ field.label }}</mj-button></mj-column></mj-section>
```

Use it in a template as `<ps-module ref="cta@2" url="https://example.com/book"/>`. Leave the version off
(`ref="cta"`) and publishing pins the newest one. A new block version never changes a template: `GET /v1/blocks` and
`GET /v1/templates/<key>` (`blockUpdates`) list templates with an update available; name the new version to take it.
