← 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";
}