Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 25 additions & 14 deletions packages/database/emails/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { buildEnv, serverEnv } from "@cap/env";
import type { JSXElementConstructor, ReactElement } from "react";
import { Resend } from "resend";

export const resend = () =>
serverEnv().RESEND_API_KEY ? new Resend(serverEnv().RESEND_API_KEY) : null;
import { getEmailProvider } from "./providers";

export const sendEmail = async ({
email,
Expand All @@ -26,27 +23,41 @@ export const sendEmail = async ({
replyTo?: string;
fromOverride?: string;
}) => {
const r = resend();
if (!r) {
return Promise.resolve();
}
const provider = getEmailProvider();
if (!provider) return;

if (marketing && !buildEnv.NEXT_PUBLIC_IS_CAP) return;
let from: string;

let from: string;
if (fromOverride) from = fromOverride;
else if (marketing) from = "Richie from Cap <richie@send.cap.so>";
else if (buildEnv.NEXT_PUBLIC_IS_CAP)
from = "Cap Auth <no-reply@auth.cap.so>";
else from = `auth@${serverEnv().RESEND_FROM_DOMAIN}`;
else {
const env = serverEnv();
if (env.EMAIL_FROM) from = env.EMAIL_FROM;
else if (env.RESEND_FROM_DOMAIN) from = `auth@${env.RESEND_FROM_DOMAIN}`;
else {
console.warn(
"[email] No EMAIL_FROM or RESEND_FROM_DOMAIN configured — skipping send",
);
return;
}
}

if (scheduledAt && provider.name !== "resend") {
console.warn(
`[email] scheduledAt requested but provider is ${provider.name} — sending immediately`,
);
}

return r.emails.send({
return provider.send({
from,
to: test ? "delivered@resend.dev" : email,
to: test && provider.name === "resend" ? "delivered@resend.dev" : email,
subject,
react,
scheduledAt,
scheduledAt: provider.name === "resend" ? scheduledAt : undefined,
cc: test ? undefined : cc,
replyTo: replyTo,
replyTo,
});
};
57 changes: 57 additions & 0 deletions packages/database/emails/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { serverEnv } from "@cap/env";
import { ResendEmailProvider } from "./resend";
import { SmtpEmailProvider } from "./smtp";
import type { EmailProvider } from "./types";

let cached: EmailProvider | null | undefined;

export function getEmailProvider(): EmailProvider | null {
if (cached !== undefined) return cached;
cached = build();
return cached;
Comment on lines +6 to +11

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.

}

export function resetEmailProvider(): void {
cached = undefined;
}

function build(): EmailProvider | null {
const env = serverEnv();
const requested =
env.EMAIL_PROVIDER ?? (env.RESEND_API_KEY ? "resend" : null);

if (requested === "smtp") {
if (!env.SMTP_HOST || !env.SMTP_PORT) {
console.warn(
"[email] EMAIL_PROVIDER=smtp but SMTP_HOST/SMTP_PORT missing — emails disabled",
);
return null;
}
if (Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASS)) {
console.warn(
"[email] Only one of SMTP_USER/SMTP_PASS is set: SMTP auth is disabled. Set both or neither.",
);
}
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.

host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE,
user: env.SMTP_USER,
pass: env.SMTP_PASS,
});
}

if (requested === "resend") {
if (!env.RESEND_API_KEY) {
console.warn(
"[email] EMAIL_PROVIDER=resend but RESEND_API_KEY missing — emails disabled",
);
return null;
}
return new ResendEmailProvider(env.RESEND_API_KEY);
}

return null;
}

export type { EmailProvider } from "./types";
32 changes: 32 additions & 0 deletions packages/database/emails/providers/resend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Resend } from "resend";
import type { EmailProvider, SendEmailInput, SendEmailResult } from "./types";

