Account emails
How Margin's sign-in and account emails are designed, edited and pushed to Clerk, for development and production.
Every email Margin sends comes from Clerk: the sign-in code, new-device alerts, email-change notices and, if email links are ever switched on, the link emails. Margin replaces Clerk's default templates with its own, kept in the repository and pushed to Clerk with one command.
Where the templates live#
| File | Holds |
|---|---|
emails/clerk/templates.ts | The list of templates: slug, subject, inbox preview text and the variables Clerk requires |
emails/clerk/layout.revolvapp.html | The shared frame: the wordmark, the white panel on warm paper, and the footer |
emails/clerk/<slug>.revolvapp.html | What goes inside the panel for one template |
emails/clerk/compile.ts | Turns the markup into the HTML Clerk sends, including dark mode |
scripts/clerk-emails.ts | The push script behind pnpm emails:push |
The markup is Revolvapp, the format Clerk's dashboard editor uses: <re-block>, <re-heading>, <re-text>, <re-button>, <re-divider> and so on, with styling as attributes. Clerk variables use Handlebars, such as {{otp_code}}, {{requested_from}} and {{#if location}}…{{/if}}.
Clerk stores the markup and the finished HTML separately and does not build one from the other, so compile.ts does it. It writes table-based HTML with inline styles that holds up in Gmail, Apple Mail and Outlook, and it adds dark mode by itself: any colour from the palette at the top of compile.ts gets a matching dark value. Use only those colours in the markup.
The design#
The emails follow the brand: warm off-white paper, one white panel, a serif title (Georgia, since email clients cannot load Newsreader), system sans body text and a single dark pill for the main action. The sign-in code sits large and spaced in monospace on a soft tinted panel. Requested-from details and the footer are small and muted. No eyebrow labels, badges or accent colours.
Subjects are short and plain. The code email keeps the code in its subject ({{otp_code}} is your Margin code) so it shows in notifications.
Edit a template#
-
Change the template's
.revolvapp.htmlfile, or its subject and preview text intemplates.ts. -
Check it without changing anything in Clerk:
pnpm emails:push --dry-run --only verification_codeThis compiles and validates the template (required variables present, Handlebars blocks balanced) and writes a preview rendered by Clerk with sample data to
emails/clerk/previews/<slug>.html. Open it in a browser and try it at phone width and in dark mode. The folder is ignored by Git. -
Push it to the development instance:
pnpm emails:push --only verification_code
Without --only, every template in templates.ts is pushed. After each upload the script reads the template back from Clerk and fails if what Clerk stored differs from what was sent. Each line of output says ok, skip or fail with the reason; the secret key is never printed.
Edit templates here rather than in the Clerk dashboard: the next push overwrites dashboard edits.
Push to production#
The script reads CLERK_SECRET_KEY from .dev.vars (the development instance) unless the variable is set in your shell. To update the production instance, pass its secret key for that one command, from the Clerk dashboard (Production → API keys):
CLERK_SECRET_KEY=sk_live_... pnpm emails:pushThe first line of output names the instance (production instance for an sk_live_ key). Run a --dry-run with the same key first if you want to see the result before anything changes. Never put the production key in .dev.vars or any file in the repository.
Clerk's content filter#
Clerk checks every template update for phishing-like content. If it blocks one, the push fails with email_template_suspicious_blocked, and after a few blocked updates Clerk locks template customisation for the instance. The script stops at the first blocked template so it never uses up those attempts. Do not push a blocked template again unchanged: rewrite it, or ask Clerk support to review it.
account_locked is blocked on the development instance, so it has push: false in templates.ts and stays on Clerk's default. A plain push skips it; --only account_locked pushes it on purpose.
Templates Margin leaves alone#
Margin has no passwords, passkeys, multi-factor sign-in, billing or waitlist, so those Clerk templates keep their defaults and are not in templates.ts. To customise one, add an entry and a markup file with the same slug, and copy its required variables from Clerk (GET /v1/templates/email/<slug>).