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
Connections
Context
Section titled “Context”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.
Options considered
Section titled “Options considered”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
3. Hand-written HTML or MJML templates
Section titled “3. Hand-written HTML or MJML templates”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.
Decision
Section titled “Decision”React Email for templates and Nodemailer over SMTP for delivery are the company standard. The SMTP provider is chosen per project.
Concretely:
- Packages:
react-email6.x (6.11.12 at time of writing) andnodemailer10.x (10.5.0). Since 6.0react-emailholds 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 withrequireTLS: true. Password authentication, or OAuth2 (auth.type: "OAuth2") where the provider demands it — Microsoft 365 needs a token with thehttps://outlook.office.com/SMTP.Sendscope - One mail module,
src/server/mail/, is the only code that importsnodemailer. It creates one pooled transporter and exposes a typedsendMail. 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 andrender(<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 withemail 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
dkimoption 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
imapflow2.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.
Consequences
Section titled “Consequences”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.

