Skip to content

feat: add SMTP support as alternative to Resend#1775

Open
julianwitzel wants to merge 1 commit intoCapSoftware:mainfrom
julianwitzel:feat/smtp-support
Open

feat: add SMTP support as alternative to Resend#1775
julianwitzel wants to merge 1 commit intoCapSoftware:mainfrom
julianwitzel:feat/smtp-support

Conversation

@julianwitzel
Copy link
Copy Markdown

@julianwitzel julianwitzel commented Apr 30, 2026

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_PASS
  • SMTP_FROM — Default From address (e.g. Cap <cap@mail.example.com>)
  • SMTP_SECUREtrue for SSL (port 465), false for STARTTLS (port 587)

packages/database/emails/config.ts — Extends sendEmail():

  • New smtp() helper that returns a configured nodemailer.Transporter when SMTP_HOST is set, otherwise null
  • sendEmail() tries SMTP first; if no transport is configured, falls back to Resend (existing behaviour)
  • React-Email templates are rendered to HTML via @react-email/render before being passed to nodemailer (Resend handles React directly, nodemailer needs HTML)
  • From-address resolution extended: fromOverride → marketing → IS_CAP → SMTP_FROMRESEND_FROM_DOMAIN → fallback

packages/database/auth/auth-options.ts — One-line fix:

-if (!serverEnv().RESEND_API_KEY) {
+if (!serverEnv().RESEND_API_KEY && !serverEnv().SMTP_HOST) {

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_HOST is 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:

  • Magic-link auth: code email arrives in the inbox with the correct React-Email template, branding, and From address
  • Verified with self-hosted Plunk SMTP (port=587, secure=false, STARTTLS)
  • Confirmed Resend path still works when only RESEND_API_KEY is set (no SMTP env)
  • Verified dev-mode console-log fallback still works when neither is configured

Example .env

# SMTP via your own server / Plunk / SES / Mailgun
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=username
SMTP_PASS=secret
SMTP_FROM=Cap <cap@mail.example.com>
SMTP_SECURE=false

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:

  • P1 — wrong test-mode recipient over SMTP: to is unconditionally set to \"delivered@resend.dev\" when test: 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

Filename Overview
packages/database/emails/config.ts Core email dispatch logic extended with SMTP transport; P1 bug where test-mode to-address is Resend-specific and sends real emails via SMTP, plus two P2 style concerns.
packages/database/auth/auth-options.ts One-line fix correctly extends the dev-mode console-log guard to also check SMTP_HOST, preventing the fallback from triggering when only SMTP is configured.
packages/env/server.ts Adds 6 well-typed optional SMTP env vars using existing Zod patterns; SMTP_PORT coercion and SMTP_SECURE boolString defaults are correct.

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:#aa0
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/database/emails/config.ts:57
**`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;
```

### Issue 2 of 3
packages/database/emails/config.ts:52-55
**`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.

### Issue 3 of 3
packages/database/emails/config.ts:10-22
**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.

Reviews (1): Last reviewed commit: "feat: add SMTP support as alternative to..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Comment thread packages/database/emails/config.ts Outdated
from = `auth@${serverEnv().RESEND_FROM_DOMAIN}`;
else from = "noreply@localhost";

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

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

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

Comment thread packages/database/emails/config.ts Outdated
Comment on lines +52 to +55
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";
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 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.

Comment on lines +10 to +22
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,
});
};
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 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
@julianwitzel
Copy link
Copy Markdown
Author

Thanks for the thorough review @greptile-apps — all three points addressed in the amended commit:

  • P1 (test-mode recipient over SMTP): Now scoped to Resend only with test && !smtp() ? "delivered@resend.dev" : email — SMTP path uses the real recipient.
  • P2 (silent noreply@localhost fallback): Replaced with a descriptive throw so misconfigurations fail loudly at first send rather than being silently dropped by SMTP servers.
  • P2 (transporter pooling): Memoised _smtpTransport following the same pattern as serverEnv(), so the connection pool persists across calls.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 30, 2026

All three fixes look correct:

  • P1: const to = test && !smtp() ? "delivered@resend.dev" : email — SMTP path correctly uses the real recipient.
  • P2 (from-address): The throw new Error(...) with a clear message is a good improvement — fails loudly with actionable guidance rather than silently misbehaving.
  • P2 (pooling): _smtpTransport is properly memoised at module scope with the undefined-sentinel pattern matching serverEnv(). The null case for no SMTP_HOST is also cached, avoiding repeated env lookups.

Implementation looks solid. LGTM.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

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