feat(emails): pluggable email provider (Resend + SMTP)#1900
Draft
alexis-morain wants to merge 2 commits into
Draft
feat(emails): pluggable email provider (Resend + SMTP)#1900alexis-morain wants to merge 2 commits into
alexis-morain wants to merge 2 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.