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); } }