From 45447109619433d5b9329cdd84ddea47a1d0479f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 26 Jul 2026 15:44:02 +0000 Subject: [PATCH 1/2] feat(leads): project-scoped lead generation and cold outreach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finds businesses, scans their sites, and pitches the fix — grounded in what the scan actually found rather than in a merge field. Modelled on two paid MCP servers, neither of which is the design. Velvet Forge is seven LLM prompt wrappers whose "personalisation" never looks at the prospect's website. Signal Found sells thousands of Reddit DMs a day through a browser extension replaying your session or a farm of managed accounts, which is against Reddit's User Agreement and ends with the domain banned sitewide. CrawlProof already runs the scanner, so it can open with a defect verifiably on their site, linked to a report they can check. The funnel — discover, scan, research, draft, send, follow up — runs unattended from a 15-minute cron tick, or step by step from the Leads tab or the MCP toolset. All three share one code path so the guardrails cannot differ between what the robot does and what the button does. Leads belong to a project: the same agency runs different outreach for different clients. Send caps stay per person — one operator with five projects still has one sending reputation. Guardrails, which are the design and not a bolt-on: - dry run by default everywhere that touches the outside world - auto_send defaults false; a new campaign builds and drafts, then stops - global do-not-contact list; one-click unsubscribe scoped to an address or a whole domain, with its own token so a cold recipient is never enrolled in the newsletter to be given an opt-out - CAN-SPAM postal address required before any live send - generated drafts are rejected if they cite a score or report that does not exist, or imply a prior relationship - sites that already score well are skipped rather than pitched a rescue - Reddit: live subreddit rules are a hard stop, public replies preferred, one cold DM per person ever, disclosure required Lead sources cost nothing by default (DuckDuckGo, Mojeek, the prospect's own pages) and use ValueSERP when its key is set. No purchased contact data. Tests cover the decisions that carry risk: suppression ordering, contact discovery and ranking, grounding checks, thread scoring, reply validation. Three bugs they caught are fixed here — logo@2x.png passing as an address, shared inboxes outranking named humans, and phone extraction returning dates and postal codes. Migration applied to production. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/projects/[id]/leads/page.tsx | 219 +++++ app/actions/leads.ts | 345 +++++++ app/api/cron/outreach/route.ts | 78 ++ app/api/mcp/route.ts | 2 + app/unsubscribe/[token]/page.tsx | 41 +- components/leads/campaign-panel.tsx | 243 +++++ components/leads/lead-actions.tsx | 154 +++ components/leads/lead-finder.tsx | 98 ++ components/project-shell.tsx | 1 + components/project-tabs-nav.tsx | 6 + docs/outreach.md | 161 ++++ lib/email.ts | 92 ++ lib/env.ts | 18 + lib/mcp/leads.ts | 901 ++++++++++++++++++ lib/outreach/cold.ts | 331 +++++++ lib/outreach/discover.ts | 238 +++++ lib/outreach/enrich.ts | 448 +++++++++ lib/outreach/freeSearch.ts | 193 ++++ lib/outreach/pipeline.ts | 550 +++++++++++ lib/outreach/reddit.ts | 305 ++++++ lib/outreach/redditPipeline.ts | 503 ++++++++++ lib/outreach/runner.ts | 323 +++++++ lib/outreach/suppress.ts | 149 +++ lib/sp/platforms/reddit.ts | 15 +- lib/sp/platforms/redditOutreach.ts | 276 ++++++ .../20260726160000_cold_outreach.sql | 268 ++++++ tests/cold-outreach.test.ts | 329 +++++++ tests/reddit-outreach.test.ts | 229 +++++ 28 files changed, 6506 insertions(+), 10 deletions(-) create mode 100644 app/(app)/projects/[id]/leads/page.tsx create mode 100644 app/actions/leads.ts create mode 100644 app/api/cron/outreach/route.ts create mode 100644 components/leads/campaign-panel.tsx create mode 100644 components/leads/lead-actions.tsx create mode 100644 components/leads/lead-finder.tsx create mode 100644 docs/outreach.md create mode 100644 lib/mcp/leads.ts create mode 100644 lib/outreach/cold.ts create mode 100644 lib/outreach/discover.ts create mode 100644 lib/outreach/enrich.ts create mode 100644 lib/outreach/freeSearch.ts create mode 100644 lib/outreach/pipeline.ts create mode 100644 lib/outreach/reddit.ts create mode 100644 lib/outreach/redditPipeline.ts create mode 100644 lib/outreach/runner.ts create mode 100644 lib/outreach/suppress.ts create mode 100644 lib/sp/platforms/redditOutreach.ts create mode 100644 supabase/migrations/20260726160000_cold_outreach.sql create mode 100644 tests/cold-outreach.test.ts create mode 100644 tests/reddit-outreach.test.ts diff --git a/app/(app)/projects/[id]/leads/page.tsx b/app/(app)/projects/[id]/leads/page.tsx new file mode 100644 index 00000000..a123cb08 --- /dev/null +++ b/app/(app)/projects/[id]/leads/page.tsx @@ -0,0 +1,219 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { serviceClient } from "@/lib/supabase/service"; +import { requireProjectAccess } from "@/lib/lx/currentSite"; +import { env } from "@/lib/env"; +import { LeadFinder } from "@/components/leads/lead-finder"; +import { LeadActions } from "@/components/leads/lead-actions"; +import { CampaignPanel, type CampaignSummary } from "@/components/leads/campaign-panel"; + +export const metadata = { title: "Leads" }; +export const dynamic = "force-dynamic"; + +type ProspectRow = { + target_key: string; + channel: string; + status: string; + score: number | null; + score_kind: string | null; + contact_email: string | null; + contact_source: string | null; + quote_usd: number | null; + top_issues: string[] | null; + report_token: string | null; + discovery_label: string | null; + last_sent_at: string | null; + last_step: number; +}; + +type SendRow = { + channel: string; + step: number; + recipient: string; + subject: string | null; + dry_run: boolean; + sent_at: string; +}; + +const STATUS_TONE: Record = { + contacted: "badge-pass", + replied: "badge-pass", + won: "badge-pass", + researched: "badge-warn", + drafted: "badge-warn", + new: "badge-unknown", + skipped: "badge-unknown", + lost: "badge-fail", +}; + +export default async function LeadsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id: projectId } = await params; + const access = await requireProjectAccess(projectId, { allowViewer: true }); + if (!access.ok) notFound(); + + // The outreach tables have no RLS policies — reads run on the service + // client after the access check above, the same way the rest of the + // project pages work. It also keeps outreach_sends unwritable from a + // browser, so the record of what was sent stays honest. + const supabase = serviceClient(); + const [{ data: prospectData }, { data: sendData }, { data: campaignData }] = await Promise.all([ + supabase + .from("outreach_prospects") + .select( + "target_key, channel, status, score, score_kind, contact_email, contact_source, quote_usd, top_issues, report_token, discovery_label, last_sent_at, last_step", + ) + .eq("project_id", projectId) + .order("updated_at", { ascending: false }) + .limit(200), + supabase + .from("outreach_sends") + .select("channel, step, recipient, subject, dry_run, sent_at") + .eq("project_id", projectId) + .order("sent_at", { ascending: false }) + .limit(10), + supabase + .from("outreach_campaigns") + .select("name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note") + .eq("project_id", projectId) + .order("updated_at", { ascending: false }) + .limit(10), + ]); + + const prospects = (prospectData as ProspectRow[] | null) ?? []; + const sends = (sendData as SendRow[] | null) ?? []; + const campaigns = (campaignData as CampaignSummary[] | null) ?? []; + + const byStatus = new Map(); + for (const p of prospects) byStatus.set(p.status, (byStatus.get(p.status) ?? 0) + 1); + + const since = Date.now() - 24 * 3600 * 1000; + const liveToday = sends.filter((s) => !s.dry_run && new Date(s.sent_at).getTime() >= since).length; + const canSendLive = Boolean(env.outreachPostalAddress); + + return ( +
+
+

Leads

+

+ Find businesses, scan their sites, and pitch the fix on behalf of this project — every + email opens with something the scan actually found, linked to a report they can check. +

+
+ + {!canSendLive && ( +

+ Live sending is off. Set OUTREACH_POSTAL_ADDRESS — CAN-SPAM + requires a physical postal address in commercial email. Dry runs work without it. +

+ )} + + + + + +
+
+

Pipeline

+

+ {[...byStatus.entries()].map(([s, n]) => `${n} ${s}`).join(" · ") || "no leads yet"} ·{" "} + {liveToday}/{env.outreachDailyCap} sent today +

+
+ + {prospects.length === 0 ? ( +

+ Nothing yet. Search for businesses above, or set up a campaign to keep the funnel full on + its own. +

+ ) : ( +
    + {prospects.map((p) => { + const isSlop = p.score_kind === "slop"; + return ( +
  • +
    +

    + {p.channel === "reddit" ? `u/${p.target_key}` : p.target_key} + {p.status} + {p.score !== null && ( + + {p.score}/100 {isSlop ? "slop" : "AEO"} + + )} + {p.quote_usd ? ( + fix ≈ ${p.quote_usd} + ) : null} +

    + +

    + {p.contact_email ? ( + <> + {p.contact_email} + {p.contact_source === "manual" ? " (manual)" : ""} + + ) : ( + "no contact address found" + )} + {p.discovery_label ? ` · found as “${p.discovery_label}”` : ""} + {p.last_sent_at + ? ` · step ${p.last_step} on ${p.last_sent_at.slice(0, 10)}` + : ""} +

    + + {p.top_issues && p.top_issues.length > 0 && ( +
      + {p.top_issues.slice(0, 3).map((issue) => ( +
    • {issue}
    • + ))} +
    + )} + + {p.report_token && ( + + View report ↗ + + )} +
    + + {p.channel === "email" && ( + + )} +
  • + ); + })} +
+ )} +
+ + {sends.length > 0 && ( +
+

Recent sends

+
    + {sends.map((s, i) => ( +
  • + + {s.dry_run ? "· dry " : "✓ live"} + {" "} + {s.sent_at.slice(0, 16).replace("T", " ")} {s.channel} step {s.step} → {s.recipient} + {s.subject ? ` — ${s.subject}` : ""} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/app/actions/leads.ts b/app/actions/leads.ts new file mode 100644 index 00000000..46b74a63 --- /dev/null +++ b/app/actions/leads.ts @@ -0,0 +1,345 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { serviceClient } from "@/lib/supabase/service"; +import { requireProjectAccess } from "@/lib/lx/currentSite"; +import { env } from "@/lib/env"; +import { normalizeHost, type OutreachStep } from "@/lib/outreach/cold"; +import { + PROSPECT_COLUMNS, + draftEmail, + researchProspect, + sendProspectEmail, + type ProspectRow, +} from "@/lib/outreach/pipeline"; +import { addSuppression } from "@/lib/outreach/suppress"; +import { discoverProspects } from "@/lib/outreach/discover"; +import { runEmailCampaignTick, CAMPAIGN_COLUMNS, summarize, type CampaignRow } from "@/lib/outreach/runner"; + +type Ok> = { ok: true } & T; +type Err = { ok: false; error: string }; + +/** + * Leads live under a project, so every action starts by proving the caller + * may act on that project. Viewers are excluded: sending mail on a project's + * behalf is not a read. + */ +async function requireLeadAccess( + projectId: string, +): Promise<{ ok: true; userId: string } | Err> { + if (!projectId) return { ok: false, error: "Missing project." }; + const access = await requireProjectAccess(projectId); + if (!access.ok) return { ok: false, error: access.error }; + if (access.isViewer) return { ok: false, error: "Viewers can't run outreach on this project." }; + return { ok: true, userId: access.userId }; +} + +/** + * Everything here goes through the service client after the access check + * above, rather than through RLS. The outreach tables have no policies at + * all, so a browser session cannot touch them directly — which is what keeps + * outreach_sends an honest record of what was sent to whom. + */ +async function projectProspect(projectId: string, host: string): Promise { + const { data } = await serviceClient() + .from("outreach_prospects") + .select(PROSPECT_COLUMNS) + .eq("project_id", projectId) + .eq("target_key", normalizeHost(host)) + .maybeSingle(); + return (data as ProspectRow | null) ?? null; +} + +function leadsPath(projectId: string): string { + return `/projects/${projectId}/leads`; +} + +/** Find businesses and queue a free scan for each — the "add leads" button. */ +export async function findLeadsAction(input: { + projectId: string; + query?: string; + seedUrl?: string; + limit?: number; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + if (!input.query?.trim() && !input.seedUrl?.trim()) { + return { ok: false, error: "Enter a search query or a directory URL." }; + } + + const limit = Math.min(input.limit ?? 10, 25); + const found = await discoverProspects({ + queries: input.query?.trim() ? [input.query.trim()] : [], + seedUrls: input.seedUrl?.trim() ? [input.seedUrl.trim()] : [], + limit, + }); + if (!found.prospects.length) { + return { ok: false, error: found.errors.join("; ") || "No businesses found for that search." }; + } + + let added = 0; + let scanning = 0; + for (const candidate of found.prospects.slice(0, limit)) { + const res = await researchProspect({ + userId: auth.userId, + projectId: input.projectId, + url: candidate.url, + discoveredVia: candidate.via, + discoveryLabel: candidate.label, + }); + if (res.status === "scanning") { + scanning += 1; + added += 1; + } else if (res.status === "researched") { + added += 1; + } + } + + revalidatePath(leadsPath(input.projectId)); + return { + ok: true, + added, + scanning, + note: scanning + ? `${added} leads added — ${scanning} are scanning now. Refresh in a minute to see scores.` + : `${added} leads added.`, + }; +} + +/** Re-run research on one lead: pick up a finished scan, refresh the contact. */ +export async function researchLeadAction(input: { + projectId: string; + host: string; + contactEmail?: string; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + const prospect = await projectProspect(input.projectId, input.host); + const url = prospect?.site_url ?? `https://${normalizeHost(input.host)}`; + + const res = await researchProspect({ + userId: auth.userId, + projectId: input.projectId, + url, + campaignId: prospect?.campaign_id ?? null, + contactEmail: input.contactEmail, + }); + if (res.status === "error") return { ok: false, error: res.message }; + revalidatePath(leadsPath(input.projectId)); + return { + ok: true, + note: + res.status === "scanning" + ? res.message + : `${res.prospect.target_key}: ${res.prospect.score ?? "—"}/100, ${ + res.contact ? `contact ${res.contact.email}` : "no contact address published" + }.`, + }; +} + +export async function draftLeadAction(input: { + projectId: string; + host: string; + step?: number; + angle?: string; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + const prospect = await projectProspect(input.projectId, input.host); + if (!prospect) return { ok: false, error: "Lead not found." }; + + const step = Math.min(Math.max(input.step ?? (prospect.last_step || 0) + 1, 1), 3) as OutreachStep; + const draft = await draftEmail({ prospect, step, angle: input.angle }); + if (!draft.ok) return { ok: false, error: draft.problems.join("; ") }; + + await serviceClient() + .from("outreach_prospects") + .update({ status: prospect.status === "new" ? "drafted" : prospect.status }) + .eq("id", prospect.id); + revalidatePath(leadsPath(input.projectId)); + return { ok: true, subject: draft.subject, body: draft.body, to: prospect.contact_email }; +} + +export async function sendLeadAction(input: { + projectId: string; + host: string; + subject: string; + body: string; + step?: number; + replyTo?: string; + dryRun?: boolean; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + const prospect = await projectProspect(input.projectId, input.host); + if (!prospect) return { ok: false, error: "Lead not found." }; + + const dryRun = input.dryRun !== false; + const step = Math.min(Math.max(input.step ?? 1, 1), 3) as OutreachStep; + const outcome = await sendProspectEmail({ + userId: auth.userId, + prospect, + subject: input.subject, + body: input.body, + step, + campaign: "leads-ui", + replyTo: input.replyTo, + dryRun, + }); + if (!outcome.ok) return { ok: false, error: outcome.reason }; + + revalidatePath(leadsPath(input.projectId)); + return { + ok: true, + dryRun: outcome.dryRun, + note: outcome.dryRun + ? `Dry run OK — nothing sent. ${outcome.sentToday}/${env.outreachDailyCap} used today.` + : `Sent to ${outcome.to}. ${outcome.sentToday + 1}/${env.outreachDailyCap} used today.`, + }; +} + +export async function suppressLeadAction(input: { + projectId: string; + value: string; + scope?: "email" | "domain" | "reddit_user"; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + const scope = input.scope ?? (input.value.includes("@") ? "email" : "domain"); + const res = await addSuppression({ + scope, + value: input.value, + reason: "added from the Leads page", + addedBy: auth.userId, + }); + if (!res.ok) return { ok: false, error: res.error ?? "Could not add the suppression." }; + + // Take them out of every funnel, not just this project's — the suppression + // list is global, so leaving a live row in a sibling project would have the + // next tick draft for someone who just asked to be left alone. + const sb = serviceClient(); + if (scope === "domain") { + await sb + .from("outreach_prospects") + .update({ status: "skipped", notes: "do-not-contact" }) + .eq("target_key", normalizeHost(input.value)); + } else if (scope === "email") { + await sb + .from("outreach_prospects") + .update({ status: "skipped", notes: "do-not-contact" }) + .ilike("contact_email", input.value); + } + + revalidatePath(leadsPath(input.projectId)); + return { ok: true, note: `${input.value} will not be contacted again (${scope}).` }; +} + +export async function saveCampaignAction(input: { + projectId: string; + name: string; + queries: string; + seedUrls: string; + maxScore: number; + dailySendLimit: number; + autoSend: boolean; + active: boolean; + angle?: string; + senderName?: string; + replyTo?: string; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + if (!input.name.trim()) return { ok: false, error: "Give the campaign a name." }; + + const queries = input.queries.split("\n").map((s) => s.trim()).filter(Boolean); + const seedUrls = input.seedUrls.split("\n").map((s) => s.trim()).filter(Boolean); + if (!queries.length && !seedUrls.length) { + return { ok: false, error: "A campaign needs at least one search query or directory URL." }; + } + if (input.autoSend && !env.outreachPostalAddress) { + return { + ok: false, + error: + "OUTREACH_POSTAL_ADDRESS is not set. CAN-SPAM requires a physical postal address in commercial email, so live sending stays off until it is.", + }; + } + + const { error } = await serviceClient() + .from("outreach_campaigns") + .upsert( + { + project_id: input.projectId, + owner_id: auth.userId, + name: input.name.trim(), + channel: "email", + active: input.active, + queries, + seed_urls: seedUrls, + max_score: input.maxScore, + daily_send_limit: input.dailySendLimit, + auto_send: input.autoSend, + angle: input.angle?.trim() || null, + sender_name: input.senderName?.trim() || null, + reply_to: input.replyTo?.trim() || null, + }, + { onConflict: "project_id,name" }, + ); + if (error) return { ok: false, error: error.message }; + + revalidatePath(leadsPath(input.projectId)); + return { + ok: true, + note: input.autoSend + ? `"${input.name}" saved — auto-send is ON, so the cron tick will email real people.` + : `"${input.name}" saved. It will find, scan and draft; sending stays off until you turn it on.`, + }; +} + +export async function runCampaignAction(input: { + projectId: string; + name: string; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + const { data } = await serviceClient() + .from("outreach_campaigns") + .select(CAMPAIGN_COLUMNS) + .eq("project_id", input.projectId) + .eq("name", input.name) + .maybeSingle(); + if (!data) return { ok: false, error: "Campaign not found." }; + + const result = await runEmailCampaignTick(data as CampaignRow); + revalidatePath(leadsPath(input.projectId)); + return { ok: true, note: `${result.campaign}: ${summarize(result)}` }; +} + +export async function toggleCampaignAction(input: { + projectId: string; + name: string; + field: "active" | "auto_send"; + value: boolean; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + if (input.field === "auto_send" && input.value && !env.outreachPostalAddress) { + return { + ok: false, + error: "OUTREACH_POSTAL_ADDRESS is not set — live sending is blocked until it is.", + }; + } + const { error } = await serviceClient() + .from("outreach_campaigns") + .update({ [input.field]: input.value }) + .eq("project_id", input.projectId) + .eq("name", input.name); + if (error) return { ok: false, error: error.message }; + revalidatePath(leadsPath(input.projectId)); + return { + ok: true, + note: + input.field === "active" + ? `"${input.name}" ${input.value ? "resumed" : "paused"}.` + : `"${input.name}" auto-send ${input.value ? "ON — it will email real people" : "off"}.`, + }; +} diff --git a/app/api/cron/outreach/route.ts b/app/api/cron/outreach/route.ts new file mode 100644 index 00000000..5c045d81 --- /dev/null +++ b/app/api/cron/outreach/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { env } from "@/lib/env"; +import { + CAMPAIGN_COLUMNS, + runEmailCampaignTick, + summarize, + type CampaignRow, +} from "@/lib/outreach/runner"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +/** + * The lead-generation autopilot. One tick advances every active campaign: + * follow-ups first, then first contacts, then research on scans that landed, + * then new discovery to refill the funnel. + * + * Schedule it every 15 minutes. Ticking faster does not help — the slow step + * is the scan worker, and the daily send caps are the real throttle. + * + * Campaigns with auto_send off (the default) run the whole funnel and stop at + * the wire, logging each message as a dry run. That is the intended way to + * start one: let it build a pipeline, read what it wrote, then turn sending + * on deliberately. + */ +export async function GET(req: Request) { + return POST(req); +} + +export async function POST(req: Request) { + const incoming = + req.headers.get("x-cron-secret") ?? + req.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + if (incoming !== env.cronSecret) { + return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); + } + + const { data, error } = await serviceClient() + .from("outreach_campaigns") + .select(CAMPAIGN_COLUMNS) + .eq("active", true) + .eq("channel", "email") + // Oldest tick first, so one busy campaign cannot starve the others when + // the run hits maxDuration. + .order("last_run_at", { ascending: true, nullsFirst: true }) + .limit(10); + + if (error) { + return NextResponse.json({ ok: false, error: error.message }, { status: 500 }); + } + const campaigns = (data as CampaignRow[] | null) ?? []; + if (!campaigns.length) { + return NextResponse.json({ ok: true, campaigns: 0, note: "no active campaigns" }); + } + + const results = []; + for (const campaign of campaigns) { + try { + const tick = await runEmailCampaignTick(campaign); + results.push({ ...tick, summary: summarize(tick) }); + } catch (err) { + const message = err instanceof Error ? err.message : "unknown error"; + results.push({ campaign: campaign.name, summary: `failed: ${message}`, errors: [message] }); + await serviceClient() + .from("outreach_campaigns") + .update({ last_run_at: new Date().toISOString(), last_run_note: `failed: ${message}` }) + .eq("id", campaign.id); + } + } + + return NextResponse.json({ + ok: true, + campaigns: campaigns.length, + sent: results.reduce((n, r) => n + (("sent" in r ? r.sent : 0) ?? 0), 0), + results, + }); +} diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index ee9d2b76..2b7b381d 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -11,6 +11,7 @@ import { authenticateToken } from "@/lib/sp/apiAuth"; import { registerPromoteTools } from "@/lib/mcp/promote"; import { registerStatsTools } from "@/lib/mcp/stats"; import { registerAuditTools } from "@/lib/mcp/audits"; +import { registerLeadTools } from "@/lib/mcp/leads"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -20,6 +21,7 @@ const handler = createMcpHandler( registerPromoteTools(server); registerStatsTools(server); registerAuditTools(server); + registerLeadTools(server); }, {}, // The route is mounted at /api/mcp, so mcp-handler must derive its endpoint diff --git a/app/unsubscribe/[token]/page.tsx b/app/unsubscribe/[token]/page.tsx index e017d3a5..b6927d7c 100644 --- a/app/unsubscribe/[token]/page.tsx +++ b/app/unsubscribe/[token]/page.tsx @@ -1,32 +1,57 @@ import Link from "next/link"; import { unsubscribeByToken } from "@/lib/marketing"; +import { suppressByToken } from "@/lib/outreach/suppress"; export const metadata = { title: "Unsubscribe" }; export const dynamic = "force-dynamic"; export default async function UnsubscribePage({ params, + searchParams, }: { params: Promise<{ token: string }>; + searchParams: Promise<{ scope?: string }>; }) { const { token } = await params; - const result = await unsubscribeByToken(token); + const { scope } = await searchParams; + + // One token space, two sources: the opt-in newsletter list and cold + // outreach. Try the newsletter first (far more tokens live there), then the + // outreach prospects. + const marketing = await unsubscribeByToken(token); + const outreach = marketing.ok + ? null + : await suppressByToken(token, scope === "domain" ? "domain" : "email"); + const ok = marketing.ok || !!outreach?.ok; return (

- {result.ok ? "You're unsubscribed" : "Unsubscribe link not recognized"} + {ok ? "You're unsubscribed" : "Unsubscribe link not recognized"}

- {result.ok ? ( + {ok ? (

- {result.email ? ( + {marketing.ok ? ( + marketing.email ? ( + <> + {marketing.email} won't receive CrawlProof + marketing emails anymore. Transactional emails (audit reports, + receipts) are unaffected. + + ) : ( + <>You won't receive any more marketing emails from us. + ) + ) : outreach?.scope === "domain" ? ( <> - {result.email} won't receive CrawlProof - marketing emails anymore. Transactional emails (audit reports, - receipts) are unaffected. + Nobody at {outreach.value} will be contacted by + CrawlProof again — every address at the domain, not just the one + we wrote to. ) : ( - <>You won't receive any more marketing emails from us. + <> + {outreach?.value} is on our do-not-contact list. + We won't write again. + )}

) : ( diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx new file mode 100644 index 00000000..98f5b22a --- /dev/null +++ b/components/leads/campaign-panel.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + runCampaignAction, + saveCampaignAction, + toggleCampaignAction, +} from "@/app/actions/leads"; + +export type CampaignSummary = { + name: string; + active: boolean; + auto_send: boolean; + daily_send_limit: number; + max_score: number; + queries: string[]; + seed_urls: string[]; + last_run_at: string | null; + last_run_note: string | null; +}; + +/** + * Campaigns are the autopilot: the cron tick finds leads, scans them, + * researches contacts and writes drafts on its own. Sending is the one thing + * it will not do until auto-send is switched on deliberately — a new campaign + * logs every message as a dry run so you can read a few first. + */ +export function CampaignPanel({ + projectId, + campaigns, + canSendLive, +}: { + projectId: string; + campaigns: CampaignSummary[]; + canSendLive: boolean; +}) { + const router = useRouter(); + const [pending, start] = useTransition(); + const [open, setOpen] = useState(campaigns.length === 0); + const [note, setNote] = useState(null); + const [error, setError] = useState(null); + + const [name, setName] = useState(""); + const [queries, setQueries] = useState(""); + const [seedUrls, setSeedUrls] = useState(""); + const [maxScore, setMaxScore] = useState(70); + const [dailyLimit, setDailyLimit] = useState(10); + const [angle, setAngle] = useState(""); + const [senderName, setSenderName] = useState(""); + const [replyTo, setReplyTo] = useState(""); + + const act = (fn: () => Promise<{ ok: true; note: string } | { ok: false; error: string }>) => + start(async () => { + setNote(null); + setError(null); + const res = await fn(); + if (res.ok) { + setNote(res.note); + router.refresh(); + } else setError(res.error); + }); + + const save = () => + act(async () => + saveCampaignAction({ + projectId, + name, + queries, + seedUrls, + maxScore, + dailySendLimit: dailyLimit, + autoSend: false, + active: true, + angle, + senderName, + replyTo, + }), + ); + + return ( +
+
+
+

Campaigns

+

+ Runs every 15 minutes: finds leads, scans them, writes drafts. Only sends when you turn + auto-send on. +

+
+ +
+ + {note &&

{note}

} + {error &&

{error}

} + + {campaigns.length > 0 && ( +
    + {campaigns.map((c) => ( +
  • +
    +

    + {c.name}{" "} + + {c.active ? "active" : "paused"} + {" "} + + {c.auto_send ? "auto-send ON" : "drafts only"} + +

    +

    + {[...c.queries, ...c.seed_urls].join(" · ") || "no sources"} — pitches ≤{c.max_score} + /100, max {c.daily_send_limit}/day +

    +

    + {c.last_run_at + ? `Last tick ${c.last_run_at.slice(0, 16).replace("T", " ")}: ${c.last_run_note ?? ""}` + : "Never run"} +

    +
    +
    + + + +
    +
  • + ))} +
+ )} + + {open && ( +
+ + +