Creating a Template#
zonai db email template create order_confirmation
This creates <emailTemplatesPath>/order_confirmation.html pre-filled with the verify_email
template as a starting point. It refuses to overwrite an existing file. Create email template
in zonai dev does the same thing. Edit the file to fit your design.
Template Format#
Templates are plain HTML with Mustache interpolation. Put CSS inline, because many email clients ignore
<style> blocks. Messages are sent as HTML only, with no plaintext alternative.
<!DOCTYPE html>
<html>
<body>
<h1>Order Confirmed</h1>
<p>Hi{{#customerName}} {{customerName}}{{/customerName}},</p>
<p>Your order <strong>#{{orderId}}</strong> has been confirmed.</p>
<p>Total: {{total}}</p>
{{#items}}
<p>- {{name}} × {{quantity}}</p>
{{/items}}
<p>Sent by {{appName}}</p>
</body>
</html>
| Syntax | Meaning |
|---|---|
{{variable}} | Insert a value, HTML-escaped |
{{{variable}}} | Insert a value without escaping |
{{#name}} … {{/name}} |
Render the block when name is truthy, or once per item when it is a list |
{{^name}} … {{/name}} |
Render the block when name is falsy or missing |
Rendering rules:
- Rendering is lenient. A missing variable renders as an empty string, with no error. Preview a template before shipping it, because a misspelled key fails silently.
-
appNameandpreheaderare always available. You never need to pass them invariables. -
The template name must be a plain file name. It can't contain
/,\or spaces, and it can't be.or... Anything else is refused before any file is read.
Sending a Custom Template#
Use email.send(Email(...)) from any extension hook or cron job:
@override
Future<void> afterCreateSuccess(Purchase purchase, Jwt? jwt) async {
final customer = await get.one(
tableName: 'users',
where: Eq('id', purchase.userId.value),
);
if (customer == null) return;
email.send(Email(
to: EmailAddress(address: customer['email'] as String),
subject: 'Order #${purchase.id} confirmed',
preheader: 'Arriving Thursday. Track it any time.',
template: 'order_confirmation',
variables: {
'customerName': customer['name'],
'orderId': purchase.id.value,
'total': purchase.total,
},
));
}
The template field is the filename without .html. email.send(...)
is fire-and-forget: the hook doesn't wait for delivery, and a failure is written to the server log. See
Side Effects: email.
Per-message options#
| Field | Purpose |
|---|---|
from | Override the default sender from EmailConfig.from |
preheader |
The inbox preview line (see below) |
thread |
Group related messages into one conversation in the recipient's inbox |
variables |
Mustache data. appName and preheader are added automatically |
For threading, use Email.createThread('order:42') on the first message and Email.createThread('order:42', continueThread: true)
on follow-ups. Zonai sets the Message-ID, In-Reply-To and References
headers from it. The built-in OTP and magic-link emails already thread their resends this way.
Sending over HTTP#
POST /email accepts the same Email shape as JSON (Email.toJson()), or
client.email.send(...) from the Dart client. It is admin-only: without an admin token the request is refused. It is also limited to 10 requests per minute per client IP. To restrict the recipients it will mail, set
ZONAI_EMAIL_RECIPIENTS in the server's process environment to a comma-separated list of addresses.
For anything a regular user triggers, send from an extension hook instead.
Preview Text#
preheader is the line the inbox shows next to the subject. Leave it unset and the client picks the first visible text it finds — usually the greeting, so every message previews as
Hi Morgan,.
It is injected into your template as {{preheader}}, the same way {{appName}}
is — you never pass it in variables. To use it, put a hidden block at the very top of the
<body>:
<body>
{{#preheader}}
<div
style="
display: none;
max-height: 0;
overflow: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
color: transparent;
opacity: 0;
"
>
{{preheader}}͏‌ ​͏‌ ​
</div>
{{/preheader}}
<!-- visible content -->
</body>
Three parts, all of them load-bearing:
-
The
{{#preheader}}section renders nothing at all when no preheader was set, rather than leaving an empty hidden div in every message. -
The style block hides the text every way a client might respect —
display: noneandmax-height: 0for most,mso-hide: allfor Outlook,opacityandcolor: transparentfor anything that strips the first two. - The trailing zero-width characters pad out the rest of the snippet. Without them the client keeps scraping and tacks the next visible text — often a button label — onto the end of your preview line. Repeat the run until it covers roughly 100 characters.
Keep the line under ~100 characters, and do not repeat the subject: the two are shown side by side, so the preheader is a second sentence, not an echo.
The built-in templates already carry this block, and the built-in auth emails set a sensible default (Your sign-in code expires in 10 minutes., and so on). Pass
preheader: to override.
A blank preheader counts as unset: '' or whitespace renders no block. The value is HTML-escaped, so an
& or < in the text can't break the markup.
Live Reloading#
Templates are read from disk at send time, so changes take effect immediately, with no recompile or server restart. In production they are read from the copy
zonai build puts in the build output.