Skip to content

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).

Distinct from the five built-in auth emails’ {PLACEHOLDER} syntax (see Resolution order):

  • {{path.to.value}} — a dotted path into the data object passed to send/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}} (from settings.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: 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 of settings.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).

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.

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):

  1. The collection’s own authOptions.*Template field, if it’s been customized away from its compiled-in default — rendered with the original {PLACEHOLDER} syntax, exactly as before this feature existed.
  2. Otherwise, the matching _emailTemplates row (auth.verification, auth.passwordReset, auth.emailChange, auth.otp, auth.loginAlert, or auth.magic-link, with locale fallback) — rendered with {{var}} syntax.
  3. 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.

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-superuser to/template send for this template is a 403.
  • "" — 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 raw subject/html/text, and no from/cc/bcc/replyTo override. Any of those present is a 403, regardless of sendRule.
  • 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 general mails:send ceiling 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.teamId

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 /variable slash 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/preview pipeline 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/send to 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-scheme on 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.