diff --git a/lib/outreach/cold.ts b/lib/outreach/cold.ts index 5f628afc..4c946cf0 100644 --- a/lib/outreach/cold.ts +++ b/lib/outreach/cold.ts @@ -90,8 +90,12 @@ export type SuppressionReason = export type ContactCandidate = { email: string; - /** Where we found it — shown in the draft so a human can sanity-check. */ - source: "mailto" | "text" | "manual"; + /** + * Where it came from — shown in the draft so a human can sanity-check. + * "guess" is a constructed role address that was never published anywhere, + * and is the one value that should make a reader look twice. + */ + source: "mailto" | "text" | "manual" | "guess"; /** True when the address is on the prospect's own domain. */ sameDomain: boolean; }; @@ -257,6 +261,10 @@ export function rankContacts(candidates: ContactCandidate[]): ContactCandidate[] let score = 0; if (c.sameDomain) score += 100; if (c.source === "mailto") score += 20; + // A constructed address was never published anywhere, so any address + // the site actually printed should win — including one this heuristic + // would otherwise rank lower. + if (c.source === "guess") score -= 200; const idx = ROLE_PREFERENCE.indexOf(localPart(c.email)); // A local part we don't recognise is likely a person's name, which is // the best possible target — worth more than the mailto: bonus, so a @@ -445,6 +453,42 @@ export function unsupportedCustomClaims(body: string, facts: string[]): string[] return [...new Set(problems)]; } +/** + * Shared inboxes worth trying when a site publishes no address at all. + * + * A guess, and marked as one. These bounce more often than a published + * address, and bounces cost sender reputation — so this is a last resort + * after crawling the site and searching for it have both failed, not a + * shortcut past either. + * + * Ordered by how likely a human is to read a cold message sent there. + * `hello` and `info` are read by whoever runs a small company; `press` is + * further down because it is monitored but narrower in purpose — though it + * demonstrably works, since the message that prompted this arrived on ours. + * + * Deliberately absent: every address in NEVER_CONTACT_LOCALPART. Guessing + * one would manufacture exactly the sends that list exists to prevent. + */ +const GUESSABLE_ROLE_LOCALPARTS = [ + "hello", + "info", + "contact", + "hi", + "team", + "press", + "sales", + "enquiries", + "inquiries", +]; + +export function roleAddressGuesses(host: string): ContactCandidate[] { + const domain = normalizeHost(host); + if (!domain || !domain.includes(".")) return []; + return GUESSABLE_ROLE_LOCALPARTS.map((local) => `${local}@${domain}`) + .filter((email) => !isNeverContactMailbox(email)) + .map((email) => ({ email, source: "guess" as const, sameDomain: true })); +} + // ------------------------------------------------- obfuscation & link crawl /** diff --git a/lib/outreach/pipeline.ts b/lib/outreach/pipeline.ts index 5d3733ef..587ecaf4 100644 --- a/lib/outreach/pipeline.ts +++ b/lib/outreach/pipeline.ts @@ -37,6 +37,7 @@ import { stepGuidance, suppressionReason, unsupportedClaims, + roleAddressGuesses, unsupportedCustomClaims, type ContactCandidate, type OutreachStep, @@ -46,6 +47,7 @@ import { isEmailSuppressed, marketingUnsubscribedAt, sendsInLast24h } from "./su import { resolvePostalAddress } from "./postalAddress"; import { findContactViaSearch } from "./contactFallback"; import { loadProjectMailbox } from "./senderMailbox"; +import { loadRecipientContext, recipientContextPrompt } from "./recipientContext"; export type ProspectRow = { id: string; @@ -454,6 +456,19 @@ async function researchWithoutScan(input: { fallbackNote = viaSearch.note; } + // Last resort: a shared inbox this domain probably has. Only after both + // the crawl and the search have found nothing, because a constructed + // address bounces more often than a published one and bounces are charged + // to the sender's reputation, not the guess. + if (!contact) { + const guessed = bestContact(roleAddressGuesses(host)); + if (guessed) { + candidates = [...candidates, guessed]; + contact = guessed; + fallbackNote = `no address published or findable — using the guessed shared inbox ${guessed.email}`; + } + } + const { data, error } = await serviceClient() .from("outreach_prospects") .upsert( @@ -529,20 +544,27 @@ export type CampaignPitch = { /** System prompt for a campaign pitching something other than a scan. */ export function customDraftSystem(pitch: CampaignPitch): string { - return `You write cold outreach email on behalf of the sender described below. You are not selling a website audit; write only the pitch described. + return `You write one short cold email on behalf of the sender described below. You are not selling a website audit; write only the pitch described. WHO IS WRITING AND WHY: ${pitch.intro} +SHAPE. Four short paragraphs, in this order, and nothing else: +1. One specific, checkable observation about the recipient, drawn only from what their own site says about itself. Name the actual thing they do. This is the sentence that decides whether the rest gets read, and a generic opening wastes it. +2. One sentence naming a problem the sender genuinely addresses and the recipient plausibly has. State it as a general observation, not as a diagnosis of them — you do not know their situation. +3. One or two sentences on what the sender offers, concretely. Name real specifics from the FACTS. "We help you grow" says nothing; naming the actual thing says everything. +4. One low-commitment ask${pitch.ask ? `: ${pitch.ask}` : ""}, then a plain sign-off with the sender's name. + Hard rules, in order of importance: -1. Every factual claim must come from the FACTS supplied to you. If something is not in the facts, you do not know it, and you may not state it. Invent no numbers, dates, durations, links, company names or credentials. -2. Say nothing about the recipient's business as fact. You have not researched them. You may say why you are writing to someone like them, not what they are doing wrong. -3. Never imply a prior relationship. They have never heard from the sender. No "following up", no "as discussed", no "thanks for your time". +1. Every factual claim must come from the FACTS supplied to you, or from the recipient's own self-description quoted in the prompt. Invent no numbers, dates, durations, links, company names or credentials. +2. Never imply a prior relationship. They have never heard from the sender. No "following up", no "as discussed", no "thanks for your time". +3. The observation in paragraph 1 is an observation, not a compliment. "You focus on X" is right. "I love your work", "impressive product", "you're crushing it" are not — praise from a stranger reads as a form letter, because it is one. 4. No invented urgency, no fake deadlines, no flattery about work you have not seen. -5. Short. Under 120 words for a first contact. +5. Under 150 words. Cold email that needs scrolling does not get read. 6. Plain language. No "In today's digital landscape", "unlock", "leverage", "elevate", "game-changer", "delve", "reach out", "circle back", "I hope this email finds you well". -7. One ask${pitch.ask ? `, and it is this: ${pitch.ask}` : ", and make it low-commitment"}. Never ask for a call in a first message. -8. Write like one person emailing another, because that is what this is.`; +7. One ask, and make it small. Offer to send more, not to take an hour of their time. Never ask for a call in a first message. +8. The subject line names the recipient's own thing and states the actual topic. Do not promise something the body does not deliver — a subject that says "quick question" with no question in it is a small lie, and the reply it earns is owed to the trick rather than the offer. +9. Write like one person emailing another, because that is what this is.`; } export type DraftResult = @@ -664,8 +686,12 @@ async function draftCustomEmail(input: { } const host = normalizeHost(input.prospect.site_url ?? input.prospect.target_key); + // Paragraph 1 of the shape above needs something true to say. Without it + // the model either opens generically or invents a detail, so the site's + // own words are fetched and quoted rather than guessed at. + const recipient = await loadRecipientContext(host); const userPrompt = [ - `Recipient: someone at ${host}. You know nothing else about them — do not characterise their work, their site, or their needs as fact.`, + recipientContextPrompt(recipient, host), "", "FACTS (the only things you may state):", ...facts.map((f) => `- ${f}`), diff --git a/lib/outreach/recipientContext.ts b/lib/outreach/recipientContext.ts new file mode 100644 index 00000000..e55eb158 --- /dev/null +++ b/lib/outreach/recipientContext.ts @@ -0,0 +1,126 @@ +// One verifiable sentence about the recipient, read off their own homepage. +// +// A cold email that opens with a specific, checkable observation about the +// reader outperforms one that opens with the sender. The custom-pitch prompt +// originally forbade saying anything about the recipient at all, which was +// the right instinct — without research, "I loved your work" is a fabrication +// — but it banned the strongest opening available. +// +// This supplies the research instead of removing the guard. Only what the +// site says about itself in its own title and description is used: no +// inference, no summarising of prose, nothing the reader could not verify by +// looking at their own homepage. If the site says nothing useful, this +// returns null and the draft opens some other way rather than guessing. + +const MAX_DESCRIPTION_CHARS = 220; + +/** Boilerplate that describes a template rather than a business. */ +const USELESS_DESCRIPTION = + /^(home|homepage|welcome|index|untitled|new page|coming soon|site|website|default|just another wordpress site)\b/i; + +function decodeEntities(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/?39;|'/g, "'") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function meta(html: string, key: string): string | null { + const patterns = [ + new RegExp(`]+(?:property|name)=["']${key}["'][^>]*content=["']([^"']*)["']`, "i"), + new RegExp(`]+content=["']([^"']*)["'][^>]*(?:property|name)=["']${key}["']`, "i"), + ]; + for (const re of patterns) { + const v = html.match(re)?.[1]; + if (v && v.trim()) return decodeEntities(v); + } + return null; +} + +export type RecipientContext = { + /** What the site says it does, in its own words. */ + selfDescription: string; + /** Which tag it came from, so the claim is auditable. */ + source: "og:description" | "meta description" | "og:title" | "title"; +}; + +/** + * Read what a site says about itself. + * + * Descriptions are preferred over titles because a title is often just the + * brand name, which supports no observation worth making. A title is only + * used when it carries a tagline — a bare brand name tells the drafter + * nothing it did not already have from the domain. + */ +export function extractRecipientContext(html: string): RecipientContext | null { + const candidates: [string | null, RecipientContext["source"]][] = [ + [meta(html, "og:description"), "og:description"], + [meta(html, "description"), "meta description"], + [meta(html, "og:title"), "og:title"], + [decodeEntities(html.match(/