export class ResendEmailProvider implements EmailProvider {
readonly name = "resend" as const;
private readonly client: Resend;

constructor(apiKey: string) {
this.client = new Resend(apiKey);
}

async send(input: SendEmailInput): Promise<SendEmailResult> {
const result = await this.client.emails.send({
from: input.from,
to: input.to,
subject: input.subject,
react: input.react,
cc: input.cc,
replyTo: input.replyTo,
scheduledAt: input.scheduledAt,
});

if (result.error) {
console.error(
`[email] Resend send failed: ${result.error.name} — ${result.error.message}`,
);
return {};
}

return { id: result.data?.id };
}
}
53 changes: 53 additions & 0 deletions packages/database/emails/providers/smtp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render } from "@react-email/render";
import type { Transporter } from "nodemailer";
import { createTransport } from "nodemailer";
import type { EmailProvider, SendEmailInput, SendEmailResult } from "./types";

export type SmtpConfig = {
host: string;
port: number;
secure: boolean;
user?: string;
pass?: string;
};

export class SmtpEmailProvider implements EmailProvider {
readonly name = "smtp" as const;
private readonly transporter: Transporter;

constructor(config: SmtpConfig) {
this.transporter = createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth:
config.user && config.pass
? { user: config.user, pass: config.pass }
: undefined,
});
Comment on lines +23 to +27

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.

}

async send(input: SendEmailInput): Promise<SendEmailResult> {
try {
const [html, text] = await Promise.all([
render(input.react),
render(input.react, { plainText: true }),
]);

const info = await this.transporter.sendMail({
from: input.from,
to: input.to,
cc: input.cc,
replyTo: input.replyTo,
subject: input.subject,
html,
text,
});

return { id: info.messageId };
} catch (error) {
console.error("[email] SMTP send failed:", error);
return {};
}
}
}
22 changes: 22 additions & 0 deletions packages/database/emails/providers/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { JSXElementConstructor, ReactElement } from "react";

export type EmailProviderName = "resend" | "smtp";

export type SendEmailInput = {
from: string;
to: string;
subject: string;
react: ReactElement<unknown, string | JSXElementConstructor<unknown>>;
cc?: string | string[];
replyTo?: string;
scheduledAt?: string;
};

export type SendEmailResult = {
id?: string;
};

export interface EmailProvider {
readonly name: EmailProviderName;
send(input: SendEmailInput): Promise<SendEmailResult>;
}
3 changes: 2 additions & 1 deletion packages/database/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"nanoid": "^5.0.4",
"next": "15.5.9",
"next-auth": "^4.24.5",
"nodemailer": "^6.9.8",
"react-email": "^4.0.16",
"resend": "4.6.0",
"zod": "^3"
Expand All @@ -41,11 +42,11 @@
"@cap/ui": "workspace:*",
"@cap/utils": "workspace:*",
"@types/node": "^20.10.0",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19.1.13",
"@types/react-dom": "19.1.9",
"dotenv-cli": "latest",
"drizzle-kit": "0.31.0",
"nodemailer": "^6.9.8",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^6.18.0",
Expand Down
22 changes: 21 additions & 1 deletion packages/env/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,30 @@ function createServerEnv() {
"32 byte hex string for encrypting values like AWS access keys",
),

// Cap uses Resend for email sending, including sending login code emails
EMAIL_PROVIDER: z
.enum(["resend", "smtp"])
.optional()
.describe(
"Email provider for transactional emails. Defaults to 'resend' if RESEND_API_KEY is set, otherwise emails are disabled.",
),
EMAIL_FROM: z
.string()
.optional()
.describe(
"Full from address (e.g. 'Cap <auth@example.com>') used by every provider. Falls back to 'auth@{RESEND_FROM_DOMAIN}' when unset.",
),

RESEND_API_KEY: z.string().optional(),
RESEND_FROM_DOMAIN: z.string().optional(),

SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().positive().optional(),
SMTP_SECURE: boolString(false).describe(
"Use TLS on connect (port 465). Leave false for STARTTLS on 587.",
),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),

/// S3 configuration
// Though they are prefixed with `CAP_AWS`, these don't have to be
// for AWS, and can instead be for any S3-compatible service
Expand Down
Loading
Loading