← back to Govarbitrage

src/lib/newsletter.ts

142 lines

import { randomBytes } from "node:crypto";
import { appendFile, mkdir } from "node:fs/promises";
import path from "node:path";

// Newsletter email dispatch — TEST mode by default.
//
// Env placeholders (all optional until a live send is approved):
//   NEWSLETTER_SEND_MODE          "test" (default) | "live". Live additionally
//                                 requires NEWSLETTER_SEND_APPROVAL_TOKEN to be
//                                 set — both must be present or we stay in test.
//   NEWSLETTER_SEND_APPROVAL_TOKEN  Steve-issued token gating live sends.
//   NEWSLETTER_FROM_NAME          Display name for the From header.
//   NEWSLETTER_FROM_EMAIL         From address (live wiring TBD via George/SMTP).
//   NEWSLETTER_MAILING_ADDRESS    CAN-SPAM physical postal address (required in
//                                 every commercial email footer before go-live).
//   NEWSLETTER_BASE_URL           Public origin for confirm/unsubscribe links;
//                                 falls back to the request origin.
//
// In TEST mode every would-be email is written to the console AND appended to
// logs/newsletter-outbox.jsonl. LIVE mode (both NEWSLETTER_SEND_MODE=live and
// NEWSLETTER_SEND_APPROVAL_TOKEN set) sends via George /api/send; both modes
// append a redacted record to the outbox as the audit trail.

export interface NewsletterEmail {
  to: string;
  subject: string;
  text: string;
}

export function newsletterSendMode(): "test" | "live" {
  const live =
    process.env.NEWSLETTER_SEND_MODE === "live" &&
    !!process.env.NEWSLETTER_SEND_APPROVAL_TOKEN;
  return live ? "live" : "test";
}

const OUTBOX = path.join(process.cwd(), "logs", "newsletter-outbox.jsonl");

// Confirm/unsubscribe tokens are live single-use credentials — anything that
// lands in a log file must carry only a redacted prefix, never the full token.
export function redactTokens(text: string): string {
  return text.replace(/(token=)([A-Za-z0-9_-]{9,})/g, (_, k, v) => `${k}${v.slice(0, 8)}…`);
}

export async function sendNewsletterEmail(email: NewsletterEmail): Promise<void> {
  const record = {
    ...email,
    text: redactTokens(email.text),
    mode: newsletterSendMode(),
    fromName: process.env.NEWSLETTER_FROM_NAME || "GovArbitrage Digest (unset)",
    mailingAddress: process.env.NEWSLETTER_MAILING_ADDRESS || "(NEWSLETTER_MAILING_ADDRESS unset)",
    at: new Date().toISOString(),
  };

  if (record.mode === "live") {
    // Live transport: George /api/send (approved 2026-07-13). External sends
    // require the X-Send-Approval token — George fail-closes without it. The
    // transport gets `email.text` (full tokens); everything logged is redacted.
    const base = process.env.GEORGE_URL;
    const auth = process.env.GEORGE_BASIC_AUTH;
    const sendToken = process.env.GEORGE_EXTERNAL_SEND_TOKEN;
    if (!base || !auth || !sendToken) {
      throw new Error("Live send needs GEORGE_URL, GEORGE_BASIC_AUTH, and GEORGE_EXTERNAL_SEND_TOKEN.");
    }
    const HTML_ESCAPE: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
    const html = `<pre style="font-family:ui-monospace,Menlo,monospace;font-size:14px;white-space:pre-wrap">${email.text
      .replace(/[&<>]/g, (c) => HTML_ESCAPE[c] ?? c)
      .replace(/(https?:\/\/[^\s]+)/g, '<a href="$1">$1</a>')}</pre>`;
    const res = await fetch(`${base}/api/send`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Basic ${auth}`,
        "X-Send-Approval": sendToken,
      },
      body: JSON.stringify({
        account: process.env.NEWSLETTER_GEORGE_ACCOUNT || "steve-office",
        to: email.to,
        subject: email.subject,
        body: html,
      }),
    });
    if (!res.ok) {
      const text = await res.text();
      throw new Error(`George send failed ${res.status}: ${text.slice(0, 300)}`);
    }
  } else {
    console.log(`[newsletter:test-outbox] to=${email.to} subject="${email.subject}"`);
    console.log(record.text);
  }

  // Both modes append to the outbox (redacted) — the audit trail of every send.
  await mkdir(path.dirname(OUTBOX), { recursive: true });
  await appendFile(OUTBOX, JSON.stringify(record) + "\n", "utf8");
}

export function newToken(): string {
  return randomBytes(32).toString("base64url");
}

export function normalizeEmail(raw: string): string {
  return raw.trim().toLowerCase();
}

function footer(): string {
  const addr = process.env.NEWSLETTER_MAILING_ADDRESS || "(mailing address pending)";
  const from = process.env.NEWSLETTER_FROM_NAME || "GovArbitrage Digest";
  return `—\n${from}\n${addr}`;
}

export function buildConfirmEmail(to: string, baseUrl: string, confirmToken: string): NewsletterEmail {
  const confirmUrl = `${baseUrl}/api/newsletter/confirm?token=${confirmToken}`;
  return {
    to,
    subject: "Confirm your GovArbitrage digest subscription",
    text: [
      "You (or someone using this address) asked to receive the GovArbitrage",
      "Top-10 government-surplus deals digest, sent twice daily (6am & 5pm PT).",
      "Confirm below and the next digest is yours.",
      "",
      `Confirm your subscription: ${confirmUrl}`,
      "",
      "If you didn't request this, ignore this email and you won't be subscribed.",
      "",
      footer(),
    ].join("\n"),
  };
}

export function buildUnsubscribeNoticeEmail(to: string): NewsletterEmail {
  return {
    to,
    subject: "You've been unsubscribed from the GovArbitrage digest",
    text: [
      "You've been unsubscribed and will receive no further digests.",
      "This was effective immediately — no login or confirmation was required.",
      "",
      footer(),
    ].join("\n"),
  };
}