Building blocks
The kit sends its own account mail (sign-in links, password resets, address
verification, security notices, organization invitations) and gives you
deliverEmail for mail of your own. You work here when you set up sending for
a deploy, change how the mails look or read, send product or marketing mail, or
plug in another provider.
How it works
- A mail is an
EmailMessage:to,subject,html,textand acategory. OptionalreplyToreplacesNUXT_EMAIL_REPLY_TOon that mail alone, andlogSubjectis logged in place of a subject that holds a sender's own words. The kit's own mails come fromrenderEmail, which fills a compiled template fromemails/(Licensees only) with localized copy fromserver/email/copy.ts(Licensees only). deliverEmail(event, message)inserver/email/index.ts(Licensees only) checks the category. A product or marketing mail goes out only to an account that still accepts that category, and getsList-Unsubscribeheaders.- It hands the mail to each configured provider in
email.providersorder. A failure the provider marks retryable gets one more try on the same provider, then the next provider gets the mail. - When every provider refuses and at least one failure was transient, the
mail goes to the
emailjob on the queue (Background jobs). OtherwisedeliverEmailthrows a 502. Pass{ queueRetry: false }as its third argument to get the 502 instead of the queue, as the contact form does, so a mail never outlives the request that sent it.
Set up sending
A provider counts as configured when NUXT_EMAIL_FROM is set and the provider
has what it needs:
| Provider | Needs | Setup |
|---|---|---|
cloudflare | The send_email binding named EMAIL in wrangler.jsonc | Onboard the sending domain once: pnpm exec wrangler email sending enable <domain> |
resend | NUXT_EMAIL_RESEND_API_KEY | resend.com, API Keys. Verify the sending domain under Domains first |
The shipped config lists cloudflare alone, and wrangler.jsonc already
declares its binding. For Resend, or Resend as a fallback:
email: {
enabled: true,
providers: ['cloudflare', 'resend'],
},
NUXT_EMAIL_FROM=hello@example.com
NUXT_EMAIL_FROM_NAME=Example
NUXT_EMAIL_RESEND_API_KEY=re_...
Every mailed link is built from NUXT_SITE_URL, so a deploy needs that too
(Deployment). Outside pnpm dev, the Worker logs at boot
which of these variables are missing, and a send with no configured provider
answers 501.
Mail in local development
With NUXT_EMAIL_FROM unset, pnpm dev sends nothing and prints each mail's
recipient, subject and text to the terminal, links included. Set it and mail
goes to the providers. Resend sends for real; the cloudflare binding runs in
Wrangler's local simulator unless you add "remote": true to it in
wrangler.jsonc.
Send your own mail
Build the message yourself and state its category:
import { deliverEmail } from '#server/email'
await deliverEmail(event, {
to: user.email,
subject: 'Exports are here',
html: '<p>You can now export any report as CSV.</p>',
text: 'You can now export any report as CSV.',
category: 'product',
})
| Category | Use it for | Refusable | Unsubscribe link |
|---|---|---|---|
transactional | Mail the recipient's own action asked for | no | no |
product | News about the product the recipient already uses | yes | yes |
marketing | Offers and announcements | yes | yes |
Product and marketing mail, the elective kinds, is sent only when the address
belongs to an account that has not opted out of that category, is not banned
and is not scheduled for deletion. Otherwise deliverEmail returns without sending and
without an error. The queue asks the same question again before each retry, so
an opt-out made while the mail waited still wins.
Don't build elective mail with renderEmail: it always marks its mail
transactional. For a layout of your own, add a template to emails/ and read
its compiled HTML from emailTemplates in #email-templates, keyed by file
name.
Unsubscribe links
Every elective mail carries List-Unsubscribe and List-Unsubscribe-Post
headers pointing at /api/email/unsubscribe?token=…, which gives Gmail and
Outlook a one-click unsubscribe. The token is one per account and category,
kept in email_unsubscribe_tokens, and never expires.
- A GET shows a page with one Unsubscribe button, so mail scanners that open links on delivery unsubscribe nobody. Only a POST records the opt-out.
- The opt-out is a timestamp on the user row,
email_product_opt_out_atoremail_marketing_opt_out_at. A repeat keeps the first time. - The page copy is
UNSUBSCRIBE_COPYinserver/email/copy.ts.
Signed-in users switch both categories on /dashboard/settings, through
PATCH /api/account/email-preferences with product and marketing booleans
(true is subscribed).
Change how the mails look
The layouts are Vue files in emails/(Licensees only), written with Maizzle's
components and Tailwind classes. Maizzle compiles them
to inlined HTML when Nuxt starts and at build, and the Worker ships only the
compiled strings. emails/, copy.ts and templates.ts belong to the
template, so a restyle or a rewording conflicts when an update changes the
same lines.
email-change-notice.vue carries the security notices (emailChangeNotice,
emailMovedNotice, passkeyAddedNotice), org-invitation.vue the
invitation, and transactional.vue every other kind.
A template holds four single-brace placeholders that renderEmail fills with
escaped values: {body}, {cta}, {link} and {ignore}. Write them exactly
like that, not as Vue's {{ }}.
To add a template:
- Create
emails/<name>.vue. It appears in#email-templatesunder its file name, typed. - For one of the kit's mails to use it, point that kind at it in
TEMPLATE_FOR_KINDinserver/email/templates.ts(Licensees only). - Add the name to
test/unit/email-templates.stub.ts(Licensees only), which stands in for the virtual module in unit tests.
The build fails when a template compiles to under 512 bytes, and
test/unit/email-templates.test.ts checks every compiled template for a
doctype, all four placeholders and inlined styles.
Change the wording
EMAIL_COPY in server/email/copy.ts(Licensees only) holds a
subject, body, cta and ignore per kind and locale. Placeholders such
as {minutes} and {date} use the same brace syntax as the locale files, and
one with no value is left as written. Mail goes out in the requester's locale;
invitations use i18n.defaultLocale. An unknown locale falls back to English.
When you add a language to i18n.locales, add it here too.
test/unit/email-copy.test.ts fails until every locale has every kind with
the same placeholders as English.
Add a provider
Drop a file in server/email/adapters/custom/. The file name is the provider
id and the default export is an EmailAdapter.
server/email/adapters/custom/postmark.ts:
import type { EmailAdapter } from '#server/email/types'
const postmark: EmailAdapter = {
id: 'postmark',
async send(message, sender, config) {
let res: Response
try {
res = await fetch('https://api.postmarkapp.com/email', {
method: 'POST',
headers: {
'X-Postmark-Server-Token': config.key,
'Content-Type': 'application/json',
},
body: JSON.stringify({
From: sender.fromName
? `${sender.fromName} <${sender.from}>`
: sender.from,
To: message.to,
Subject: message.subject,
HtmlBody: message.html,
TextBody: message.text,
...(sender.replyTo ? { ReplyTo: sender.replyTo } : {}),
Headers: Object.entries(message.headers ?? {}).map(
([Name, Value]) => ({ Name, Value }),
),
}),
})
} catch (error) {
return { ok: false, reason: String(error), retryable: true }
}
if (res.ok) return { ok: true }
return {
ok: false,
reason: `${res.status} ${await res.text()}`,
retryable: res.status === 429 || res.status >= 500,
}
},
}
export default postmark
Then list it and name its credential:
email: {
enabled: true,
providers: ['postmark'],
adapters: { postmark: { credentials: ['apiKey'] } },
},
That reads NUXT_EMAIL_POSTMARK_API_KEY. A credential named apiKey arrives
as config.key; every declared credential is also in config.credentials.
Without an adapters entry, the whole block is one JSON variable, such as
NUXT_EMAIL_POSTMARK='{"apiKey":"…"}'.
- Return a result instead of throwing.
retryable: trueearns one repeat on the same provider; never set it for a 4xx. - The id must be one lowercase word, and not
from,fromNameorreplyTo. The build refuses both. - A file named
resend.tsorcloudflare.tsreplaces the shipped adapter, and the build logs which file won. - Restart
pnpm devafter adding the file so the id typechecks.
Turn email off
Set email.enabled: false. No adapter or template is bundled, and the
password-reset routes are not registered. The build refuses it while
auth.emailVerification (with password sign-in), orgs.invitations or
account.deletionGraceDays still needs mail. With auth.primary: 'passwordless' the build only warns, and magic-link sign-in then fails.
Configuration
| Key or variable | Where | Default | What it does |
|---|---|---|---|
email.enabled | starter.config.ts | true | Bundles the providers and compiles the templates. |
email.providers | starter.config.ts | ['cloudflare'] | Providers in delivery order. Later ones only see a failed send. |
email.adapters | starter.config.ts | none | Credential names for an adapter you added. |
NUXT_EMAIL_FROM | env | unset | Sender address. Unset, no provider counts as configured. |
NUXT_EMAIL_FROM_NAME | env | unset | Display name inboxes show. |
NUXT_EMAIL_REPLY_TO | env | unset | Reply-to address. |
NUXT_EMAIL_RESEND_API_KEY | env | unset | Resend API key. |
NUXT_EMAIL_<ID>_<KEY>, NUXT_EMAIL_<ID> | env | unset | An added adapter's credentials, one per key or the block as JSON. |
send_email binding EMAIL | wrangler.jsonc | declared | The cloudflare provider. The build fails without it while listed. |
Good to know
- Keep
NUXT_EMAIL_FROMandNUXT_EMAIL_REPLY_TOoffNUXT_CONTACT_ADDRESS(Contact). Every mail carries both, sign-in links included, and anyone can ask for one. A deployed Worker warns at boot when either matches. - On the Workers Free plan, Cloudflare Email Sending delivers only to
addresses verified in your account, so sign-up and reset mail reaches nobody
else. List
resendwith its key on Free. renderEmailescapes the copy it fills in. HTML you build yourself goes out as written, so escape any value a user typed.- Cloudflare refuses a header name that is not on its allowlist unless it
starts with
X-. Name custom headersX-…. - Put padding on a
Sectioninside<Body>, never on<Body>: it is 100% wide, and padding it makes Gmail scroll sideways. - Templates compile when Nuxt starts, so restart
pnpm devto see an edit toemails/.
A link with a lock opens the kit’s private repository, which every licensee can read.