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`;