← 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> = { "&": "&", "<": "<", ">": ">" };
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"),
};
}