feat: add Gmail SMTP mailer
Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
e964993619
commit
312f061ad7
1 changed files with 116 additions and 0 deletions
116
lib/email.ts
Normal file
116
lib/email.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue