Skip to content
Build With SyntaxKit

Email

React Email templates and Plunk delivery in packages/email. Rendering stays provider-agnostic; sendEmail branches on EMAIL_DELIVERY_MODE (log, noop, or plunk). Dev defaults to a local HTML outbox so you never need a provider account.

Last updated on

11 min read

On This Page

How An Email Flows

Every send goes through renderEmail (React → HTML) then sendEmail. The delivery mode decides what happens next: write a local HTML file, no-op, or call Plunk.

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.

Dev defaults to log, so a fresh clone can sign up and open a real HTML verification email in the browser without a Plunk account. noop keeps tests silent. plunk is production. The mode is resolved once at module load by resolveEmailDeliveryMode and stays fixed for the process lifetime.

Package Layout

index.tsPublic entry: renderEmail (React Email to HTML) plus 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

Two import subpaths:

  • Transport: import { sendEmail, renderEmail } from "@syntaxkit/email"
  • Templates: import { WelcomeEmail } from "@syntaxkit/email/templates"

Three Delivery Modes

ModeBehavior
logWrites one HTML file per send to .local/email-outbox/ (override with EMAIL_OUTBOX_DIR). Files are named <timestamp>-<recipient>-<subject>.html. Default in dev.
noopReturns true without sending. Default when NODE_ENV is test. Hard-blocked at boot in production.
plunkSends via Plunk's transactional API. Requires PLUNK_API_KEY. The only mode allowed in production.

Resolution order: explicit EMAIL_DELIVERY_MODE wins, else PLUNK_API_KEY set means plunk, else NODE_ENV=test means noop, else log. Set EMAIL_DELIVERY_MODE only to override that default (for example, force noop outside tests).

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. Both modes report sendEmail as successful without delivering, which would let a misconfigured production deploy show "email sent" UX while 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, all wrapped in shared EmailLayout. Five auth templates plus the layout chrome are localized via @syntaxkit/i18n. The seven billing and contact templates are English-only today; they use the same pattern when you localize them.

TemplateTriggerSubject namespaceNotes
EmailVerificationEmailoRPC auth.sendVerificationEmail (BA hook is a best-effort fallback)EmailVerification.subjectAwaits delivery; throws SERVICE_UNAVAILABLE on failure. Localized.
PasswordResetEmailoRPC auth.requestPasswordReset (BA hook is a best-effort fallback)PasswordReset.subjectJWT reset link for auth.resetPassword. Anti-enumeration on unknown emails. Localized.
EmailChangeEmailoRPC user.updateEmail (BA hook is a best-effort fallback)EmailChange.subjectSent to the current address; awaits delivery. Follow-up to the new address is still BA-driven. Localized.
OrganizationInvitationEmailoRPC organization.inviteMember (BA sendInvitationEmail is a no-op)OrgInvitation.subjectThrottled per inviter and invitee. Inviter locale; locale-prefixed accept link. Localized.
WelcomeEmailafterUserCreate hookWelcome.subjectFire-and-forget; 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
ContactFormEmailoRPC contact.submitContact form: <name>Fail-closed on missing abuse config

Templates are React components. The smallest live one, WelcomeEmail, shows 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 covered in tests, but no production sender invokes it yet. To send it during a Stripe past_due window, wire a handler in packages/payments/src/stripe/webhook.ts.

Localization

The five auth-driven templates and shared EmailLayout use next-intl with a per-package catalog at packages/email/messages/<locale>.json. Add a locale with a JSON file plus an entry in messagesByLocale in packages/email/src/i18n.ts. No other global wiring.

The email package is consumed by @syntaxkit/auth, @syntaxkit/payments, and the contact-form oRPC procedure. A per-package catalog keeps those packages from depending on apps/web/messages.

Send sites often run outside an active next-intl request scope (welcome email from a Better Auth hook, Stripe webhooks). Use createTranslator({ locale, messages, namespace }) with explicit messages; it works synchronously everywhere. Do not use getTranslations here.

Every auth send site resolves locale through resolveAuthEmailLocale (packages/auth/src/locale.ts, re-exported from @syntaxkit/auth). Pass a full Request (BA hooks) or Headers (oRPC via context.headers). Both paths read the same NEXT_LOCALE cookie:

  1. NEXT_LOCALE cookie on the in-flight request.
  2. User.locale column (persisted preference; used when there is no request).
  3. defaultLocale (en).

setLocale in apps/web/i18n/actions.ts writes the cookie and, when there is a session, the User.locale column. New signups capture the cookie in the afterUserCreate hook so the welcome email matches. User.locale is nullable: null means fall back to the cookie or the default.

Invitation recipients have no account yet, so the send site uses the inviter's User.locale for the body and the locale-prefixed getAcceptInvitationPath(locale, id) accept URL.

