Skip to content

Commit 8a47f89

Browse files
committed
Merge main into feat/tech-stack
# Conflicts: # packages/providers/src/index.ts
2 parents e67ad4b + a3ae48e commit 8a47f89

17 files changed

Lines changed: 569 additions & 17 deletions

File tree

‎apps/api/src/mailboxes.test.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
import { afterEach, describe, expect, test } from 'bun:test';
77
import type { Hono } from 'hono';
8-
import { now, type Client } from '@outreachgraph/db';
9-
import { recordReplyCheck } from '@outreachgraph/pipeline';
8+
import { now, queryAll, type Client } from '@outreachgraph/db';
9+
import { recordReplyCheck, sweepBlacklists } from '@outreachgraph/pipeline';
1010
import { generateSecretKey, parseSecretKey } from '@outreachgraph/secrets';
1111
import { createApp } from './app';
1212
import type { AppEnv, RequestActor } from './context';
@@ -137,6 +137,51 @@ describe('GET /mailboxes', () => {
137137
expect(body.mailboxes.find((m) => m.id === ana.id)!.repliesError).toBeNull();
138138
});
139139

140+
test('the daily blocklist sweep records a listing, alerts once, and drops health', async () => {
141+
const { app, db } = await harness('mailboxes-blocklist');
142+
await call(app, 'PUT', '/integrations/email', {
143+
host: 'mail.acme.test',
144+
port: 587,
145+
secure: false,
146+
username: 'cy@acme.test',
147+
password: 'pw',
148+
fromEmail: 'cy@acme.test',
149+
imapHost: 'mail.acme.test',
150+
skipVerification: true,
151+
});
152+
153+
const resolve4 = async (name: string): Promise<string[]> => {
154+
if (name === 'acme.test.dbl.spamhaus.org') return ['127.0.1.2'];
155+
throw Object.assign(new Error('not found'), { code: 'ENOTFOUND' });
156+
};
157+
const before = (
158+
(await (await call(app, 'GET', '/mailboxes')).json()) as { mailboxes: MailboxJson[] }
159+
).mailboxes[0]!;
160+
161+
const swept = await sweepBlacklists(db, SEED.workspaceId, { deps: { resolve4 } });
162+
expect(swept).toEqual({ checked: 1, listed: 1 });
163+
164+
const after = (
165+
(await (await call(app, 'GET', '/mailboxes')).json()) as {
166+
mailboxes: Array<MailboxJson & { blacklistedOn: string[] }>;
167+
}
168+
).mailboxes[0]!;
169+
expect(after.blacklistedOn).toEqual(['Spamhaus DBL']);
170+
expect(after.healthIssues[0]).toContain('Spamhaus DBL');
171+
expect(after.healthScore).toBeLessThan(before.healthScore);
172+
173+
// Checked today: the next sweep leaves it alone, so no second alert.
174+
expect(await sweepBlacklists(db, SEED.workspaceId, { deps: { resolve4 } })).toEqual({
175+
checked: 0,
176+
listed: 0,
177+
});
178+
const alerts = await queryAll<{ message: string }>(
179+
db,
180+
`SELECT message FROM workflow_events WHERE message LIKE '%is listed on%'`,
181+
);
182+
expect(alerts).toHaveLength(1);
183+
});
184+
140185
test('404s the DNS report for a mailbox in another workspace', async () => {
141186
const { app } = await harness('mailboxes-dns-404');
142187
expect((await call(app, 'GET', '/mailboxes/ita_nope/dns')).status).toBe(404);

‎apps/cli/src/commands.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,10 +1729,13 @@ export const COMMANDS: readonly Command[] = [
17291729
};
17301730
return [
17311731
`${text(dns, 'domain')}: ${text(dns, 'status')}`,
1732-
...['spf', 'dkim', 'dmarc', 'mx'].map((key) => {
1733-
const check = (dns[key] ?? {}) as Record<string, unknown>;
1734-
return ` ${pad(key.toUpperCase(), 6)} ${pad(text(check, 'status'), 5)} ${text(check, 'detail')}`;
1735-
}),
1732+
...['spf', 'dkim', 'dmarc', 'mx', 'blacklist']
1733+
.filter((key) => dns[key] !== undefined)
1734+
.map((key) => {
1735+
const check = (dns[key] ?? {}) as Record<string, unknown>;
1736+
const label = key === 'blacklist' ? 'BL' : key.toUpperCase();
1737+
return ` ${pad(label, 6)} ${pad(text(check, 'status'), 5)} ${text(check, 'detail')}`;
1738+
}),
17361739
].join('\n');
17371740
}
17381741

‎apps/server/src/index.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
workspacesAwaitingPhotos,
7171
runCadences,
7272
promoteAbWinners,
73+
sweepBlacklists,
7374
runPlanner,
7475
bumpQuietThreads,
7576
runCrawlJob,
@@ -128,6 +129,7 @@ const AB_SWEEP_MS = 3_600_000;
128129
const lastAbSweepAt = new Map<string, number>();
129130
const lastBumpSweepAt = new Map<string, number>();
130131
const lastPlannerSweepAt = new Map<string, number>();
132+
const lastBlacklistSweepAt = new Map<string, number>();
131133

132134
/** Last successful poll per workspace, so the slower clock survives a tick. */
133135
const lastPolledAt = new Map<string, number>();
@@ -1390,6 +1392,23 @@ async function tick(): Promise<void> {
13901392
}
13911393
}
13921394

