Email layer
Resend setup, env vars, the shipped templates, per-category notification preferences, HMAC unsubscribe links, and the dev no-op behavior that keeps local dev frictionless.
Email layer
Transactional email is provided by src/libs/Email.ts, built on top of
Resend and @react-email/components. The module
exposes ready-to-customise templates, validates every recipient with Zod,
honours per-category preferences, and degrades gracefully to stdout when
no provider key is configured.
Env vars
# Optional. When unset, every Email.* call logs the rendered HTML to
# stdout instead of dispatching. Local installs work without any external
# account.
RESEND_API_KEY=re_your_resend_api_key
# Sender address. Default: no-reply@example.com.
EMAIL_FROM=no-reply@yourdomain.com
# Display name. Also used as the brand name in template headers and the
# signature line. Default: "Next.js Boilerplate".
EMAIL_FROM_NAME=Your SaaS NameThe # OPTIONAL tag on RESEND_API_KEY is deliberate — see "Dev no-op
behaviour" below.
Provider setup (production)
- Create a Resend account.
- Verify your sending domain (DNS : MX, TXT, DKIM, optional DMARC). The Resend dashboard walks through the records.
- Create an API key under
Settings → API Keys. Copy it into the production secret manager asRESEND_API_KEY=re_…. - Set
EMAIL_FROMto an address on the verified domain. - Set
EMAIL_FROM_NAMEto your brand name.
Once the four vars are in place, every Email.* call dispatches through
Resend instead of stdout — no code change needed.
How to send
import { Email } from '@/libs/Email';
await Email.sendWelcome('user@example.com', 'Mo', 'en');
await Email.sendPasswordReset('user@example.com', 'https://app/reset?t=abc', 'fr');
await Email.sendVerifyEmail('user@example.com', 'https://app/verify?t=abc', 'en');
await Email.sendInvoice('user@example.com', 'https://app/inv/123', 4900, 'en'); // amount in cents
await Email.sendInvite({
to: 'invitee@example.com',
inviterName: 'Mo',
orgName: 'Acme Inc',
acceptUrl: 'https://app/accept-invite/token',
locale: 'en',
});
await Email.sendDataExportReady(
'user@example.com',
'https://app/exports/signed-url',
'en',
);
await Email.sendAccountDeletionScheduled(
'user@example.com',
'https://app/cancel-deletion/token',
new Date('2026-05-30'), // scheduledAt
'en',
);Each method validates the recipient with Zod, renders the React Email
template to HTML, and returns { success, id?, error? }. They never
throw — callers can fire-and-forget with void so a Resend outage
doesn't block UX :
// src/actions/register-user.ts
void Email.sendVerifyEmail(email, verifyUrl, locale);The void keyword discards the returned Promise without awaiting it,
which is ESLint-clean under the strict no-floating-promises rule.
Shipped templates
| Method | Subject | When | Category |
|---|---|---|---|
sendWelcome | Welcome to {{ brand }} | Right after sign-up, post email-verify | product_updates |
sendVerifyEmail | "Verify your email address" | On sign-up + on email-change | security_alerts |
sendPasswordReset | "Reset your password" | User clicks "Forgot password" on /sign-in | security_alerts |
sendInvoice | Invoice from {{ brand }} | Stripe invoice.payment_succeeded webhook | billing |
sendInvite | {{ inviterName }} invited you | Owner/admin invites a teammate | team |
sendDataExportReady | "Your data export is ready" | RGPD export job finishes | security_alerts |
sendAccountDeletionScheduled | "Account deletion scheduled" | User requests account deletion | security_alerts |
Every template uses the shared EmailLayout component (header, brand
name, footer, unsubscribe link) and the design tokens defined at the top
of src/libs/Email.ts.
Per-category notification preferences (G16)
Users opt in / out of email categories from
/dashboard/settings?tab=notifications. Backed by the
user_preferences.notifications JSONB column. Default per-category
behaviour lives in EMAIL_CATEGORY_DEFAULTS in src/models/Schema.ts
— security_alerts defaults to true and is not user-disable-able
(security floor).
Categories
type EmailCategory =
| 'security_alerts' // verify, password reset, deletion, export ready — non-optional
| 'billing' // invoice, payment failure, dunning
| 'product_updates' // welcome, feature announcements, changelog
| 'team' // invites, role changes, member activity
| 'marketing' // optional newsletters, opt-in only
;Preference resolution
Email.shouldSend(userId, category) is called before dispatch :
- No
userId(anonymous email — verify-email-on-signup, etc.) → defaults fromEMAIL_CATEGORY_DEFAULTS. - No
user_preferencesrow → defaults apply. - Explicit boolean in
notifications[category]→ respected. - Otherwise → category default.
// Inside Email.sendInvoice(...)
if (!(await Email.shouldSend(userId, 'billing'))) {
return { success: true, skipped: 'billing_disabled' };
}Security alerts always send regardless of preference — there is no path
to disable security_alerts from the UI, and the resolver short-circuits
on category before reading the row.
HMAC unsubscribe links (G16)
Every dispatched email contains a one-click unsubscribe link in the footer. The link is a stateless HMAC token — no DB row to revoke, no server-side state to clean up :
// src/libs/Email.ts
function computeUnsubscribeToken(userId: string, category: EmailCategory): string {
const secret = process.env.AUTH_SECRET ?? 'dev_unsubscribe_secret';
return createHmac('sha256', secret)
.update(`${userId}|${category}`)
.digest('hex');
}The link target is
/api/email/unsubscribe?u=<userId>&c=<category>&t=<token>. The handler
verifies the HMAC, sets notifications[category] = false, and confirms
visually with a "You're unsubscribed" page.
Because the token is HMAC-derived, an attacker who guesses a userId
cannot unsubscribe a user — the token is unforgeable without
AUTH_SECRET. CASL / CAN-SPAM compliance: the unsubscribe link works in
one click, no sign-in required.
Dev no-op behaviour
When RESEND_API_KEY is not set :
Email.*returns{ success: true }without contacting Resend.- The rendered HTML is printed to stdout, prefixed with the recipient and subject. Devs can copy the block into a browser to preview.
- The dev route
GET /api/dev/test-email?template=welcome|password_reset|verify|invoice&locale=en|frtriggers a send to the seeded dev user (dev@example.com). Returns404in production.
# Preview a welcome email rendered in French
curl 'http://localhost:3000/api/dev/test-email?template=welcome&locale=fr'The dev route is gated on process.env.NODE_ENV !== 'production' and
returns the rendered HTML in the response body so you can pipe it
straight into a browser.
Adding a new template
-
Create
src/components/emails/MyTemplate.tsx, mimicking the existing templates : importEmailLayoutand the shared style tokens, accept astringsprop with pre-translated copy.import { EmailLayout } from '@/components/emails/EmailLayout'; export type MyTemplateStrings = { subject: string; headline: string; body: string; ctaLabel: string; }; export function MyTemplate({ strings, ctaUrl, brand, unsubscribeUrl }: { strings: MyTemplateStrings; ctaUrl: string; brand: string; unsubscribeUrl: string; }) { return ( <EmailLayout brand={brand} unsubscribeUrl={unsubscribeUrl}> <h1>{strings.headline}</h1> <p>{strings.body}</p> <a href={ctaUrl}>{strings.ctaLabel}</a> </EmailLayout> ); } -
Add a subsection under
Email.<my_template>in bothsrc/locales/en.jsonandsrc/locales/fr.json(FR must be translated, not a placeholder — seenpm run check:i18n). -
Add
Email.sendMyTemplate(...)insrc/libs/Email.ts, wiring Zod validation, the locale-scoped strings, and thedispatch()helper :async sendMyTemplate( to: string, ctaUrl: string, userId: string | null, locale: string, ): Promise<EmailResult> { if (!(await Email.shouldSend(userId, 'product_updates'))) { return { success: true, skipped: 'product_updates_disabled' }; } const parsed = z.string().email().safeParse(to); if (!parsed.success) { return { success: false, error: 'invalid_recipient' }; } const strings = (await loadLocale(locale)).Email.my_template; const html = render(<MyTemplate strings={strings} ctaUrl={ctaUrl} brand={brandName()} unsubscribeUrl={unsubscribeUrl(userId, 'product_updates')} />); return dispatch({ to, subject: strings.subject, html }); } -
(Optional) Add the template name to the dev test route at
src/app/api/dev/test-email/route.tsto preview it locally.
What's next
- Compliance moat —
audit_chainemits on every email-bearing event so SOC 2 reviewers see a single immutable trail. - Customizing the boilerplate — swap
Resend for SES, Postmark, or any other provider via the
dispatch()abstraction.
Billing & subscriptions
Stripe setup, the three recurring tiers, checkout flow, customer portal, webhook contract, and how the audit chain captures every lifecycle event.
Compliance moat
Audit chain (WORM), RGPD export & deletion, idempotent webhooks, CSP. The compliance primitives that turn a SaaS demo into a SaaS your CFO will sign off on — with code examples.