From 26606309f09d394096d406d87abd2d0ce7a1eb81 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 12 Oct 2026 11:04:52 +0000 Subject: [PATCH] Count only real replies, keep our own domains out, write real subject lines Replies: a human message counts only when its In-Reply-To/References names a Message-ID we sent, or it comes from an address we wrote to under a reply subject. The only "reply" in 1,247 prod sends was 20 Porkbun receipts matched on address alone; migration 0067 refiles them as automated. Own domains: ownDomains spans the organization's offerings, members and mailboxes (skipping webmail, shared hosts and RFC 2606 names), and card creation skips people on those domains, so no intake path queues our products. Subjects: every cold email went out as "Quick question about {Company}" because the composer was told never to write one. Email drafts now carry a model-written subject, checked for grounding and shape; a failing one falls back to the old default. Co-Authored-By: Claude Opus 5.5 --- .../0067_unthreaded_inbound_not_replies.sql | 15 ++ .../0067_unthreaded_inbound_not_replies.sql | 23 ++++ packages/ai/src/composer.test.ts | 65 ++++++++- packages/ai/src/composer.ts | 83 +++++++++++- packages/ai/src/draft.ts | 8 +- packages/pipeline/src/pipeline.ts | 8 ++ packages/pipeline/src/receive-email.test.ts | 116 +++++++++++++++- packages/pipeline/src/receive-email.ts | 93 ++++++++++++- packages/pipeline/src/recipient-guard.test.ts | 81 ++++++++++- packages/pipeline/src/recipient-guard.ts | 128 ++++++++++++++++-- 10 files changed, 597 insertions(+), 23 deletions(-) create mode 100644 migrations-pg/0067_unthreaded_inbound_not_replies.sql create mode 100644 migrations/0067_unthreaded_inbound_not_replies.sql diff --git a/migrations-pg/0067_unthreaded_inbound_not_replies.sql b/migrations-pg/0067_unthreaded_inbound_not_replies.sql new file mode 100644 index 0000000..9be5ab8 --- /dev/null +++ b/migrations-pg/0067_unthreaded_inbound_not_replies.sql @@ -0,0 +1,15 @@ +-- 0067_unthreaded_inbound_not_replies.sql (Postgres). See migrations/0067_unthreaded_inbound_not_replies.sql for the reasoning. +UPDATE interactions + SET direction = 'automated', + state = 'auto_replied', + reply_label = 'other', + reply_label_source = 'rule', + reply_label_reason = 'not an answer to anything we sent' + WHERE network = 'email' AND direction = 'inbound' + AND NOT (LOWER(COALESCE(subject, '')) LIKE 're:%' OR LOWER(COALESCE(subject, '')) LIKE 're[%' + OR LOWER(COALESCE(subject, '')) LIKE 'aw:%' OR LOWER(COALESCE(subject, '')) LIKE 'sv:%') + AND NOT EXISTS ( + SELECT 1 FROM actions a + WHERE a.workspace_id = interactions.workspace_id AND a.network = 'email' + AND a.external_id IS NOT NULL + AND interactions.references_header LIKE '%' || a.external_id || '%'); diff --git a/migrations/0067_unthreaded_inbound_not_replies.sql b/migrations/0067_unthreaded_inbound_not_replies.sql new file mode 100644 index 0000000..9166ccf --- /dev/null +++ b/migrations/0067_unthreaded_inbound_not_replies.sql @@ -0,0 +1,23 @@ +-- 0067_unthreaded_inbound_not_replies.sql: mail that answers nothing of ours is not a reply. +-- +-- Replies were matched on the sender's address alone, and the mailbox read is +-- a real person's inbox. A prospect who is also a vendor writes to it all the +-- time: in production (2026-10-12) the only "reply" in 1,247 sends was twenty +-- Porkbun receipts and renewal notices. The reader now asks for the thread +-- (References naming a Message-ID we sent) or a reply subject; this refiles +-- what the old rule recorded under neither, as `automated`, which no reply +-- count reads. Kept, not deleted: the inbox still shows them. +UPDATE interactions + SET direction = 'automated', + state = 'auto_replied', + reply_label = 'other', + reply_label_source = 'rule', + reply_label_reason = 'not an answer to anything we sent' + WHERE network = 'email' AND direction = 'inbound' + AND NOT (LOWER(COALESCE(subject, '')) LIKE 're:%' OR LOWER(COALESCE(subject, '')) LIKE 're[%' + OR LOWER(COALESCE(subject, '')) LIKE 'aw:%' OR LOWER(COALESCE(subject, '')) LIKE 'sv:%') + AND NOT EXISTS ( + SELECT 1 FROM actions a + WHERE a.workspace_id = interactions.workspace_id AND a.network = 'email' + AND a.external_id IS NOT NULL + AND interactions.references_header LIKE '%' || a.external_id || '%'); diff --git a/packages/ai/src/composer.test.ts b/packages/ai/src/composer.test.ts index 589085f..d29c040 100644 --- a/packages/ai/src/composer.test.ts +++ b/packages/ai/src/composer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { composeDraft, type ComposeInput } from './composer'; +import { composeDraft, splitSubject, subjectProblem, type ComposeInput } from './composer'; import { StubModel, type GenerateInput, type GenerateResult, type TextModel } from './model'; function input(overrides: Partial = {}): ComposeInput { @@ -377,3 +377,66 @@ describe('cadence guidance', () => { expect(model.calls[0]!.user).not.toContain('The purpose of this particular message'); }); }); + +describe('email subject lines', () => { + const email = (text: string) => + composeDraft(new StubModel(text), input({ action: 'send_email', network: 'email' })); + + test('an email asks for a subject; other channels do not', async () => { + const model = new StubModel(GOOD_DRAFT); + await composeDraft(model, input({ action: 'send_email', network: 'email' })); + expect(model.calls[0]!.system).toContain('Subject:'); + + const x = new StubModel(GOOD_DRAFT); + await composeDraft(x, input()); + expect(x.calls[0]!.system).not.toContain('Subject:'); + }); + + test('the subject is split off and the body is checked without it', async () => { + const result = await email(`Subject: cross-border payouts\n\n${GOOD_DRAFT}`); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.subject).toBe('cross-border payouts'); + expect(result.body).toBe(GOOD_DRAFT); + }); + + test('a subject that breaks the rules is dropped, and the draft still stands', async () => { + const result = await email(`Subject: Quick question about Acme!\n\n${GOOD_DRAFT}`); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.subject).toBeUndefined(); + expect(result.body).toBe(GOOD_DRAFT); + }); + + test('no subject line at all leaves the body whole', async () => { + const result = await email(GOOD_DRAFT); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.subject).toBeUndefined(); + expect(result.body).toBe(GOOD_DRAFT); + }); + + test('splitSubject tolerates the ways models write it', () => { + expect(splitSubject('subject: "fees on payouts"\nbody')).toEqual({ + subject: 'fees on payouts', + body: 'body', + }); + expect(splitSubject('**Subject:** settlement days\n\nbody').body).toBe('body'); + }); + + test('subjectProblem refuses template, pitch, fake threads and invented specifics', () => { + const grounding = { + evidence: ['settlement takes days on cross-border payouts'], + facts: ['Jane', 'Acme'], + offering: ['ExamplePay'], + }; + expect(subjectProblem('settlement taking days', grounding, 'ExamplePay')).toBeUndefined(); + expect(subjectProblem('Acme payouts', grounding, 'ExamplePay')).toBeUndefined(); + expect(subjectProblem('quick question', grounding, 'ExamplePay')).toBe('template phrase'); + expect(subjectProblem('Re: payouts at Acme', grounding, 'ExamplePay')).toBe('fake thread'); + expect(subjectProblem('ExamplePay for payouts', grounding, 'ExamplePay')).toBe('pitch'); + expect(subjectProblem('payouts', grounding, 'ExamplePay')).toBe('length'); + expect(subjectProblem('payouts at Globex', grounding, 'ExamplePay')).toBe('unsupported'); + expect(subjectProblem('PAYOUTS AT ACME', grounding, 'ExamplePay')).toBe('all caps'); + }); +}); diff --git a/packages/ai/src/composer.ts b/packages/ai/src/composer.ts index fcabb94..ab5a815 100644 --- a/packages/ai/src/composer.ts +++ b/packages/ai/src/composer.ts @@ -24,7 +24,12 @@ import { type PersonKind, type ReplyLabel, } from '@outreachgraph/domain'; -import { runChecks, type CheckReport, type GroundingContext } from './checks'; +import { + findUnsupportedClaims, + runChecks, + type CheckReport, + type GroundingContext, +} from './checks'; import type { TextModel } from './model'; export interface OfferingContext { @@ -88,6 +93,8 @@ export type ComposeResult = | { readonly ok: true; readonly body: string; + /** Email only, and only when it passed `subjectProblem`. */ + readonly subject?: string; readonly report: CheckReport; readonly groundedSignalIds: readonly string[]; readonly model: string; @@ -142,7 +149,13 @@ export async function composeDraft(model: TextModel, input: ComposeInput): Promi if (generated.refused) return { ok: false, reason: 'model_refused', attempts: attempt }; - const body = stripWrapper(generated.text); + // An email's first line is its subject; it is checked on its own below, + // and the body's gates (length, similarity) see only the body. + const split = + input.network === 'email' + ? splitSubject(stripWrapper(generated.text)) + : { body: stripWrapper(generated.text) }; + const body = stripWrapper(split.body); if (!body) return { ok: false, reason: 'empty', attempts: attempt }; lastBody = body; @@ -162,9 +175,16 @@ export async function composeDraft(model: TextModel, input: ComposeInput): Promi lastReport = report; if (report.passed) { + // A subject that fails its own rules is dropped, not retried: the body + // passed, and the sender's fallback subject is a safe floor. + const subject = + split.subject && !subjectProblem(split.subject, grounding, input.offering.name) + ? split.subject + : undefined; return { ok: true, body, + ...(subject ? { subject } : {}), report, groundedSignalIds: [input.trigger.id], model: generated.model, @@ -227,7 +247,7 @@ function buildCachedPrefix(input: ComposeInput): string { '- No flattery. Do not open by praising them or their work.', '- No urgency, no scarcity, no manufactured enthusiasm.', '- Write as one person to another. Not marketing copy.', - '- Output only the message. No subject line, no signature, no preamble, no quotation marks.', + '- Output only the message. No signature, no preamble, no quotation marks, and no subject line unless the instructions below ask for one.', '', `About the business: ${input.offering.name}, ${input.offering.category}.`, input.offering.valuePropositions.length > 0 @@ -260,11 +280,68 @@ function buildSystem(input: ComposeInput): string { input.action === 'reply' || input.action === 'comment' ? 'This is a public reply in an existing thread. Other people will read it. Be useful to them too.' : '', + input.network === 'email' ? SUBJECT_INSTRUCTIONS : '', ] .filter(Boolean) .join('\n'); } +/** + * Subject lines, which decide whether the rest is read at all. + * + * Every cold email in production (1,310 to 2026-10-12) went out as "Quick + * question about {Company}", because the composer was told never to write a + * subject and the sender filled the gap with one template. One subject + * across a thousand messages reads as a campaign to a filter and to a person. + * What gets opened is short, lower-key, and plainly about the recipient — the + * way a colleague titles an email, not the way a marketer does. + */ +const SUBJECT_INSTRUCTIONS = [ + 'This is an email. Start with one line "Subject: ..." then a blank line, then the message.', + 'The subject: two to six words, lowercase except names, about their specific thing from the CONTEXT — what a colleague would type, not a headline.', + 'Never in the subject: "quick question", "Re:" or "Fwd:", exclamation marks, all caps, emoji, our product name, or anything the CONTEXT does not support.', +].join('\n'); + +const SUBJECT_BANNED = + /\b(quick question|following up|touching base|checking in|free|guarantee|urgent|act now|limited time|opportunity|partnership)\b/i; + +/** + * Splits the "Subject:" line off a generated email. + * + * A model that forgot the line yields the whole text as the body and no + * subject, which is what every email had before — never a lost message. + */ +export function splitSubject(text: string): { subject?: string; body: string } { + const match = /^\s*\**\s*subject\s*:\s*\**\s*(.+?)\s*(?:\r?\n|$)/i.exec(text); + if (!match) return { body: text }; + const subject = match[1]!.replace(/^["'*]+|["'*]+$/g, '').trim(); + const body = text.slice(match[0].length).trim(); + return subject ? { subject, body } : { body }; +} + +/** + * Why a generated subject must not be sent, or undefined when it may. + * + * Held to the same grounding rule as the body — a subject is the first claim + * the reader sees — plus the shape that gets mail opened rather than filtered. + */ +export function subjectProblem( + subject: string, + grounding: GroundingContext, + offeringName: string, +): string | undefined { + const words = subject.split(/\s+/).filter(Boolean); + if (words.length < 2 || words.length > 8 || subject.length > 70) return 'length'; + if (/^(re|fwd?)\s*:/i.test(subject)) return 'fake thread'; + if (/[!?]{2,}|!/.test(subject)) return 'exclamation'; + if (/\p{Extended_Pictographic}/u.test(subject)) return 'emoji'; + if (subject === subject.toUpperCase() && /[A-Z]/.test(subject)) return 'all caps'; + if (SUBJECT_BANNED.test(subject)) return 'template phrase'; + if (offeringName && subject.toLowerCase().includes(offeringName.toLowerCase())) return 'pitch'; + if (findUnsupportedClaims(subject, grounding).unsupported.length > 0) return 'unsupported'; + return undefined; +} + function buildUser(input: ComposeInput, failed?: CheckReport): string { const trigger = input.trigger!; const inbox = input.prospect.kind === 'company_inbox'; diff --git a/packages/ai/src/draft.ts b/packages/ai/src/draft.ts index 793adb7..72ef4ec 100644 --- a/packages/ai/src/draft.ts +++ b/packages/ai/src/draft.ts @@ -223,13 +223,15 @@ export async function draftForRecommendation( await db.batch([ { - sql: `INSERT INTO drafts (id, workspace_id, recommendation_id, body, grounded_signal_ids, - checks_json, similarity_hash, model, edited_by_user, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, + sql: `INSERT INTO drafts (id, workspace_id, recommendation_id, subject, body, + grounded_signal_ids, checks_json, similarity_hash, model, edited_by_user, created_at, + updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, args: [ draftId, recommendation.workspace_id, recommendation.id, + result.subject ?? null, result.body, JSON.stringify(result.groundedSignalIds), JSON.stringify(result.report.results), diff --git a/packages/pipeline/src/pipeline.ts b/packages/pipeline/src/pipeline.ts index 3616a53..c7cff99 100644 --- a/packages/pipeline/src/pipeline.ts +++ b/packages/pipeline/src/pipeline.ts @@ -39,6 +39,7 @@ import { recordDiscovered, recordStatus } from './stages'; import { storeDiscoveredPhoto } from './photos'; import { enqueueFindEmail } from './find-email-queue'; import { screenHold } from './lead-screen'; +import { ownDomains, personOnOwnDomain } from './recipient-guard'; export interface PipelineOptions { readonly db: Client; @@ -880,6 +881,13 @@ async function createRecommendation( }>(db, 'SELECT identity_confidence, status, believed_minor FROM people WHERE id = ?', [personId]); if (!person) return undefined; + // Our own products are found by discovery like anyone else in the market, + // and they are not prospects. Refused here, before any card, so every + // intake path is covered and nothing waits in the queue to be dismissed. + if (await personOnOwnDomain(db, workspaceId, personId, await ownDomains(db, workspaceId))) { + return undefined; + } + const campaign = await queryOne<{ approval_mode: string; budget_json: string }>( db, 'SELECT approval_mode, budget_json FROM campaigns WHERE id = ?', diff --git a/packages/pipeline/src/receive-email.test.ts b/packages/pipeline/src/receive-email.test.ts index a9bc8d2..3875d47 100644 --- a/packages/pipeline/src/receive-email.test.ts +++ b/packages/pipeline/src/receive-email.test.ts @@ -15,7 +15,7 @@ import { now, queryAll, queryOne, type Client } from '@outreachgraph/db'; import type { IncomingMessage, MailReader } from '@outreachgraph/email'; import { newId } from '@outreachgraph/domain'; import { seedDatabase, SEED, type SeededDatabase } from '../../../apps/api/src/test-seed'; -import { receiveReplies } from './receive-email'; +import { isReplySubject, receiveReplies, threadIds } from './receive-email'; let seeded: SeededDatabase | undefined; @@ -377,3 +377,117 @@ describe('a human reply', () => { expect(row).toEqual({ reply_label: 'unsubscribe_request', reply_label_source: 'rule' }); }); }); + +/** Records a send the way `recordEmailSent` does: an action with our Message-ID, and its interaction. */ +async function sentWithId(db: Client, address: string, messageId: string): Promise { + const actionId = newId('action'); + await db.execute({ + sql: `INSERT INTO actions (id, workspace_id, recommendation_id, person_id, kind, network, mode, + status, external_id, created_at, executed_at) + VALUES (?, ?, ?, ?, 'send_email', 'email', 'customer_managed', 'completed', ?, ?, ?)`, + args: [ + actionId, + SEED.workspaceId, + SEED.recommendationId, + SEED.personId, + messageId, + now(), + now(), + ], + }); + await db.execute({ + sql: `INSERT INTO interactions (id, workspace_id, person_id, action_id, network, direction, + state, contact_address, shared_inbox, occurred_at, recorded_at) + VALUES (?, ?, ?, ?, 'email', 'outbound', 'contacted', ?, 1, ?, ?)`, + args: [newId('interaction'), SEED.workspaceId, SEED.personId, actionId, address, now(), now()], + }); +} + +describe('a reply answers something we sent', () => { + test('mail from a prospect that is not a reply is not one (a vendor receipt)', async () => { + seeded = await seedDatabase('receive-not-a-reply'); + await sentTo(seeded.db, 'support@porkbun.com'); + + const result = await receiveReplies({ + db: seeded.db, + workspaceId: SEED.workspaceId, + reader: reader([ + message({ + fromAddress: 'support@porkbun.com', + subject: 'porkbun.com | Order - Thank You - 12002499', + messageId: '', + }), + ]), + }); + + expect(result.recorded).toBe(0); + expect(result.unmatched).toBe(1); + expect(await inboundCount(seeded.db)).toBe(0); + }); + + test('a threaded answer counts whatever its subject, against the thread’s person', async () => { + seeded = await seedDatabase('receive-threaded'); + await sentWithId(seeded.db, 'info@acme.com', ''); + + const result = await receiveReplies({ + db: seeded.db, + workspaceId: SEED.workspaceId, + reader: reader([ + message({ + // A colleague, answering the shared inbox's thread from their own address. + fromAddress: 'bob@acme.com', + subject: 'your note about settlement', + inReplyTo: '', + }), + ]), + }); + + expect(result.recorded).toBe(1); + const row = await queryOne<{ + person_id: string; + contact_address: string; + shared_inbox: number; + }>( + seeded.db, + `SELECT person_id, contact_address, shared_inbox FROM interactions + WHERE workspace_id = ? AND direction = 'inbound'`, + [SEED.workspaceId], + ); + expect(row?.person_id).toBe(SEED.personId); + expect(row?.contact_address).toBe('bob@acme.com'); + expect(Number(row?.shared_inbox)).toBe(0); + }); + + test('References pointing at a message we never sent do not count', async () => { + seeded = await seedDatabase('receive-foreign-thread'); + await sentTo(seeded.db, 'jane@acme.com'); + + const result = await receiveReplies({ + db: seeded.db, + workspaceId: SEED.workspaceId, + reader: reader([ + message({ subject: 'Your invoice', references: '' }), + ]), + }); + + expect(result.recorded).toBe(0); + }); +}); + +describe('threadIds and isReplySubject', () => { + test('reads every id from In-Reply-To and References, bracketed and bare', () => { + const ids = threadIds({ inReplyTo: '', references: ' ' }); + expect(ids).toContain(''); + expect(ids).toContain('a@x.com'); + expect(ids).toContain(''); + }); + + test('knows a reply subject in the usual languages, and a receipt is not one', () => { + for (const subject of ['Re: hi', 'RE: hi', 'Aw: hallo', 'SV: hej', 'Re[2]: hi', '回复:你好']) { + expect(isReplySubject(subject)).toBe(true); + } + for (const subject of ['Order - Thank You', 'Regarding your order', undefined]) { + expect(isReplySubject(subject)).toBe(false); + } + }); +}); diff --git a/packages/pipeline/src/receive-email.ts b/packages/pipeline/src/receive-email.ts index c9d0044..8b9efbd 100644 --- a/packages/pipeline/src/receive-email.ts +++ b/packages/pipeline/src/receive-email.ts @@ -194,7 +194,7 @@ export async function receiveReplies(input: ReceiveRepliesInput): Promise { + const threaded = await matchThread(db, workspaceId, message); + if (threaded) return threaded; + if (!isReplySubject(message.subject)) return undefined; + return matchSender(db, workspaceId, message.fromAddress); +} + +/** The prospect whose thread this message's threading headers point into. */ +async function matchThread( + db: Client, + workspaceId: string, + message: IncomingMessage, +): Promise { + const ids = threadIds(message); + if (ids.length === 0) return undefined; + + const row = await queryOne<{ + person_id: string; + campaign_id: string | null; + contact_address: string; + shared_inbox: number; + }>( + db, + `SELECT i.person_id, i.campaign_id, i.contact_address, i.shared_inbox + FROM actions a JOIN interactions i ON i.action_id = a.id AND i.direction = 'outbound' + WHERE a.workspace_id = ? AND a.network = 'email' + AND a.external_id IN (${ids.map(() => '?').join(', ')}) + ORDER BY i.occurred_at DESC LIMIT 1`, + [workspaceId, ...ids], + ); + if (!row) return undefined; + + // Whoever answered is who the conversation is with now, which may not be + // the shared inbox we wrote to. + const from = message.fromAddress.trim().toLowerCase(); + const sameMailbox = from === row.contact_address; + return { + personId: row.person_id, + campaignId: row.campaign_id, + address: from || row.contact_address, + shared: sameMailbox && row.shared_inbox === 1, + }; +} + +/** + * Every Message-ID the message says it answers, in both the bracketed form + * the headers use and the bare form some mailers return as a send id. + */ +export function threadIds(message: Pick): string[] { + const raw = `${message.inReplyTo ?? ''} ${message.references ?? ''}`; + const ids = new Set(); + for (const match of raw.matchAll(/<([^<>\s]+)>|([^<>\s]+@[^<>\s]+)/g)) { + const bare = match[1] ?? match[2]; + if (!bare) continue; + ids.add(`<${bare}>`); + ids.add(bare); + } + return [...ids]; +} + +/** "Re:" as mail clients write it in the languages our prospects answer in. */ +const REPLY_PREFIX = /^\s*(re|aw|sv|antw|odp|ynt|vs|rif|r|res|ref|回复|答复)\s*(\[\d+\])?\s*[::]/i; + +export function isReplySubject(subject: string | undefined): boolean { + return Boolean(subject && REPLY_PREFIX.test(subject)); +} + interface MatchedSender { readonly personId: string; /** The campaign that last wrote to them, which is the one whose rules answer. */ diff --git a/packages/pipeline/src/recipient-guard.test.ts b/packages/pipeline/src/recipient-guard.test.ts index dfefa0b..f3be57e 100644 --- a/packages/pipeline/src/recipient-guard.test.ts +++ b/packages/pipeline/src/recipient-guard.test.ts @@ -1,5 +1,82 @@ -import { describe, expect, test } from 'bun:test'; -import { isNonBuyerDesk, refuseRecipient } from './recipient-guard'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { now } from '@outreachgraph/db'; +import { seedDatabase, SEED, type SeededDatabase } from '../../../apps/api/src/test-seed'; +import { isNonBuyerDesk, ownDomains, personOnOwnDomain, refuseRecipient } from './recipient-guard'; + +let seeded: SeededDatabase | undefined; + +afterEach(() => { + seeded?.cleanup(); + seeded = undefined; +}); + +describe('ownDomains', () => { + test('spans the organization, its people and mailboxes, and skips shared hosts', async () => { + seeded = await seedDatabase('own-domains-org'); + const stamp = now(); + await seeded.db.batch([ + { + sql: `UPDATE offerings SET url = 'https://www.ugig.net/jobs' WHERE id = ?`, + args: [SEED.offeringId], + }, + { + // A teammate's workspace in the same organization sells another product. + sql: `INSERT INTO workspaces (id, organization_id, name, slug, min_outreach_confidence, + created_at, updated_at) VALUES ('wsp_team', ?, 'Team', 'team', 0.85, ?, ?)`, + args: [SEED.organizationId, stamp, stamp], + }, + { + sql: `INSERT INTO offerings (id, workspace_id, name, category, url, created_at, updated_at) + VALUES ('off_team', 'wsp_team', 'CoinPay', 'payments', 'coinpayportal.com', ?, ?)`, + args: [stamp, stamp], + }, + { + sql: `INSERT INTO offerings (id, workspace_id, name, category, url, created_at, updated_at) + VALUES ('off_repo', 'wsp_team', 'Repo', 'tools', 'https://github.com/acme/tool', ?, ?)`, + args: [stamp, stamp], + }, + { + sql: `INSERT INTO users (id, email, name, created_at, updated_at) + VALUES ('usr_gmail', 'someone@gmail.com', 'Someone', ?, ?)`, + args: [stamp, stamp], + }, + { + sql: `INSERT INTO organization_members (organization_id, user_id, role, created_at) + VALUES (?, 'usr_gmail', 'member', ?)`, + args: [SEED.organizationId, stamp], + }, + { + sql: `INSERT INTO users (id, email, name, created_at, updated_at) + VALUES ('usr_mate', 'preshy@profullstack.com', 'Mate', ?, ?)`, + args: [stamp, stamp], + }, + { + sql: `INSERT INTO organization_members (organization_id, user_id, role, created_at) + VALUES (?, 'usr_mate', 'member', ?)`, + args: [SEED.organizationId, stamp], + }, + ]); + + const own = await ownDomains(seeded.db, SEED.workspaceId); + expect(own.has('ugig.net')).toBe(true); + expect(own.has('coinpayportal.com')).toBe(true); + // A teammate's company address; the seed user's example.com is reserved. + expect(own.has('profullstack.com')).toBe(true); + expect(own.has('example.com')).toBe(false); + expect(own.has('github.com')).toBe(false); + expect(own.has('gmail.com')).toBe(false); + }); + + test('a person at one of our domains is ours; a prospect elsewhere is not', async () => { + seeded = await seedDatabase('own-domains-person'); + expect( + await personOnOwnDomain(seeded.db, SEED.workspaceId, SEED.personId, new Set(['ugig.net'])), + ).toBe(false); + expect( + await personOnOwnDomain(seeded.db, SEED.workspaceId, SEED.personId, new Set(['acme.com'])), + ).toBe(true); + }); +}); const OWN = new Set(['ugig.net', 'coinpayportal.com']); diff --git a/packages/pipeline/src/recipient-guard.ts b/packages/pipeline/src/recipient-guard.ts index 8926989..6437200 100644 --- a/packages/pipeline/src/recipient-guard.ts +++ b/packages/pipeline/src/recipient-guard.ts @@ -20,6 +20,7 @@ */ import { queryAll, type Client } from '@outreachgraph/db'; +import { isConsumerMailDomain } from '@outreachgraph/domain'; const NON_BUYER_DESKS = new Set([ 'support', @@ -80,14 +81,126 @@ export function isNonBuyerDesk(address: string): boolean { ); } -/** Every domain this workspace sells from, lower-cased, without `www.`. */ +/** + * Hosts that many unrelated people publish under. An offering whose URL is a + * GitHub repo or a bare `vercel.app` names a place, not a company, and taking + * the host as "ours" would refuse every developer on GitHub. A subdomain of + * one (`acme.vercel.app`) is specific and stays. + */ +const SHARED_HOSTS = new Set([ + 'github.com', + 'gitlab.com', + 'bitbucket.org', + 'vercel.app', + 'netlify.app', + 'pages.dev', + 'workers.dev', + 'herokuapp.com', + 'railway.app', + 'fly.dev', + 'onrender.com', + 'github.io', + 'substack.com', + 'medium.com', + 'notion.site', + 'linkedin.com', + 'x.com', + 'twitter.com', +]); + +/** RFC 2606 names, which belong to nobody — fixtures and demo accounts use them. */ +const RESERVED = /(^|\.)example\.(com|net|org)$|\.(test|example|invalid|localhost)$/; + +function isOwnable(host: string): boolean { + return ( + host.includes('.') && + !SHARED_HOSTS.has(host) && + !RESERVED.test(host) && + !isConsumerMailDomain(host) + ); +} + +/** + * Every domain this workspace's organization sells or sends from, lower-cased, + * without `www.`. + * + * The organization, not the workspace: a teammate's workspace that sells one + * product is still part of a company that owns fifty, and in production + * (2026-10-12) it knew only its own one. Beyond the offerings, the domains the + * organization's people and mailboxes use are ours too — `profullstack.com` + * is not an offering anywhere, and is where every message is sent from. + */ export async function ownDomains(db: Client, workspaceId: string): Promise> { - const rows = await queryAll<{ url: string }>( + const offerings = await queryAll<{ url: string }>( db, - 'SELECT url FROM offerings WHERE workspace_id = ? AND url IS NOT NULL', + `SELECT o.url FROM offerings o + WHERE o.url IS NOT NULL + AND o.workspace_id IN ( + SELECT w.id FROM workspaces w + WHERE w.organization_id = (SELECT organization_id FROM workspaces WHERE id = ?))`, [workspaceId], ); - return new Set(rows.map((row) => hostOf(row.url)).filter((host) => host.includes('.'))); + + const addresses = await queryAll<{ address: string | null }>( + db, + `SELECT u.email AS address FROM users u + JOIN organization_members m ON m.user_id = u.id + WHERE m.organization_id = (SELECT organization_id FROM workspaces WHERE id = ?) + UNION ALL + SELECT handle AS address FROM integration_accounts + WHERE workspace_id = ? AND network = 'email'`, + [workspaceId, workspaceId], + ); + + const hosts = [ + ...offerings.map((row) => hostOf(row.url)), + ...addresses.filter((row) => row.address?.includes('@')).map((row) => domainOf(row.address!)), + ]; + return new Set(hosts.filter(isOwnable)); +} + +/** + * Whether a person belongs to one of these domains: their company's domain or + * published inbox, or any address we hold for them. + * + * Asked before a card is made, so our own products never reach the approval + * queue on any network — the send-time check only ever covered email, and a + * card nobody may approve is clutter a reviewer has to dismiss by hand. + */ +export async function personOnOwnDomain( + db: Client, + workspaceId: string, + personId: string, + own: ReadonlySet, +): Promise { + if (own.size === 0) return false; + + const rows = await queryAll<{ value: string | null }>( + db, + `SELECT co.domain AS value FROM people p JOIN companies co ON co.id = p.current_company_id + WHERE p.id = ? + UNION ALL + SELECT co.contact_email FROM people p JOIN companies co ON co.id = p.current_company_id + WHERE p.id = ? + UNION ALL + SELECT handle FROM social_identities WHERE person_id = ? AND network = 'email' + UNION ALL + SELECT address FROM person_emails WHERE person_id = ? AND workspace_id = ?`, + [personId, personId, personId, personId, workspaceId], + ); + + return rows.some((row) => { + if (!row.value) return false; + const domain = row.value.includes('@') ? domainOf(row.value) : hostOf(row.value); + return onDomain(domain, own); + }); +} + +function onDomain(domain: string, own: ReadonlySet): boolean { + for (const host of own) { + if (domain === host || domain.endsWith(`.${host}`)) return true; + } + return false; } /** @@ -97,11 +210,8 @@ export async function ownDomains(db: Client, workspaceId: string): Promise): string | undefined { - const domain = domainOf(address); - for (const host of own) { - if (domain === host || domain.endsWith(`.${host}`)) { - return `${address} belongs to one of this workspace's own products`; - } + if (onDomain(domainOf(address), own)) { + return `${address} belongs to one of this workspace's own products`; } if (isNonBuyerDesk(address)) { return `${address} is a ${localPart(address)} desk, not a buyer`;