← back to Trademarks Copyright
src/lib/email.ts
152 lines
/**
* Email delivery with three backends, picked by env:
* 1. Resend (RESEND_API_KEY) — preferred, clean API, generous free tier
* 2. SMTP (SMTP_HOST + SMTP_USER + SMTP_PASS) — anything: Google Workspace, Mailgun SMTP, Postmark
* 3. File drop (fallback) — writes HTML + meta JSON to /tmp/drops/<timestamp>.html
*
* All backends return the same { ok, id?, error? } contract.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
export type EmailResult = { ok: boolean; id?: string; error?: string; via: "resend" | "smtp" | "file" };
export interface SendArgs {
to: string;
subject: string;
html: string;
text?: string;
fromOverride?: string;
/** Token for the one-click unsubscribe header (Gmail/Yahoo bulk policy). */
unsubscribeToken?: string;
}
const FROM = process.env.DROPS_FROM || "Drops <drops@localhost>";
const APP_BASE = process.env.APP_BASE_URL || "http://localhost:9770";
function buildHeaders(args: SendArgs): Record<string, string> {
const h: Record<string, string> = {};
if (args.unsubscribeToken) {
const u = `${APP_BASE}/api/drops/unsubscribe/${encodeURIComponent(args.unsubscribeToken)}`;
// RFC 8058 one-click. Both headers required for Gmail/Yahoo bulk-sender policy.
h["List-Unsubscribe"] = `<${u}>`;
h["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
}
return h;
}
/**
* P0 — CAN-SPAM Act 16 C.F.R. § 316.5 + Cal. Bus. & Prof. § 17529.5.
* Every commercial email body must contain a real physical postal address.
* `DROPS_MAILING_ADDRESS` is the env-gated source. If unset, file-drop is the
* only allowed backend (writes to /tmp/drops, not over the wire). Any wire
* backend (Resend, SMTP) without the address = certain FTC violation per
* recipient, $50,750+ federal + $1,000 California stacking. Fail closed.
*
* Pre-flight check is enforced HERE, not at compose time, because Resend/SMTP
* envs can be added at runtime and the check must be evaluated each send.
*/
function assertCommercialAddressOrFail(via: "resend" | "smtp"): EmailResult | null {
const addr = (process.env.DROPS_MAILING_ADDRESS || "").trim();
if (!addr) {
return {
ok: false,
via,
error:
`BLOCKED: DROPS_MAILING_ADDRESS is unset. CAN-SPAM 16 C.F.R. § 316.5 ` +
`requires a valid physical postal address in every commercial email. ` +
`Set DROPS_MAILING_ADDRESS in .env.local (street + city + state + ZIP, ` +
`or USPS-registered PO box / Form 1583 mailbox) before enabling a ` +
`wire backend. https://www.ftc.gov/legal-library/browse/rules/can-spam-act-business-guide`,
};
}
return null;
}
export async function sendEmail(args: SendArgs): Promise<EmailResult> {
const from = args.fromOverride ?? FROM;
if (process.env.RESEND_API_KEY) {
const blocked = assertCommercialAddressOrFail("resend");
if (blocked) return blocked;
return sendViaResend(args, from);
}
if (process.env.SMTP_HOST) {
const blocked = assertCommercialAddressOrFail("smtp");
if (blocked) return blocked;
return sendViaSmtp(args, from);
}
return sendViaFile(args, from);
}
async function sendViaResend(args: SendArgs, from: string): Promise<EmailResult> {
try {
const extraHeaders = buildHeaders(args);
const r = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.RESEND_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
from,
to: [args.to],
subject: args.subject,
html: args.html,
text: args.text,
headers: Object.keys(extraHeaders).length ? extraHeaders : undefined,
}),
});
const j = (await r.json()) as { id?: string; name?: string; message?: string };
if (!r.ok) return { ok: false, error: j.message || `HTTP ${r.status}`, via: "resend" };
return { ok: true, id: j.id, via: "resend" };
} catch (e) {
return { ok: false, error: (e as Error).message, via: "resend" };
}
}
async function sendViaSmtp(args: SendArgs, from: string): Promise<EmailResult> {
try {
// Dynamic import — nodemailer is optional; install with `npm i nodemailer` to enable.
const mod = await import("nodemailer" as string).catch(() => null) as
| { createTransport: (opts: Record<string, unknown>) => { sendMail: (m: Record<string, unknown>) => Promise<{ messageId: string }> } }
| null;
if (!mod) {
return { ok: false, error: "nodemailer not installed — run `npm install nodemailer` to enable SMTP", via: "smtp" };
}
const tx = mod.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === "true",
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
});
const info = await tx.sendMail({ from, to: args.to, subject: args.subject, html: args.html, text: args.text, headers: buildHeaders(args) });
return { ok: true, id: info.messageId, via: "smtp" };
} catch (e) {
return { ok: false, error: (e as Error).message, via: "smtp" };
}
}
async function sendViaFile(args: SendArgs, from: string): Promise<EmailResult> {
try {
const dir = "/tmp/drops";
await fs.mkdir(dir, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const safeTo = args.to.replace(/[^a-z0-9@._-]/gi, "_");
const file = path.join(dir, `${ts}__${safeTo}.html`);
const meta = `<!--\nFrom: ${from}\nTo: ${args.to}\nSubject: ${args.subject}\n-->\n`;
await fs.writeFile(file, meta + args.html, "utf8");
return { ok: true, id: file, via: "file" };
} catch (e) {
return { ok: false, error: (e as Error).message, via: "file" };
}
}
export function currentBackend(): "resend" | "smtp" | "file" {
if (process.env.RESEND_API_KEY) return "resend";
if (process.env.SMTP_HOST) return "smtp";
return "file";
}