Templates
_emailTemplates is a superuser-only system collection: key (e.g.
auth.verification, welcome), name, subject, html, text
(optional — auto-derived from html when empty), locale (optional,
default ""), layout (bool), description, sendRule (see below),
design and editor (dashboard-only, see
Dashboard editor). It’s seeded with seven rows on
first boot: the five built-in auth emails (auth.verification,
auth.passwordReset, auth.emailChange, auth.otp, auth.loginAlert),
auth.magic-link, and a welcome example — all editable, none wired to
anything by default beyond the auth-flow resolution below. Every seeded
row’s sendRule starts null (superuser/API-key only).
{{var}} syntax
Section titled “{{var}} syntax”Distinct from the five built-in auth emails’ {PLACEHOLDER} syntax (see
Resolution order):
{{path.to.value}}— a dotted path into thedataobject passed tosend/preview, HTML-escaped.{{{path.to.value}}}— the same, but unescaped (raw HTML).- A path that resolves to nothing renders as an empty string, not the
literal
{{...}}. - Built-ins, always available regardless of
data:{{appName}},{{appUrl}}(fromsettings.meta).
Subject: Welcome to {{appName}}!<p>Hi {{user.name}}, welcome to {{appName}}.</p><p><a href="{{appUrl}}">Get started</a></p>The subject and the derived/explicit text alternative are never
HTML-escaped (they’re not an HTML context) — only the html field’s
{{var}}s are escaped by default.
Layout
Section titled “Layout”layout: true wraps html in a shared, table-based, email-client-safe
base layout before sending: a centered ~600px card on a soft page
background, a logo (or a text wordmark of the app name, if no logo is
set) above the card, and a muted footer below it. It’s responsive
(full-width card on mobile), dark-mode friendly (color-scheme meta
plus a prefers-color-scheme: dark stylesheet with safe fallbacks for
clients that ignore it, e.g. classic Outlook), and uses only inline
styles/table layout — no external CSS, matching the look of the five
built-in auth emails, magic-link, and the welcome example. Two
settings style it for every layout-wrapped template at once (see
Settings object):
settings.meta.logoUrl— the header image. Empty falls back to a text wordmark ofsettings.meta.appName.settings.meta.brandColor— the{{brandColor}}built-in (see above) that a template’s own content interpolates into its call-to- action button; empty falls back to a neutral near-black default. The layout itself has no fixed button color — not every template has a button, so the color lives in the content, not the shell.
A call-to-action button built from this brand color
(cratebase_mailer::template::cta_button, used by every seeded
template that has one) is “bulletproof”: a real <a> for every normal
client, a VML roundrect fallback for Outlook’s Word rendering engine
(which ignores border-radius/padding on a plain link), and a
plain-text “copy and paste this link” paragraph underneath so the URL
survives even in a client that strips both. The OTP template’s code is
shown in a boxed, large, letter-spaced monospace block
(otp_code_block) instead.
layout: false sends html verbatim — useful for a template that
already brings its own full HTML document (e.g. exported from an email
design tool).
Locales
Section titled “Locales”Pass locale explicitly to send/preview/the auth-flow resolution,
or let it come from the recipient record’s own locale/lang field
(checked in that order) when present. A key’s lookup tries the resolved
locale first, then falls back to the "" (no-locale) row — so a
welcome template with only a ""-locale row still works for every
locale, and adding a fr row overrides it just for French.
Resolution order
Section titled “Resolution order”For the five built-in auth emails plus magic-link, sending goes through
this priority chain (crate::mail_templates::resolve_auth_mail — see
Magic link and
Email flows for the flows
themselves):
- The collection’s own
authOptions.*Templatefield, if it’s been customized away from its compiled-in default — rendered with the original{PLACEHOLDER}syntax, exactly as before this feature existed. - Otherwise, the matching
_emailTemplatesrow (auth.verification,auth.passwordReset,auth.emailChange,auth.otp,auth.loginAlert, orauth.magic-link, with locale fallback) — rendered with{{var}}syntax. - Otherwise (the seeded row was deleted), the same built-in default as step 1, rendered the same legacy way.
This means an unmodified install’s mail is byte-for-byte unchanged: step
1 and step 3 render identical content, and step 2 only ever fires when
an admin edited the _emailTemplates row without also customizing the
collection’s own template field. Editing either one is a real,
independent way to change what gets sent — customizing the collection
field always wins if both are touched.
sendRule: who else can send this
Section titled “sendRule: who else can send this”POST /api/mails/send (and cb.mails.send) is superuser/API-key-only by
default, same as every other admin-facing endpoint. sendRule is what
lets a frontend call it directly, with no backend of its own, for
exactly the templates an admin opts in — the same three-state shape a
collection’s listRule/viewRule/etc. already uses:
null(the default for every new/seeded template) — superuser or API key only. A non-superuserto/templatesend for this template is a403.""— anyone may send it, including an anonymous caller. This is a real capability, not a placeholder — see the danger note below.- anything else — a filter-rule expression, evaluated per recipient, exactly like a collection rule.
A non-superuser caller (an authenticated app user, or anonymous) that sends this way is restricted well beyond what a superuser/API key may do:
- Only
to+template(+data/locale) — no rawsubject/html/text, and nofrom/cc/bcc/replyTooverride. Any of those present is a403, regardless ofsendRule. - At most 5 recipients (vs. 50 for a superuser/API key).
- Also rate-limited on its own, tighter tag (
mails:send:user, 10/minute per auth record by default — see Settings object → rateLimits), on top of the generalmails:sendceiling every caller shares.
The rule sees @request.auth.* (the caller, or all-null when
anonymous), @request.body.to (one address — with multiple to
recipients, the rule is evaluated once per address and every one must
pass), @request.body.data.*, and @request.body.locale. A denial is
always a plain 403 with no detail — a template that doesn’t exist, has
no sendRule, or a rule that rejected the caller/recipient all look
identical from the outside, so this endpoint never confirms which
templates exist or leaks a rule’s logic to a probing caller.
// A frontend, signed in as an ordinary `users` record (or anonymous),// calling Cratebase directly — no backend route of its own.await cb.mails.send({ to: someUsersEmail, template: "invite", data: { invitedBy: cb.auth.record?.name, teamName: "Acme" },});For that to work, the invite template’s sendRule needs to actually
allow it — e.g., only the inviting team’s own members:
@request.auth.id != "" && @request.body.data.teamId = @request.auth.teamIdDashboard editor
Section titled “Dashboard editor”Settings → Email templates lists every row (key, name, locale,
editor, and a sendRule badge) with create/duplicate/delete. Creating a
new one first offers a starter gallery — Blank, Welcome, Verify
email, Reset password, OTP code, Magic link, Receipt, Notification, and
Newsletter — each a ready-made visual-editor document to start from and
customize, rather than a blank canvas.
The editor itself has a top bar (name, key, locale, subject — subject
also takes {{var}}s), a center canvas at email width with a
desktop/mobile toggle, and a right sidebar split into tabs:
- Theme — pick a built-in theme (Basic or Minimal) and customize its
brand color, font, page background, content width, and corner radius.
Defaults come from
settings.meta.brandColor/logoUrl. This is powered by@react-email/editor’s own theming system — the same one the exported HTML is styled with, so what you see in the canvas is what gets sent. - Variables — an “Insert variable” list, populated from the sample
data below plus the always-available
appName/appUrl, and available via the/variableslash command too. In the visual editor a variable inserts as a small chip while editing; on export it becomes the literal{{path}}text the{{var}}engine above resolves at send time. A link or button’s URL is a plain text field — type{{path}}directly into it (e.g.{{appUrl}}/invite/{{token}}) the same way you would in HTML mode. - Settings — the layout toggle,
sendRule(via the same rule editor a collection API rule uses), and the plain-text alternative field with an “Auto-derive from HTML” button (a client-side approximation of the server’s own strip-tags fallback — a starting point to edit, not a preview of exactly what the server would derive). - Preview — the editable sample-data JSON blob and the live preview described below.
Visual mode is a full block-based editor: headings, paragraphs,
lists, quotes, code, tables, dividers, buttons, images, 2/3/4-column
layouts, and links, with a bubble menu for formatting/links/alignment/
colors on text selection and a / slash-command menu for every block
type. Images upload straight to Cratebase’s own storage (a superuser-
only _emailAssets system collection) and get a real, publicly-servable
URL — no separate host needed. The document is stored in the opaque
design field (the server never reads it, only the html/text it
exports); saving derives html/text from it, which is what actually
gets sent.
HTML mode stays a CodeMirror editor for hand-written or externally-authored (e.g. exported React Email) HTML, with the same “Insert variable” menu. Switching from HTML to visual (or back) re-parses the current HTML into/out of the editor’s document — round-tripping through both a few times can lose formatting the visual editor doesn’t represent, so pick one mode per template rather than bouncing between them.
- Live preview — renders through the real
POST /api/mails/previewpipeline against the sample-data JSON blob, debounced, with a desktop/mobile width toggle, in a sandboxed (srcdoc, no scripts) iframe. It reflects unsaved edits, including mid-edit visual-editor content — nothing needs to be saved first to see it. - Test send —
POST /api/mails/sendto an address (defaulting to the signed-in superuser’s own), also unsaved-edit-aware. When the dev mail inbox is active (no real SMTP configured), a link jumps straight to it. - Dark mode — the editor UI itself follows the dashboard’s own
light/dark theme; the email dark-mode behavior (
prefers-color-schemeon the recipient’s end) is a property of the rendered layout (see Layout above), not something toggled in the editor — preview it by switching your own system/mail-client theme, not the dashboard’s.