Skip to content
Draft
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
33 changes: 24 additions & 9 deletions packages/database/emails/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { buildEnv, serverEnv } from "@cap/env";
import type { JSXElementConstructor, ReactElement } from "react";
import { Resend } from "resend";
import { getEmailProvider } from "./providers";

export const resend = () =>
serverEnv().RESEND_API_KEY ? new Resend(serverEnv().RESEND_API_KEY) : null;
Expand All @@ -26,27 +27,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,
subject,
react,
scheduledAt,
scheduledAt: provider.name === "resend" ? scheduledAt : undefined,
cc: test ? undefined : cc,
replyTo: replyTo,
replyTo,
});
};
48 changes: 48 additions & 0 deletions packages/database/emails/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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;
}

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;
}
return new SmtpEmailProvider({
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,
});
}

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