Repository navigation
Read replies over IMAP, so the reply gate has an input - #35
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
PR #34 added a `conversation_open` gate that refuses to write to a contact who has answered — but nothing could tell it that anyone had. The product sent over SMTP and read no mailbox, so every reply was invisible, the funnel's `replied` count was structurally zero, and the queue kept offering prospects who were already mid-conversation. SMTP cannot close that gap. Its whole vocabulary is MAIL FROM / RCPT TO / DATA; there is no verb for "what arrived". Reading is IMAP, on a different port, with the same credentials the mailbox was already connected with — so this adds no second password, and one provider choice fills in both halves. - `packages/email/src/imap.ts` turns a mailbox into a list of messages. - `packages/pipeline/src/receive-email.ts` decides what they mean. - Presets carry IMAP coordinates for Gmail, Microsoft 365, Fastmail, Forward Email and Zoho; the generic entry lets both be typed. - The worker polls on its own five-minute clock, *before* the send sweep — a reply noticed this tick has to stop a message that would otherwise go out on the same tick. Most of the care is in what must not be recorded. The two failure modes are not symmetric: missing a reply means we mail someone who answered, which is visible and recoverable, while inventing one silently removes a prospect from outreach for good on the strength of an out-of-office. So auto-replies, bounces and bulk mail are classified and skipped, mail from an address we never wrote to is left alone, and migration 0012 adds a Message-ID key so an overlapping poll window cannot double-count. A reply to a shared inbox is recorded against that address, which — because `conversationOpen` matches on address as well as person — protects every colleague written to there. Attributing it to one of them is a guess; that the mailbox answered is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The funnel is built from campaign_people.status and lead_stage_events, so setting only interaction_state recorded the reply everywhere except the chart that exists to show replies — the prospect sat in Contacted having answered. Routes through recordStatus, per campaign membership. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0012 is taken by listening-per-campaign's 0012_listening_targets.sql. The ledger is keyed on filename so both would apply, but two 0012s is exactly the ambiguity that makes an out-of-order apply hard to reason about later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a04d832 to
1749ac0
Compare
# Conflicts: # apps/server/src/index.ts
Stacked on #34 — review/merge that first; this branch targets it.
Why
#34 added a
conversation_opengate that refuses to write to a contact who has answered. Nothing could tell it that anyone had. The product sent over SMTP and read no mailbox, so every reply was invisible, the funnel'srepliedcount was structurally zero, and the queue kept offering prospects who were already mid-conversation.SMTP cannot close that gap — its whole vocabulary is
MAIL FROM / RCPT TO / DATAand there is no verb for "what arrived". Reading is IMAP, on a different port, with the same credentials the mailbox was already connected with. So there is no second password, and one provider choice fills in both halves.What's here
packages/email/src/imap.ts— turns a mailbox into a list of messages. Nothing more; it does not decide what a reply means.packages/pipeline/src/receive-email.ts— decides what they mean and records them./settingsgains optional IMAP host/port, auto-filled by the provider picker.RECEIVE_POLL_MS), before the send sweep — a reply noticed on this tick has to stop a message that would otherwise go out on the same tick.The part that matters
Most of the care is in what must not be recorded, because the two failure modes are not symmetric:
So auto-replies, bounces and bulk mail are classified and skipped (
Auto-Submitted,Precedence,X-Autoreply, nullReturn-Path,mailer-daemon/postmaster/no-replysenders), mail from an address we never wrote to is left alone, and one unplaceable message never abandons the reply behind it.Auto-Submitted: nois explicitly treated as a real reply, since RFC 3834 requires ordinary mail to say exactly that.Migration
0012adds aMessage-IDkey with a partial unique index, so an overlapping poll window cannot double-count — and duplicates here are not cosmetic, because the gate counts them.A reply to a shared inbox is recorded against that address, which — because
conversationOpenmatches on address as well as person — protects every colleague written to there. Attributing it to one of them is a guess; that the mailbox answered is not.Verification
imapflow(new dependency) verified to load and run under Bun.Not done
No live end-to-end test against a real mailbox — that needs a connected account with IMAP configured, which is a
/settingsaction. The classification and matching logic is covered by unit tests against a fake reader.🤖 Generated with Claude Code