feat: add Gmail SMTP mailer

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 00:56:27 +02:00
parent e964993619
commit 312f061ad7

116
lib/email.ts Normal file
View file

@ -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<unknown>;
};
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<unknown> {
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);
}
}