diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 260fe9d..cec3bc5 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1683,7 +1683,7 @@ export function createApp(options: AppOptions): Hono { const limit = clampLimit(c.req.query('limit')); const rows = await c.get('db').execute({ - sql: `SELECT p.id, p.display_name, p.current_title, p.avatar_url, p.identity_confidence, + sql: `SELECT p.id, p.kind, p.display_name, p.current_title, p.avatar_url, p.identity_confidence, p.status, co.name AS current_company, cp.status AS prospect_status, cp.interaction_state, diff --git a/apps/api/src/repository.ts b/apps/api/src/repository.ts index 6cf8b98..9b3cb9b 100644 --- a/apps/api/src/repository.ts +++ b/apps/api/src/repository.ts @@ -191,7 +191,7 @@ export async function listPendingRecommendations( return queryAll( db, - `SELECT r.*, p.display_name, p.current_title, p.avatar_url, p.identity_confidence, + `SELECT r.*, p.kind, p.display_name, p.current_title, p.avatar_url, p.identity_confidence, s.summary AS signal_summary, s.source_url AS signal_url, s.source_timestamp AS signal_at, d.body AS draft_body, d.subject AS draft_subject, diff --git a/apps/web/components/approval-card.tsx b/apps/web/components/approval-card.tsx index 98fde95..1e3d06d 100644 --- a/apps/web/components/approval-card.tsx +++ b/apps/web/components/approval-card.tsx @@ -171,7 +171,9 @@ export function ApprovalCard({ card }: { card: Card }) {

{card.display_name}

-

{card.current_title ?? '—'}

+

+ {card.kind === 'company_inbox' ? 'Company inbox' : (card.current_title ?? '—')} +

diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index f2d4029..61dd655 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -20,6 +20,8 @@ export interface ApprovalCard { display_name: string; current_title: string | null; avatar_url?: string | null; + /** Absent or `person` is a named human; `company_inbox` is a shared mailbox lead. */ + kind?: 'person' | 'company_inbox'; action: string; network: string; priority: number; @@ -156,6 +158,8 @@ export interface ProspectRow { display_name: string; current_title: string | null; avatar_url?: string | null; + /** Absent or `person` is a named human; `company_inbox` is a shared mailbox lead. */ + kind?: 'person' | 'company_inbox'; current_company: string | null; identity_confidence: number; prospect_status: string; @@ -199,6 +203,8 @@ export interface ProspectDetail { display_name: string; current_title: string | null; avatar_url?: string | null; + /** Absent or `person` is a named human; `company_inbox` is a shared mailbox lead. */ + kind?: 'person' | 'company_inbox'; identity_confidence: number; status: string; }; diff --git a/apps/web/public/.well-known/openaccess.json b/apps/web/public/.well-known/openaccess.json index 52c0858..3b71398 100644 --- a/apps/web/public/.well-known/openaccess.json +++ b/apps/web/public/.well-known/openaccess.json @@ -3,9 +3,7 @@ "name": "OutreachGraph", "url": "https://outreachgraph.com", "operator": "https://logicsrc.com/.well-known/openprofile.md", - "redirect_uris": [ - "https://outreachgraph.com/api/v1/openaccess/callback" - ], + "redirect_uris": ["https://outreachgraph.com/api/v1/openaccess/callback"], "jwks": { "keys": [ { @@ -19,11 +17,7 @@ ] }, "scopes": {}, - "honours": [ - "profullstack.com/all-access" - ], + "honours": ["profullstack.com/all-access"], "webhooks": "https://outreachgraph.com/api/v1/openaccess/events", - "hubs": [ - "https://openaccess.logicsrc.com" - ] + "hubs": ["https://openaccess.logicsrc.com"] } diff --git a/migrations/0034_company_inbox_leads.sql b/migrations/0034_company_inbox_leads.sql new file mode 100644 index 0000000..76b6931 --- /dev/null +++ b/migrations/0034_company_inbox_leads.sql @@ -0,0 +1,11 @@ +-- A company's published inbox as a lead in its own right. +-- +-- A crawl that names nobody still finds a company and, usually, a support@ or +-- info@ address. Until now that address was recorded and then unreachable: a +-- recommendation only ever hangs off a person, so a small store whose site +-- says "family-owned" and publishes one shared mailbox produced a company row +-- and an empty queue. `kind` marks the person row that stands in for that +-- inbox so the rest of the product can treat it honestly: greet the team +-- rather than a first name, never propose or enrich a personal address for +-- it, never look up a photo of it. +ALTER TABLE people ADD COLUMN kind TEXT NOT NULL DEFAULT 'person'; diff --git a/packages/ai/src/composer.test.ts b/packages/ai/src/composer.test.ts index 20ed8ef..a732da8 100644 --- a/packages/ai/src/composer.test.ts +++ b/packages/ai/src/composer.test.ts @@ -290,3 +290,55 @@ describe('determinism of the checked surface', () => { expect(stripVolatile(a.calls[0]!)).toEqual(stripVolatile(b.calls[0]!)); }); }); + +describe('a company inbox as the recipient', () => { + test('is written to as the team, about what the site says', async () => { + const model = new StubModel( + 'Shipping research supplies the same day is a lot of parcels to reconcile by hand.', + ); + + const result = await composeDraft( + model, + input({ + action: 'send_email', + network: 'email', + prospect: { + kind: 'company_inbox', + displayName: 'Family Shop', + companyName: 'Family Shop', + identityConfidence: 0.9, + }, + trigger: { + id: 'sig_inbox', + summary: + 'Publishes hello@familyshop.example as the contact address on the company website.', + evidence: + 'A family-owned store shipping research supplies the same day. ' + + 'Contact: hello@familyshop.example', + sourceUrl: 'https://familyshop.example', + network: 'website', + ageDescription: 'today', + }, + }), + ); + + expect(result.ok).toBe(true); + + // Nobody is named, so the prompt must not pretend someone is. + const prompt = model.calls[0]!.user; + expect(prompt).toContain('the Family Shop team'); + expect(prompt).toContain('Nobody specific is named'); + expect(prompt).toContain('Greet the team, never a first name'); + expect(prompt).not.toContain('What they did'); + expect(prompt).not.toContain('Person:'); + }); + + test('a named person is still written to by name', async () => { + const model = new StubModel(GOOD_DRAFT); + await composeDraft(model, input()); + + const prompt = model.calls[0]!.user; + expect(prompt).toContain('Write a message to Jane'); + expect(prompt).not.toContain('team'); + }); +}); diff --git a/packages/ai/src/composer.ts b/packages/ai/src/composer.ts index a81cb72..f5e112e 100644 --- a/packages/ai/src/composer.ts +++ b/packages/ai/src/composer.ts @@ -16,7 +16,7 @@ * rejected — including on a retry — rather than shown with a warning. */ -import type { ActionKind, Network, OutreachStyle } from '@outreachgraph/domain'; +import type { ActionKind, Network, OutreachStyle, PersonKind } from '@outreachgraph/domain'; import { runChecks, type CheckReport, type GroundingContext } from './checks'; import type { TextModel } from './model'; @@ -29,6 +29,8 @@ export interface OfferingContext { } export interface ProspectContext { + /** Absent means a person. A `company_inbox` is written to as a team. */ + readonly kind?: PersonKind; readonly displayName: string; readonly firstName?: string; readonly title?: string; @@ -250,19 +252,37 @@ function buildSystem(input: ComposeInput): string { function buildUser(input: ComposeInput, failed?: CheckReport): string { const trigger = input.trigger!; - const name = input.prospect.firstName ?? input.prospect.displayName; - - const sections = [ - 'CONTEXT — the only facts you may use:', - `Person: ${input.prospect.displayName}${input.prospect.title ? `, ${input.prospect.title}` : ''}${ - input.prospect.companyName ? ` at ${input.prospect.companyName}` : '' - }`, - `What they did: ${trigger.summary} (${trigger.network}, ${trigger.ageDescription})`, - `Their exact words:\n"""\n${trigger.evidence}\n"""`, - '', - `Write a message to ${name} responding to what they said.`, - 'Reference their words specifically enough that it could not have been sent to anyone else.', - ]; + const inbox = input.prospect.kind === 'company_inbox'; + const company = input.prospect.companyName ?? input.prospect.displayName; + const name = inbox + ? `the ${company} team` + : (input.prospect.firstName ?? input.prospect.displayName); + + // A shared inbox has no first name to greet and nobody's words to quote. + // What it has is a company that published a way to be reached and, often, + // a line about itself. So the message is to the team, about the company, + // and grounded in what the site says rather than in what a person said. + const sections = inbox + ? [ + 'CONTEXT — the only facts you may use:', + `Recipient: the shared inbox of ${company}. Nobody specific is named; whoever handles the company's mail will read this.`, + `What their site says: ${trigger.summary} (${trigger.network}, ${trigger.ageDescription})`, + `The site's exact words:\n"""\n${trigger.evidence}\n"""`, + '', + `Write a message to ${name}. Greet the team, never a first name.`, + 'Reference what the site says specifically enough that it could not have been sent to any other company.', + ] + : [ + 'CONTEXT — the only facts you may use:', + `Person: ${input.prospect.displayName}${input.prospect.title ? `, ${input.prospect.title}` : ''}${ + input.prospect.companyName ? ` at ${input.prospect.companyName}` : '' + }`, + `What they did: ${trigger.summary} (${trigger.network}, ${trigger.ageDescription})`, + `Their exact words:\n"""\n${trigger.evidence}\n"""`, + '', + `Write a message to ${name} responding to what they said.`, + 'Reference their words specifically enough that it could not have been sent to anyone else.', + ]; if (failed) { // Naming the exact rejected fragments works far better than repeating the diff --git a/packages/ai/src/draft.ts b/packages/ai/src/draft.ts index b81b0b4..8aadfe6 100644 --- a/packages/ai/src/draft.ts +++ b/packages/ai/src/draft.ts @@ -77,6 +77,7 @@ export async function draftForRecommendation( if (!signal?.evidence) return { ok: false, reason: 'no_evidence' }; const person = await queryOne<{ + kind: string; display_name: string; first_name: string | null; current_title: string | null; @@ -84,7 +85,8 @@ export async function draftForRecommendation( identity_confidence: number; }>( db, - `SELECT display_name, first_name, current_title, current_company_id, identity_confidence + `SELECT kind, display_name, first_name, current_title, current_company_id, + identity_confidence FROM people WHERE id = ?`, [recommendation.person_id], ); @@ -149,6 +151,7 @@ export async function draftForRecommendation( competitors: parseArray(offering.competitors), }, prospect: { + ...(person.kind === 'company_inbox' ? { kind: 'company_inbox' as const } : {}), displayName: person.display_name, ...(person.first_name ? { firstName: person.first_name } : {}), ...(person.current_title ? { title: person.current_title } : {}), diff --git a/packages/domain/src/person.ts b/packages/domain/src/person.ts index 19a3782..65042d0 100644 --- a/packages/domain/src/person.ts +++ b/packages/domain/src/person.ts @@ -25,8 +25,22 @@ export interface Company { export const PERSON_STATUS = ['active', 'suppressed', 'deleted'] as const; export type PersonStatus = (typeof PERSON_STATUS)[number]; +/** + * What a `people` row stands for. + * + * `person` is a named human. `company_inbox` is a company's shared mailbox + * (`support@`, `info@`) standing in for the nobody its site named, so a + * company that publishes a way to reach it can be reached through the same + * queue, policy and approval as everyone else. The kind is set deliberately by + * the crawl, never inferred from a scraped name: `isLikelyRoleAccount` still + * rejects "webmaster" and "admin" as people. + */ +export const PERSON_KINDS = ['person', 'company_inbox'] as const; +export type PersonKind = (typeof PERSON_KINDS)[number]; + export interface Person { readonly id: PrefixedId<'person'>; + readonly kind: PersonKind; readonly displayName: string; readonly firstName?: string; readonly lastName?: string; diff --git a/packages/pipeline/src/crawl.test.ts b/packages/pipeline/src/crawl.test.ts index 3bc27b2..37902f3 100644 --- a/packages/pipeline/src/crawl.test.ts +++ b/packages/pipeline/src/crawl.test.ts @@ -44,6 +44,19 @@ const COMPANY_HTML = ` `; +/** A small store's homepage: a company, a line about itself, an inbox, nobody named. */ +const INBOX_ONLY_HTML = ` + Family Shop + + + +

We are family-owned and operated. Orders before 4 PM ship today.

+ +`; + function stubNetwork(html = COMPANY_HTML): FetchLike { return async (input) => { const url = input.toString(); @@ -196,6 +209,124 @@ describe('URL to approval card', () => { expect(job?.status).toBe('done'); }); + test( + 'a page naming nobody but publishing an inbox makes the inbox the lead', + async () => { + const { db } = await setup('e2e-inbox-lead'); + + await enqueue(db, { + workspaceId: SEED.workspaceId, + kind: 'crawl_site', + payload: { url: 'https://familyshop.example' }, + }); + + const site = new SiteProvider({ fetchImpl: stubNetwork(INBOX_ONLY_HTML) }); + + const summary = await drainQueue(db, async (job: QueuedJob) => { + await runCrawlJob({ db, site, providers: [], emailSendingEnabled: true }, job); + }); + expect(summary.succeeded).toBe(1); + + // The lead is the company's inbox, typed as such, not a scraped "person". + const lead = await queryOne<{ + id: string; + kind: string; + display_name: string; + first_name: string | null; + current_title: string | null; + identity_confidence: number; + }>( + db, + `SELECT id, kind, display_name, first_name, current_title, identity_confidence + FROM people WHERE kind = 'company_inbox'`, + ); + expect(lead?.display_name).toBe('Family Shop'); + expect(lead?.first_name).toBeNull(); + expect(lead?.current_title).toBeNull(); + // Its own site published the address: clears the default outreach bar. + expect(lead!.identity_confidence).toBeGreaterThanOrEqual(0.85); + + // The address stays the company's. No email identity is written, so the + // send path resolves the company inbox and flags it shared, and every + // shared-inbox limit applies exactly as it does for a named colleague. + const personal = await queryOne<{ id: string }>( + db, + `SELECT id FROM social_identities WHERE person_id = ? AND network = 'email'`, + [lead!.id], + ); + expect(personal).toBeUndefined(); + + const company = await queryOne<{ contact_email: string }>( + db, + `SELECT co.contact_email FROM companies co + JOIN people p ON p.current_company_id = co.id WHERE p.id = ?`, + [lead!.id], + ); + expect(company?.contact_email).toBe('hello@familyshop.example'); + + // What the site said is the evidence, and it names the address. + const signal = await queryOne<{ summary: string; evidence: string }>( + db, + 'SELECT summary, evidence FROM signals WHERE person_id = ?', + [lead!.id], + ); + expect(signal?.summary).toContain('hello@familyshop.example'); + expect(signal?.evidence).toContain('family-owned'); + + // And it reaches the queue as an email to approve, like anyone else. + const card = await queryOne<{ action: string; network: string; status: string }>( + db, + 'SELECT action, network, status FROM recommendations WHERE person_id = ?', + [lead!.id], + ); + expect(card).toEqual({ action: 'send_email', network: 'email', status: 'pending' }); + + // Re-reading the site finds the same lead, not a second one. + await enqueue(db, { + workspaceId: SEED.workspaceId, + kind: 'crawl_site', + payload: { url: 'https://familyshop.example/contact' }, + }); + await drainQueue(db, async (job: QueuedJob) => { + await runCrawlJob({ db, site, providers: [], emailSendingEnabled: true }, job); + }); + const leads = await queryAll(db, `SELECT id FROM people WHERE kind = 'company_inbox'`); + expect(leads).toHaveLength(1); + }, + SLOW_CHAIN_MS, + ); + + test('a page naming someone does not also queue the inbox', async () => { + const { db } = await setup('e2e-inbox-not-doubled'); + + await enqueue(db, { + workspaceId: SEED.workspaceId, + kind: 'crawl_site', + payload: { url: 'https://loopwright.io' }, + }); + + // The team page above, plus a published inbox. + const html = COMPANY_HTML.replace( + '