Skip to content

Building blocks

Email

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

  1. A mail is an EmailMessage: to, subject, html, text and a category. Optional replyTo replaces NUXT_EMAIL_REPLY_TO on that mail alone, and logSubject is logged in place of a subject that holds a sender's own words. The kit's own mails come from renderEmail, which fills a compiled template from emails/(Licensees only) with localized copy from server/email/copy.ts(Licensees only).
  2. deliverEmail(event, message) in server/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 gets List-Unsubscribe headers.
  3. It hands the mail to each configured provider in email.providers order. A failure the provider marks retryable gets one more try on the same provider, then the next provider gets the mail.
  4. When every provider refuses and at least one failure was transient, the mail goes to the email job on the queue (Background jobs). Otherwise deliverEmail throws 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:

ProviderNeedsSetup
cloudflareThe send_email binding named EMAIL in wrangler.jsoncOnboard the sending domain once: pnpm exec wrangler email sending enable <domain>
resendNUXT_EMAIL_RESEND_API_KEYresend.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',
})
CategoryUse it forRefusableUnsubscribe link
transactionalMail the recipient's own action asked fornono
productNews about the product the recipient already usesyesyes
marketingOffers and announcementsyesyes

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.

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_at or email_marketing_opt_out_at. A repeat keeps the first time.
  • The page copy is UNSUBSCRIBE_COPY in server/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:

  1. Create emails/<name>.vue. It appears in #email-templates under its file name, typed.
  2. For one of the kit's mails to use it, point that kind at it in TEMPLATE_FOR_KIND in server/email/templates.ts(Licensees only).
  3. 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: true earns one repeat on the same provider; never set it for a 4xx.
  • The id must be one lowercase word, and not from, fromName or replyTo. The build refuses both.
  • A file named resend.ts or cloudflare.ts replaces the shipped adapter, and the build logs which file won.
  • Restart pnpm dev after 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 variableWhereDefaultWhat it does
email.enabledstarter.config.tstrueBundles the providers and compiles the templates.
email.providersstarter.config.ts['cloudflare']Providers in delivery order. Later ones only see a failed send.
email.adaptersstarter.config.tsnoneCredential names for an adapter you added.
NUXT_EMAIL_FROMenvunsetSender address. Unset, no provider counts as configured.
NUXT_EMAIL_FROM_NAMEenvunsetDisplay name inboxes show.
NUXT_EMAIL_REPLY_TOenvunsetReply-to address.
NUXT_EMAIL_RESEND_API_KEYenvunsetResend API key.
NUXT_EMAIL_<ID>_<KEY>, NUXT_EMAIL_<ID>envunsetAn added adapter's credentials, one per key or the block as JSON.
send_email binding EMAILwrangler.jsoncdeclaredThe cloudflare provider. The build fails without it while listed.

Good to know

  • Keep NUXT_EMAIL_FROM and NUXT_EMAIL_REPLY_TO off NUXT_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 resend with its key on Free.
  • renderEmail escapes 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 headers X-….
  • Put padding on a Section inside <Body>, never on <Body>: it is 100% wide, and padding it makes Gmail scroll sideways.
  • Templates compile when Nuxt starts, so restart pnpm dev to see an edit to emails/.

A link with a lock opens the kit’s private repository, which every licensee can read.