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