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
7 changes: 7 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1752,6 +1752,13 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
fromEmail: body.fromEmail,
...(body.fromName ? { fromName: body.fromName } : {}),
...(body.replyTo ? { replyTo: body.replyTo } : {}),
...(body.imapHost
? {
imapHost: body.imapHost,
imapPort: body.imapPort ?? 993,
imapSecure: body.imapSecure ?? true,
}
: {}),
},
encryptionKey: options.encryptionKey,
verify: body.skipVerification !== true,
Expand Down
65 changes: 64 additions & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
OpenAIModel,
type FallbackEntry,
} from '@outreachgraph/ai';
import { ResendMailer } from '@outreachgraph/email';
import { ImapReader, ResendMailer } from '@outreachgraph/email';
import { secretKeyFromEnv } from '@outreachgraph/secrets';
import { createApp } from '../../api/src/app';
import { pruneSessions } from '../../api/src/auth';
Expand All @@ -42,9 +42,12 @@ import {
runAutopilot,
runCrawlJob,
runDiscoveryJob,
loadImapCredentials,
receiveReplies,
runListening,
sendDailyDigest,
sendLeadAlerts,
workspacesWithReadableMailbox,
type ListeningTargets,
type QueuedJob,
} from '@outreachgraph/pipeline';
Expand All @@ -61,6 +64,19 @@ import {
const PORT = Number(process.env.PORT ?? 8080);
const WEB_PORT = Number(process.env.WEB_PORT ?? 3001);
const TICK_MS = Number(process.env.WORKER_TICK_MS ?? 60_000);

/**
* How often a mailbox is read, as opposed to how often the worker ticks.
*
* Five minutes because a reply is not urgent — the thing it must beat is the
* next outreach send, not the reader's patience — and an IMAP login per
* workspace per minute is a great deal of connection churn for a mailbox that
* receives a handful of replies a day.
*/
const RECEIVE_POLL_MS = Number(process.env.RECEIVE_POLL_MS ?? 300_000);

/** Last successful poll per workspace, so the slower clock survives a tick. */
const lastPolledAt = new Map<string, number>();
const ENVIRONMENT = process.env.NODE_ENV ?? 'development';

const db = getDatabase();
Expand Down Expand Up @@ -609,6 +625,53 @@ async function tick(): Promise<void> {
}
}

// ------------------------------------------------------------- receiving
//
// Replies, read over IMAP. This runs *before* the send sweep on purpose: a
// reply noticed on this tick has to stop a message that would otherwise go
// out on the same tick, and reading afterwards would let exactly the send we
// are trying to prevent slip through every time.
//
// Polled on its own, slower clock. The worker tick is a minute; opening an
// IMAP connection per workspace per minute is a lot of login traffic for a
// mailbox that gets a handful of replies a day.
for (const workspaceId of await workspacesWithReadableMailbox(db)) {
const last = lastPolledAt.get(workspaceId) ?? 0;
if (Date.now() - last < RECEIVE_POLL_MS) continue;
lastPolledAt.set(workspaceId, Date.now());

try {
const credentials = await loadImapCredentials(db, workspaceId, encryptionKey);
// Undefined covers "no mailbox", "no IMAP host" and "the key changed so
// the password no longer decrypts". All three mean the same thing here.
if (!credentials) continue;

const received = await receiveReplies({
db,
workspaceId,
reader: new ImapReader(credentials),
});

if (received.recorded > 0) {
console.log(`replies ${workspaceId}: ${received.recorded} recorded`);
}

// Worth a line even at zero: an inbox full of skipped auto-replies and
// an inbox nobody answers look identical from the outside, and only one
// of them is a problem.
const skipped = Object.entries(received.automated);
if (skipped.length > 0) {
console.log(
`replies ${workspaceId}: ignored ${skipped.map(([k, n]) => `${n} ${k}`).join(', ')}`,
);
}
} catch (error) {
// A mailbox that will not open is this workspace's problem, not the
// tick's. Sending still has to happen for everyone else.
console.error(`reading replies failed for ${workspaceId}`, error);
}
}

