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/.
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 templateImporting 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(itsindex.html, stylesheets and images; an.mjmlfile in a zip wins), an HTML file, MJML, or a public web page (url). JSON (file: {name, content: <base64>},html,mjmlorurl) or multipart (file). Options:brand(needed when the workspace has several),key(else made fromnameor the title),name,subject(else the page title or first heading),save: falseto 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 becomesmj-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 anunknown_propertyerror. - Out:
{key, version, created, saved, subject, mjml, fields, changes, issues, previews: {desktop, mobile}, next}.fieldsare what to edit next (headline, text, buttons with their links, images, repeated cards as one list, and personalisation);changessay what was changed, in plain English;issuesare the checks below plus the import's own warnings. It is saved as a new version only when there are no errors; otherwise fixmjmlandPUTit.
Liquid
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 markedrequired: truein 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'ssubject/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.
<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 byand/or(no parentheses); filtersdays_ago,days_until,size,downcase,upcase,abs.each: a list property (the item isitemunless named);limitdefaults 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-UnsubscribeandList-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 withPATCH /v1/tenant/settings {"utm": false}, per template withutm. The links as sent (with their UTMs) are stored on the send (GET /v1/sends/<id>→links) and shown bytemplate_preview.
Check before sending
template_previewrenders exactly what a contact (or sample data) would receive. Nothing is sent.template_test_sendsends 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_getshows it).template_lintbefore saving; errors on save list every issue with its field, line and fix.POST /v1/templates/<key>/previewwithdraft: {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/propertieslists 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).
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; usemj-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(MCPbrandkit_plan) lists every template of the brand rendered before and after.POST /v1/brandkits/from-url(MCPbrandkit_from_url) drafts a kit from a website; it saves nothing. POST /v1/brands/<brand>/copy {toBrand, templates}(MCPbrand_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:
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.