Skip to content

ADR-0029 Transactional email

Company standard for app email — React Email templates sent with Nodemailer over SMTP, from a background job; the SMTP provider is chosen per project.

Decision
React Email + Nodemailer over SMTPBuild the app
Status
Accepted
Date
Scope
Company-wide default for all new apps

Every app sends mail. Better Auth calls our code to send verification, password-reset, and magic-link messages (ADR-0009), and products add their own notifications.

  • The provider differs per project: a client’s Microsoft 365 or Azure tenant, a relay they already pay for, or one we pick. There is no company provider, and there will not be one
  • We self-host (ADR-0003), so no platform mail service comes with the deployment
  • Templates are code: they take typed data, they are reviewed, and they must render in Outlook and Gmail, not only in a browser
  • A failed send must not fail the request that caused it, and must be retried

What is open: how templates are written, how mail leaves the app, and what is left to each project.

1. A provider’s HTTP API and SDK (Resend, Postmark, SES, Azure Communication Services)

Section titled “1. A provider’s HTTP API and SDK (Resend, Postmark, SES, Azure Communication Services)”

Pros

  • Bounce and complaint webhooks, delivery analytics, suppression lists
  • No SMTP connections to manage

Cons

  • One SDK per provider: a project that must use its client’s provider rewrites the mail module
  • Keys, limits, and features differ per vendor, so nothing is a company standard

2. SMTP with Nodemailer, templates in React Email

Section titled “2. SMTP with Nodemailer, templates in React Email”

Pros

  • Every provider speaks SMTP: changing provider is changing environment variables
  • Nodemailer has no runtime dependencies, and covers pooling, OAuth2, and DKIM signing
  • React Email 6 is one package: components, render() to HTML and plain text, and a preview server. Its docs include a Nodemailer guide
  • Templates are React with typed props — the language the team already writes (ADR-0001)

Cons

  • No bounce or complaint feedback over SMTP; that stays provider-specific
  • Retries, connection limits, and OAuth2 token refresh are ours
  • React Email is maintained by Resend, a provider we may not use

Pros: no React on the mail path; MJML is mature. Cons: a second templating language, no type-checked data, and nothing React Email does not already cover.

React Email for templates and Nodemailer over SMTP for delivery are the company standard. The SMTP provider is chosen per project.

Concretely:

  • Packages: react-email 6.x (6.11.12 at time of writing) and nodemailer 10.x (10.5.0). Since 6.0 react-email holds the components, render, and the CLI — do not install @react-email/components
  • Provider per project, set only through environment variables: SMTP host, port, credentials, and the sender address, declared in server/env.ts (ADR-0026). Nothing in the code names a provider
  • TLS always: port 465 with secure: true, or 587 with requireTLS: true. Password authentication, or OAuth2 (auth.type: "OAuth2") where the provider demands it — Microsoft 365 needs a token with the https://outlook.office.com/SMTP.Send scope
  • One mail module, src/server/mail/, is the only code that imports nodemailer. It creates one pooled transporter and exposes a typed sendMail. Feature code receives it as a parameter (ADR-0027)
  • Templates live in src/server/mail/templates/ as React Email components with typed props. Every message is sent with both parts: render(<Template />) for HTML and render(<Template />, { plainText: true }) for text. Only React Email’s components — mail clients do not run our CSS, so shadcn/ui (ADR-0011) stays out. Preview with email dev --dir src/server/mail/templates
  • Mail is sent from a job (ADR-0028): the request enqueues, the worker renders and sends, and an SMTP failure is retried with backoff
  • Better Auth callbacks (sendVerificationEmail, sendResetPassword, magic links) enqueue and return without awaiting — its docs warn that awaiting the send exposes timing
  • Deliverability is set up per deployment with the provider: SPF, DKIM, and DMARC for the sending domain. Nodemailer’s dkim option signs messages when the relay does not
  • Logs carry the user ID and the template name, never the recipient address or the body
  • Reading a mailbox is opt-in per product: IMAP through imapflow 2.x, run in a worker
  • Tests pass a transporter created with jsonTransport: true: nothing leaves the process. SMTP is not HTTP, so the MSW rule of ADR-0014 does not see it

The deciding factors: one mail path that works with whichever provider a project brings, templates in the language we already use, and retries from the queue we already run. We accept handling bounces and provider limits per project.

Easier:

  • Moving a product to another provider is a configuration change
  • Auth mail and product mail share one module, one template style, and one retry policy
  • Templates are type-checked and previewable without sending

Harder:

  • Bounces, complaints, and sending limits differ per provider and are handled per project
  • OAuth2 for Microsoft 365 means obtaining and refreshing tokens ourselves
  • Email HTML is its own craft: test templates in real clients, not only in the preview

Given up: provider analytics, webhooks, and suppression lists as a standard. Accepted — a project that needs them adds its provider’s API beside this module.