Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Build With SyntaxKit

Email

Transactional templates and delivery via Plunk.

Last updated on

13 min read

The email subsystem in packages/email separates rendering (React Email components, provider-agnostic) from delivery (a sendEmail function that branches on EMAIL_DELIVERY_MODE). Templates are React components; delivery is currently wired to Plunk but the seam to swap providers is one function. The default mode in dev writes every send to .local/email-outbox/ as an HTML file, so local development never requires a real provider account.

How An Email Flows

Triggers (Better Auth callbacks, hooks, oRPC procedures, Stripe webhooks) all funnel through renderEmail and sendEmail, then branch on EMAIL_DELIVERY_MODE to log, noop, or Plunk.

Why log mode is the default. A fresh clone runs pnpm dev, signs up, and immediately gets a verification email rendered to a real HTML file you can open in any browser. You see exactly what your users would see, without signing up for anything. You can keep developing for weeks without a Plunk account.

Why three modes. log for dev, noop for tests and CI (silent, fast, no I/O), plunk for production. The mode is resolved at module load by resolveEmailDeliveryMode and is fixed for the process lifetime.

The diagram source lives at apps/docs/diagrams/email-flow.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.

Package Layout

index.tsPublic entry: renderEmail (React Email -> HTML) and re-exports of sendEmail and the plunk client
client.tssendEmail with the noop / log / plunk branch, the local outbox writer, and the lazy Plunk client
index.tsBarrel re-exporting every template component

The package exposes two subpaths so consumers can pick what they need: import { sendEmail, renderEmail } from "@syntaxkit/email" for the transport layer, and import { WelcomeEmail } from "@syntaxkit/email/templates" for the React components themselves.

Three Delivery Modes

log

Writes one HTML file per send to .local/email-outbox/ (override the directory with EMAIL_OUTBOX_DIR). Files are named <timestamp>-<recipient>-<subject>.html and stay there until you delete them. Default mode in dev.

noop

Returns true without doing anything. Default mode when NODE_ENV is test. Useful in CI to keep tests deterministic. Hard-blocked at boot in production (see callout below).

plunk

Sends via Plunk's transactional API. Requires PLUNK_API_KEY. The only mode that's allowed in production.

The mode resolution order is: explicit EMAIL_DELIVERY_MODE wins, else PLUNK_API_KEY set means plunk, else NODE_ENV=test means noop, else log. So you only need to set EMAIL_DELIVERY_MODE explicitly when you want to override the smart default. A common case: forcing noop in a non-test environment to silence email entirely.

EMAIL_DELIVERY_MODE=noop and EMAIL_DELIVERY_MODE=log are rejected at boot when NODE_ENV=production. assertValidSetupEnv raises an Invalid SyntaxKit environment configuration error before the app starts. The reason: both modes report sendEmail as successful without delivering anything, which would let a misconfigured production deploy show "email sent" UX while quietly dropping every signup, password reset, and invitation. Production must use plunk with a real PLUNK_API_KEY.

What Ships Out Of The Box

Twelve templates are wired to live senders. The five auth-driven templates plus the shared EmailLayout chrome are localized via @syntaxkit/i18n; the remaining seven (billing + contact) are still English-only and follow the same pattern when you're ready to localize them. Every template uses the shared EmailLayout so the brand chrome stays consistent.

The bundle covers three areas: authentication (verification, password reset, email change, organization invitation, welcome), billing (subscription created, subscription canceled, trial ending, payment succeeded, payment failed, payment action required), and other (the public contact form).

