Write templates that actually render.
Handlebars syntax, the variables system, partials + layouts, the lint endpoint that scores deliverability before send, and a handful of patterns that survive Outlook.
1
Handlebars in a paragraph
{{var}} interpolates. {{#if}} blocks branch. {{#each}} iterates. {{> partial.name}} includes a partial you authored; {{> bp.name}} includes a built-in one. HTML escapes by default — {{{rawHtml}}} (triple-stache) opts out. Use sparingly.
<h1>Hi {{customer.name}},</h1>
<p>Your order #{{order.id}} is confirmed.</p>
{{#if order.gift}}
<p>This is a gift for {{order.gift.recipientName}}.</p>
{{/if}}
<table>
{{#each order.items}}
<tr>
<td>{{name}}</td>
<td>{{quantity}}</td>
<td>{{price}}</td>
</tr>
{{/each}}
</table>
<p>Total: <strong>{{order.total}}</strong></p>
{{> bp.footer}}No <html>/<head> boilerplate needed — Mail wraps the body in a dark-mode-safe document at render time.
2
The variable contract
Variables come from the data field on send. By default Mail doesn't enforce a schema — pass whatever your template needs. Missing variables render as empty strings (Handlebars default), not errors.
Name variables in camelCase. Keys inside data are normalized on the wire — recipient_name arrives as recipientName, so a snake_case variable in the template never receives its value.
The variable manifest lists every expression a saved template references (dotted paths preserved):
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/templates/welcome/variables \
-H 'authorization: Bearer pcft_live_...'{
"data": ["product", "name", "startUrl"]
}Use it to render a form in your operator UI, or add a CI check that compares the manifest against what your code passes — catches removed variables before they break production.
To make validation strict, set variables_schema on the template (shape: {version: 1, vars: {<name>: {type, required?, default?, options?}}}). Render and send then 400 with per-variable errors on mismatch.
3
Partials + layouts
Repeating chrome lives in partials. Create one with "kind": "partial" (or "kind": "layout" for a full wrapper) on POST /templates — same endpoint, the subject is ignored for both. Reference them as {{> partial.<name>}} / {{> layout.<name>}}. Kind can't be changed after create.
Mail also ships built-in components under the bp. namespace. They're bulletproof-table markup themed by your workspace brand tokens ({{brand.*}} — set in Console → Mail → Brand; change a color there and every email picks it up on next send):
{{> bp.button text="..." href=url}}— a button that renders correctly in every major client (Outlook included). Optionalvariant/align.{{> bp.hero title="..." subtitle="..."}}— heading block, optionalcta_text+cta_href.{{> bp.footer}}— brand footer: logo, footer text, address, © line, all from brand settings.{{> bp.divider}}and{{> bp.spacer}}— rules and vertical space that actually render in Outlook.{{#> bp.section}}...{{/bp.section}}— a block partial that wraps your content in the branded card.
Use in a template:
{{#> bp.section}}
<p>Click the button to verify:</p>
{{> bp.button text="Verify your email" href=verifyUrl}}
{{/bp.section}}
{{> bp.footer}}4
Lint
Lint scores a draft for deliverability before you save it: subject-line problems (empty, all-caps, exclamation pileups), image-heavy bodies, missing alt text, spammy phrases, external stylesheets, oversized HTML, missing unsubscribe link. It returns a 0–100 score — ≥90 looks great, <70 is likely to spam-folder.
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/templates/lint \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"subject": "Welcome!",
"body_html": "<h1>Hi {{name}}</h1><p>CLICK HERE!</p>"
}'{
"score": 86,
"findings": [
{
"id": "body.spammy_phrases",
"severity": "info",
"message": "Contains 1 spammy phrase: \"click here\".",
"suggestion": "Rewrite to sound like a real human telling someone what happened."
},
{
"id": "body.no_unsubscribe",
"severity": "info",
"message": "No \"unsubscribe\" link in the body."
}
]
}Pass optional data to render variables with sample values first. Broken Handlebars (unclosed {{#if}}, malformed expression) fails the lint with a 422 and the parse error — the same failure you'd otherwise hit at render or send time.
5
Authoring patterns that survive Outlook
- Table-based layout. Outlook's engine is the Word renderer. Modern CSS layout (flex, grid) doesn't exist there. Wrap your content in
<table>s with explicit widths — or use thebp.*components, which do this for you. - Inline styles, not stylesheets. Gmail strips
<style>; Outlook ignores external CSS entirely. Putstyle="..."on every element you care about. - Absolute URLs only. Relative links break in webmail. Always full
https://acme.com/.... - Alt text on every image. Many corporate filters block images by default; alt text is what your customer sees. Lint flags missing alt.
- Hex colors, not named.
#1E40AFnotblue. Some clients don't resolve named colors consistently. - Fallback fonts. Always declare a system-font fallback after your web font.
6
Where errors surface
- 400 on create — bad
name(must match^[a-z0-9][a-z0-9_-]*$) or missing required fields. - Broken Handlebars is accepted at create. The upsert stores whatever you post; the parse error surfaces as a 422 at lint, render, or send. Lint before save.
- 422 on render/send — Handlebars parse or runtime failure. The message carries the parse error with line context.
- 400 schema validation — only if the template declares
variables_schema; all variable errors are reported at once. Without a schema, missing variables silently render empty.
7
Version control your templates
Mail stores the current version of each template — there's no built-in history. If you overwrite, the old content is gone. Two ways to treat templates as code:
- GitHub integration. Connect a repo (Console → Mail → Integrations → Connect GitHub, or
POST /workspaces/:ws/integrations/github/connect), pick a branch, and Mail syncs template files from it. Synced templates show"source": "github"plus an “Open on GitHub” link, and re-sync on a schedule or on demand viaPOST /workspaces/:ws/integrations/github/sync. - Plain upsert from CI.
POST /workspaces/:ws/templatesis an upsert onname— a deploy step that re-posts every template file is idempotent.