feat: add SMTP support as alternative to Resend#1775
feat: add SMTP support as alternative to Resend#1775julianwitzel wants to merge 1 commit intoCapSoftware:mainfrom
Conversation
| from = `auth@${serverEnv().RESEND_FROM_DOMAIN}`; | ||
| else from = "noreply@localhost"; | ||
|
|
||
| const to = test ? "delivered@resend.dev" : email; |
There was a problem hiding this comment.
test to-address is Resend-specific, sends real email via SMTP
"delivered@resend.dev" is a Resend sandbox sink — it only has meaning when delivery goes through the Resend API. When SMTP is the active transport, this resolves to an ordinary external address and sendMail() will deliver a real email there. Any caller that passes test: true expecting sandboxed delivery while SMTP is configured will silently send to the wrong recipient.
| const to = test ? "delivered@resend.dev" : email; | |
| const to = test && !smtp() ? "delivered@resend.dev" : email; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/emails/config.ts
Line: 57
Comment:
**`test` to-address is Resend-specific, sends real email via SMTP**
`"delivered@resend.dev"` is a Resend sandbox sink — it only has meaning when delivery goes through the Resend API. When SMTP is the active transport, this resolves to an ordinary external address and `sendMail()` will deliver a real email there. Any caller that passes `test: true` expecting sandboxed delivery while SMTP is configured will silently send to the wrong recipient.
```suggestion
const to = test && !smtp() ? "delivered@resend.dev" : email;
```
How can I resolve this? If you propose a fix, please make it concise.| else if (serverEnv().SMTP_FROM) from = serverEnv().SMTP_FROM!; | ||
| else if (serverEnv().RESEND_FROM_DOMAIN) | ||
| from = `auth@${serverEnv().RESEND_FROM_DOMAIN}`; | ||
| else from = "noreply@localhost"; |
There was a problem hiding this comment.
SMTP_FROM resolves independently of which transport is active
The from-address chain checks SMTP_FROM then RESEND_FROM_DOMAIN before falling back to "noreply@localhost". A user who configures only SMTP and never sets SMTP_FROM will fall through to "noreply@localhost", which most SMTP servers will reject as an invalid sender, causing a silent non-delivery. Consider validating that at least one from-address env var is present when a transport is configured, or documenting this fallback clearly.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/emails/config.ts
Line: 52-55
Comment:
**`SMTP_FROM` resolves independently of which transport is active**
The from-address chain checks `SMTP_FROM` then `RESEND_FROM_DOMAIN` before falling back to `"noreply@localhost"`. A user who configures only SMTP and never sets `SMTP_FROM` will fall through to `"noreply@localhost"`, which most SMTP servers will reject as an invalid sender, causing a silent non-delivery. Consider validating that at least one from-address env var is present when a transport is configured, or documenting this fallback clearly.
How can I resolve this? If you propose a fix, please make it concise.| export const smtp = () => { | ||
| const env = serverEnv(); | ||
| if (!env.SMTP_HOST) return null; | ||
| return nodemailer.createTransport({ | ||
| host: env.SMTP_HOST, | ||
| port: env.SMTP_PORT, | ||
| secure: env.SMTP_SECURE, | ||
| auth: | ||
| env.SMTP_USER && env.SMTP_PASS | ||
| ? { user: env.SMTP_USER, pass: env.SMTP_PASS } | ||
| : undefined, | ||
| }); | ||
| }; |
There was a problem hiding this comment.
New transporter created on every
sendEmail() call
smtp() calls nodemailer.createTransport() afresh for every email, discarding the internal connection pool each time. Following the memoisation pattern already used for serverEnv() would keep a single pooled transporter alive for the process lifetime and avoid repeated setup overhead.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/emails/config.ts
Line: 10-22
Comment:
**New transporter created on every `sendEmail()` call**
`smtp()` calls `nodemailer.createTransport()` afresh for every email, discarding the internal connection pool each time. Following the memoisation pattern already used for `serverEnv()` would keep a single pooled transporter alive for the process lifetime and avoid repeated setup overhead.
How can I resolve this? If you propose a fix, please make it concise.Adds nodemailer-based SMTP transport as an alternative to Resend for self-hosted deployments. nodemailer is already a transitive dependency in @cap/database, so no new packages are added. Changes: - packages/env/server.ts: Add SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_SECURE env variables - packages/database/emails/config.ts: Extend sendEmail() to use SMTP via nodemailer when SMTP_HOST is set, falling back to Resend otherwise. React-Email templates are rendered to HTML via @react-email/render before being passed to nodemailer. - packages/database/auth/auth-options.ts: One-line fix to include the SMTP_HOST check in the dev-mode fallback condition, so magic-link authentication uses the SMTP path when configured. Fully backwards-compatible: existing Resend setups continue to work unchanged when SMTP_HOST is not set. Closes CapSoftware#1766
15f27be to
7a65df9
Compare
|
Thanks for the thorough review @greptile-apps — all three points addressed in the amended commit:
|
|
All three fixes look correct:
Implementation looks solid. LGTM. Tip: You can customize Greptile's behavior for this repo with |
Summary
Adds SMTP transport as an alternative to Resend for sending emails (magic-link auth, notifications, etc.) on self-hosted Cap deployments. Uses
nodemailer, which is already a transitive dependency of@cap/database, so no new packages are introduced.Closes #1766.
Why
Self-hosted Cap currently requires a Resend account for any email functionality. For users running Cap on private infrastructure, sending email through their own SMTP server (Postfix, Plunk, AWS SES via SMTP, Mailgun via SMTP, etc.) is often the natural choice — it avoids vendor lock-in, doesn't require an external account, and keeps the email path inside their own network.
This PR makes Cap's email layer pluggable: SMTP is tried first if configured, Resend is the fallback, and existing Resend deployments continue to work unchanged.
Changes
packages/env/server.ts— Adds 6 new optional env variables:SMTP_HOST,SMTP_PORT(default 587),SMTP_USER,SMTP_PASSSMTP_FROM— Default From address (e.g.Cap <cap@mail.example.com>)SMTP_SECURE—truefor SSL (port 465),falsefor STARTTLS (port 587)packages/database/emails/config.ts— ExtendssendEmail():smtp()helper that returns a configurednodemailer.TransporterwhenSMTP_HOSTis set, otherwisenullsendEmail()tries SMTP first; if no transport is configured, falls back to Resend (existing behaviour)@react-email/renderbefore being passed to nodemailer (Resend handles React directly, nodemailer needs HTML)fromOverride→ marketing → IS_CAP →SMTP_FROM→RESEND_FROM_DOMAIN→ fallbackpackages/database/auth/auth-options.ts— One-line fix:Without this, the magic-link
sendVerificationRequest()fell through to the dev-mode console-log branch even when SMTP was configured.Backwards compatibility
Fully backwards-compatible. When
SMTP_HOSTis not set, behaviour is identical to before — Resend handles everything as today. No migrations, no breaking changes.Tested
End-to-end on a self-hosted deployment:
port=587,secure=false, STARTTLS)RESEND_API_KEYis set (no SMTP env)Example .env
Greptile Summary
This PR adds nodemailer-based SMTP as a first-class alternative to Resend for self-hosted Cap deployments. The implementation is clean and fully backwards-compatible, but there is one present defect in
packages/database/emails/config.ts:tois unconditionally set to\"delivered@resend.dev\"whentest: true. That address is a Resend sandbox sink; when SMTP is the active transport it is treated as an ordinary external address and a real email is dispatched there, bypassing the intended recipient.Confidence Score: 3/5
Safe to merge for typical self-hosted setups, but the test-mode recipient bug should be fixed before merging to avoid silently misdirected emails in any path that passes test: true with SMTP configured.
One P1 defect (Resend-specific test-sink address sent over real SMTP transport) drops the ceiling to 4/5; the issue affects a real code path and produces incorrect runtime behaviour, pulling the score to 3.
packages/database/emails/config.ts — test-mode to-address logic and from-address fallback chain.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[sendEmail called] --> B{marketing && !IS_CAP?} B -- yes --> Z[return early] B -- no --> C[Resolve from address\nfromOverride → marketing → IS_CAP\n→ SMTP_FROM → RESEND_FROM_DOMAIN\n→ noreply@localhost] C --> D{test mode?} D -- yes --> E[to = delivered@resend.dev] D -- no --> F[to = email] E --> G{SMTP_HOST set?} F --> G G -- yes --> H[smtp transport\nnodemailer.createTransport] G -- no --> I{RESEND_API_KEY set?} H --> J[render React → HTML\ntransport.sendMail] I -- yes --> K[resend.emails.send\nReact passed directly] I -- no --> L[return Promise.resolve\nsilent no-op] J --> M[Email sent via SMTP] K --> N[Email sent via Resend] style E fill:#f9c,stroke:#c00 style G fill:#ffd,stroke:#aa0Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat: add SMTP support as alternative to..." | Re-trigger Greptile