TemplateTriggerSubject namespaceNotes
EmailVerificationEmailoRPC auth.sendVerificationEmail (BA hook retained as a best-effort fallback for direct API consumers)EmailVerification.subjectUI flows go through the oRPC mutation, which awaits delivery and throws SERVICE_UNAVAILABLE on failure. Localized.
PasswordResetEmailoRPC auth.requestPasswordReset (BA hook retained as a best-effort fallback)PasswordReset.subjectMints a JWT-backed reset link our own auth.resetPassword mutation consumes. Anti-enumeration: unknown emails return { success: true } and the client collapses PASSWORD_RESET_EMAIL_SEND_FAILED into the same success card, so an attacker can't distinguish an existing-but-failed send from an unknown-email response (see the oRPC anti-enumeration note below). Localized.
EmailChangeEmailoRPC user.updateEmail (BA hook retained as a best-effort fallback)EmailChange.subjectSent to the current email address. Awaits delivery and throws SERVICE_UNAVAILABLE on failure. The follow-up email BA dispatches to the new address after click-through is still BA-driven (known phase-2 limitation). Localized.
OrganizationInvitationEmailoRPC organization.inviteMember (BA's sendInvitationEmail hook is a no-op; we send after auth.api.createInvitation returns)OrgInvitation.subjectThrottled per inviter and invitee. Recipient has no account yet, so the inviter's locale wins. Locale-prefixed accept link. Localized.
WelcomeEmailafterUserCreate hookWelcome.subjectFire-and-forget; not awaited. Always logs failures. Localized.
SubscriptionCreatedEmailStripe customer.subscription.createdWelcome to <plan>!Deduped via outboundEffect
SubscriptionCanceledEmailStripe customer.subscription.deleted"Your subscription has been canceled"Sent on deletion only, not pause
TrialEndingEmailStripe customer.subscription.trial_will_endYour <plan> trial ends soonStripe fires ~3 days before trial end
PaymentSucceededEmailStripe invoice.payment_succeededPayment receipt - <amount>Receipt with formatted amount
PaymentFailedEmailStripe invoice.payment_failed"Action required: Payment failed"User must update payment method
PaymentActionRequiredEmailStripe invoice.payment_action_required"Action required: Confirm your payment"3DS / SCA confirmation flow
ContactFormEmailoRPC contact.submitContact form: <name>Fail-closed on missing abuse config

Templates are React components, which makes them easy to read and extend. The smallest live one, WelcomeEmail, is a fair representation of the shape:

import { Button, Text } from "@react-email/components";
import { defaultLocale, type Locale } from "@syntaxkit/i18n";
import { EmailLayout } from "./components/email-layout";
import { heading, paragraph, button } from "./components/email-styles";
import { getEmailTranslatorWithBrand } from "../src/i18n";

interface WelcomeEmailProps {
  userName?: string;
  loginUrl?: string;
  locale?: Locale;
}

export function WelcomeEmail({
  userName,
  loginUrl = "/",
  locale = defaultLocale,
}: WelcomeEmailProps) {
  const t = getEmailTranslatorWithBrand(locale, "Welcome");
  return (
    <EmailLayout preview={t("preview")} locale={locale}>
      <Text style={heading}>{t("heading")}</Text>
      <Text style={paragraph}>
        {userName
          ? t("greetingNamed", { name: userName })
          : t("greetingAnonymous")}
      </Text>
      <Button style={button} href={loginUrl}>
        {t("ctaButton")}
      </Button>
    </EmailLayout>
  );
}

GracePeriodWarningEmail is exported from @syntaxkit/email/templates and rendered in tests, but no production sender currently invokes it. It's plumbing in search of a webhook event. If you want it sent during a Stripe past_due window, wire a handler in packages/payments/src/stripe/webhook.ts.

Localization

The five auth-driven templates and the shared EmailLayout are translated via next-intl using a per-package message catalog at packages/email/messages/<locale>.json. Adding a locale is a JSON file plus an entry in messagesByLocale in packages/email/src/i18n.ts, with no global wiring elsewhere.

Why a per-package catalog. @syntaxkit/email is consumed by @syntaxkit/auth, @syntaxkit/payments, and the contact-form oRPC procedure. Sharing apps/web/messages/<locale>.json would couple the email package back to a specific app and break the dependency direction. The email catalog is independent.

Why createTranslator and not getTranslations. Several email send sites run outside an active next-intl request scope: the welcome email fires from a Better Auth database hook, and Stripe webhook handlers don't carry one either. createTranslator({ locale, messages, namespace }) works synchronously with explicit messages, so it's safe everywhere.

Locale resolution chain. Every auth send site uses resolveAuthEmailLocale (packages/auth/src/locale.ts, re-exported from @syntaxkit/auth). It accepts either a full Request (BA-side hooks) or a bare Headers instance (oRPC handlers, via context.headers), so both code paths read the same NEXT_LOCALE cookie identically:

  1. NEXT_LOCALE cookie on the in-flight request: the user's current preference at the moment they triggered the action.
  2. User.locale column: their persisted preference; survives across browsers and into flows that have no request (welcome email, future webhook-driven mail).
  3. defaultLocale (en): the kit's safety net.

Persisting locale. The setLocale server action in apps/web/i18n/actions.ts writes both the cookie and (when there's a session) the User.locale column, so a logged-in user who switches language gets that preference picked up by subsequent emails. New signups capture the cookie value in the afterUserCreate BA hook so the welcome email and every later send hit the right locale. The User.locale column is nullable; a null value means "no explicit preference, fall back to the cookie or the default".

Invitations. Invitation recipients don't have an account yet, so there's no recipient locale. The send site reads the inviter's User.locale instead and uses it for both the email body and the locale-prefixed getAcceptInvitationPath(locale, id) accept URL. A German team inviting a teammate is overwhelmingly likely to be inviting another German speaker.

The seven non-auth templates (billing, contact) still use hard-coded English. The mechanism is identical when you're ready: add namespaces + strings to both message files, swap the template's hardcoded copy for t(...) calls, and resolve the recipient's locale at the send site (the Stripe webhook handler can read the org owner's User.locale).

Local Development Workflow

Two distinct things to know about.

Preview server. Run pnpm email:dev to launch React Email's preview UI on its own port. Each template renders in isolation with the props you supply, hot-reloads on edit, and you don't have to trigger anything from the app to iterate on copy or styling.

Outbox. When the app actually sends an email in dev (because EMAIL_DELIVERY_MODE=log is the default), the file lands in .local/email-outbox/. Open it in any browser. The first three lines of the HTML are HTML comments with the recipient and subject, so a quick head is enough to scan what was sent without opening every file.

pnpm setup:doctor reports the active mode under "Email" and tells you how to upgrade from log to Plunk when you're ready.

Why The UI Flows Go Through oRPC Instead Of BA Hooks

Better Auth fires its email hooks in the background and catches every throw, so a provider outage or bad PLUNK_API_KEY returns a 200 OK with no email sent. The user hits "check your inbox" and is silently locked out. To surface those failures, the verification, password-reset, email-change, and invitation flows run through oRPC mutations that call sendEmail synchronously and propagate a typed SERVICE_UNAVAILABLE the UI can actually show. The BA hooks stay as best-effort fallbacks for direct API callers.

The deep detail lives in the collapsibles below. Expand only the part you need.

How the oRPC mutations work

Each mutation mints a BA-compatible JWT (so BA's /api/auth/verify-email route validates our tokens unchanged), builds the same email URL shape, then calls sendEmail synchronously. On failure it throws SERVICE_UNAVAILABLE with a machine-readable sentinel (VERIFICATION_EMAIL_SEND_FAILED, PASSWORD_RESET_EMAIL_SEND_FAILED, INVITATION_EMAIL_SEND_FAILED, CHANGE_EMAIL_SEND_FAILED) that the client maps to a translated toast + banner via resolveOrpcSendEmailError. It enforces the per-surface Upstash abuse policy directly and preserves user-row state on failure (the recovery path is the resend button, not a new account).

Captcha failures throw BAD_REQUEST with the sentinel "CAPTCHA_VERIFICATION_FAILED", matched explicitly so a later validation error can't inherit the captcha copy. sendInvitationEmail is the one no-op hook (BA's createInvitation requires it to exist, but we dispatch downstream), and emailVerification.sendOnSignUp is disabled so BA can't race-send behind our dispatch.

Anti-enumeration on unauthenticated surfaces

forgot-password-form.tsx and verify-email-client.tsx are reachable by unauthenticated probes, so they diverge from the "always surface failures" posture:

  • Server-side. auth.requestPasswordReset and auth.sendVerificationEmail short-circuit with { success: true } when the email doesn't match a user (or is already verified). Rate-limit phases run before that lookup, so they fire identically for existing and unknown emails.
  • Client-side. Both forms collapse a SERVICE_UNAVAILABLE send-failure into the same success card + toast the unknown-email branch produces, so the response shape is identical regardless of account existence. Failures are still logged server-side for ops.

Other codes (TOO_MANY_REQUESTS, captcha BAD_REQUEST, abuse SERVICE_UNAVAILABLE) fire identically for known and unknown emails. The authenticated surfaces (post-signup send + resend, change-email, invitation) keep the "show real failures" posture: there's nothing to leak when the user already knows they exist.

Outbound URL origin validation

requestPasswordReset.redirectTo and sendVerificationEmail.callbackURL take a relative path from the client and end up in outbound URLs. Both run through assertSameOriginRedirect (packages/api/src/lib/safe-redirect.ts), which resolves the path against the trusted base with the WHATWG URL parser and rejects any value whose origin shifts. That closes the URL-parser-confusion attacks (@evil.com/, //evil.com/, http://evil.com/, javascript:…) that naive ${base}${path} concatenation would let leak the single-use reset JWT to an attacker origin. The check fires before the user-existence lookup, so a hostile redirect returns BAD_REQUEST + INVALID_REDIRECT identically for known and unknown emails.

Single-use password reset tokens

The reset JWT is gated by an atomic Redis SET NX EX claim before the password update runs: the token's SHA-256 hash is the key, the TTL is its remaining lifetime, and a second use loses the race and gets INVALID_TOKEN. Only the hash is stored, so a leak of the ledger can't reconstruct a reset URL. In production a link is consumed at most once inside its 1-hour window; without Upstash in dev it degrades to natural JWT expiration (one-time warning). Sessions are revoked on every successful reset regardless, so the degraded replay surface is just "re-setting the password the user just submitted".

Abuse Protection

Every email-sending surface (verification, password reset, email change, invitation, and the public contact form) runs through the same Upstash-backed abuse policy and the same resolveAbuseDecision helper in packages/shared/src/abuse.ts. The enforcement points live next to the synchronous sendEmail call site in each oRPC mutation (enforceAuthRpcAbusePolicy in packages/api/src/lib/auth-abuse.ts), not in the Better Auth hooks. Those are no-ops or best-effort fallbacks now (see Why The UI Flows Go Through oRPC above).

Production: required, fail-closed

Won't boot without Upstash; every surface fails closed when the limiter is unreachable.

Dev: auto-bypassed

No Upstash in dev means sends are allowed with a one-time warning, so a fresh checkout just works.

Tests: explicit override

DISABLE_ABUSE_PROTECTION=true forces a bypass for deterministic CI runs. Blocked at boot in production.

See Security for the full posture matrix.

Adding A New Template

Author the React component

Create packages/email/emails/your-template.tsx. Mirror the existing templates: a typed props interface, the EmailLayout wrapper from ./components/email-layout, and the shared email-styles tokens for typography and the call-to-action button.

Re-export from the templates barrel

Add an export to packages/email/emails/index.ts so consumers can import { YourEmail } from "@syntaxkit/email/templates". The transport layer doesn't need to know about your template; only the senders that actually use it do.

Preview it

Run pnpm email:dev and iterate until it looks right at every viewport. React Email uses table-based layout, so the preview UI shows mobile and desktop side-by-side and the result matches what most email clients will render.

Wire a sender

Call from a Better Auth callback, an oRPC handler, a hook, or a webhook handler, whichever fires the trigger. The pattern is await renderEmail(YourEmail({ ... })) to get HTML, then await sendEmail({ to, subject, body }) to deliver. Use void sendEmail(...).catch(...) if you want fire-and-forget like the welcome email does.

Decide on abuse protection

Wrap the send in enforceAbusePolicy and route the result through resolveAbuseDecision so the new surface joins the unified posture (fail-closed in production, auto-bypass with one-time warning in non-production). The existing send sites in packages/auth/src/server.ts and packages/api/src/router/contact.ts are the templates to mirror: both translate unavailable into a SERVICE_UNAVAILABLE (oRPC) or thrown error (Better Auth), and both honor the bypassReason field so the dev-bypass and explicit-flag-bypass cases log differently.

Switching The Delivery Provider

There is no formal provider-plugin API today; swapping providers means editing the three-way branch in sendEmail. That's deliberately small: about ten lines of code if you mirror the existing Plunk pattern.

Pick a provider

Resend, AWS SES, Mailgun, Postmark, and Sendgrid all work; anything with a Node SDK and an HTML-body send method does. Subject and body shapes from sendEmail's options object map directly to most providers.

Add the SDK

pnpm --filter @syntaxkit/email add <provider-sdk>.

Add a client constructor

Mirror the getPlunkClient pattern in packages/email/src/client.ts: a lazy singleton that throws if its env key is missing. Don't construct the client at module load; only when the first send happens.

Extend EMAIL_DELIVERY_MODE resolution

Add your mode (e.g. "resend") to resolveEmailDeliveryMode in packages/shared/src/setup.ts. Make sure the auto-resolver picks it when the right env var is set, mirroring the Plunk branch.

Add the branch in sendEmail

Add if (emailCapability.mode === "resend") { return sendViaResend(options); } alongside the existing noop, log, and Plunk branches. Return true on success and false on failure to match the existing Promise<boolean> contract.

Update setup doctor + the env example

Add the new env vars to the email block in packages/shared/src/setup.ts and document them in apps/web/.env.example so contributors see them. pnpm setup:doctor will report the new mode automatically because it reads from the same capability state.

A formal provider-plugin interface is on the future-work list. Until then, this six-step swap is the fastest path. If you want to support multiple providers simultaneously (for example, transactional via Postmark, marketing via Resend), that requires a small refactor to make sendEmail route by message kind. Out of scope today; cleanly addable.

Where To Go Next

Was this page helpful?

On this page