feat(emails): pluggable email provider (Resend + SMTP)#1900
feat(emails): pluggable email provider (Resend + SMTP)#1900alexis-morain wants to merge 3 commits into
Conversation
Adds a small EmailProvider abstraction so self-hosted deployments can use any SMTP server (Postfix, internal relay, Brevo, Postmark via SMTP, etc.) instead of being locked into Resend. Closes CapSoftware#1766 - packages/database/emails/providers/{types,resend,smtp,index}.ts: EmailProvider interface + ResendEmailProvider (existing behavior) + SmtpEmailProvider (nodemailer with React-Email -> html/text rendering). - packages/database/emails/config.ts: dispatches to the selected provider, preserves marketing/test/scheduledAt/fromOverride semantics. scheduledAt is silently dropped (with a warning) when the active provider doesn't support it. - packages/env/server.ts: adds EMAIL_PROVIDER ('resend'|'smtp'), EMAIL_FROM, SMTP_HOST/PORT/SECURE/USER/PASS. RESEND_API_KEY + RESEND_FROM_DOMAIN unchanged. - packages/database/package.json: promotes nodemailer to dependencies, adds @types/nodemailer. Backward compatible: existing deployments with RESEND_API_KEY keep working unchanged. SMTP is opt-in via EMAIL_PROVIDER=smtp.
- ResendEmailProvider: log + return on send error instead of throw, to preserve the original silent-failure behavior of callers (auth-options, send-invites, Notification, send-download-link). They all currently `await sendEmail()` without inspecting the return. - SmtpEmailProvider: wrap send in try/catch with the same log + return behavior, so SMTP failures don't crash the auth flow either. - SmtpEmailProvider: drop the scheduledAt throw — the dispatcher in config.ts already warns when scheduledAt is requested with a non-Resend provider and strips the field before send. - config.ts: cleaner EMAIL_FROM vs RESEND_FROM_DOMAIN dispatch (two explicit branches instead of a conditional formatter). - pnpm-lock.yaml: regenerated after promoting nodemailer to deps + adding @types/nodemailer. Includes incidental patch bumps for rolldown 1.0.1 -> 1.1.0 and its transitive native bindings, which pnpm resolved when re-evaluating caret ranges.
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
| return r.emails.send({ | ||
| return provider.send({ | ||
| from, | ||
| to: test ? "delivered@resend.dev" : email, |
There was a problem hiding this comment.
delivered@resend.dev is a Resend-specific test sink; with EMAIL_PROVIDER=smtp this would send to a real external address instead of the requested recipient.
| to: test ? "delivered@resend.dev" : email, | |
| to: test && provider.name === "resend" ? "delivered@resend.dev" : email, |
There was a problem hiding this comment.
Fixed in 350636a: the delivered@resend.dev sink is now gated with test && provider.name === "resend", so under EMAIL_PROVIDER=smtp test sends go to the real recipient instead of a Resend-only address.
| ); | ||
| return null; | ||
| } | ||
| return new SmtpEmailProvider({ |
There was a problem hiding this comment.
If only one of SMTP_USER/SMTP_PASS is set, we silently drop auth and then fail later with less obvious errors. A small warning here could save debugging time.
| return new SmtpEmailProvider({ | |
| if ( | |
| (env.SMTP_USER && !env.SMTP_PASS) || | |
| (!env.SMTP_USER && env.SMTP_PASS) | |
| ) { | |
| console.warn( | |
| "[email] SMTP_USER/SMTP_PASS should be set together; continuing without auth", | |
| ); | |
| } | |
| return new SmtpEmailProvider({ |
There was a problem hiding this comment.
Fixed in 350636a: build() now warns when exactly one of SMTP_USER/SMTP_PASS is set, surfacing the auth-disabled misconfiguration early.
| return r.emails.send({ | ||
| return provider.send({ | ||
| from, | ||
| to: test ? "delivered@resend.dev" : email, |
There was a problem hiding this comment.
Resend-specific test address used for SMTP provider
delivered@resend.dev is a Resend magic sink that silently captures test emails — it's not meaningful for nodemailer/SMTP. When EMAIL_PROVIDER=smtp and test: true, the transport will actually attempt to deliver to delivered@resend.dev through the configured SMTP server, with no guarantee it'll be absorbed silently. Consider gating the override to the Resend provider only, or using a configurable EMAIL_TEST_TO address.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/emails/config.ts
Line: 60
Comment:
**Resend-specific test address used for SMTP provider**
`delivered@resend.dev` is a Resend magic sink that silently captures test emails — it's not meaningful for nodemailer/SMTP. When `EMAIL_PROVIDER=smtp` and `test: true`, the transport will actually attempt to deliver to `delivered@resend.dev` through the configured SMTP server, with no guarantee it'll be absorbed silently. Consider gating the override to the Resend provider only, or using a configurable `EMAIL_TEST_TO` address.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Fixed in 350636a: gated to the Resend provider only (test && provider.name === "resend").
| let cached: EmailProvider | null | undefined; | ||
|
|
||
| export function getEmailProvider(): EmailProvider | null { | ||
| if (cached !== undefined) return cached; | ||
| cached = build(); | ||
| return cached; |
There was a problem hiding this comment.
Module-level singleton breaks test isolation
cached is a module-level variable that persists for the lifetime of the module. In Jest / Vitest test suites that modify process.env between tests but don't re-require the module, getEmailProvider() will return the first-call result regardless of later env changes. If tests for SMTP and Resend paths run in the same process, the second test suite will receive a stale provider. A resetEmailProvider() export (or moving the cache inside serverEnv's own lifecycle) would make this testable without module mocks.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/emails/providers/index.ts
Line: 6-11
Comment:
**Module-level singleton breaks test isolation**
`cached` is a module-level variable that persists for the lifetime of the module. In Jest / Vitest test suites that modify `process.env` between tests but don't re-require the module, `getEmailProvider()` will return the first-call result regardless of later env changes. If tests for SMTP and Resend paths run in the same process, the second test suite will receive a stale provider. A `resetEmailProvider()` export (or moving the cache inside `serverEnv`'s own lifecycle) would make this testable without module mocks.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in 350636a: added an exported resetEmailProvider() that clears the module cache, so test suites mutating env between cases aren't pinned to the first provider.
| auth: | ||
| config.user && config.pass | ||
| ? { user: config.user, pass: config.pass } | ||
| : undefined, | ||
| }); |
There was a problem hiding this comment.
Partial credentials silently disable SMTP authentication
If a deployer sets SMTP_USER but forgets SMTP_PASS (or vice versa), the && guard falls through to auth: undefined. Nodemailer will then attempt an unauthenticated session; most modern SMTP servers will reject it with a generic "auth required" error rather than a clear "check your SMTP_PASS" message. Adding a warning in build() (in providers/index.ts) when exactly one credential is supplied would surface the misconfiguration early.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/emails/providers/smtp.ts
Line: 23-27
Comment:
**Partial credentials silently disable SMTP authentication**
If a deployer sets `SMTP_USER` but forgets `SMTP_PASS` (or vice versa), the `&&` guard falls through to `auth: undefined`. Nodemailer will then attempt an unauthenticated session; most modern SMTP servers will reject it with a generic "auth required" error rather than a clear "check your SMTP_PASS" message. Adding a warning in `build()` (in `providers/index.ts`) when exactly one credential is supplied would surface the misconfiguration early.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in 350636a: build() (in providers/index.ts) now warns when only one of SMTP_USER/SMTP_PASS is set, before constructing the transport.
| import { Resend } from "resend"; | ||
| import { getEmailProvider } from "./providers"; | ||
|
|
||
| export const resend = () => | ||
| serverEnv().RESEND_API_KEY ? new Resend(serverEnv().RESEND_API_KEY) : null; |
There was a problem hiding this comment.
resend() export is now dead code
import { Resend } from "resend" and the exported resend() factory are no longer called by any file in the repo — every previous call-site now goes through getEmailProvider(). Leaving the export in place creates two divergent ways to get a Resend client (the cached provider vs. an ad-hoc, uncached instance), which may confuse future contributors. Consider removing resend() and its import in a follow-up cleanup.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/emails/config.ts
Line: 3-7
Comment:
**`resend()` export is now dead code**
`import { Resend } from "resend"` and the exported `resend()` factory are no longer called by any file in the repo — every previous call-site now goes through `getEmailProvider()`. Leaving the export in place creates two divergent ways to get a Resend client (the cached provider vs. an ad-hoc, uncached instance), which may confuse future contributors. Consider removing `resend()` and its import in a follow-up cleanup.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Fixed in 350636a: removed the resend() factory and its Resend import. Confirmed no remaining callers; everything goes through getEmailProvider().
- config.ts: gate the delivered@resend.dev test sink to the Resend provider only, so test sends under EMAIL_PROVIDER=smtp reach the real recipient instead of a Resend-only magic address. - config.ts: remove the now-dead resend() factory and its Resend import; every caller already goes through getEmailProvider(). - providers/index.ts: warn when exactly one of SMTP_USER/SMTP_PASS is set, surfacing the silently auth-disabled misconfiguration early. - providers/index.ts: add resetEmailProvider() so test suites that mutate env vars between cases aren't pinned to the first cached provider.
feat(emails): pluggable email provider (Resend + SMTP)
Summary
Adds a small
EmailProviderabstraction so self-hosted Cap can use any SMTPserver (Postfix, internal relay, Brevo, Postmark via SMTP, etc.) for
transactional emails instead of being locked into Resend.
Backward compatible — existing deployments keep working with
RESEND_API_KEYunchanged. SMTP is opt-in via
EMAIL_PROVIDER=smtp.Motivation
packages/database/emails/config.tswas wired directly to the Resend SDK.Self-hosters who already run an SMTP server (or want a non-Resend provider)
had to add another SaaS account. Issue #1766 documents the friction.
Changes
packages/database/emails/providers/types.tsEmailProviderinterfacepackages/database/emails/providers/resend.tsResendEmailProvider(preserves existing behavior, includingscheduledAt)packages/database/emails/providers/smtp.tsSmtpEmailProviderusingnodemailer+@react-email/renderfor HTML/plain-textpackages/database/emails/providers/index.tsgetEmailProvider()— picks provider from env, cachedpackages/database/emails/config.tssendEmail()dispatches via the provider; logs a warning ifscheduledAtis requested with a non-Resend providerpackages/env/server.tsEMAIL_PROVIDER,EMAIL_FROM,SMTP_HOST,SMTP_PORT,SMTP_SECURE,SMTP_USER,SMTP_PASSpackages/database/package.jsonnodemailerto dependencies, adds@types/nodemailerNew env vars
RESEND_API_KEYandRESEND_FROM_DOMAINare untouched — existingdeployments keep working without any config change.
Error semantics — backward compatible
The original
r.emails.send(...) as anycast meant Resend errors(
{ data: null, error: {...} }) were silently swallowed by every caller(
auth-options.ts,send-invites.ts,Notification.ts,send-download-link.ts). To avoid changing that contract, both providerslog via
console.errorand return an empty{}on send failure ratherthan throwing. Callers continue to behave exactly as before.
Trade-offs
scheduledAtis Resend-only. With SMTP active,sendEmail()warnsand sends immediately rather than throwing. Only one in-tree caller
uses
scheduledAt(the desktop/first-viewnotification flow).marketingemails continue to gate onNEXT_PUBLIC_IS_CAPexactlyas before — they aren't sent from self-host deployments at all.
Verification
pnpm exec biome checkclean on the touched filespnpm typecheck(=pnpm tsc -bover the whole workspace) cleansendEmail()caller — none inspect the returnvalue, all just
await, so the error-semantics change above is safePromise<{ data, error }>) and@react-email/render1.x (Promise<string>, withOptions.plainText)cross-checked against the installed type declarations
What I'd like reviewed first
EmailProviderabstraction — happy to fold it differentlyif there's a preferred location (e.g. living next to
packages/web-domainrather than under
packages/database).EMAIL_FROMshould be renamed (right now it's deliberatelygeneric, but I can rename it to
EMAIL_DEFAULT_FROMor scope it toSMTP_FROMif you'd rather keep Resend and SMTP fully separate).bump that
pnpm installproduced when resolving the new deps) is OK toride along, or you'd prefer me to manually trim it.
Not yet done
An end-to-end test of the SMTP path against a real relay is still pending
on my self-hosted deployment (I run on
cap-web:morain-patchedand wantto keep that stable while review iterates). I'll report the live result
on this PR before requesting merge.
Review updates
350636a7eaddresses the Greptile review on the SMTP path: thedelivered@resend.devtest sink is now gated to the Resend provider only, a warning fires when exactly one ofSMTP_USER/SMTP_PASSis set, the now-deadresend()export is removed, andresetEmailProvider()is exported for test isolation.