// ------------------------------------------------------------- autopilot
//
// Sending, alerting and the digest, per workspace. Each is wrapped
Expand Down
41 changes: 41 additions & 0 deletions apps/web/components/mailbox-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export function MailboxForm({ initial }: { initial: EmailIntegrationView }) {
const [fromEmail, setFromEmail] = useState(account.fromEmail ?? '');
const [fromName, setFromName] = useState(account.fromName ?? '');
const [replyTo, setReplyTo] = useState(account.replyTo ?? '');
const [imapHost, setImapHost] = useState(account.imapHost ?? '');
const [imapPort, setImapPort] = useState(account.imapPort ?? 993);

const [busy, setBusy] = useState<'save' | 'remove' | undefined>();
const [error, setError] = useState<string | undefined>();
Expand All @@ -53,6 +55,15 @@ export function MailboxForm({ initial }: { initial: EmailIntegrationView }) {
setHost(chosen.host);
setPort(chosen.port);
setSecure(chosen.secure);

// One picker fills in both halves, because they are one mailbox on one
// credential. Making someone look up that Forward Email sends on
// smtp.forwardemail.net and is read on imap.forwardemail.net is asking
// them for something we already know.
if (chosen.imapHost) {
setImapHost(chosen.imapHost);
setImapPort(chosen.imapPort ?? 993);
}
}

async function submit(event: FormEvent): Promise<void> {
Expand All @@ -75,6 +86,7 @@ export function MailboxForm({ initial }: { initial: EmailIntegrationView }) {
fromEmail: fromEmail || username,
...(fromName ? { fromName } : {}),
...(replyTo ? { replyTo } : {}),
...(imapHost ? { imapHost, imapPort: Number(imapPort), imapSecure: true } : {}),
}),
});

Expand Down Expand Up @@ -214,6 +226,35 @@ export function MailboxForm({ initial }: { initial: EmailIntegrationView }) {
<span>TLS on connect (port 465). Uncheck for STARTTLS submission (587).</span>
</label>

<div className="grid grid-cols-3 gap-2">
<label className="col-span-2 text-sm">
<span className="text-ink-muted text-xs">IMAP host (to read replies)</span>
<input
value={imapHost}
onChange={(e) => setImapHost(e.target.value)}
autoComplete="off"
placeholder="imap.example.com"
className="border-border bg-surface text-ink mt-1 w-full rounded-xl border p-2 text-sm"
/>
</label>

<label className="text-sm">
<span className="text-ink-muted text-xs">IMAP port</span>
<input
type="number"
value={imapPort}
onChange={(e) => setImapPort(Number(e.target.value))}
className="border-border bg-surface text-ink mt-1 w-full rounded-xl border p-2 text-sm"
/>
</label>
</div>

<p className="text-ink-muted text-xs">
Optional, and no second password — IMAP and SMTP are two ports on the same mailbox. Leave
it blank to send only; replies then have to be spotted by hand, and the queue will keep
offering people who have already written back.
</p>

<label className="text-sm">
<span className="text-ink-muted text-xs">Username</span>
<input
Expand Down
7 changes: 7 additions & 0 deletions apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export interface EmailAccountView {
fromEmail?: string;
fromName?: string;
replyTo?: string;
imapHost?: string;
imapPort?: number;
imapSecure?: boolean;
status?: string;
connectedAt?: string;
}
Expand All @@ -84,6 +87,10 @@ export interface SmtpPresetView {
port: number;
secure: boolean;
note?: string;
/** The read side of the same mailbox, so one choice fills in both. */
imapHost?: string;
imapPort?: number;
imapSecure?: boolean;
}

export interface EmailIntegrationView {
Expand Down
Loading
Loading