01Four engines, one entry point
Everything that renders a message — the send path, broadcast fan-out, the dashboard preview — calls one function, renderTemplate, and nothing else. That is not tidiness for its own sake: it is the only way a template can be guaranteed to render identically in the preview you approved and in the broadcast that goes out an hour later. Two code paths would eventually be two behaviours.
The choice comes down to two questions: who edits this, and what runtime does it have to render on. Everything else is taste.
Who edits this? If the answer includes anybody who does not want to see angle brackets, you want a structured document rather than a string, because only a structured document can be presented as a form. What runtime does it have to render on? If the answer is a Cloudflare Worker — which it is, for every send on a default deployment — then MJML is out, and that is a hard constraint rather than a preference.
02The structured AST, and its fifteen components
The jsx-ast engine is the one that sounds most exotic and is in practice the most boring, which is the point. You author a React-Email-shaped .tsx file; mailysend templates push runs it through a real parser on your machine, where a parser and a filesystem are entirely reasonable things to have; and what gets stored is a data-only JSON tree.
The server never sees JSX and never evaluates anything — which is what makes a visual editor and a safe render the same feature rather than two competing ones.
// welcome.tsx — what you write <Container> <Preview>Your account is ready</Preview> <Heading level={1}>Hi {contact.first_name | default:"there"}</Heading> <Text>Two things to do first.</Text> <Button href={"{{ activation_url }}"}>Activate</Button> </Container>
That split is the whole design. A template body is customer-controlled data that gets stored and later rendered inside a shared isolate on behalf of somebody else’s send, so anything in that path capable of evaluating an expression from the body is a sandbox escape waiting to be found.
| The expression language has | Because |
|---|---|
| No function calls, no arithmetic | Neither can be bounded, and neither is needed to name a field. |
| No operators outside a fixed comparison set | An enumerable set is the only kind that can be reviewed. |
| No __proto__, constructor or prototype | The path schema refuses those property names outright. |
| A cap of twelve path segments | A path that deep is a bug, not a lookup. |
| Escaping at every interpolation site | There is no opt-out, so there is no gap. |
Fifteen components make up the document. They are deliberately email components rather than web ones — there is no div, because a div is not how you lay out a message that has to survive Outlook.
| Component | What it is |
|---|---|
| Html | The document root |
| Head | Where a style block lives |
| Body | The outer background |
| Container | The centred fixed-width column |
| Section | A horizontal band |
| Row | A table row |
| Column | A cell inside a row |
| Text | A paragraph |
| Heading | H1 through H6 |
| Button | A bulletproof padded anchor |
| Link | An inline anchor |
| Img | An image with dimensions |
| Hr | A rule |
| Preview | The hidden inbox preview line |
| CodeBlock | Monospaced, for tokens and ids |
The filter chain — {value | formatDate:“short”} — resolves against the exact same helper table the handlebars engine uses, on purpose, so that {{formatDate x “short”}} and its AST equivalent cannot drift into producing different output.
03Handlebars, interpreted rather than compiled
This is a handlebars interpreter, not a handlebars compiler. Upstream handlebars compiles a template into a JavaScript function; this one parses the template into a tree and walks it.
The runtime has no new Function and no eval, by design, and that single fact explains every difference you will notice.
| Group | The whole list | Note |
|---|---|---|
| Block helpers | if · unless · each · with | Four. There is no way to add a fifth from a template. |
| Formatting | upper · lower · capitalize · truncate · default · formatDate · formatNumber · pluralize · link | formatDate defaults to UTC and takes an explicit tz when you want otherwise. default treats the empty string as missing, because “Hi ,” is the failure everybody has received. |
| Comparison and logic | eq · ne · gt · gte · lt · lte · and · or · not | Being able to enumerate what a template can do is the only form of sandboxing that survives contact with untrusted authors. |
Hi {{capitalize first_name}},
{{#if plan}}You are on the {{plan}} plan.{{/if}}
You have {{formatNumber credits}} {{pluralize credits "credit" "credits"}} left,
expiring {{formatDate expires_at "medium"}}.
{{#each items}} · {{this.name}}
{{/each}}The UTC default is not a shrug. A Worker’s clock is always UTC while a self-hosted Node box is whatever the operator set, and “the same broadcast rendered a different date depending on which runtime picked up the job” is a bug that only appears near midnight, in production.
{{{value}}} produces exactly the same output as {{value}}, and a warning is emitted so the author finds out from the render rather than from a recipient.
Two reasons, and the second is the one that decides it. Mechanically, honouring raw interpolation on an interpreter with no new Function would require either an eval path — the thing this design exists to avoid — or a second, subtly different renderer, and two renderers that disagree at the margins is worse than one that is honest about its limit. Substantively, “elsewhere” in an email template means contact data: a first name, a company name, a free-text field from a signup form. Honouring the triple stash means any contact who typed a script tag into a form field gets it rendered in every client that runs script — and stored HTML injection in an email body is also a phishing primitive, because it is an attacker-supplied anchor inside a legitimately DKIM-signed message from a brand the reader trusts.
04MJML, and the runtime it needs
MJML is supported and it is Node-only. The compiler needs Node APIs that Cloudflare Workers does not provide, so on a Worker the engine throws MjmlUnavailableError immediately rather than attempting a partial render.
The design decision is in the word “immediately”: an error that names the fix is worth more than a capability check, and both supported answers are build-time.
| What the engine checks | What it concludes |
|---|---|
| navigator.userAgent === "Cloudflare-Workers" | The documented Workers signal. MJML cannot run here; throw before doing anything else. |
| process.versions.node | A version string is what distinguishes real Node from the Deno and Bun shims that also define process. |
| A dynamic import, indirected via a variable | So that bundlers which statically rewrite import("mjml") leave the Worker build alone. |
| The optional dependency is not installed | The same error class with the reason in it — not a stack trace about a missing module. |
// on a Worker: MjmlUnavailableError: MJML cannot be compiled on Cloudflare Workers. // Compile MJML before it reaches the send path: run `mailysend templates push`, // which compiles it locally and uploads the HTML, or add an MJML build step in // CI and store the compiled HTML on the template version.
Either way the send path receives plain HTML and nothing on the hot path depends on a Node-only dependency. If you are starting a template from scratch and want MJML’s layout guarantees without its runtime, the AST engine was designed for exactly this constraint.
05What happens to your HTML afterwards
Whichever engine produced it, the HTML then goes through a fixed post-render pipeline before it becomes a MIME message.
None of these steps is optional decoration. Each one exists because email clients are not browsers.
| Step | What it does | Why it has to |
|---|---|---|
| inlineCss | Flattens a <style> block into style attributes on the elements it matched. Runs only when there is a style block, and reports selectors it could not handle as warnings rather than dropping them silently. | Gmail strips head styles in several contexts and older clients never supported them, so a design that relies on a stylesheet arrives unstyled. |
| ensureTableLayout | Stamps the attributes every layout table needs: cellpadding, cellspacing and role="presentation". | cellpadding and cellspacing default to non-zero in Outlook and older Gmail — precisely where the mystery two-pixel gaps in a sliced hero image come from. role="presentation" stops a screen reader announcing layout scaffolding as a data table. |
| injectTracking | Adds the open pixel and rewrites links, with three deliberate exemptions. | See the exemptions below — each one is a link that would break if it were signed into the click tracker. |
| hasUnsubscribe | The pre-send check: does the body already resolve to an unsubscribe, through a placeholder or the literal word. Both {{unsubscribe_url}} and the Mailchimp-era %unsubscribe_url% are recognised, because senders migrate and paste. | If nothing is found a default footer is appended before the closing body tag, rather than the message going out without one. |
| htmlToText | Derives the plain-text part when you have not supplied one, and records that it did so as a warning. | A multipart message with a real text alternative is treated better by filters than an HTML-only one, and there is a population of readers who see only that part. |
| Threshold | Warning | What it costs you |
|---|---|---|
| ~102 KB of rendered body | Gmail clipping | Gmail shows a “View entire message” link and hides everything after the cut — including, in practice, your unsubscribe footer and your open pixel, so a clipped broadcast simultaneously under-reports opens and over-reports complaints. |
| 150 characters of subject | Subject too long | Most clients show fewer than eighty. The subject is rendered as plain text rather than HTML, since it ends up in a MIME header where &amp; would be shown to the recipient literally. |
Both are warnings on the render result. Wire them into your publish check rather than reading them by eye.
What just happened
You can now pick an engine on purpose rather than by default: the AST when a template needs to be edited by someone who is not you, handlebars when it is a string with names in it, MJML when you already have MJML and a build step, raw HTML when the body came from somewhere else and must not be touched. The thing most likely to bite you is that {{{value}}} escapes exactly like {{value}} here. That is a deliberate divergence from upstream handlebars, it is documented rather than discovered, and a warning is emitted so you find out from the render result instead of from a recipient.