1395+
// Deliverability: each active mailbox's domain (and self-hosted server)
1396+
// against the public blocklists, once a day, a few per run. A new
1397+
// listing is an error in the live feed and drops the mailbox's health.
1398+
if (Date.now() - (lastBlacklistSweepAt.get(workspace.id) ?? 0) >= AB_SWEEP_MS) {
1399+
lastBlacklistSweepAt.set(workspace.id, Date.now());
1400+
try {
1401+
const swept = await sweepBlacklists(db, workspace.id);
1402+
if (swept.listed > 0) {
1403+
console.warn(
1404+
`blocklists ${workspace.id}: ${swept.listed} of ${swept.checked} mailbox(es) listed`,
1405+
);
1406+
}
1407+
} catch (error) {
1408+
console.error(`blocklist sweep failed for ${workspace.id}`, error);
1409+
}
1410+
}
1411+
13931412
// The Outreach Planner: this month's plays, launched once per product per
13941413
// month from engagement data. Checked hourly so a play whose segment was
13951414
// empty on the 1st still launches once the data arrives.

‎apps/web/app/(app)/planner/page.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ export default async function PlannerPage() {
8787
This month builds fresh lists: every product’s seeds are re-read.
8888
</p>
8989
)}
90+
{!view.tracksEngagement ? (
91+
<p className="text-ink-muted pt-1 text-xs">
92+
Opens and clicks are not tracked (plain-text email, as the planner recommends), so plays
93+
pick people by delivery and replies: written to, delivered, no answer.
94+
</p>
95+
) : null}
9096
<p className="text-ink-muted pt-1 text-xs">Next month: {view.next.label}</p>
9197
</section>
9298

‎apps/web/components/mailboxes-view.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,7 @@ interface DnsReportView {
536536
spf: DnsCheckView;
537537
dkim: DnsCheckView;
538538
dmarc: DnsCheckView;
539+
blacklist?: DnsCheckView;
539540
status: 'pass' | 'warn' | 'fail';
540541
}
541542

@@ -571,6 +572,9 @@ function DnsTable({ report, error }: { report?: DnsReportView; error?: string })
571572
['DKIM', report.dkim],
572573
['DMARC', report.dmarc],
573574
['MX', report.mx],
575+
...(report.blacklist
576+
? ([['Blocklists', report.blacklist]] as Array<[string, DnsCheckView]>)
577+
: []),
574578
];
575579

576580
return (

‎apps/web/lib/api.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,7 @@ export interface PlannerView {
858858
readonly sequence: string;
859859
}>;
860860
readonly next: { readonly period: string; readonly label: string };
861+
readonly tracksEngagement: boolean;
861862
readonly offerings: ReadonlyArray<{
862863
readonly offeringId: string;
863864
readonly name: string;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- 0056_mailbox_blacklists.sql (Postgres). See migrations/0056_mailbox_blacklists.sql for the reasoning.
2+
-- One new, empty table. Nothing existing is altered.
3+
4+
create table if not exists mailbox_blacklist_checks (
5+
account_id text PRIMARY KEY REFERENCES integration_accounts(id) ON DELETE CASCADE,
6+
workspace_id text NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
7+
listed_on text NOT NULL DEFAULT '[]',
8+
results_json text NOT NULL DEFAULT '[]',
9+
checked_at text NOT NULL
10+
);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- 0056_mailbox_blacklists.sql
2+
--
3+
-- The deliverability check, daily, by the worker: is a mailbox's sending
4+
-- domain (or its self-hosted SMTP server's IP) on a public blocklist?
5+
-- One row per mailbox, the latest check. listed_on is a JSON array of list
6+
-- names, empty when clean; results_json keeps every answer, including the
7+
-- lists that refused to answer, so "clean" and "could not check" differ.
8+
9+
CREATE TABLE IF NOT EXISTS mailbox_blacklist_checks (
10+
account_id TEXT PRIMARY KEY REFERENCES integration_accounts(id) ON DELETE CASCADE,
11+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
12+
listed_on TEXT NOT NULL DEFAULT '[]',
13+
results_json TEXT NOT NULL DEFAULT '[]',
14+
checked_at TEXT NOT NULL
15+
);

‎packages/domain/src/mailbox-health.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ export interface MailboxHealthInput {
119119
readonly replyCheckFailed: boolean;
120120
/** Warm-up progress, 0 to 1; 1 when off or finished. */
121121
readonly warmupProgress: number;
122+
/** Public blocklists that name the sending domain or server, from the daily check. */
123+
readonly blacklistedOn?: readonly string[];
122124
}
123125

124126
export interface MailboxHealth {
@@ -164,6 +166,13 @@ export function mailboxHealth(input: MailboxHealthInput): MailboxHealth {
164166
issues.push('Could not read the inbox on the last check');
165167
}
166168

169+
if (input.blacklistedOn && input.blacklistedOn.length > 0) {
170+
score -= 40;
171+
issues.unshift(
172+
`Listed on ${input.blacklistedOn.join(', ')}: request delisting before sending more`,
173+
);
174+
}
175+
167176
if (progress < 1) issues.push('Warming up');
168177

169178
return { score: Math.max(0, Math.min(100, score)), bounceRisk: risk, issues };

‎packages/pipeline/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,11 @@ export {
135135
} from './warmup-network';
136136
export {
137137
detectMailbox,
138+
blacklistCheck,
138139
listMailboxes,
139140
mailboxDns,
141+
recordBlacklistCheck,
142+
sweepBlacklists,
140143
recordReplyCheck,
141144
MailboxDetectError,
142145
type DetectedMailbox,

0 commit comments

Comments
 (0)