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
27 changes: 27 additions & 0 deletions apps/api/src/autogtm-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,33 @@ export const OPERATIONS: readonly Operation[] = [
},
},
},
{
method: 'get',
path: '/autogtm/campaigns/{campaign_id}/list-health',
id: 'getListHealth',
tag: 'Campaigns',
summary: 'Bounce rate, the 2% pause, and address verdicts',
description:
'Every address is verified before its first message and again after 90 days (MX, then an ' +
'SMTP RCPT probe where the host allows port 25). A campaign whose bounce rate passes 2% over ' +
'50+ sends pauses itself, re-verifies every queued address, drops the ones that fail and ' +
'resumes on a fresh window. Accept-all addresses only send while the campaign bounces at or ' +
'under 1%. A bounced address is never sent to again.',
params: [idParam('campaign_id', 'The campaign.')],
response: {
type: 'object',
properties: {
sends: { type: 'integer' },
bounces: { type: 'integer' },
bounce_rate: { type: 'number' },
max_bounce_rate: { type: 'number' },
window_start: { type: ['string', 'null'] },
paused: { type: 'boolean' },
paused_at: { type: ['string', 'null'] },
addresses: { type: 'object' },
},
},
},
{
method: 'get',
path: '/autogtm/campaigns/import/{task_id}',
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/autogtm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
CONTACT_PRICE_USD,
isConsumerMailDomain,
mapHeaders,
MAX_BOUNCE_RATE,
newId,
parseCsv,
replyRate,
Expand All @@ -53,6 +54,7 @@ import {
type LeadEnrichDeps,
finishContactImport,
importContactChunk,
listHealthReport,
normaliseDomain,
peopleMatchingKeys,
recordDiscovered,
Expand Down Expand Up @@ -971,6 +973,29 @@ export function autogtmRoutes(deps: AutogtmDeps): Hono<AppEnv> {
});
});

/**
* The campaign's list quality: bounce rate on the current window, whether
* the 2% gate has it paused for re-verification, and the verdicts on the
* addresses it has sent to.
*/
r.get('/campaigns/:id/list-health', async (c) => {
const actor = c.get('actor');
const db = c.get('db');
const campaign = await ownedCampaign(db, actor.workspaceId, c.req.param('id'));
const report = await listHealthReport(db, campaign.id);
return c.json({
campaign_id: campaign.id,
sends: report.sends,
bounces: report.bounces,
bounce_rate: report.bounceRate,
max_bounce_rate: MAX_BOUNCE_RATE,
window_start: report.windowStart,
paused: report.pausedAt !== null,
paused_at: report.pausedAt,
addresses: report.addresses,
});
});

