Appearance
Template helpers
Postcraft registers a small set of helpers on top of the Handlebars built-ins (if, unless, each, with, lookup, log). The helpers below are available in every template render, both in code-mode templates and inside visual-mode block content.
capitalize
{{capitalize value}} → uppercases the first character, lowercases the tail.
If you want each word capitalized, use titleCase instead.
chart
{{chart type="bar" data=… }} → renders a chart as an <img>. The chart is drawn server-side and served as a PNG, so it displays everywhere a normal image does, including email clients (no JavaScript required).
Types: bar (vertical), hbar (horizontal), line, pie, segmented (one horizontal bar whose colored segments show each category's share of the whole, with a legend).
Data (data=) accepts a bound value, an inline JSON string, or block-body JSON, in any of these shapes:
js
[{ "label": "Jan", "value": 1200 }] // label/value pairs
{ "Jan": 1200, "Feb": 1800 } // object map (keys are labels)
[{ "month": "Jan", "revenue": 1200 }] // records: set labels="month" values="revenue"When the data is records (an array of objects), labels and values name the fields to read.
Options (all optional):
| Option | Meaning |
|---|---|
title | Heading above the chart |
color | Series color (defaults to the template's theme accent) |
palette | Comma-separated colors for pie slices / segments, e.g. palette="#6e7ef0,#22c55e" |
valueLabels | valueLabels=true prints each value on its bar/slice/point (percentages for pie / segmented) |
xLabel / yLabel | Axis captions (not used for pie / segmented) |
width | Display width in px, defaults to 100% of the container |
height | Chart height in px (sets the aspect; defaults per type) |
align | left / center / right, defaults to center |
The chart background is transparent, so it sits on whatever background your template uses. If the data is missing or can't be parsed, the helper renders a small "Chart data unavailable" placeholder instead of failing the whole template.
eq / ne / gt / gte / lt / lte
Comparisons. The built-in {{#if}} only tests whether a value is truthy, so pair it with one of these as a subexpression (in parentheses) to branch on a specific value:
| Helper | Test |
|---|---|
eq | equal |
ne | not equal |
gt | greater than |
gte | greater than or equal to |
lt | less than |
lte | less than or equal to |
eq and ne are strict: no type coercion, so (eq 1 "1") is false. Watch for this with form posts, which often deliver numbers as strings.
The other four order numbers as numbers, including numeric strings: (gt "10" "9") is true, because a form field carrying "10" still means ten. Two non-numeric strings compare alphabetically, which is what makes ISO dates work:
Values that can't be ordered (a missing field, a number against a word) make all four false. So on absent data, neither half of a gt / lte pair renders, rather than both guessing. Use {{#if data.total}} first if you need to handle "no value" explicitly.
To branch across more than two values, switch / case / default reads better than a chain of {{else if (eq …)}}.
and / or / not
Combine conditions. All three are truthiness tests that match {{#if}} exactly, so 0, "", and an empty list all count as absent. and and or take any number of arguments.
not is not the same as {{#unless}}: unless is a block, so it can't nest inside a condition. Reach for not when a negation needs to sit inside an and / or, as above.
formatDate
{{formatDate value "pattern"}} → a date, formatted. JSON carries dates as ISO strings, and no email should show a human 2026-10-15T10:30:00Z.
The pattern is optional and defaults to d MMMM yyyy.
Tokens
| Token | Output | Token | Output | |
|---|---|---|---|---|
yyyy | 2026 | HH | 09 (24-hour, padded) | |
yy | 26 | H | 9 (24-hour) | |
MMMM | October | hh | 09 (12-hour, padded) | |
MMM | Oct | h | 9 (12-hour) | |
MM | 10 | mm | 05 | |
M | 10 (no padding) | m | 5 (no padding) | |
EEEE | Thursday | ss | 45 | |
EEE | Thu | s | 45 (no padding) | |
dd | 15 | a | AM / PM | |
d | 15 (no padding) |
Any other character passes through, so d/M/yyyy and d.M.yyyy both work.
Quote your literal words
Letters in the pattern are tokens, so a bare word gets eaten: the a in at becomes AM. Wrap literal text in single quotes:
Write '' for a literal apostrophe.
Time zone
Dates are formatted in UTC unless you say otherwise. This is deliberate: the editor preview runs in your browser and the real send runs on the server, so a zone-dependent default would make the preview lie. Pass tz (any IANA zone) to shift, and daylight saving is handled for you:
Language
Pass locale (any BCP 47 tag) to translate month names, weekday names, and AM/PM. Numbers stay as digits, and a locale never rewrites dd/MM/yyyy.
When the value isn't a date
A missing or empty value renders nothing. A value that can't be parsed renders as-is, rather than disappearing. An appointment email that silently drops its date is worse than one showing a value you can see is wrong in the preview. An unusable tz or locale falls back to the default instead of failing the render.
Accepted inputs are an ISO 8601 string (recommended), a Date, or epoch milliseconds. A seconds-based timestamp (as some webhooks send) will render as 1970, so multiply by 1000 first.
icon
{{icon "github"}} → renders an icon as an <img>. Like charts, the glyph is drawn server-side and served as a PNG, so it displays everywhere a normal image does, including email clients, which don't render SVG (inline or data URI). The Social block emits these calls under the hood.
Names: twitter, facebook, instagram, linkedin, youtube, github, tiktok, mastodon, bluesky, patreon, onlyfans.
Options (all optional):
| Option | Meaning |
|---|---|
color | Icon color, baked into the PNG (defaults to near-black ink) |
size | Display size in px, 12–256 (defaults to 24; rendered at 2× for retina) |
An unknown name or invalid color renders nothing rather than failing the template: an icon is decorative, and a visible error artifact in a sent email would be worse than its absence.
storeBadge
{{storeBadge "apple"}} → renders a store badge as an <img>. Drawn server-side and served as a PNG, for the same reason icons are. The Store badges block emits these calls under the hood.
The helper renders the badge only; wrap it in your own <a> to make it clickable.
Names: apple (App Store), googleplay, mac (Mac App Store), microsoft (Microsoft Store), amazon (Amazon Appstore), steam, itchio.
Options (all optional):
| Option | Meaning |
|---|---|
variant | black (default) or white. A store publishing one colorway uses it either way |
height | Lockup height in px, 24–120 (defaults to 40; rendered at 2× for retina) |
There is deliberately no color option: the only choice is which published colorway to draw.
codeblock
Renders a syntax-highlighted code panel (the Code block emits these calls under the hood). Everything is inline-styled, so the panel survives email clients: lines become <br />, indentation becomes non-breaking spaces, and token colors ride on each <span>. No <pre>, no stylesheet, nothing Outlook's Word renderer strips.
Two forms:
Both forms interpolate the source, so a snippet can carry runtime data: const ref = "{{bookingRef}}"; renders the recipient's own reference. To show the braces themselves, which is what a snippet about a template language needs, escape them the Handlebars way:
The difference between the forms is only where the source lives. In the block form the body sits in the template, so an unbalanced {{ in it breaks the surrounding parse. The src form keeps the source out of that parse, which is why the visual Code block uses it: a pasted snippet that is not valid Handlebars renders verbatim instead of taking the template down.
Options:
| Option | Meaning |
|---|---|
lang | js, ts, tsx, json, bash, html, css, python, go, php, ruby, java, sql, yaml, handlebars, or plain (default: plain) |
theme | postcraft-light, postcraft-dark (default), github-light, github-dark, nord, dracula, one-dark-pro, catppuccin-mocha |
bg | Panel background override, e.g. bg="#0b1021"; token colors stay the theme's own |
radius | Corner radius in px, 0–64 (default: 6); radius=0 squares the panel |
src | base64url-encoded source; used instead of a block body |
The picked theme is absolute: a panel keeps its own background and ink on light and dark rows alike. Unknown lang or theme values fall back to plain / the default theme rather than failing the render, and source longer than 16 KB is truncated.
nl2br
{{nl2br value}} → HTML-escapes the input and converts newlines (\n, \r\n, \r) to <br> tags.
The input is escaped first, so only the literal <br> Postcraft inserts is trusted output. Safe to use with user-submitted text.
pluralize
{{pluralize count "noun"}} → emits count + " " + noun (with a default +s plural).
Optional third positional argument overrides the default plural for irregular nouns.
sanitizeUrl
{{sanitizeUrl url}} → returns the URL if it passes the safe-URL allowlist (http, https, mailto, tel, relative, anchor), otherwise returns #.
The visual-mode button renderer wraps variable-bound href values in this helper automatically, so a context value like javascript:alert(1) cannot produce a clickable XSS link. Use it explicitly in code-mode templates when you embed user-controlled URLs.
stringify
{{stringify value}} → JSON-serializes the value. Optional indent=N pretty-prints with N-space indentation.
Useful for debug previews and for embedding structured data into <script type="application/json"> blocks.
titleCase
{{titleCase value}} → Title-cases each word. Word boundary is "leading word char + non-space run", so apostrophes and hyphens carry through:
switch / case / default
Cooperating block helpers for clean multi-branch logic.
Behavior:
- The first matching
caserenders its body; subsequentcaseblocks and thedefaultblock stay quiet. defaultis optional; if no case matches and there's no default, the switch renders nothing.caseanddefaultoutside aswitchno-op (they do not throw), so a misnested template renders blank rather than failing with a 422.- Nested switches each get their own state, so inner cases don't bleed into the outer one.