Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/outreach/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,10 +834,20 @@ async function draftCustomEmail(input: {

// Facts, plus the intro and ask — everything the operator authored counts
// as grounded, or an ask that names a URL makes its own draft invalid.
//
// And the recipient's own identity, which the operator could not have
// written because it differs per prospect. Addressing a company called
// "4 Corner Resources" states the number four, and the guard rejected every
// draft to them for inventing it — the campaign, quite correctly, had never
// mentioned a four. Naming who you are writing to is not a claim about
// them; it is the minimum a personalised email does.
const problems = unsupportedCustomClaims(output.body, [
...facts,
input.pitch.intro,
input.pitch.ask ?? "",
host,
input.prospect.target_key,
recipient?.selfDescription ?? "",
]);
if (problems.length) return { ok: false, problems };

Expand Down
13 changes: 12 additions & 1 deletion lib/outreach/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,18 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
// it is genuinely spent by then — a search that returned no usable candidate
// still cost a call — but billing for a tick with no output is a worse trade
// than eating that occasionally.
if (billing.charged() && !result.discovered && !result.researched && !result.drafted && !result.sent) {
//
// People count as output. A run against a people-directory names humans
// without necessarily adding a prospect, and leaving them out of this test
// refunded every such run: eleven CTOs rendered, paginated, parsed and
// recorded, billed as nothing.
const producedSomething =
result.discovered ||
result.researched ||
result.drafted ||
result.sent ||
result.peopleRecorded;
if (billing.charged() && !producedSomething) {
await billing.refund();
}
result.creditsSpent = billing.charged() ? LEAD_RUN_CREDITS : 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- Let a prospect record how its address was actually found.
--
-- contact_source was constrained to ('mailto','text','manual') when the only
-- ways to find an address were reading a link, reading page text, or being
-- told. Two more have been added since — a search fallback and, last of all, a
-- guessed role address — and neither could be written down.
--
-- The failure was total rather than cosmetic: the whole prospect row was
-- rejected by the check, so a business whose address had to be guessed was
-- discovered, crawled, searched for and then dropped on the floor with a
-- constraint error in the run log. builtinla.com is in that log twice.
--
-- 'guess' matters most of the four to keep distinguishable. A constructed
-- address bounces far more often than a published one, and bounces are
-- charged to the sender's reputation — so "we made this up" has to survive
-- into the record where the send path can still see it.

alter table public.outreach_prospects
drop constraint if exists outreach_prospects_contact_source_check;

alter table public.outreach_prospects
add constraint outreach_prospects_contact_source_check
check (contact_source = any (array['mailto', 'text', 'manual', 'search', 'guess', 'page', 'json-ld']));

comment on column public.outreach_prospects.contact_source is
'How the address was found. guess = constructed role address, never published; it bounces more often and the send path treats it accordingly.';
70 changes: 70 additions & 0 deletions tests/prod-regressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { unsupportedCustomClaims } from "@/lib/outreach/cold";

// Three failures that only appeared once campaigns ran against real
// businesses. Each is pinned by the case that produced it.

describe("a company's own name is not an invented claim", () => {
const campaign = ["We build websites.", "Reply if useful."];

it("lets a draft name 4 Corner Resources", () => {
// The guard rejected every draft to them for stating "4" — which the
// campaign, quite correctly, had never mentioned. You cannot address the
// company without writing its name.
const body = "I came across 4 Corner Resources and had a thought.";
expect(unsupportedCustomClaims(body, [...campaign, "4cornerresources.com"])).toEqual([]);
});

it("still catches a number that is genuinely invented", () => {
// The guard has to keep earning its place: a fabricated credential is
// exactly what it exists to stop reaching a stranger.
const problems = unsupportedCustomClaims(
"We have 30 years of experience and 500 clients.",
[...campaign, "4cornerresources.com"],
);
expect(problems.length).toBeGreaterThan(0);
expect(problems.join(" ")).toMatch(/30|500/);
});

it("still catches an invented link", () => {
expect(
unsupportedCustomClaims("See https://example.com/made-up", campaign).join(" "),
).toMatch(/links to/);
});
});

describe("every contact source the code emits is storable", () => {
// A prospect whose address had to be guessed was discovered, crawled,
// searched for, and then rejected wholesale by a check constraint that
// predated the guess path. builtinla.com is in the run log twice.
const cold = readFileSync(new URL("../lib/outreach/cold.ts", import.meta.url), "utf8");
const migration = readFileSync(
new URL("../supabase/migrations/20260729050000_prospect_contact_source_guess.sql", import.meta.url),
"utf8",
);

it("the constraint covers the declared ContactCandidate sources", () => {
const declared = cold.match(/source:\s*("(?:mailto|text|manual|guess|search)"\s*\|?\s*)+/);
expect(declared).not.toBeNull();
for (const source of ["mailto", "text", "manual", "guess"]) {
expect(migration).toContain(`'${source}'`);
}
});
});

describe("people are output worth paying for", () => {
// A run against a people-directory names humans without necessarily adding
// a prospect. Leaving them out of the produced-something test refunded
// every such run: eleven CTOs rendered, paginated, parsed — billed as zero.
const runner = readFileSync(new URL("../lib/outreach/runner.ts", import.meta.url), "utf8");

it("counts recorded people when deciding whether to refund", () => {
const block = runner.slice(runner.indexOf("const producedSomething"));
expect(block.slice(0, 300)).toContain("result.peopleRecorded");
});

it("still refunds a run that truly did nothing", () => {
expect(runner).toMatch(/if \(billing\.charged\(\) && !producedSomething\)/);
});
});
Loading