Transactional React Email templates. Delivery is one of six providers, selected by EMAIL_DELIVERY_MODE.
The email subsystem in packages/email separates rendering (React Email components, provider-agnostic) from delivery. Templates are React components. sendEmail branches on EMAIL_DELIVERY_MODE: log and noop for local work, or one of six providers (Plunk, Resend, Postmark, Brevo, SendGrid, SMTP). The default with no credentials writes every send to .local/email-outbox/ as an HTML file, so local development never requires a provider account.
How an email flows
Triggers funnel through renderEmail and sendEmail, then branch on the delivery mode.
Delivery modes
log for dev, noop for tests, and one of six providers in production.
What ships out of the box
The twelve templates wired to live auth, billing, and contact senders.
Add a new template
Author a React component, re-export it, preview it, and wire a sender.
How An Email Flows
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 a provider. You can keep developing without a Plunk, Resend, or SMTP account.
Why these modes. log for dev, noop for tests and CI (silent, fast, no I/O), and one real provider for production. The mode is resolved at module load by resolveEmailDeliveryMode in packages/shared/src/setup/state.ts 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
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.
Delivery Modes
log
Writes one HTML file per send to .local/email-outbox/ (override the directory with EMAIL_OUTBOX_DIR). Files are named timestamp-hash-subject.html. The hash is sha256 of the recipient, truncated to 12 characters, so the address is not in the filename. Default when no provider credential is set and NODE_ENV is not test.
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).
provider
One of plunk, resend, postmark, brevo, sendgrid, or smtp. Any of the six is allowed in production. Each needs EMAIL_FROM and that mode's credential.
The mode resolution order is: explicit EMAIL_DELIVERY_MODE wins. If it is unset, the first configured provider wins, in order: plunk (PLUNK_API_KEY), resend (RESEND_API_KEY), postmark (POSTMARK_SERVER_TOKEN), brevo (BREVO_API_KEY), sendgrid (SENDGRID_API_KEY), smtp (SMTP_HOST, and boot also requires SMTP_USER and SMTP_PASS). If none is set, the mode is noop when NODE_ENV=test, otherwise log. Two or more provider credentials without an explicit mode is a boot error. EMAIL_FROM is required for every real provider.
EMAIL_DELIVERY_MODE=noop and EMAIL_DELIVERY_MODE=log are rejected when NODE_ENV=production. The boot doctor flags it as a fatal misconfiguration (Invalid SyntaxKit environment configuration): rather than crash, the app serves the branded "Configuration error" page instead of the normal UI and /api/health returns 503, so a health-gated deploy rolls back. 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 a real provider (plunk, resend, postmark, brevo, sendgrid, or smtp).
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).
| Template | Trigger | Subject namespace | Notes |
|---|---|---|---|
EmailVerificationEmail | oRPC auth.sendVerificationEmail (BA hook retained as a best-effort fallback for direct API consumers) | EmailVerification.subject | UI flows go through the oRPC mutation, which awaits delivery and throws SERVICE_UNAVAILABLE on failure. Localized. |
PasswordResetEmail | oRPC auth.requestPasswordReset (BA hook retained as a best-effort fallback) | PasswordReset.subject | Mints 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. |
EmailChangeEmail | oRPC user.updateEmail (BA hook retained as a best-effort fallback) | EmailChange.subject | Sent 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. |
OrganizationInvitationEmail | oRPC organization.inviteMember (BA's sendInvitationEmail hook is a no-op; we send after auth.api.createInvitation returns) | OrgInvitation.subject | Throttled per inviter and invitee. Recipient has no account yet, so the inviter's locale wins. Locale-prefixed accept link. Localized. |
WelcomeEmail | afterUserCreate hook | Welcome.subject | Fire-and-forget; not awaited. Always logs failures. Localized. |
SubscriptionCreatedEmail | Stripe customer.subscription.created | Welcome to <plan>! | Deduped via outboundEffect |
SubscriptionCanceledEmail | Stripe customer.subscription.deleted | "Your subscription has been canceled" | Sent on deletion only, not pause |
TrialEndingEmail | Stripe customer.subscription.trial_will_end | Your <plan> trial ends soon | Stripe fires ~3 days before trial end |
PaymentSucceededEmail | Stripe invoice.payment_succeeded | Payment receipt - <amount> | Receipt with formatted amount |
PaymentFailedEmail | Stripe invoice.payment_failed | "Action required: Payment failed" | User must update payment method |
PaymentActionRequiredEmail | Stripe invoice.payment_action_required | "Action required: Confirm your payment" | 3DS / SCA confirmation flow |
ContactFormEmail | oRPC contact.submit | Contact 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:
NEXT_LOCALEcookie on the in-flight request: the user's current preference at the moment they triggered the action.User.localecolumn: their persisted preference; survives across browsers and into flows that have no request (welcome email, future webhook-driven mail).defaultLocale(en): the kit's safety net.
Persisting locale. Interactive switches update NEXT_LOCALE client-side, then enqueue the cookie-free persistLocalePreference action in the background. Its per-tab queue keeps one write in flight and collapses rapid changes to the latest locale, so User.locale can't finish out of order within a tab. The cookie-writing setLocale variant remains for server-side callers and defers its database write with after(); interactive flows avoid it because a Server Action cookie mutation re-renders the current route and wipes the client prefetch cache. New signups still 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 log is the default when no provider credential is set), the file lands in .local/email-outbox/. Open it in any browser. The HTML comments store a recipient hash and the subject, not the address.
pnpm setup:doctor reports the active mode under "Email" and tells you how to set EMAIL_DELIVERY_MODE to plunk, resend, postmark, brevo, sendgrid, or smtp when you are 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 a bad credential 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.requestPasswordResetandauth.sendVerificationEmailshort-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_UNAVAILABLEsend-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
Missing Upstash serves the Configuration error page and returns 503 from /api/health; 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
The kit ships adapters for Plunk, Resend, Postmark, Brevo, SendGrid, and SMTP under packages/email/src/providers/. Set EMAIL_DELIVERY_MODE explicitly when more than one provider's credentials are present; otherwise the auto-resolver picks the first configured provider.
Pick a provider and set env vars
Configure the provider's credentials and EMAIL_FROM in apps/web/.env. Run pnpm setup:doctor — it reports the active mode and any missing vars.
Add a new adapter (if not already shipped)
pnpm --filter @syntaxkit/email add <provider-sdk>, then create packages/email/src/providers/<provider>.ts mirroring an existing adapter: a lazy client that throws if its credentials are missing, and a send(message) returning Promise<boolean>.
Extend EMAIL_DELIVERY_MODE resolution
Add your mode to EMAIL_PROVIDER_MODES, add its credential to the SetupEnv interface, and add the matching PROVIDER_CREDENTIAL_ENV entry in packages/shared/src/setup/types.ts. Read the new env var in packages/shared/src/setup/fields.ts. resolveEmailDeliveryMode in packages/shared/src/setup/state.ts walks EMAIL_PROVIDER_MODES; you do not add a one-off branch in packages/shared/src/setup.ts.
Add the branch in sendEmail
Add a case for the new mode in the sendEmail switch in packages/email/src/client.ts, next to the existing noop, log, and provider cases. Return true on success and false on failure. Do not use resend as the example: that adapter already ships.
Update setup doctor + the env example
pnpm setup:doctor reads the active mode from setup state. The log hint in scripts/setup-doctor.ts still lists the six shipped modes by name, so a seventh mode needs that string updated too. Document the new env vars in apps/web/.env.example.
Those steps are for a provider that is not already shipped. Plunk, Resend, Postmark, Brevo, SendGrid, and SMTP only need credentials, EMAIL_FROM, and EMAIL_DELIVERY_MODE. The kit sends one HTML message. It does not route marketing mail separately.
Where To Go Next
Authentication
The Better Auth callbacks that fire four of the auth-related emails on this page.
Billing
The Stripe webhooks that fire the six billing-related emails, plus the outboundEffect dedupe pattern.
API
Where the contact-form and other oRPC procedures that send email live.
Setup
The full env matrix, including EMAIL_DELIVERY_MODE and each provider credential.
Environment Variables
Feature flags and per-package configuration, including the abuse-protection toggles.