Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions migrations-pg/0067_unthreaded_inbound_not_replies.sql
Original file line number Diff line number Diff line change
@@ -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 || '%');
23 changes: 23 additions & 0 deletions migrations/0067_unthreaded_inbound_not_replies.sql
Original file line number Diff line number Diff line change
@@ -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 || '%');
65 changes: 64 additions & 1 deletion packages/ai/src/composer.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): ComposeInput {
Expand Down Expand Up @@ -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');
});
});
83 changes: 80 additions & 3 deletions packages/ai/src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down
8 changes: 5 additions & 3 deletions packages/ai/src/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions packages/pipeline/src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = ?',
Expand Down
Loading
Loading