← back to Govarbitrage
src/lib/send-digest.ts
89 lines
import { prisma } from "@/lib/db";
import { findHotDeals, type HotDeal } from "@/lib/hot-deals";
import { sendNewsletterEmail, newsletterSendMode } from "@/lib/newsletter";
import { persistDigestSnapshot, currentSlot } from "@/lib/digest-snapshot";
// Digest send-to-list. Renders the same honesty-gated Top-10 the paid alerts
// use and fans it out to every CONFIRMED subscriber, each with their own
// one-click unsubscribe link (CAN-SPAM). All sends go through
// sendNewsletterEmail(), which is TEST-logged by default and throws on an
// unapproved live flip — so calling this is safe until Steve approves live
// transport. Nothing schedules this yet; the launchd/cron wiring is part of
// the gated go-live (see pending-approval memo).
const fmtUsd = (n: number) => `$${Math.round(n).toLocaleString("en-US")}`;
export function renderDigestText(deals: HotDeal[], unsubscribeUrl: string): string {
const lines: string[] = [
"GovArbitrage — Top-10 Deals Digest",
new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }),
"",
];
deals.forEach((d, i) => {
const where = [d.listing.locationCity, d.listing.locationState].filter(Boolean).join(", ");
lines.push(
`${i + 1}. ${d.listing.title}`,
` Current bid ${fmtUsd(d.currentBid)} · rec. max bid ${fmtUsd(d.recMax)} · ` +
`est. resale ${fmtUsd(d.expectedSaleLow)} (${d.confidence} confidence)`,
` Conservative ROI ${(d.roiConservative * 100).toFixed(0)}%${d.roiCapped ? " (capped)" : ""} · ${d.disclaimer}`,
...(d.listing.sourceUrl ? [` ${d.listing.sourceUrl}`] : []),
...(where ? [` Location: ${where}`] : []),
""
);
});
if (!deals.length) lines.push("No deals cleared the hot-deal gate this run.", "");
lines.push(
"—",
"Every figure above is confidence-discounted and capped, never inflated.",
"Estimates are heuristic — always verify comps before bidding.",
"",
`Unsubscribe (one click, effective immediately): ${unsubscribeUrl}`
);
return lines.join("\n");
}
export interface DigestSendResult {
mode: "test" | "live";
subscribers: number;
sent: number;
errors: number;
dealCount: number;
}
export async function sendDigestToSubscribers(opts: { baseUrl?: string; limit?: number } = {}): Promise<DigestSendResult> {
const baseUrl = opts.baseUrl || process.env.NEWSLETTER_BASE_URL;
// Never let a live send fabricate localhost unsubscribe links — a broken
// unsubscribe URL in a real email is a CAN-SPAM problem, not a dev nit.
if (!baseUrl || (newsletterSendMode() === "live" && !baseUrl.startsWith("https://"))) {
throw new Error("sendDigestToSubscribers needs a public NEWSLETTER_BASE_URL (https) — refusing to build localhost links.");
}
const deals = await findHotDeals({ limit: opts.limit ?? 10 });
// Freeze this edition for the public /deals archive — even if every send
// below fails, the archive records what the digest showed at send time.
await persistDigestSnapshot(currentSlot(), deals);
const subscribers = await prisma.subscriber.findMany({ where: { status: "CONFIRMED" } });
let sent = 0;
let errors = 0;
for (const sub of subscribers) {
const unsubscribeUrl = `${baseUrl}/api/newsletter/unsubscribe?token=${sub.unsubscribeToken}`;
try {
await sendNewsletterEmail({
to: sub.email,
subject: `Top-10 gov-surplus deals — ${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}`,
text: renderDigestText(deals, unsubscribeUrl),
});
sent++;
} catch {
errors++;
}
}
return { mode: newsletterSendMode(), subscribers: subscribers.length, sent, errors, dealCount: deals.length };
}