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
47 changes: 47 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
registerSchema,
snoozeRecommendationSchema,
updateSenderSchema,
detectMailboxSchema,
workspaceProfileSchema,
} from '@outreachgraph/contracts';
import {
Expand Down Expand Up @@ -111,6 +112,10 @@ import {
loadListeningTargets,
loadNotifySettings,
listSenders,
listMailboxes,
detectMailbox,
mailboxDns,
MailboxDetectError,
mailerForSend,
normaliseTargets,
removeSender,
Expand Down Expand Up @@ -4792,6 +4797,48 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
return c.json({ removed: true });
});

// ------------------------------------------------------------- mailboxes
//
// The email half of the pool, as the Mailboxes page shows it: each address
// with its provider, health score, bounce risk and whether its replies are
// read. Adding one still goes through PUT /integrations/email, which logs
// in (SMTP and IMAP) before storing anything; pause, cap, warm-up and
// remove are the /senders routes above.

api.get('/mailboxes', async (c) => {
const actor = c.get('actor');
const result = await listMailboxes(c.get('db'), actor.workspaceId);
return c.json({
...result,
canConnect: options.encryptionKey !== undefined,
platformFallback: options.mailer !== undefined,
presets: SMTP_PRESETS,
});
});

/**
* Turns an address into its servers, so adding a mailbox asks for nothing
* but the address and its password. Public DNS and Mozilla's ISPDB only:
* no request goes to a host the caller chose.
*/
api.post('/mailboxes/detect', async (c) => {
const body = await parseBody(c.req.raw, detectMailboxSchema);
try {
return c.json({ detected: await detectMailbox(body.email) });
} catch (error) {
if (error instanceof MailboxDetectError) throw ApiError.badRequest(error.message);
throw error;
}
});

/** SPF, DKIM, DMARC and MX for the domain one mailbox sends from. */
api.get('/mailboxes/:id/dns', async (c) => {
const actor = c.get('actor');
const report = await mailboxDns(c.get('db'), actor.workspaceId, c.req.param('id'));
if (!report) throw ApiError.notFound('mailbox');
return c.json({ dns: report });
});

// ---------------------------------------------------------- integrations
//
// The mailboxes outreach is sent from. Connecting a new address adds it to
Expand Down
169 changes: 169 additions & 0 deletions apps/api/src/mailboxes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* The Mailboxes page over HTTP: the list with health and reply status, and
* detecting an address's servers.
*/

import { afterEach, describe, expect, test } from 'bun:test';
import type { Hono } from 'hono';
import { now, type Client } from '@outreachgraph/db';
import { recordReplyCheck } from '@outreachgraph/pipeline';
import { generateSecretKey, parseSecretKey } from '@outreachgraph/secrets';
import { createApp } from './app';
import type { AppEnv, RequestActor } from './context';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';

const OWNER: RequestActor = {
userId: SEED.userId,
workspaceId: SEED.workspaceId,
organizationId: SEED.organizationId,
role: 'owner',
};

const KEY = parseSecretKey(generateSecretKey());

let active: SeededDatabase | undefined;

afterEach(() => {
active?.cleanup();
active = undefined;
});

async function harness(label: string): Promise<{ app: Hono<AppEnv>; db: Client }> {
const seeded = await seedDatabase(label);
active = seeded;
const app = createApp({
db: seeded.db,
authenticate: async () => OWNER,
encryptionKey: KEY,
});
return { app, db: seeded.db };
}

