diff --git a/lib/email.ts b/lib/email.ts new file mode 100644 index 00000000..8951c4e8 --- /dev/null +++ b/lib/email.ts @@ -0,0 +1,116 @@ +import nodemailer, { type SendMailOptions } from "nodemailer"; + +/** + * Gmail SMTP mailer. + * + * Replaces the previous Resend-based delivery-statement email path. All + * outbound mail is sent through the address configured in + * `GMAIL_SMTP_USER`, authenticated with a Gmail App Password stored in + * `GMAIL_SMTP_APP_PASSWORD`. The sender address is always forced to the + * authenticated user — callers cannot override it and cannot add CC/BCC + * (intentional: keeps the audit trail clean and prevents header injection). + * + * Errors from the underlying transport are mapped to a single safe + * "Gmail email send failed" message. We never include the SMTP password, + * response bodies, or other provider detail in thrown errors — those can + * leak credentials or PII if surfaced to the UI. + */ + +/** + * Minimal env shape we need — accepts `process.env` or a hand-built + * object in tests. Indexed by string so `process.env` (whose own type + * uses an index signature) is assignable without forcing every test + * fixture to satisfy `NodeJS.ProcessEnv`. + */ +export type GmailEnv = { + [key: string]: string | undefined; +}; + +export type EmailAttachment = { + filename: string; + content: Buffer; +}; + +export type EmailMessage = { + to: string; + subject: string; + html: string; + attachments?: EmailAttachment[]; +}; + +export type MailTransport = { + sendMail(message: SendMailOptions): Promise; +}; + +type SendEmailOptions = { + env?: GmailEnv; + transport?: MailTransport; +}; + +const MISSING_CREDENTIALS_MESSAGE = "Gmail email credentials are not configured"; +const SEND_FAILED_MESSAGE = "Gmail email send failed"; + +export type GmailCredentials = { + user: string; + appPassword: string; +}; + +/** + * Read and validate the Gmail SMTP credentials from `env`. Trims surrounding + * whitespace (Gmail app passwords are sometimes copy-pasted with a stray + * space). Throws if either variable is missing or blank. + */ +export function getGmailCredentials(env: GmailEnv = process.env): GmailCredentials { + const user = env.GMAIL_SMTP_USER?.trim(); + const appPassword = env.GMAIL_SMTP_APP_PASSWORD?.trim(); + if (!user || !appPassword) { + throw new Error(MISSING_CREDENTIALS_MESSAGE); + } + return { user, appPassword }; +} + +/** + * Build a real nodemailer transport pointed at smtp.gmail.com on port 587 + * with STARTTLS. Used by production code; tests inject their own transport + * via `sendEmail(message, { transport })`. + */ +export function createGmailTransport(env: GmailEnv = process.env): MailTransport { + const { user, appPassword } = getGmailCredentials(env); + return nodemailer.createTransport({ + host: "smtp.gmail.com", + port: 587, + secure: false, + requireTLS: true, + auth: { + user, + pass: appPassword, + }, + }); +} + +/** + * Send an email via Gmail SMTP. The `from` field is always set to the + * authenticated Gmail user — any value provided by the caller is ignored. + * CC/BCC are intentionally not forwarded (not part of `EmailMessage`). + * + * Any failure from the underlying transport is wrapped in a new Error with + * a generic message so we never leak credentials, SMTP response bodies, or + * other provider detail back up the call stack. + */ +export async function sendEmail(message: EmailMessage, options: SendEmailOptions = {}): Promise { + const env = options.env ?? process.env; + const transport = options.transport ?? createGmailTransport(env); + const { user } = getGmailCredentials(env); + + try { + return await transport.sendMail({ + from: user, + to: message.to, + subject: message.subject, + html: message.html, + attachments: message.attachments, + }); + } catch { + throw new Error(SEND_FAILED_MESSAGE); + } +}