Skip to content

Read replies over IMAP, so the reply gate has an input - #35

Merged
ralyodio merged 4 commits into
mainfrom
feat/imap-replies
Aug 16, 2026
Merged

ralyodio merged 4 commits into
mainfrom
feat/imap-replies

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Stacked on #34 — review/merge that first; this branch targets it.

Why

#34 added a conversation_open gate 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'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 and 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.
  • Presets carry IMAP coordinates for Gmail, Microsoft 365, Fastmail, Forward Email and Zoho; the generic "Other" entry lets both halves be typed.
  • /settings gains optional IMAP host/port, auto-filled by the provider picker.
  • The worker polls on its own five-minute clock (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:

  • Missing a reply → we mail someone who answered. Visible, recoverable.
  • Inventing one → we stop contacting a prospect for good because of an out-of-office. Silent, and nobody goes looking for outreach that never happened.

So auto-replies, bounces and bulk mail are classified and skipped (Auto-Submitted, Precedence, X-Autoreply, null Return-Path, mailer-daemon/postmaster/no-reply senders), mail from an address we never wrote to is left alone, and one unplaceable message never abandons the reply behind it. Auto-Submitted: no is explicitly treated as a real reply, since RFC 3834 requires ordinary mail to say exactly that.

Migration 0012 adds a Message-ID key 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 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.

Verification

  • 814 tests pass (793 before, +21), format and typecheck clean.
  • Migrations 0011 and 0012 applied against a scratch database in sequence: 14 applied, 0 pending.
  • 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 /settings action. The classification and matching logic is covered by unit tests against a fake reader.

🤖 Generated with Claude Code

@socket-security

socket-security Bot commented Aug 16, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedimapflow@​1.7.1991009896100

View full report

@socket-security

socket-security Bot commented Aug 16, 2026 •

Copy link
Copy Markdown

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.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
License policy violation: npm @zone-eu/mailsplit under EUPL-1.2

License: EUPL-1.2 - The applicable license policy does not permit this license (5) (package/LICENSE.EUPL-1.2)

License: unrecognized license - This license was not allowed or given any lesser classification by the applicable policy (package/LICENSE.EUPL-1.2)

License: EUPL-1.1+ - This license classifier is not allowed by the applicable policy (package/package.json)

From: ? → npm/imapflow@1.7.1 → npm/@zone-eu/mailsplit@5.4.15

ℹ Read more on: This package | This alert | What is a license policy violation?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@zone-eu/mailsplit@5.4.15. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Publisher changed: npm process-warning is now published by climba03003

Author: climba03003

From: ? → npm/imapflow@1.7.1 → npm/process-warning@5.1.0

ℹ Read more on: This package | This alert | What is unstable ownership?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Try to reduce the number of authors you depend on to reduce the risk to malicious actors gaining access to your supply chain. Packages should remove inactive collaborators with publishing rights from packages on npm.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/process-warning@5.1.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

ralyodio and others added 3 commits August 16, 2026 18:17
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>
@ralyodio
ralyodio changed the base branch from fix/no-resend-guard to main August 16, 2026 18:18
@ralyodio
ralyodio merged commit a7f9c48 into main Aug 16, 2026
4 checks passed
@ralyodio
ralyodio deleted the feat/imap-replies branch August 16, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant