Mail guides
04 · Template authoring

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.

A reasonably complete template
<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):

GET /workspaces/:ws/templates/:name/variables
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). Optional variant / align.
  • {{> bp.hero title="..." subtitle="..."}} — heading block, optional cta_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.

POST /workspaces/:ws/templates/lint
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 the bp.* components, which do this for you.
  • Inline styles, not stylesheets. Gmail strips <style>; Outlook ignores external CSS entirely. Put style="..." 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. #1E40AF not blue. 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 via POST /workspaces/:ws/integrations/github/sync.
  • Plain upsert from CI. POST /workspaces/:ws/templates is an upsert on name — a deploy step that re-posts every template file is idempotent.