diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 4d9fa71a10..411601b1de 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -73,7 +73,7 @@ export const authOptions = (): NextAuthOptions => { async sendVerificationRequest({ identifier, token }) { console.log("sendVerificationRequest"); - if (!serverEnv().RESEND_API_KEY) { + if (!serverEnv().RESEND_API_KEY && !serverEnv().SMTP_HOST) { console.log("\n"); console.log( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", diff --git a/packages/database/emails/config.ts b/packages/database/emails/config.ts index 0f27b402b9..9eba2b2c5b 100644 --- a/packages/database/emails/config.ts +++ b/packages/database/emails/config.ts @@ -1,10 +1,33 @@ import { buildEnv, serverEnv } from "@cap/env"; +import { render } from "@react-email/render"; +import nodemailer from "nodemailer"; import type { JSXElementConstructor, ReactElement } from "react"; import { Resend } from "resend"; export const resend = () => serverEnv().RESEND_API_KEY ? new Resend(serverEnv().RESEND_API_KEY) : null; +let _smtpTransport: ReturnType | null | undefined; + +export const smtp = () => { + if (_smtpTransport !== undefined) return _smtpTransport; + const env = serverEnv(); + if (!env.SMTP_HOST) { + _smtpTransport = null; + return null; + } + _smtpTransport = 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, + }); + return _smtpTransport; +}; + export const sendEmail = async ({ email, subject, @@ -26,23 +49,51 @@ export const sendEmail = async ({ replyTo?: string; fromOverride?: string; }) => { - const r = resend(); - if (!r) { - return Promise.resolve(); - } - if (marketing && !buildEnv.NEXT_PUBLIC_IS_CAP) return; - let from; + let from: string; if (fromOverride) from = fromOverride; else if (marketing) from = "Richie from Cap "; else if (buildEnv.NEXT_PUBLIC_IS_CAP) from = "Cap Auth "; - else from = `auth@${serverEnv().RESEND_FROM_DOMAIN}`; + else if (serverEnv().SMTP_FROM) from = serverEnv().SMTP_FROM!; + else if (serverEnv().RESEND_FROM_DOMAIN) + from = `auth@${serverEnv().RESEND_FROM_DOMAIN}`; + else { + // No from-address configured. Most SMTP servers reject "noreply@localhost" + // as invalid sender, which would silently drop the email. Fail loudly instead. + throw new Error( + "No email from-address configured. Set SMTP_FROM (when using SMTP) or RESEND_FROM_DOMAIN (when using Resend).", + ); + } + + // Resend has a sandbox sink at delivered@resend.dev for test mode; + // over SMTP we just use the real recipient + const to = test && !smtp() ? "delivered@resend.dev" : email; + + // Try SMTP first if configured (preferred for self-hosted) + const transport = smtp(); + if (transport) { + const html = await render(react); + return transport.sendMail({ + from, + to, + subject, + html, + cc: test ? undefined : cc, + replyTo, + }) as any; + } + + // Fall back to Resend if configured + const r = resend(); + if (!r) { + return Promise.resolve(); + } return r.emails.send({ from, - to: test ? "delivered@resend.dev" : email, + to, subject, react, scheduledAt, diff --git a/packages/env/server.ts b/packages/env/server.ts index febc9e3e87..5af6510b02 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -30,6 +30,15 @@ function createServerEnv() { RESEND_API_KEY: z.string().optional(), RESEND_FROM_DOMAIN: z.string().optional(), + // SMTP configuration (alternative to Resend) + // If SMTP_HOST is set, emails will be sent via SMTP instead of Resend + SMTP_HOST: z.string().optional().describe("SMTP server hostname"), + SMTP_PORT: z.coerce.number().optional().default(587).describe("SMTP server port"), + SMTP_USER: z.string().optional().describe("SMTP username"), + SMTP_PASS: z.string().optional().describe("SMTP password"), + SMTP_FROM: z.string().optional().describe("Default From address (e.g. cap@mail.example.com)"), + SMTP_SECURE: boolString(false).describe("Use TLS/SSL (true for port 465, false for 587/STARTTLS)"), + /// 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