= {};
+ 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");
+ });
+});