Skip to content

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

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

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

Conversation

@alexis-morain
Copy link
Copy Markdown
Contributor

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

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.

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