-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat(emails): pluggable email provider (Resend + SMTP) #1900
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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({ | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If only one of
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 350636a: |
||||||||||||||||||||||
| 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"; | ||||||||||||||||||||||
| 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 }; | ||
| } | ||
| } |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a deployer sets Prompt To Fix With AIThis 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 350636a: |
||
| } | ||
|
|
||
| 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 {}; | ||
| } | ||
| } | ||
| } | ||
| 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>; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cachedis a module-level variable that persists for the lifetime of the module. In Jest / Vitest test suites that modifyprocess.envbetween 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. AresetEmailProvider()export (or moving the cache insideserverEnv's own lifecycle) would make this testable without module mocks.Prompt To Fix With AI
There was a problem hiding this comment.
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.