r.get('/campaigns/:id/enrichment', async (c) => {
const actor = c.get('actor');
const db = c.get('db');
Expand Down
25 changes: 23 additions & 2 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,25 @@ async function runLeads({ client, args, flags }: CommandContext): Promise<string
.join('\n');
}

if (verb === 'health') {
if (!target) throw new Error('usage: og leads health <campaignId>');
const health = (await client.get(
`/autogtm/campaigns/${encodeURIComponent(target)}/list-health`,
)) as Record<string, unknown>;
const addresses = (health.addresses ?? {}) as Record<string, unknown>;
const rate = Number(health.bounce_rate ?? 0);
return [
`${text(health, 'sends', '0')} sent, ${text(health, 'bounces', '0')} bounced ` +
`(${(rate * 100).toFixed(1)}%, stop line 2%)`,
health.paused
? `PAUSED since ${text(health, 'paused_at')}: re-verifying every queued address, resumes by itself`
: 'sending',
`addresses: ${text(addresses, 'valid', '0')} valid, ${text(addresses, 'catch_all', '0')} accept-all, ` +
`${text(addresses, 'unverified', '0')} MX-only, ${text(addresses, 'invalid', '0')} invalid, ` +
`${text(addresses, 'unchecked', '0')} not yet checked`,
].join('\n');
}

if (verb === 'allow' || verb === 'hold') {
if (!target) throw new Error(`usage: og leads ${verb} <personId>`);
await client.post(`/autogtm/leads/${encodeURIComponent(target)}/screening`, {
Expand All @@ -316,7 +335,9 @@ async function runLeads({ client, args, flags }: CommandContext): Promise<string
: `${target} held back from sending again.`;
}

throw new Error('usage: og leads add|report|screened|enrich|enrichment|allow|hold … (og help)');
throw new Error(
'usage: og leads add|report|screened|enrich|enrichment|health|allow|hold … (og help)',
);
}

async function runJobs({ client, args, flags }: CommandContext): Promise<string> {
Expand Down Expand Up @@ -1695,7 +1716,7 @@ export const COMMANDS: readonly Command[] = [
{
name: 'leads',
usage:
'og leads add <campaignId> <file.csv> --consent-source "<where>" [--allow-flagged] [--keep-project-duplicates] [--report out.csv] | report <taskId> [--csv] | screened <campaignId> [--all] | enrich <campaignId> [--max <searches>] | enrichment <campaignId> | allow <personId> | hold <personId>',
'og leads add <campaignId> <file.csv> --consent-source "<where>" [--allow-flagged] [--keep-project-duplicates] [--report out.csv] | report <taskId> [--csv] | screened <campaignId> [--all] | enrich <campaignId> [--max <searches>] | enrichment <campaignId> | health <campaignId> | allow <personId> | hold <personId>',
summary: 'Add a CSV of leads to a running campaign, with a per-row report and screening.',
run: runLeads,
},
Expand Down
18 changes: 18 additions & 0 deletions apps/mcp/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,24 @@ export const TOOLS: readonly ToolDefinition[] = [
`/autogtm/campaigns/${encodeURIComponent(require(args, 'campaignId'))}/enrichment`,
),
},
{
name: 'get_list_health',
title: 'A campaign’s bounce rate and address verdicts',
description:
'Bounces against the 2% stop line, whether the campaign is paused re-verifying its queue ' +
'(it resumes by itself), and how many sent-to addresses are valid, accept-all, MX-only, ' +
'invalid or unchecked.',
readOnly: true,
inputSchema: {
type: 'object',
properties: { campaignId: { type: 'string' } },
required: ['campaignId'],
},
run: (client, args) =>
client.get(
`/autogtm/campaigns/${encodeURIComponent(require(args, 'campaignId'))}/list-health`,
),
},
{
name: 'list_inbox',
title: 'List conversations',
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,9 @@ async function tick(): Promise<void> {
// reported. Without this the sweep only ever says "no drafted
// message", once per tick, forever.
...(model ? { model } : {}),
// Verify before sending: MX always, an SMTP RCPT probe while port 25
// answers. A blocked port degrades to MX-only, never to a hold.
verifier: smtpProber && !smtpProber.blocked() ? { smtp: smtpProber } : {},
},
workspace.id,
);
Expand Down
20 changes: 20 additions & 0 deletions migrations-pg/0053_list_quality.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- 0053_list_quality.sql (Postgres). See migrations/0053_list_quality.sql for the reasoning.
-- Two new, empty tables. Nothing existing is altered.

create table if not exists email_verifications (
address text PRIMARY KEY,
status text NOT NULL,
reason text,
mx text,
checked_at text NOT NULL
);

create table if not exists campaign_list_health (
campaign_id text PRIMARY KEY REFERENCES campaigns(id) ON DELETE CASCADE,
workspace_id text NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
window_start text NOT NULL,
paused_at text,
paused_rate double precision,
resumed_at text,
updated_at text NOT NULL
);
34 changes: 34 additions & 0 deletions migrations/0053_list_quality.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- 0053_list_quality.sql
--
-- Verify before sending, and stop a campaign that bounces (Hunter's outreach
-- planner, run by the worker rather than remembered by a person).
--
-- email_verifications is the verdict on one address: valid, catch_all,
-- unverified (the domain takes mail but no server could be asked) or invalid.
-- Keyed by the lower-cased address and shared across workspaces, because
-- whether a mailbox exists is a fact about the mailbox. A bounce writes
-- `invalid` here, which is what keeps a bounced address from ever being sent to
-- again. checked_at is what the 90-day re-verify reads.
--
-- campaign_list_health holds a campaign's bounce gate. window_start is where
-- its bounce rate is counted from; paused_at is set when the rate passed 2%
-- over 50+ sends, and cleared (with window_start moved forward) once every
-- queued address has been re-verified since the pause.

CREATE TABLE IF NOT EXISTS email_verifications (
address TEXT PRIMARY KEY,
status TEXT NOT NULL,
reason TEXT,
mx TEXT,
checked_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS campaign_list_health (
campaign_id TEXT PRIMARY KEY REFERENCES campaigns(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
window_start TEXT NOT NULL,
paused_at TEXT,
paused_rate REAL,
resumed_at TEXT,
updated_at TEXT NOT NULL
);
1 change: 1 addition & 0 deletions packages/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ export * from './sender-pool';
export * from './replies';
export * from './webhooks';
export * from './mailbox-health';
export * from './list-quality';
export * from './warmup';
118 changes: 118 additions & 0 deletions packages/domain/src/list-quality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* List quality: the rules every send list has to meet before a message leaves.
*
* These are the standards from Hunter's outreach planner, applied by the
* machine instead of by a person remembering to:
*
* - **Verify before sending.** An address is checked (MX, then an SMTP RCPT
* probe where port 25 allows it) before its first message, and again once
* the check is older than `REVERIFY_AFTER_DAYS`: roughly a fifth of B2B
* addresses decay every year, so a list verified last spring is not.
* - **Under 2% bounce.** A campaign whose bounce rate passes
* `MAX_BOUNCE_RATE` over at least `MIN_BOUNCE_SAMPLE` sends stops, re-verifies
* everyone still queued in it, and starts again on a fresh window. Nobody
* has to notice, pause, re-verify and resume by hand.
* - **Accept-all addresses are a separate list.** A catch-all domain says yes
* to every recipient, so its addresses bounce more than verified ones. They
* are sent only while the campaign has room under the gate
* (`CATCH_ALL_MAX_RATE`), so they can never be the thing that trips it.
*
* Pure functions. Reading and writing the verification cache is the pipeline's.
*/

export const MAX_BOUNCE_RATE = 0.02;
export const MIN_BOUNCE_SAMPLE = 50;
export const REVERIFY_AFTER_DAYS = 90;
/** Catch-all addresses send only while the campaign bounces at or under this. */
export const CATCH_ALL_MAX_RATE = 0.01;

/**
* What is known about one address.
*
* - `valid` — a server accepted it, on a domain that refuses made-up names.
* - `catch_all` — the domain accepts every recipient, so the yes proves nothing.
* - `unverified` — the domain takes mail but no server could be asked (port 25
* blocked, greeting refused). Sendable: this is the most a cloud host can learn.
* - `invalid` — no mail exchanger, a server refused the recipient, or it bounced.
*/
export type AddressStatus = 'valid' | 'catch_all' | 'unverified' | 'invalid';

export interface AddressVerification {
readonly status: AddressStatus;
readonly checkedAt: string;
readonly reason?: string | null;
}

/** True when the check is recent enough to trust without asking again. */
export function verificationFresh(
checkedAt: string,
at: Date,
options: { readonly notBefore?: string | null } = {},
): boolean {
const stamp = Date.parse(checkedAt);
if (Number.isNaN(stamp)) return false;
// A campaign that tripped the bounce gate wants every address re-checked
// from that moment on, however recent the last check was.
if (options.notBefore) {
const floor = Date.parse(options.notBefore);
if (!Number.isNaN(floor) && stamp < floor) return false;
}
return at.getTime() - stamp < REVERIFY_AFTER_DAYS * 86_400_000;
}

/** What the verifier saw, reduced to the fields this decision needs. */
export interface VerifierEvidence {
/** Mail exchangers found. Empty means the domain takes no mail. */
readonly mx: readonly string[];
readonly smtp: 'probed' | 'unavailable' | 'skipped';
readonly catchAll?: boolean | undefined;
readonly verdict: 'accepted' | 'rejected' | 'unknown';
}

export function statusFromEvidence(evidence: VerifierEvidence): {
status: AddressStatus;
reason: string;
} {
if (evidence.mx.length === 0) return { status: 'invalid', reason: 'the domain takes no mail' };
if (evidence.smtp !== 'probed') {
return { status: 'unverified', reason: 'the domain takes mail; no server could be asked' };
}
if (evidence.catchAll === true) {
return { status: 'catch_all', reason: 'the domain accepts every address' };
}
if (evidence.verdict === 'rejected') {
return { status: 'invalid', reason: 'the mail server refused this address' };
}
if (evidence.verdict === 'accepted')
return { status: 'valid', reason: 'the mail server accepted it' };
return { status: 'unverified', reason: 'the mail server would not say' };
}

export interface BounceWindow {
readonly sends: number;
readonly bounces: number;
}

export function bounceRate(window: BounceWindow): number {
return window.sends > 0 ? window.bounces / window.sends : 0;
}

/**
* True when the campaign must stop and re-verify.
*
* Below the sample size the rate is noise: one bounce in ten sends is 10%
* and says nothing. Above it, more than 2% is the planner's stop line.
*/
export function bounceGateTripped(window: BounceWindow): boolean {
return window.sends >= MIN_BOUNCE_SAMPLE && bounceRate(window) > MAX_BOUNCE_RATE;
}

/** True when a catch-all address may go out without risking the gate. */
export function catchAllAllowed(window: BounceWindow): boolean {
return bounceRate(window) <= CATCH_ALL_MAX_RATE;
}

/** Percent with one decimal, for messages people read. */
export function formatRate(rate: number): string {
return `${(Math.round(rate * 1000) / 10).toFixed(1)}%`;
}
Loading
Loading