The seven non-auth templates still use hard-coded English. To localize one: add namespaces and strings to both message files, swap hardcoded copy for t(...), and resolve the recipient locale at the send site (Stripe handlers can read the org owner's User.locale).

Local Development Workflow

Preview server. Run pnpm email:dev to open React Email's preview UI. Each template renders in isolation with the props you supply and hot-reloads on edit. You do not need to trigger a send from the app.

Outbox. In dev (EMAIL_DELIVERY_MODE=log by default), each send writes a file under .local/email-outbox/. Open it in any browser. The first three lines are HTML comments with the recipient and subject, so head is enough to scan what was sent.

pnpm setup:doctor reports the active mode under "Email" and how to move from log to Plunk.

Why The UI Flows Go Through oRPC Instead Of BA Hooks

Better Auth runs its email hooks in the background and catches every throw. A provider outage or bad PLUNK_API_KEY can still return 200 OK with no email sent. The user sees "check your inbox" and is locked out.

Verification, password-reset, email-change, and invitation flows therefore go through oRPC mutations that call sendEmail synchronously and throw a typed SERVICE_UNAVAILABLE the UI can show. BA hooks stay as best-effort fallbacks for direct API callers.

Expand the recipe you need.

How the oRPC mutations work

Each mutation mints a BA-compatible JWT (so BA's /api/auth/verify-email route still validates the token), builds the same email URL shape, then awaits sendEmail. On failure it throws SERVICE_UNAVAILABLE with a sentinel (VERIFICATION_EMAIL_SEND_FAILED, PASSWORD_RESET_EMAIL_SEND_FAILED, INVITATION_EMAIL_SEND_FAILED, CHANGE_EMAIL_SEND_FAILED). The client maps those through resolveOrpcSendEmailError to a translated toast and banner.

The mutation also enforces the per-surface Upstash abuse policy and leaves user-row state unchanged on failure (recover with resend, not a new account). Captcha failures throw BAD_REQUEST with "CAPTCHA_VERIFICATION_FAILED" so a later validation error cannot reuse captcha copy. sendInvitationEmail is a no-op hook (BA requires it; we send after createInvitation). emailVerification.sendOnSignUp is disabled so BA cannot race-send behind our dispatch.

Anti-enumeration on unauthenticated surfaces

forgot-password-form.tsx and verify-email-client.tsx are reachable without a session, so they do not always surface send failures:

  • Server. auth.requestPasswordReset and auth.sendVerificationEmail return { success: true } when the email does not match a user (or is already verified). Rate-limit phases run before that lookup, so known and unknown emails hit the same limits.
  • Client. Both forms collapse a SERVICE_UNAVAILABLE send failure into the same success card and toast as the unknown-email branch. Failures still log server-side.

Other codes (TOO_MANY_REQUESTS, captcha BAD_REQUEST, abuse SERVICE_UNAVAILABLE) look the same for known and unknown emails. Authenticated surfaces (post-signup send and resend, change-email, invitation) still show real failures: there is nothing left to leak once the user is signed in.

Outbound URL origin validation

requestPasswordReset.redirectTo and sendVerificationEmail.callbackURL take a relative path from the client and land in outbound URLs. Both run through assertSameOriginRedirect (packages/api/src/lib/safe-redirect.ts), which resolves against the trusted base with the WHATWG URL parser and rejects any value whose origin changes. That blocks parser-confusion attacks (@evil.com/, //evil.com/, http://evil.com/, javascript:…) that naive string concat would turn into a leaked reset JWT. The check runs before the user-existence lookup, so a hostile redirect returns BAD_REQUEST + INVALID_REDIRECT for known and unknown emails alike.

Single-use password reset tokens

Before the password update, the reset JWT is claimed with an atomic Redis SET NX EX: the key is the token's SHA-256 hash, the TTL is its remaining lifetime, and a second use loses the race and gets INVALID_TOKEN. Only the hash is stored. In production a link is consumed at most once inside its 1-hour window. Without Upstash in dev, it falls back to natural JWT expiration (one-time warning). Successful resets always revoke sessions, so the degraded replay surface is only re-setting the password the user just submitted.

Abuse Protection

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

EnvironmentBehavior
ProductionRequired and fail-closed. Won't boot without Upstash; every surface fails closed when the limiter is unreachable.
DevAuto-bypassed. No Upstash means sends are allowed with a one-time warning.
TestsExplicit override. DISABLE_ABUSE_PROTECTION=true forces a bypass for deterministic CI. 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 existing templates: typed props, EmailLayout from ./components/email-layout, and shared email-styles tokens for typography and the CTA 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 does not need to know about the template; only the senders that use it do.

Preview it

Run pnpm email:dev and iterate until it looks right. React Email uses table-based layout; the preview UI shows mobile and desktop side-by-side.

Wire a sender

Call from a Better Auth callback, oRPC handler, hook, or webhook: await renderEmail(YourEmail({ ... })), then await sendEmail({ to, subject, body }). Use void sendEmail(...).catch(...) for fire-and-forget (same pattern as welcome email).

Decide on abuse protection

Wrap the send in enforceAbusePolicy and route the result through resolveAbuseDecision so the surface matches the unified posture (fail-closed in production, auto-bypass with a one-time warning elsewhere). Mirror the send sites in packages/auth/src/server.ts and packages/api/src/router/contact.ts: both map unavailable to SERVICE_UNAVAILABLE (oRPC) or a thrown error (Better Auth), and both honor bypassReason so the two bypass cases log differently.

Switching The Delivery Provider

There is no provider-plugin API today. Swap providers by editing the three-way branch in sendEmail (about ten lines if you mirror the Plunk pattern).

Pick a provider

Resend, AWS SES, Mailgun, Postmark, and Sendgrid all work: any Node SDK with an HTML-body send method. Subject and body from sendEmail's options map to most providers directly.

Add the SDK

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

Add a client constructor

Mirror getPlunkClient in packages/email/src/client.ts: a lazy singleton that throws if its env key is missing. Construct on first send, not at module load.

Extend EMAIL_DELIVERY_MODE resolution

Add your mode (for example "resend") to resolveEmailDeliveryMode in packages/shared/src/setup.ts. Auto-resolve it when the matching env var is set, same as the Plunk branch.

Add the branch in sendEmail

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

Update setup doctor and 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. pnpm setup:doctor reports the new mode from the same capability state.

A formal provider-plugin interface is planned. Until then, this six-step swap is the path. Routing multiple providers at once (for example Postmark for transactional and Resend for marketing) needs a small refactor so sendEmail can choose by message kind. Out of scope today.

Where To Go Next

Also useful: API for contact-form and other oRPC senders, Setup for the email env block, Environment Variables for abuse-protection toggles, and Security for the full abuse posture.

Was this page helpful?

On this page