Write templates that actually render.
Handlebars syntax, the variables system, partials, the lint endpoint that catches bad markup before send, and a handful of patterns that survive Outlook.
1
Handlebars in a paragraph
{{var}} interpolates. {{#if}} blocks branch. {{#each}} iterates. {{> partial}} includes a named partial. HTML escapes by default — {{{rawHtml}}} (triple-stache) opts out. Use sparingly.
<!doctype html>
<html>
<body style="font-family: -apple-system, sans-serif;">
<h1>Hi {{customer.name}},</h1>
<p>Your order #{{order.id}} is confirmed.</p>
{{#if order.gift}}
<p>This is a gift for {{order.gift.recipient_name}}.</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>
{{> footer}}
</body>
</html>2
The variable contract
Variables in templates come from the data field on send. Mail doesn't enforce a schema — pass whatever your template needs.
Missing variables render as empty strings (Handlebars default), not errors. Send-time validation catches the common screw-ups via the variable manifest:
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/templates/welcome/variables \
-H 'authorization: Bearer pcft_live_...'{
"variables": [
{ "name": "name", "path": "name", "kind": "scalar" },
{ "name": "product", "path": "product", "kind": "scalar" },
{ "name": "startUrl", "path": "startUrl", "kind": "scalar" }
]
}Use this endpoint from your operator UI to render a form with the right fields. Send your CI a check that compares the manifest against what your code passes — catches removed variables before they break production.
3
Partials
Repeating chrome (footer, header, unsubscribe link) lives in partials. Mail ships a small built-in set you can use without authoring:
{{> button}}— a button that renders correctly in every major mail client (Outlook's quirks included). Takeslabel+href.{{> footer}}— workspace brand footer, auto-includes the support email + unsubscribe link.{{> divider}}— a horizontal rule that actually renders in Outlook (a real concern).
Use in a template:
<p>Click the button to verify:</p>
{{> button label="Verify your email" href=verifyUrl}}
{{> footer}}4
Lint
Lint catches the markup mistakes that work in Chrome but fail in Outlook — unclosed tags, inline-style requirements, absolute-positioned elements, missing alt text. Run before every save.
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/templates/lint \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"body_html": "<h1>Hi {{name}}</h1><div style=\"position:absolute\">Floating!</div>"
}'{
"errors": [],
"warnings": [
{
"rule": "no-absolute-positioning",
"severity": "high",
"message": "position:absolute is unsupported in Outlook + most webmail. Use table layouts.",
"snippet": "<div style=\"position:absolute\">..."
}
]
}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. - 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.
- 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
Common errors at lint + send
400 INVALID_HANDLEBARS— unclosed{{#if}}/{{#each}}/ malformed expression. Lint catches this first.400 INVALID_HTML— unbalanced tags. Lint catches.422 MISSING_REQUIRED_VARIABLE— your data object doesn't include a variable the template uses. Render with a no-op{{#if var}}wrapper if optional.422 TEMPLATE_TOO_LARGE— rendered output over 1 MB. Cut inline images; reference them by URL.
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. Treat templates as code: keep canonical copies in your repo, push to Mail on deploy via an API call. The CI/CD pattern is:
# templates/welcome.yaml
name: welcome
tags: [onboarding]
subject: "Welcome to {{product}}, {{name}}!"
body_html: |
<!doctype html>
<html><body>...</body></html>
body_text: |
Hi {{name}}, thanks for ...
# deploy step (pseudocode)
for f in templates/*.yaml; do
mail-cli templates upsert -f $f --workspace <ws>
done