async function call(app: Hono<AppEnv>, method: string, path: string, body?: unknown) {
return app.request(`/api/v1${path}`, {
method,
headers: { 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}

interface MailboxJson {
id: string;
fromEmail: string;
domain: string;
provider: string;
readsReplies: boolean;
repliesError: string | null;
repliesCheckedAt: string | null;
healthScore: number;
healthIssues: string[];
bounceRisk: string;
}

describe('GET /mailboxes', () => {
test('is empty, with presets, before anything is connected', async () => {
const { app } = await harness('mailboxes-empty');
const response = await call(app, 'GET', '/mailboxes');
expect(response.status).toBe(200);
const body = (await response.json()) as {
mailboxes: unknown[];
summary: { mailboxes: number };
canConnect: boolean;
presets: { id: string }[];
};
expect(body.mailboxes).toEqual([]);
expect(body.summary.mailboxes).toBe(0);
expect(body.canConnect).toBe(true);
expect(body.presets.map((p) => p.id)).toContain('forwardemail');
});

test('shows provider, reply reading and health per mailbox', async () => {
const { app, db } = await harness('mailboxes-list');

expect(
(
await call(app, 'PUT', '/integrations/email', {
host: 'smtp.forwardemail.net',
port: 465,
secure: true,
username: 'ana@acme.test',
password: 'pw',
fromEmail: 'ana@acme.test',
imapHost: 'imap.forwardemail.net',
skipVerification: true,
})
).status,
).toBe(200);
expect(
(
await call(app, 'PUT', '/integrations/email', {
host: 'smtp.example.test',
port: 587,
secure: false,
username: 'bo@acme.test',
password: 'pw',
fromEmail: 'bo@acme.test',
skipVerification: true,
})
).status,
).toBe(200);

let body = (await (await call(app, 'GET', '/mailboxes')).json()) as {
mailboxes: MailboxJson[];
summary: { mailboxes: number; notReadingReplies: number };
};
expect(body.summary.mailboxes).toBe(2);

const ana = body.mailboxes.find((m) => m.fromEmail === 'ana@acme.test')!;
const bo = body.mailboxes.find((m) => m.fromEmail === 'bo@acme.test')!;
expect(ana.provider).toBe('forwardemail');
expect(ana.domain).toBe('acme.test');
expect(ana.readsReplies).toBe(true);
expect(bo.provider).toBe('custom');
expect(bo.readsReplies).toBe(false);
expect(bo.healthIssues.join(' ')).toContain('IMAP');
expect(bo.healthScore).toBeLessThan(ana.healthScore);
expect(body.summary.notReadingReplies).toBe(1);

await recordReplyCheck(db, ana.id, 'Command failed: AUTHENTICATIONFAILED');
body = (await (await call(app, 'GET', '/mailboxes')).json()) as typeof body;
const failed = body.mailboxes.find((m) => m.id === ana.id)!;
expect(failed.repliesError).toContain('AUTHENTICATIONFAILED');
expect(failed.repliesCheckedAt! <= now()).toBe(true);
expect(body.summary.notReadingReplies).toBe(2);

await recordReplyCheck(db, ana.id);
body = (await (await call(app, 'GET', '/mailboxes')).json()) as typeof body;
expect(body.mailboxes.find((m) => m.id === ana.id)!.repliesError).toBeNull();
});

test('404s the DNS report for a mailbox in another workspace', async () => {
const { app } = await harness('mailboxes-dns-404');
expect((await call(app, 'GET', '/mailboxes/ita_nope/dns')).status).toBe(404);
});
});

describe('POST /mailboxes/detect', () => {
test('knows a consumer domain without any lookup', async () => {
const { app } = await harness('mailboxes-detect');
const response = await call(app, 'POST', '/mailboxes/detect', { email: 'Ana@gmail.com' });
expect(response.status).toBe(200);
const { detected } = (await response.json()) as {
detected: {
provider: string;
smtp: { host: string };
imap: { host: string };
source: string;
};
};
expect(detected.provider).toBe('gmail');
expect(detected.source).toBe('known');
expect(detected.smtp.host).toBe('smtp.gmail.com');
expect(detected.imap.host).toBe('imap.gmail.com');
});

test('rejects something that is not an address', async () => {
const { app } = await harness('mailboxes-detect-bad');
expect((await call(app, 'POST', '/mailboxes/detect', { email: 'nope' })).status).toBe(400);
});
});
61 changes: 61 additions & 0 deletions apps/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,67 @@ describe('explain', () => {
});
});

describe('og mailboxes', () => {
test('lists health, bounce risk and whether replies are read', async () => {
const { client: api } = client({
mailboxes: [
{
id: 'ita_1',
fromEmail: 'ana@acme.com',
status: 'active',
healthScore: 80,
bounceRisk: 'low',
sentToday: 3,
effectiveCapToday: 8,
readsReplies: false,
repliesError: null,
},
],
});
const output = await commandByName('mailboxes')!.run({ client: api, args: [], flags: {} });
expect(output).toContain('ana@acme.com');
expect(output).toContain('health 80');
expect(output).toContain('replies NOT read');
});

test('add detects the servers, connects, then sets cap and warm-up', async () => {
const { client: api, calls } = client({
detected: {
providerLabel: 'Forward Email',
source: 'mx',
smtp: { host: 'smtp.forwardemail.net', port: 465, secure: true },
imap: { host: 'imap.forwardemail.net', port: 993 },
note: null,
},
account: { accountId: 'ita_9' },
});
process.env.OG_MAILBOX_PASSWORD = 'pw';
try {
const output = await commandByName('mailboxes')!.run({
client: api,
args: ['add', 'ana@acme.com'],
flags: { cap: '30', 'no-warmup': true },
});
expect(output).toContain('Connected ana@acme.com (ita_9)');
} finally {
delete process.env.OG_MAILBOX_PASSWORD;
}

expect(calls.map((call) => [call.method, call.url])).toEqual([
['POST', 'https://api.test/api/v1/mailboxes/detect'],
['PUT', 'https://api.test/api/v1/integrations/email'],
['PATCH', 'https://api.test/api/v1/senders/ita_9'],
]);
expect(calls[1]!.body).toMatchObject({
host: 'smtp.forwardemail.net',
imapHost: 'imap.forwardemail.net',
username: 'ana@acme.com',
password: 'pw',
});
expect(calls[2]!.body).toEqual({ dailyCap: 30, warmup: false });
});
});

describe('og senders', () => {
test('lists each account with today’s numbers and warm-up day', async () => {
const { client: api } = client({
Expand Down
Loading
Loading