From ae8866e8819c78943309533f9d89838c705b6547 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 26 Jul 2026 07:57:27 +0000 Subject: [PATCH] feat(leads): personalised re-engagement campaign for captured PDF leads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an admin-only endpoint that emails people who ran a scan and asked for the PDF, driving them to /hire. Not a broadcast. Each email is built from the recipient's OWN most recent report — their host, their score, their top three failing findings — with a /hire link prefilled with the site and address we already have. That is the thing they asked us for, so it converts better than a newsletter and stays tied to the request that captured the address. Why not /api/admin/email-broadcast: it selects from `profiles`, so it structurally cannot reach a lead who never registered (51 of the 108 we have). It also sends raw HTML through sendBulk with no unsubscribe footer and no List-Unsubscribe headers, and applies no unsubscribed_at filter at all — nobody has unsubscribed yet so no harm has occurred, but the first person who does would keep receiving mail. This route goes through sendMarketingEmail, which adds both, and filters the suppression list twice: once when selecting the audience and again immediately before each send, in case someone opts out mid-run. Audience selection is a pure module with its own tests, because choosing wrongly doesn't throw — it silently mails someone who opted out. Excluded: unsubscribed (which beats a later consent record), our own domains, role accounts, and anyone with no report to talk about. Segmentable into existing customers vs cold leads, deduped per address. DRY RUN BY DEFAULT — sending requires an explicit {"dryRun": false}, and a testTo override sends the whole thing to one inbox first. Paced at 400ms between sends. Co-Authored-By: Claude Opus 5 (1M context) --- app/(marketing)/hire/page.tsx | 9 +- app/api/admin/lead-campaign/route.ts | 239 +++++++++++++++++++++++++++ components/hire-form.tsx | 15 +- lib/email.ts | 94 +++++++++++ lib/leadCampaign.ts | 86 ++++++++++ tests/lead-campaign.test.ts | 138 ++++++++++++++++ 6 files changed, 576 insertions(+), 5 deletions(-) create mode 100644 app/api/admin/lead-campaign/route.ts create mode 100644 lib/leadCampaign.ts create mode 100644 tests/lead-campaign.test.ts diff --git a/app/(marketing)/hire/page.tsx b/app/(marketing)/hire/page.tsx index aded9638..fbaded18 100644 --- a/app/(marketing)/hire/page.tsx +++ b/app/(marketing)/hire/page.tsx @@ -13,7 +13,12 @@ export const metadata = { }, }; -export default function HirePage() { +export default async function HirePage({ + searchParams, +}: { + searchParams: Promise<{ email?: string; website?: string }>; +}) { + const sp = await searchParams; return (

@@ -63,7 +68,7 @@ export default function HirePage() { A few quick details and we'll reply with next steps.

- +
diff --git a/app/api/admin/lead-campaign/route.ts b/app/api/admin/lead-campaign/route.ts new file mode 100644 index 00000000..3ed21c93 --- /dev/null +++ b/app/api/admin/lead-campaign/route.ts @@ -0,0 +1,239 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { buildShareCard } from "@/lib/audit/share-card"; +import { leadCampaignEmailHtml, sendMarketingEmail } from "@/lib/email"; +import { recordLead } from "@/lib/marketing"; +import { + campaignSubject, + hireUrlFor, + selectRecipients, + type LeadRow, + type Segment, +} from "@/lib/leadCampaign"; +import { env } from "@/lib/env"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +// One-off re-engagement of people who ran a scan and asked for the PDF. +// +// Why this exists rather than /api/admin/email-broadcast: that route selects +// from `profiles`, so it cannot reach a lead who never registered, and it +// sends raw HTML through sendBulk with no unsubscribe footer and no +// List-Unsubscribe headers. This one goes through sendMarketingEmail, which +// adds both, and it filters on unsubscribed_at. +// +// DRY RUN BY DEFAULT. You must pass {"dryRun": false} to actually send. + +async function checkAdmin(): Promise<{ ok: true } | { ok: false; response: NextResponse }> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return { ok: false, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; + } + const { data: me } = await supabase + .from("profiles") + .select("is_admin") + .eq("id", user.id) + .maybeSingle(); + if (!me?.is_admin) { + return { ok: false, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) }; + } + return { ok: true }; +} + +async function loadAudience(): Promise { + const svc = serviceClient(); + + // Every completed scan that captured an email, newest first — the first row + // per address therefore carries their most recent report. + const { data: audits } = await svc + .from("audits") + .select("pdf_email, target_url, share_token, status, score, engine, summary, id, completed_at") + .not("pdf_email", "is", null) + .eq("status", "complete") + .order("completed_at", { ascending: false }) + .limit(2000); + + const rows = (audits ?? []) as Array>; + const emails = Array.from( + new Set(rows.map((r) => String(r.pdf_email ?? "").trim().toLowerCase()).filter(Boolean)), + ); + if (emails.length === 0) return []; + + const [{ data: contacts }, { data: profiles }] = await Promise.all([ + svc.from("marketing_contacts").select("email, unsubscribed_at, consented_at").in("email", emails), + svc.from("profiles").select("email").in("email", emails), + ]); + + const contactByEmail = new Map( + (contacts ?? []).map((c) => [String(c.email).toLowerCase(), c]), + ); + const customerEmails = new Set( + (profiles ?? []).map((p) => String(p.email ?? "").toLowerCase()), + ); + + // Top findings for the most recent report per address. + const firstAuditIdByEmail = new Map(); + for (const r of rows) { + const e = String(r.pdf_email ?? "").trim().toLowerCase(); + if (e && !firstAuditIdByEmail.has(e)) firstAuditIdByEmail.set(e, String(r.id)); + } + const { data: findings } = await svc + .from("audit_findings") + .select("audit_id, title, status, priority") + .in("audit_id", Array.from(firstAuditIdByEmail.values())) + .in("status", ["fail", "warn"]) + .order("priority", { ascending: true }); + + const issuesByAudit = new Map(); + for (const f of (findings ?? []) as Array<{ audit_id: string; title: string }>) { + const list = issuesByAudit.get(f.audit_id) ?? []; + if (list.length < 3) list.push(f.title); + issuesByAudit.set(f.audit_id, list); + } + + const out: LeadRow[] = []; + const seen = new Set(); + for (const r of rows) { + const email = String(r.pdf_email ?? "").trim().toLowerCase(); + if (!email || seen.has(email)) continue; + seen.add(email); + + const card = buildShareCard(r as Parameters[0]); + const contact = contactByEmail.get(email); + out.push({ + email, + host: card.host, + reportToken: (r.share_token as string | null) ?? null, + score: card.score, + scoreLabel: card.label, + scaleHint: card.scaleHint, + topIssues: issuesByAudit.get(String(r.id)) ?? [], + isCustomer: customerEmails.has(email), + unsubscribedAt: (contact?.unsubscribed_at as string | null) ?? null, + consentedAt: (contact?.consented_at as string | null) ?? null, + }); + } + return out; +} + +export async function GET(req: NextRequest) { + const auth = await checkAdmin(); + if (!auth.ok) return auth.response; + + const segment = (req.nextUrl.searchParams.get("segment") ?? "all") as Segment; + const audience = await loadAudience(); + const { send, excluded } = selectRecipients(audience, segment); + + const byReason: Record = {}; + for (const e of excluded) byReason[e.reason] = (byReason[e.reason] ?? 0) + 1; + + return NextResponse.json({ + segment, + wouldSend: send.length, + excluded: byReason, + customers: send.filter((r) => r.isCustomer).length, + coldLeads: send.filter((r) => !r.isCustomer).length, + sample: send.slice(0, 5).map((r) => ({ + email: r.email, + host: r.host, + score: r.score, + subject: campaignSubject(r), + topIssues: r.topIssues, + })), + }); +} + +export async function POST(req: NextRequest) { + const auth = await checkAdmin(); + if (!auth.ok) return auth.response; + + let body: { segment?: Segment; dryRun?: boolean; limit?: number; testTo?: string } = {}; + try { + body = await req.json(); + } catch { + /* empty body = dry run over everything */ + } + + const segment: Segment = body.segment ?? "all"; + // Opt IN to sending. An accidental POST must never mail 100 people. + const dryRun = body.dryRun !== false; + const limit = Math.max(1, Math.min(body.limit ?? 500, 500)); + + const audience = await loadAudience(); + const { send } = selectRecipients(audience, segment); + const batch = send.slice(0, limit); + + if (dryRun) { + return NextResponse.json({ + dryRun: true, + segment, + wouldSend: batch.length, + recipients: batch.map((r) => r.email), + }); + } + + const svc = serviceClient(); + const base = env.siteUrl.replace(/\/$/, ""); + let sent = 0; + let failed = 0; + const errors: string[] = []; + + for (const row of batch) { + // A send-to-one override so the whole campaign can be previewed in a real + // inbox before it goes out. + const to = body.testTo || row.email; + + // Every recipient needs a marketing_contacts row, because that is where + // the unsubscribe token lives. recordLead is a no-op if one exists and + // never upgrades consent. + await recordLead({ email: row.email, source: "lead-campaign" }); + const { data: contact } = await svc + .from("marketing_contacts") + .select("unsubscribe_token, unsubscribed_at") + .ilike("email", row.email) + .maybeSingle(); + + // Re-check immediately before sending: the audience was selected earlier + // in this request and somebody may have unsubscribed in between. + if (!contact?.unsubscribe_token || contact.unsubscribed_at) { + failed++; + errors.push(`${row.email}: no token or unsubscribed`); + continue; + } + + const res = await sendMarketingEmail({ + to, + subject: campaignSubject(row), + html: leadCampaignEmailHtml({ + host: row.host, + scoreLabel: row.scoreLabel, + score: row.score, + scaleHint: row.scaleHint, + topIssues: row.topIssues, + reportUrl: `${base}/r/${row.reportToken}`, + hireUrl: hireUrlFor(row, base), + isCustomer: row.isCustomer, + }), + unsubscribeToken: contact.unsubscribe_token as string, + }); + + if (res.sent) sent++; + else { + failed++; + if (errors.length < 10) errors.push(`${row.email}: ${res.error}`); + } + + // Paced so a burst doesn't trip the provider's rate limit or look like a + // spam cannon to receiving domains. + await new Promise((r) => setTimeout(r, 400)); + if (body.testTo) break; + } + + console.log(`[admin/lead-campaign] segment=${segment} sent=${sent} failed=${failed}`); + return NextResponse.json({ dryRun: false, segment, sent, failed, errors }); +} diff --git a/components/hire-form.tsx b/components/hire-form.tsx index 71827671..a3b809ba 100644 --- a/components/hire-form.tsx +++ b/components/hire-form.tsx @@ -3,12 +3,21 @@ import { useState, useTransition } from "react"; import { submitHireInquiry } from "@/app/actions/hireInquiry"; -export function HireForm() { +// defaultEmail / defaultWebsite are prefilled from the query string when the +// visitor arrives from a campaign email, so the form is already half-filled +// with what we know. Every field stays editable. +export function HireForm({ + defaultEmail = "", + defaultWebsite = "", +}: { + defaultEmail?: string; + defaultWebsite?: string; +} = {}) { const [pending, start] = useTransition(); const [name, setName] = useState(""); - const [email, setEmail] = useState(""); + const [email, setEmail] = useState(defaultEmail); const [phone, setPhone] = useState(""); - const [website, setWebsite] = useState(""); + const [website, setWebsite] = useState(defaultWebsite); const [monthlyRevenue, setMonthlyRevenue] = useState(""); const [location, setLocation] = useState(""); const [message, setMessage] = useState(""); diff --git a/lib/email.ts b/lib/email.ts index f6e9fc60..7964c39a 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -829,3 +829,97 @@ export async function sendWatchChangeEmail(input: { if (!res.sent) return { sent: false, error: res.error }; return { sent: true }; } + +// ============================================================ +// Lead re-engagement campaign — personalised from the recipient's OWN scan. +// +// Deliberately not a newsletter blast. Everyone who gets this asked us for a +// PDF of a specific report, so the email is about that report: their host, +// their score, their worst findings. That converts better than a generic +// pitch, and it keeps the message tied to the thing they actually requested. +// ============================================================ + +export function leadCampaignEmailHtml(input: { + host: string; + scoreLabel: string; + score: number | null; + scaleHint: string; + topIssues: string[]; + reportUrl: string; + hireUrl: string; + isCustomer: boolean; +}): string { + const issues = input.topIssues.length + ? `
    + ${input.topIssues.map((i) => `
  • ${escapeHtml(i)}
  • `).join("")} +
` + : ""; + + const scoreBlock = + input.score !== null + ? ` + + + + + + +
+ ${input.score} / 100 + + ${escapeHtml(input.scoreLabel)}
+ ${escapeHtml(input.scaleHint)} +
+ + ` + : ""; + + const innerHtml = ` + +

+ About your ${escapeHtml(input.host)} scan +

+

+ You ran a CrawlProof report on + ${escapeHtml(input.host)} + and asked us to email you the PDF. Here's where it landed${input.isCustomer ? "" : " — and an offer, if you'd rather not fix it yourself"}. +

+ + + ${scoreBlock} + + + ${input.topIssues.length ? `

Biggest items on that report

` : ""} + ${issues} + + + + +

+ We also fix these directly. Tell us about the site and we'll come + back with what it takes to move that number — no obligation. +

+ + + + + + Get a fix quote → + + + + + +

+ Your report is still live:
+ ${input.reportUrl} +

+ + `; + + return emailShell({ + title: `Your CrawlProof scan of ${input.host}`, + innerHtml, + }); +} diff --git a/lib/leadCampaign.ts b/lib/leadCampaign.ts new file mode 100644 index 00000000..8272d188 --- /dev/null +++ b/lib/leadCampaign.ts @@ -0,0 +1,86 @@ +// Audience selection for the lead re-engagement campaign. +// +// Pure, so the thing that decides WHO gets emailed can be tested without a +// mail provider attached. Getting this wrong doesn't throw an exception — it +// silently mails someone who opted out, which is the failure mode that costs +// a sending domain. + +export type LeadRow = { + email: string; + host: string; + reportToken: string | null; + score: number | null; + scoreLabel: string; + scaleHint: string; + topIssues: string[]; + /** Has a profiles row — an existing relationship, not a cold lead. */ + isCustomer: boolean; + unsubscribedAt: string | null; + consentedAt: string | null; +}; + +export type Segment = "users" | "leads" | "all"; + +/** Addresses we never mail: our own, and role accounts that aren't a person. */ +const INTERNAL = /@(profullstack\.com|crawlproof\.com)$/i; +const ROLE_LOCALPART = + /^(postmaster|abuse|noreply|no-reply|donotreply|mailer-daemon|admin|webmaster|hostmaster)@/i; + +export type ExclusionReason = + | "unsubscribed" + | "internal" + | "role-account" + | "no-report" + | "wrong-segment"; + +export function excludeReason(row: LeadRow, segment: Segment): ExclusionReason | null { + // Unsubscribe beats everything, including an explicit later consent record — + // if both are set, the safe reading is that they want out. + if (row.unsubscribedAt) return "unsubscribed"; + if (INTERNAL.test(row.email)) return "internal"; + if (ROLE_LOCALPART.test(row.email)) return "role-account"; + // The whole message is about their report. Without one there is nothing to + // say, and it degrades into the generic blast we're avoiding. + if (!row.reportToken) return "no-report"; + if (segment === "users" && !row.isCustomer) return "wrong-segment"; + if (segment === "leads" && row.isCustomer) return "wrong-segment"; + return null; +} + +export function selectRecipients( + rows: LeadRow[], + segment: Segment, +): { send: LeadRow[]; excluded: Array<{ email: string; reason: ExclusionReason }> } { + const send: LeadRow[] = []; + const excluded: Array<{ email: string; reason: ExclusionReason }> = []; + const seen = new Set(); + + for (const row of rows) { + const key = row.email.trim().toLowerCase(); + // One send per address even if they scanned five sites. + if (seen.has(key)) continue; + seen.add(key); + + const reason = excludeReason(row, segment); + if (reason) excluded.push({ email: key, reason }); + else send.push({ ...row, email: key }); + } + return { send, excluded }; +} + +export function campaignSubject(row: LeadRow): string { + if (row.score === null) return `Your CrawlProof scan of ${row.host}`; + return `${row.host} scored ${row.score}/100 — want us to fix it?`; +} + +/** Deep-links /hire with what we already know, so the form is half-filled. */ +export function hireUrlFor(row: LeadRow, siteUrl: string): string { + const base = siteUrl.replace(/\/$/, ""); + const params = new URLSearchParams({ + website: `https://${row.host}`, + email: row.email, + utm_source: "lead-campaign", + utm_medium: "email", + }); + return `${base}/hire?${params.toString()}`; +} diff --git a/tests/lead-campaign.test.ts b/tests/lead-campaign.test.ts new file mode 100644 index 00000000..e9a2326d --- /dev/null +++ b/tests/lead-campaign.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { + campaignSubject, + excludeReason, + hireUrlFor, + selectRecipients, + type LeadRow, +} from "@/lib/leadCampaign"; + +function lead(over: Partial = {}): LeadRow { + return { + email: "person@example.com", + host: "example.com", + reportToken: "tok123456", + score: 54, + scoreLabel: "AEO Score", + scaleHint: "out of 100 · higher is better", + topIssues: ["Blocks GPTBot", "No structured data"], + isCustomer: false, + unsubscribedAt: null, + consentedAt: null, + ...over, + }; +} + +describe("excludeReason", () => { + it("sends to a plain lead with a report", () => { + expect(excludeReason(lead(), "all")).toBeNull(); + }); + + it("never mails an unsubscribed address", () => { + expect(excludeReason(lead({ unsubscribedAt: "2026-01-01" }), "all")).toBe("unsubscribed"); + }); + + it("lets unsubscribe beat a later consent record", () => { + // If both are set the safe reading is that they want out. Getting this + // backwards is the single most expensive bug in a sending system. + const row = lead({ unsubscribedAt: "2026-02-01", consentedAt: "2026-03-01" }); + expect(excludeReason(row, "all")).toBe("unsubscribed"); + }); + + it("skips our own addresses", () => { + expect(excludeReason(lead({ email: "anthony@profullstack.com" }), "all")).toBe("internal"); + expect(excludeReason(lead({ email: "x@crawlproof.com" }), "all")).toBe("internal"); + }); + + it("skips role accounts that aren't a person", () => { + for (const e of ["postmaster@x.com", "no-reply@x.com", "abuse@x.com"]) { + expect(excludeReason(lead({ email: e }), "all")).toBe("role-account"); + } + }); + + it("skips anyone with no report to talk about", () => { + // The whole email is about their scan; without one it's a generic blast. + expect(excludeReason(lead({ reportToken: null }), "all")).toBe("no-report"); + }); +}); + +describe("segments", () => { + it("'users' keeps only existing customers", () => { + expect(excludeReason(lead({ isCustomer: true }), "users")).toBeNull(); + expect(excludeReason(lead({ isCustomer: false }), "users")).toBe("wrong-segment"); + }); + + it("'leads' keeps only non-customers", () => { + expect(excludeReason(lead({ isCustomer: false }), "leads")).toBeNull(); + expect(excludeReason(lead({ isCustomer: true }), "leads")).toBe("wrong-segment"); + }); + + it("'all' keeps both", () => { + expect(excludeReason(lead({ isCustomer: true }), "all")).toBeNull(); + expect(excludeReason(lead({ isCustomer: false }), "all")).toBeNull(); + }); +}); + +describe("selectRecipients", () => { + it("mails an address once even if it scanned several sites", () => { + const { send } = selectRecipients( + [ + lead({ email: "a@x.com", host: "one.com" }), + lead({ email: "A@X.com", host: "two.com" }), + lead({ email: "b@x.com" }), + ], + "all", + ); + expect(send.map((r) => r.email)).toEqual(["a@x.com", "b@x.com"]); + // First occurrence wins, and callers order newest-first, so the retained + // row is the most recent report. + expect(send[0].host).toBe("one.com"); + }); + + it("normalizes the address it will send to", () => { + const { send } = selectRecipients([lead({ email: " MiXeD@Example.COM " })], "all"); + expect(send[0].email).toBe("mixed@example.com"); + }); + + it("reports why each address was dropped", () => { + const { send, excluded } = selectRecipients( + [ + lead({ email: "ok@x.com" }), + lead({ email: "gone@x.com", unsubscribedAt: "2026-01-01" }), + lead({ email: "me@profullstack.com" }), + lead({ email: "noreport@x.com", reportToken: null }), + ], + "all", + ); + expect(send).toHaveLength(1); + expect(excluded).toEqual([ + { email: "gone@x.com", reason: "unsubscribed" }, + { email: "me@profullstack.com", reason: "internal" }, + { email: "noreport@x.com", reason: "no-report" }, + ]); + }); +}); + +describe("campaignSubject", () => { + it("leads with the score they already know", () => { + expect(campaignSubject(lead({ host: "acme.com", score: 54 }))).toBe( + "acme.com scored 54/100 — want us to fix it?", + ); + }); + + it("falls back when there is no score", () => { + expect(campaignSubject(lead({ host: "acme.com", score: null }))).toBe( + "Your CrawlProof scan of acme.com", + ); + }); +}); + +describe("hireUrlFor", () => { + it("prefills the form and tags the source", () => { + const url = new URL(hireUrlFor(lead({ host: "acme.com", email: "p@x.com" }), "https://crawlproof.com/")); + expect(url.pathname).toBe("/hire"); + expect(url.searchParams.get("website")).toBe("https://acme.com"); + expect(url.searchParams.get("email")).toBe("p@x.com"); + expect(url.searchParams.get("utm_source")).toBe("lead-campaign"); + }); +});