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