Skip to content

Commit c12a4ed

Browse files
ralyodioclaude
andauthored
feat(leads): remove the outreach campaign autopilot (#333)
Anthony, on the AI-spend thread: "please remove this feature." The 15-minute campaign tick re-drafted the same prospects whenever a send failed, ~$56/day for four days with nothing sent (#332 stopped the loop; this removes the feature). Removed: /api/cron/outreach, lib/outreach/runner.ts (tick, intent sweep and alerts, discovery back-off), the Campaigns panel, save/run/toggle campaign actions, the MCP `campaign` tool, per-campaign funnels, and the crawlproof-outreach pg_cron job (migration 20260929090000). Kept: manual find/research/draft/send, suppression, reply scan, and the outreach_campaigns / outreach_campaign_runs tables with their history. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5757bea commit c12a4ed

16 files changed

Lines changed: 34 additions & 2208 deletions

File tree

‎app/(app)/dashboard/projects/[id]/leads/page.tsx‎

Lines changed: 8 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { requireProjectAccess } from "@/lib/lx/currentSite";
55
import { env } from "@/lib/env";
66
import { LeadFinder } from "@/components/leads/lead-finder";
77
import { LeadActions } from "@/components/leads/lead-actions";
8-
import { CampaignPanel, type CampaignSummary } from "@/components/leads/campaign-panel";
98
import { SenderAddress } from "@/components/leads/sender-address";
109
import { MailboxConnect, type ConnectedMailbox } from "@/components/leads/mailbox-connect";
1110
import { SeedLogins } from "@/components/leads/seed-logins";
@@ -14,7 +13,7 @@ import { RepliesPanel, type ReplyRow } from "@/components/leads/replies-panel";
1413
import { ContactsPanel } from "@/components/leads/contacts-panel";
1514
import { IntentPanel, type IntentSignalRow } from "@/components/leads/intent-panel";
1615
import { contactNiches } from "@/lib/outreach/contactsExport";
17-
import { campaignFunnels, projectFunnel } from "@/lib/outreach/funnel";
16+
import { projectFunnel } from "@/lib/outreach/funnel";
1817
import { listSeedCredentials, type StoredSeedCredential } from "@/lib/outreach/seedCredentials";
1918
import { RefreshLeads } from "@/components/leads/refresh-leads";
2019
import { loadAddressSettings } from "@/lib/outreach/postalAddress";
@@ -74,7 +73,7 @@ export default async function LeadsPage({
7473
// project pages work. It also keeps outreach_sends unwritable from a
7574
// browser, so the record of what was sent stays honest.
7675
const supabase = serviceClient();
77-
const [{ data: prospectData }, { data: sendData }, { data: campaignData }] = await Promise.all([
76+
const [{ data: prospectData }, { data: sendData }] = await Promise.all([
7877
supabase
7978
.from("outreach_prospects")
8079
.select(
@@ -89,51 +88,10 @@ export default async function LeadsPage({
8988
.eq("project_id", projectId)
9089
.order("sent_at", { ascending: false })
9190
.limit(10),
92-
supabase
93-
.from("outreach_campaigns")
94-
.select(
95-
"id, name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note, auth_required_hosts, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects, min_intent, sells_description, angle, sender_name, reply_to",
96-
)
97-
.eq("project_id", projectId)
98-
.order("updated_at", { ascending: false })
99-
.limit(10),
10091
]);
10192

10293
const prospects = (prospectData as ProspectRow[] | null) ?? [];
10394
const sends = (sendData as SendRow[] | null) ?? [];
104-
const campaignRows = (campaignData as (CampaignSummary & { id: string })[] | null) ?? [];
105-
106-
// Recent ticks per campaign. last_run_note holds only the newest line, so
107-
// without this there is no way to see that a campaign has been erroring for
108-
// an hour, or that it has never found anything at all.
109-
const { data: runData } = await supabase
110-
.from("outreach_campaign_runs")
111-
.select("campaign_id, ran_at, summary, ok, errors")
112-
.in("campaign_id", campaignRows.map((c) => c.id))
113-
.order("ran_at", { ascending: false })
114-
.limit(60);
115-
116-
const runsByCampaign = new Map<string, CampaignSummary["runs"]>();
117-
for (const r of (runData as Record<string, unknown>[] | null) ?? []) {
118-
const key = r.campaign_id as string;
119-
const list = runsByCampaign.get(key) ?? [];
120-
if (list.length >= 8) continue;
121-
list.push({
122-
ran_at: r.ran_at as string,
123-
summary: (r.summary as string) ?? "",
124-
ok: (r.ok as boolean) ?? true,
125-
errors: Array.isArray(r.errors) ? (r.errors as string[]) : [],
126-
});
127-
runsByCampaign.set(key, list);
128-
}
129-
130-
const campaigns: CampaignSummary[] = campaignRows.map((c) => ({
131-
...c,
132-
pitch_facts: Array.isArray(c.pitch_facts) ? c.pitch_facts : [],
133-
auth_required_hosts: Array.isArray(c.auth_required_hosts) ? c.auth_required_hosts : [],
134-
runs: runsByCampaign.get(c.id) ?? [],
135-
}));
136-
13795
const byStatus = new Map<string, number>();
13896
for (const p of prospects) byStatus.set(p.status, (byStatus.get(p.status) ?? 0) + 1);
13997

@@ -152,7 +110,6 @@ export default async function LeadsPage({
152110
projectId,
153111
ownerId: access.userId,
154112
});
155-
const canSendLive = Boolean(addressSettings.address);
156113

157114
// The org's default email sender, when it's a connected mailbox rather than
158115
// an API-key provider — that's what the connect panel reflects back.
@@ -162,12 +119,10 @@ export default async function LeadsPage({
162119
.eq("id", projectId)
163120
.maybeSingle();
164121
const orgId = (projectRow?.organization_id as string | null) ?? null;
165-
// Measured outcomes, project-wide and per campaign. Both read the same
166-
// tables the pipeline already writes, so this costs two queries rather than
167-
// any new bookkeeping.
168-
const [funnel, perCampaignFunnel, contacts] = await Promise.all([
122+
// Measured outcomes, read from the same tables the pipeline already writes,
123+
// so this costs a query rather than any new bookkeeping.
124+
const [funnel, contacts] = await Promise.all([
169125
projectFunnel(projectId),
170-
campaignFunnels(projectId),
171126
contactNiches(projectId),
172127
]);
173128

@@ -195,17 +150,6 @@ export default async function LeadsPage({
195150
let seedCredentials: StoredSeedCredential[] = [];
196151
if (orgId) seedCredentials = await listSeedCredentials(orgId);
197152

198-
// Hosts any campaign in this project is parked on, waiting for a sign-in.
199-
const waitingHosts = [
200-
...new Set(
201-
campaigns.flatMap((c) =>
202-
Array.isArray((c as { auth_required_hosts?: string[] }).auth_required_hosts)
203-
? ((c as { auth_required_hosts?: string[] }).auth_required_hosts as string[])
204-
: [],
205-
),
206-
),
207-
];
208-
209153
let mailbox: ConnectedMailbox | null = null;
210154
if (orgId) {
211155
const { data: senderRow } = await supabase
@@ -249,11 +193,9 @@ export default async function LeadsPage({
249193

250194
<LeadFinder projectId={projectId} />
251195

252-
<CampaignPanel projectId={projectId} campaigns={campaigns} canSendLive={canSendLive} />
253-
254196
<IntentPanel signals={intentSignals} />
255197

256-
<FunnelPanel project={funnel} campaigns={perCampaignFunnel} />
198+
<FunnelPanel project={funnel} campaigns={[]} />
257199

258200
<RepliesPanel replies={replies} />
259201

@@ -266,7 +208,7 @@ export default async function LeadsPage({
266208

267209
<SeedLogins
268210
projectId={projectId}
269-
waitingHosts={waitingHosts}
211+
waitingHosts={[]}
270212
credentials={seedCredentials}
271213
/>
272214

@@ -284,8 +226,7 @@ export default async function LeadsPage({
284226

285227
{prospects.length === 0 ? (
286228
<p className="mt-4 text-sm text-[var(--color-muted)]">
287-
Nothing yet. Search for businesses above, or set up a campaign to keep the funnel full on
288-
its own.
229+
Nothing yet. Search for businesses above.
289230
</p>
290231
) : (
291232
<ul className="mt-4 divide-y divide-[var(--color-border)]">

‎app/actions/leads.ts‎

Lines changed: 0 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { discoverProspects } from "@/lib/outreach/discover";
1818
import { recordDiscoveredPeople } from "@/lib/outreach/contacts";
1919
import { generatePitch } from "@/lib/outreach/generatePitch";
2020
import { leadRunBilling, manualRunPrice } from "@/lib/outreach/billing";
21-
import { runEmailCampaignTick, CAMPAIGN_COLUMNS, summarize, type CampaignRow } from "@/lib/outreach/runner";
2221

2322
type Ok<T = Record<string, never>> = { ok: true } & T;
2423
type Err = { ok: false; error: string };
@@ -487,173 +486,3 @@ export async function importPostalAddressAction(input: {
487486
revalidatePath(leadsPath(input.projectId));
488487
return { ok: true, address: source, note: `Imported from ${input.from === "account" ? "your account" : "the organization"}.` };
489488
}
490-
491-
export async function saveCampaignAction(input: {
492-
projectId: string;
493-
name: string;
494-
queries: string;
495-
seedUrls: string;
496-
maxScore: number;
497-
dailySendLimit: number;
498-
autoSend: boolean;
499-
active: boolean;
500-
angle?: string;
501-
senderName?: string;
502-
replyTo?: string;
503-
pitchMode?: "audit" | "custom";
504-
pitchIntro?: string;
505-
pitchAsk?: string;
506-
pitchFacts?: string;
507-
scanProspects?: boolean;
508-
/**
509-
* Minimum public buying intent a lead must show, 0-100. Null or absent
510-
* leaves the campaign selecting on resemblance alone, which is what every
511-
* campaign did before intent existed.
512-
*/
513-
minIntent?: number | null;
514-
/** Plain prose: what this campaign sells. Enables description matching. */
515-
sellsDescription?: string;
516-
}): Promise<Ok<{ note: string }> | Err> {
517-
const auth = await requireLeadAccess(input.projectId);
518-
if (!auth.ok) return auth;
519-
if (!input.name.trim()) return { ok: false, error: "Give the campaign a name." };
520-
521-
const queries = input.queries.split("\n").map((s) => s.trim()).filter(Boolean);
522-
const seedUrls = input.seedUrls.split("\n").map((s) => s.trim()).filter(Boolean);
523-
if (!queries.length && !seedUrls.length) {
524-
return { ok: false, error: "A campaign needs at least one search query or directory URL." };
525-
}
526-
const pitchMode = input.pitchMode ?? "audit";
527-
const pitchFacts = (input.pitchFacts ?? "")
528-
.split("\n")
529-
.map((s) => s.trim())
530-
.filter(Boolean);
531-
if (pitchMode === "custom") {
532-
// The same bar the audit pitch holds itself to: without a description
533-
// and some declared facts there is nothing truthful for a draft to say,
534-
// and the grounding guard would reject every draft anyway.
535-
if (!input.pitchIntro?.trim()) {
536-
return { ok: false, error: "A custom pitch needs a description of who is writing and why." };
537-
}
538-
if (!pitchFacts.length) {
539-
return {
540-
ok: false,
541-
error:
542-
"A custom pitch needs at least one fact. Drafts may only state what you list here, so an empty list means every draft gets rejected.",
543-
};
544-
}
545-
}
546-
547-
// Scanning is the audit pitch's evidence step; default it off for a custom
548-
// pitch rather than scanning people we're emailing about something else.
549-
const scanProspects = input.scanProspects ?? pitchMode === "audit";
550-
if (pitchMode === "audit" && !scanProspects) {
551-
return {
552-
ok: false,
553-
error:
554-
"The audit pitch is built from scan findings, so it can't run with scanning turned off. Switch to a custom pitch instead.",
555-
};
556-
}
557-
558-
if (input.autoSend) {
559-
const postal = await loadAddressSettings({ projectId: input.projectId, ownerId: auth.userId });
560-
if (!postal.address) {
561-
return {
562-
ok: false,
563-
error:
564-
"No sender postal address is set. CAN-SPAM requires one in commercial email, so live sending stays off until you add it above.",
565-
};
566-
}
567-
}
568-
569-
const { error } = await serviceClient()
570-
.from("outreach_campaigns")
571-
.upsert(
572-
{
573-
project_id: input.projectId,
574-
owner_id: auth.userId,
575-
name: input.name.trim(),
576-
channel: "email",
577-
active: input.active,
578-
queries,
579-
seed_urls: seedUrls,
580-
max_score: input.maxScore,
581-
daily_send_limit: input.dailySendLimit,
582-
auto_send: input.autoSend,
583-
// Clamped rather than rejected: a bar above 100 silently matches
584-
// nothing, which looks like the sweep being broken.
585-
min_intent:
586-
input.minIntent === null || input.minIntent === undefined
587-
? null
588-
: Math.max(0, Math.min(100, Math.round(input.minIntent))),
589-
sells_description: input.sellsDescription?.trim() || null,
590-
angle: input.angle?.trim() || null,
591-
sender_name: input.senderName?.trim() || null,
592-
reply_to: input.replyTo?.trim() || null,
593-
pitch_mode: pitchMode,
594-
pitch_intro: input.pitchIntro?.trim() || null,
595-
pitch_ask: input.pitchAsk?.trim() || null,
596-
pitch_facts: pitchFacts,
597-
scan_prospects: scanProspects,
598-
},
599-
{ onConflict: "project_id,name" },
600-
);
601-
if (error) return { ok: false, error: error.message };
602-
603-
revalidatePath(leadsPath(input.projectId));
604-
return {
605-
ok: true,
606-
note: input.autoSend
607-
? `"${input.name}" saved — auto-send is ON, so the cron tick will email real people.`
608-
: `"${input.name}" saved. It will find, scan and draft; sending stays off until you turn it on.`,
609-
};
610-
}
611-
612-
export async function runCampaignAction(input: {
613-
projectId: string;
614-
name: string;
615-
}): Promise<Ok<{ note: string }> | Err> {
616-
const auth = await requireLeadAccess(input.projectId);
617-
if (!auth.ok) return auth;
618-
const { data } = await serviceClient()
619-
.from("outreach_campaigns")
620-
.select(CAMPAIGN_COLUMNS)
621-
.eq("project_id", input.projectId)
622-
.eq("name", input.name)
623-
.maybeSingle();
624-
if (!data) return { ok: false, error: "Campaign not found." };
625-
626-
const result = await runEmailCampaignTick(data as CampaignRow);
627-
revalidatePath(leadsPath(input.projectId));
628-
return { ok: true, note: `${result.campaign}: ${summarize(result)}` };
629-
}
630-
631-
export async function toggleCampaignAction(input: {
632-
projectId: string;
633-
name: string;
634-
field: "active" | "auto_send";
635-
value: boolean;
636-
}): Promise<Ok<{ note: string }> | Err> {
637-
const auth = await requireLeadAccess(input.projectId);
638-
if (!auth.ok) return auth;
639-
if (input.field === "auto_send" && input.value) {
640-
const postal = await loadAddressSettings({ projectId: input.projectId, ownerId: auth.userId });
641-
if (!postal.address) {
642-
return { ok: false, error: "No sender postal address is set — live sending is blocked until you add one." };
643-
}
644-
}
645-
const { error } = await serviceClient()
646-
.from("outreach_campaigns")
647-
.update({ [input.field]: input.value })
648-
.eq("project_id", input.projectId)
649-
.eq("name", input.name);
650-
if (error) return { ok: false, error: error.message };
651-
revalidatePath(leadsPath(input.projectId));
652-
return {
653-
ok: true,
654-
note:
655-
input.field === "active"
656-
? `"${input.name}" ${input.value ? "resumed" : "paused"}.`
657-
: `"${input.name}" auto-send ${input.value ? "ON — it will email real people" : "off"}.`,
658-
};
659-
}

0 commit comments

Comments
 (0)