Skip to content

feat(emails): pluggable email provider (Resend + SMTP)#1900

Open
alexis-morain wants to merge 3 commits into
CapSoftware:mainfrom
alexis-morain:feat/smtp-email-provider
Open

feat(emails): pluggable email provider (Resend + SMTP)#1900
alexis-morain wants to merge 3 commits into
CapSoftware:mainfrom
alexis-morain:feat/smtp-email-provider

Conversation

@alexis-morain

@alexis-morain alexis-morain commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

feat(emails): pluggable email provider (Resend + SMTP)

Closes #1766

Summary

Adds a small EmailProvider abstraction so self-hosted Cap can use any SMTP
server (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_KEY
unchanged. SMTP is opt-in via EMAIL_PROVIDER=smtp.

Motivation

packages/database/emails/config.ts was 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

File Change
packages/database/emails/providers/types.ts EmailProvider interface
packages/database/emails/providers/resend.ts ResendEmailProvider (preserves existing behavior, including scheduledAt)
packages/database/emails/providers/smtp.ts SmtpEmailProvider using nodemailer + @react-email/render for HTML/plain-text
packages/database/emails/providers/index.ts getEmailProvider() — picks provider from env, cached
packages/database/emails/config.ts sendEmail() dispatches via the provider; logs a warning if scheduledAt is requested with a non-Resend provider
packages/env/server.ts New env vars: EMAIL_PROVIDER, EMAIL_FROM, SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS
packages/database/package.json Promotes nodemailer to dependencies, adds @types/nodemailer

New env vars

EMAIL_PROVIDER=smtp                  # or "resend" (default when RESEND_API_KEY is set)
EMAIL_FROM="Cap <auth@example.com>"  # falls back to auth@{RESEND_FROM_DOMAIN}

# Only needed when EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false                    # true for TLS-on-connect (port 465)
SMTP_USER=auth@example.com
SMTP_PASS=•••••••••••

RESEND_API_KEY and RESEND_FROM_DOMAIN are untouched — existing
deployments keep working without any config change.

Error semantics — backward compatible

The original r.emails.send(...) as any cast 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 providers
log via console.error and return an empty {} on send failure rather
than throwing. Callers continue to behave exactly as before.

Trade-offs

  • scheduledAt is Resend-only. With SMTP active, sendEmail() warns
    and sends immediately rather than throwing. Only one in-tree caller
    uses scheduledAt (the desktop /first-view notification flow).
  • marketing emails continue to gate on NEXT_PUBLIC_IS_CAP exactly
    as before — they aren't sent from self-host deployments at all.

Verification

  • pnpm exec biome check clean on the touched files
  • pnpm typecheck (= pnpm tsc -b over the whole workspace) clean
  • ✅ Reviewed every existing sendEmail() caller — none inspect the return
    value, all just await, so the error-semantics change above is safe
  • ✅ Resend SDK signature (Promise<{ data, error }>) and
    @react-email/render 1.x (Promise<string>, with Options.plainText)
    cross-checked against the installed type declarations

What I'd like reviewed first

  • Shape of the EmailProvider abstraction — happy to fold it differently
    if there's a preferred location (e.g. living next to packages/web-domain
    rather than under packages/database).
  • Whether EMAIL_FROM should be renamed (right now it's deliberately
    generic, but I can rename it to EMAIL_DEFAULT_FROM or scope it to
    SMTP_FROM if you'd rather keep Resend and SMTP fully separate).
  • Whether the lockfile drift (incidental rolldown 1.0.1 → 1.1.0 transitive
    bump that pnpm install produced when resolving the new deps) is OK to
    ride 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-patched and want
to keep that stable while review iterates). I'll report the live result
on this PR before requesting merge.

Review updates

  • 350636a7e addresses the Greptile review on the SMTP path: the delivered@resend.dev test sink is now gated to the Resend provider only, a warning fires when exactly one of SMTP_USER/SMTP_PASS is set, the now-dead resend() export is removed, and resetEmailProvider() is exported for test isolation.

alexis-morain and others added 2 commits June 8, 2026 11:19
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.
@alexis-morain alexis-morain marked this pull request as ready for review June 13, 2026 12:32
@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

Comment thread packages/database/emails/config.ts Outdated
return r.emails.send({
return provider.send({
from,
to: test ? "delivered@resend.dev" : email,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
to: test ? "delivered@resend.dev" : email,
to: test && provider.name === "resend" ? "delivered@resend.dev" : email,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 350636a: build() now warns when exactly one of SMTP_USER/SMTP_PASS is set, surfacing the auth-disabled misconfiguration early.

Comment thread packages/database/emails/config.ts Outdated
return r.emails.send({
return provider.send({
from,
to: test ? "delivered@resend.dev" : email,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 350636a: gated to the Resend provider only (test && provider.name === "resend").

Comment on lines +6 to +11
let cached: EmailProvider | null | undefined;

export function getEmailProvider(): EmailProvider | null {
if (cached !== undefined) return cached;
cached = build();
return cached;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +23 to +27
auth:
config.user && config.pass
? { user: config.user, pass: config.pass }
: undefined,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 350636a: build() (in providers/index.ts) now warns when only one of SMTP_USER/SMTP_PASS is set, before constructing the transport.

Comment thread packages/database/emails/config.ts Outdated
Comment on lines 3 to 7
import { Resend } from "resend";
import { getEmailProvider } from "./providers";

export const resend = () =>
serverEnv().RESEND_API_KEY ? new Resend(serverEnv().RESEND_API_KEY) : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feat: Add generic SMTP support for transactional emails

1 participant