Mail guides
04 · Template authoring

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.

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

GET /workspaces/:ws/templates/:name/variables
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). Takes label + 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.

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 '{
    "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. 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.
  • 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

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:

Sync from a YAML manifest in your repo
# 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