- {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/hire-form.tsx b/components/hire-form.tsx index a3b809ba..c755928a 100644 --- a/components/hire-form.tsx +++ b/components/hire-form.tsx @@ -179,8 +179,8 @@ export function HireForm({ {pending ? "Sending…" : "Request a fix"}- We typically respond within a few hours and turn around fixes in under - 24 hours. + We typically respond within a few hours with a scope and a quote. The + work itself usually runs two to three weeks.
); 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] = useStateCampaigns
++ Runs every 15 minutes: finds leads, scans them, writes drafts. Only sends when you turn + auto-send on. +
+{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"} +
++ + + ++
+ ))}
+
+ Starts in drafts-only mode. Read a few of what it writes, then enable sending. +
+{note}
} + {error &&{error}
} + + {draft && ( +To: {draft.to ?? "(no address)"}
+ setDraft({ ...draft, subject: e.target.value })} + aria-label="Subject" + /> ++ {mode === "query" + ? "Finds businesses, then queues a free scan of each so the pitch can cite real findings." + : "Every outbound link on that page becomes a candidate. Platforms and aggregators are filtered out."} +
+ {note &&{note}
} + {error &&{error}
} +${escapeHtml(
+ p,
+ ).replace(/\n/g, "
")}
"); + + return emailShell({ + title: `About ${input.host}`, + innerHtml, + footerNote, + }); +} + +export async function sendColdOutreachEmail(input: { + to: string; + subject: string; + html: string; + unsubscribeUrl: string; + replyTo?: string; +}): Promise<{ sent: boolean; error?: string }> { + const c = client(); + if (!c) return { sent: false, error: "RESEND_API_KEY not set" }; + const res = await c.send({ + from: env.resendFrom, + to: input.to, + subject: input.subject, + html: input.html, + replyTo: input.replyTo, + headers: { + "List-Unsubscribe": `<${input.unsubscribeUrl}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + }); + if (!res.sent) return { sent: false, error: res.error }; + return { sent: true }; +} diff --git a/lib/env.ts b/lib/env.ts index 6b2211ad..3c18d771 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -167,5 +167,23 @@ export const env = { githubAppClientSecret: process.env.GITHUB_APP_CLIENT_SECRET ?? "", githubAppPrivateKey: process.env.GITHUB_APP_PRIVATE_KEY ?? "", githubAppSlug: process.env.GITHUB_APP_SLUG ?? "", + // ---- Cold outreach ---- + // CAN-SPAM §7704(a)(5) requires a valid physical postal address in every + // commercial email. A cold pitch is commercial by definition, so + // send_outreach refuses to send live without this set. Dry runs still work, + // which is how you develop the copy before the address exists. + outreachPostalAddress: process.env.OUTREACH_POSTAL_ADDRESS ?? "", + // Hard ceiling on live cold emails per user per rolling 24h. Deliberately + // small: cold outreach that works is researched one prospect at a time, and + // a runaway agent loop on a shared sending domain is unrecoverable. + outreachDailyCap: Number(process.env.OUTREACH_DAILY_CAP ?? "50"), + // Same idea for Reddit, where the account — not just the domain — is what + // gets burned. Per rolling 24h, per user. + redditOutreachDailyCap: Number(process.env.REDDIT_OUTREACH_DAILY_CAP ?? "10"), + // Per subreddit, per rolling 24h. Reddit's spam heuristics key on + // concentration in one community more than on raw volume. + redditOutreachSubredditCap: Number( + process.env.REDDIT_OUTREACH_SUBREDDIT_CAP ?? "3", + ), required, }; diff --git a/lib/mcp/leads.ts b/lib/mcp/leads.ts new file mode 100644 index 00000000..0075ebdc --- /dev/null +++ b/lib/mcp/leads.ts @@ -0,0 +1,901 @@ +// Leads: one toolset for finding businesses, researching them, and reaching +// out — by email or on Reddit. +// +// This replaces two separate toolsets that had grown sixteen tools between +// them, most of which differed only by channel. An agent picking between +// `send_outreach` and `reddit_send` is answering a question the tool should +// answer for itself, so the channel is a parameter now, not a tool. +// +// Seven tools: +// +// find_leads businesses by search query, or Reddit threads by keyword +// research_lead scan the site, price the fix, find the contact address +// draft_message write it, grounded in the scan or in what they asked +// send_message the only thing that touches the outside world +// campaign create / update / run the autopilot +// leads list, filter, export +// suppress do-not-contact, any channel +// +// What makes the output different from a prompt wrapper: CrawlProof runs the +// scanner, so a cold email opens with a defect that is verifiably on their +// site, linked to a report they can check. +// +// Leads belong to a project — the same agency runs different outreach for +// different clients — so every tool takes an optional `project` (id, name or +// site URL) and defaults to the only project when there is just one. +// +// Auth: the crp_ bearer token resolved by app/api/mcp/route.ts. + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { env } from "@/lib/env"; +import { serviceClient } from "@/lib/supabase/service"; +import { hostOf } from "@/lib/audit/share-card"; +import { isThirdPartyHost } from "@/lib/leadCampaign"; +import { nextStepReadyAt, normalizeHost, type OutreachStep } from "@/lib/outreach/cold"; +import { + draftEmail, + factsOf, + isWeakEnough, + loadProspect, + researchProspect, + sendProspectEmail, + siteBase, +} from "@/lib/outreach/pipeline"; +import { addSuppression, isEmailSuppressed, sendsInLast24h } from "@/lib/outreach/suppress"; +import { discoverProspects } from "@/lib/outreach/discover"; +import { enrichContact, findEmail, leadsToCsv, leadsToJson, type ExportableLead } from "@/lib/outreach/enrich"; +import { CAMPAIGN_COLUMNS, runEmailCampaignTick, summarize, type CampaignRow } from "@/lib/outreach/runner"; +import { + draftRedditReply, + findRedditThreads, + sendRedditOutreach, +} from "@/lib/outreach/redditPipeline"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getUserId(extra: any): string { + const info = extra?.authInfo; + const uid = info?.extra?.userId ?? info?.clientId; + if (!uid || typeof uid !== "string") throw new Error("Unauthenticated."); + return uid; +} +function textResult(s: string) { + return { content: [{ type: "text" as const, text: s }] }; +} +function errorResult(s: string) { + return { content: [{ type: "text" as const, text: s }], isError: true }; +} + +/** + * Leads belong to a project, so every tool needs one. Accepts a project id, + * name, or site URL; with none given and exactly one project on the account, + * that one is used — the common case, and making an agent pass an id it has + * to look up first is friction for nothing. + */ +async function resolveProject( + userId: string, + hint?: string, +): Promise<{ ok: true; id: string; name: string } | { ok: false; error: string }> { + const sb = serviceClient(); + const [{ data: owned }, { data: memberships }] = await Promise.all([ + sb.from("projects").select("id, name, url").eq("owner_id", userId).limit(100), + sb.from("project_members").select("project_id").eq("user_id", userId).limit(100), + ]); + const rows = (owned as Array<{ id: string; name: string; url: string }> | null) ?? []; + const memberIds = ((memberships as Array<{ project_id: string }> | null) ?? []).map( + (m) => m.project_id, + ); + if (memberIds.length) { + const { data: shared } = await sb + .from("projects") + .select("id, name, url") + .in("id", memberIds) + .limit(100); + for (const r of (shared as Array<{ id: string; name: string; url: string }> | null) ?? []) { + if (!rows.some((p) => p.id === r.id)) rows.push(r); + } + } + if (!rows.length) { + return { ok: false, error: "No projects on this account. Create one first — leads belong to a project." }; + } + + if (!hint) { + if (rows.length === 1) return { ok: true, id: rows[0].id, name: rows[0].name }; + return { + ok: false, + error: `Which project? Pass project: ${rows.slice(0, 8).map((p) => `"${p.name}"`).join(", ")}`, + }; + } + + const needle = hint.trim().toLowerCase(); + const match = + rows.find((p) => p.id === hint) ?? + rows.find((p) => p.name.toLowerCase() === needle) ?? + rows.find((p) => normalizeHost(p.url) === normalizeHost(needle)) ?? + rows.find((p) => p.name.toLowerCase().includes(needle)); + if (!match) { + return { + ok: false, + error: `No project matching "${hint}". Yours: ${rows.map((p) => p.name).join(", ")}`, + }; + } + return { ok: true, id: match.id, name: match.name }; +} + +type AuditRow = { + id: string; + target_url: string; + score: number | null; + engine: string; + share_token: string | null; +}; + +export function registerLeadTools(server: McpServer): void { + // ---------------------------------------------------------- find_leads + server.registerTool( + "find_leads", + { + description: + "Find leads. source 'search' finds businesses by query ('dentists in Miami'); 'reddit' finds recent threads worth answering; 'scans' lists sites in your own completed CrawlProof scans that scored badly enough to pitch. Read-only — contacts nobody.", + inputSchema: { + project: z + .string() + .optional() + .describe("Project id, name, or site URL. Optional when the account has exactly one project."), + source: z + .enum(["search", "reddit", "scans"]) + .optional() + .describe("Default 'search'."), + query: z.string().optional().describe("Search query for source='search'."), + keywords: z + .array(z.string()) + .optional() + .describe("Problem phrases for source='reddit', e.g. ['llms.txt', 'ChatGPT cites competitor']."), + subreddits: z.array(z.string()).optional().describe("Subreddits to search. Omit for all of Reddit."), + seed_urls: z + .array(z.string()) + .optional() + .describe("Directory or listicle pages whose outbound links are candidates (source='search')."), + limit: z.number().optional().describe("Max results. Default 10."), + enrich: z + .boolean() + .optional() + .describe("source='search': fetch each site for email/phone/address. Slower."), + max_score: z.number().optional().describe("source='scans': only sites at or below this. Default 70."), + }, + }, + async (args, extra) => { + const userId = getUserId(extra); + const project = await resolveProject(userId, args.project); + if (!project.ok) return errorResult(project.error); + const source = args.source ?? "search"; + const limit = Math.min(args.limit ?? 10, 30); + + // ---- Reddit threads + if (source === "reddit") { + if (!args.keywords?.length) return errorResult("source='reddit' needs keywords."); + const res = await findRedditThreads({ + userId, + keywords: args.keywords, + subreddits: args.subreddits, + limit, + }); + if (!res.ok) return errorResult(res.error); + if (!res.threads.length) return textResult(res.note ?? "No threads worth answering right now."); + return textResult( + [ + `${res.threads.length} thread${res.threads.length === 1 ? "" : "s"} worth answering (as u/${res.username}):`, + "", + ...res.threads.map((t) => + [ + `[${t.relevance}] r/${t.subreddit} — ${t.title}`, + ` ${t.id} · u/${t.author} · ${t.ageHours}h old · ${t.numComments} comments`, + ` ${t.reasons}`, + t.ruleWarning ? ` ⚠ ${t.ruleWarning}` : "", + ` ${t.permalink}`, + ] + .filter(Boolean) + .join("\n"), + ), + "", + `Next: draft_message({ channel: "reddit", thread_id: "…" }).`, + ].join("\n"), + ); + } + + // ---- Your own scans + if (source === "scans") { + const maxScore = args.max_score ?? 70; + const sb = serviceClient(); + const { data } = await sb + .from("audits") + .select("id, target_url, score, engine, share_token") + .eq("owner_id", userId) + .eq("status", "complete") + .order("completed_at", { ascending: false }) + .limit(500); + const audits = (data as AuditRow[] | null) ?? []; + const { data: existing } = await sb + .from("outreach_prospects") + .select("target_key") + .eq("project_id", project.id) + .eq("channel", "email"); + const known = new Set( + ((existing as Array<{ target_key: string }> | null) ?? []).map((r) => r.target_key), + ); + + const seen = new Set