diff --git a/lib/outreach/pipeline.ts b/lib/outreach/pipeline.ts index 5b8ef02b..5157d6a8 100644 --- a/lib/outreach/pipeline.ts +++ b/lib/outreach/pipeline.ts @@ -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 }; diff --git a/lib/outreach/runner.ts b/lib/outreach/runner.ts index 28da64a5..55c16aba 100644 --- a/lib/outreach/runner.ts +++ b/lib/outreach/runner.ts @@ -329,7 +329,18 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise { + 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\)/); + }); +});