Skip to content

Stop outreach mailing the same inbox once per colleague - #34

Merged
ralyodio merged 2 commits into
mainfrom
fix/no-resend-guard
Aug 16, 2026
Merged

ralyodio merged 2 commits into
mainfrom
fix/no-resend-guard

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What happened

You sent the same email twice to a lead who had already replied. The real number is worse: 24 outbound emails reached 6 distinct addresses, and support@canny.io alone received 14 of them.

Every rate limit is keyed on person_id — the right key for "how often do we contact this human", the wrong one for "how much mail does this mailbox get". A prospect with no personal address falls back to their company's shared inbox, so 14 prospects at Canny were 14 separate people, each comfortably inside its own weekly limit, all delivering to one mailbox. Nothing was ever contacted twice by its own key, which is why no existing gate saw it.

Three gaps, not one

The delivered address was never recorded, so it could not be counted. interactions now stores contact_address and shared_inbox, written by both the automated and the by-hand send paths. Migration 0011 backfills it using the sender's own resolution order — all 24 existing rows resolve to a shared inbox, so the limits protect those mailboxes the moment it runs.

The policy engine had no address-keyed limits. Adds rate_limit_address and an address cooldown beside the per-person ones. A personal mailbox is unaffected: for it the two keys are the same thing.

Nothing ever wrote an inbound interaction, so a reply could not stop anything — the replied figure in analytics was structurally zero. Adds POST /people/:id/replied and a conversation_open gate. A contact who has answered is out of cold outreach; a follow-up is denied unless explicitly flagged, and then still needs human approval. A reply to a shared inbox protects that person's colleagues too.

Also here, because the queue is unusable without them

  • MODEL_CHAIN orders the provider fallback from the environment (e.g. openai,anthropic), so the cheap model can lead without a deploy. A provider left out of the list is not built at all, so the cheap chain cannot quietly fall through to an expensive one.
  • POST /recommendations/drafts/backfill writes the drafts a queue is missing, bounded by limit because every card is a model call. The approvals page had 74 pending recommendations and zero drafts.

Not done

No inbound polling — the product still sends over SMTP and reads no mailbox, so replies have to be recorded through the new route until IMAP ingestion exists. That is the one thing standing between this and the gate being automatic.

Verification

  • 790 tests pass (773 before, +17 new); format and typecheck clean.
  • New apps/api/src/shared-inbox.test.ts reproduces the production scenario end to end: two colleagues sharing one inbox, second send refused, nothing on the wire.
  • Backfill dry-run against production data confirms all 24 existing rows resolve.

🤖 Generated with Claude Code

ralyodio and others added 2 commits August 16, 2026 17:36
Every rate limit was keyed on person_id, which is the right key for "how
often do we contact this human" and the wrong one for "how much mail does
this mailbox get". A prospect with no personal address falls back to their
company's shared inbox, so N prospects at one company are N separate people
— each comfortably inside its own weekly limit — and one support@ mailbox
receives N messages.

In production that meant 24 outbound emails reaching 6 distinct addresses,
with support@canny.io alone receiving 14. Nothing was ever contacted twice
by its own key, which is why no existing gate saw it.

Three things were missing, not one:

- The delivered address was never recorded, so it could not be counted.
  `interactions` now stores contact_address and shared_inbox, written by
  both the automated and the by-hand send paths. Migration 0011 backfills
  it using the sender's own resolution order; all 24 existing rows resolve
  to a shared inbox, so the limits protect those mailboxes immediately.

- The policy engine had no address-keyed limits. Adds rate_limit_address
  and an address cooldown alongside the per-person ones. A personal mailbox
  is unaffected — for it the two keys are the same.

- Nothing ever wrote an inbound interaction, so a reply could not stop
  anything and the "replied" figure in analytics was structurally zero.
  Adds POST /people/:id/replied and a conversation_open gate: a contact who
  has answered is out of cold outreach, and a follow-up needs a human. A
  reply to a shared inbox protects that person's colleagues too.

Also, because both are needed to use this queue at all:

- MODEL_CHAIN orders the provider fallback from the environment, so the
  cheap model can lead without a deploy and an omitted provider is not
  built at all.
- POST /recommendations/drafts/backfill writes the drafts a queue is
  missing, bounded by `limit` because every card is a model call. The
  approvals page had 74 pending recommendations and no drafts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The approvals page looked like a wall of blank cards, and the obvious
reading — "drafting is broken" — was wrong. Production has 77
recommendations without a draft, of which 75 are `refresh_research`:
internal actions that have no message by definition and never will. Only 2
are genuinely undrafted emails.

So the expensive mistake was not failing to draft, it was drafting
everything. As first written this route would have spent 75 model calls
producing nothing and reported each one as a refusal — on a queue whose
whole problem is that it costs too much to run.

Restricts the backfill to OUTBOUND_ACTION_KINDS, and tests that a research
card produces no model call at all rather than a cheap one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 4bce920 into main Aug 16, 2026
4 checks passed
ralyodio added a commit that referenced this pull request Aug 16, 2026
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>
ralyodio added a commit that referenced this pull request Aug 16, 2026
* feat: read replies over IMAP, so the reply gate has an input

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>

* fix(replies): move the funnel, not just the interaction row

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>

* chore(migrations): renumber inbound dedupe to 0013

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>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Aug 17, 2026
…ly sends (#48)

#34 added address-keyed rate limits to the policy engine to stop one
`support@` receiving a message per colleague. The gates are right. Only
half the product passes them.

`evaluatePolicy` takes `actionsToThisAddressThisWeek`,
`hoursSinceLastActionToAddress` and `addressShared` as **optional** inputs,
documented as "omitted when the caller could not resolve an address, in
which case these gates simply do not fire". `apps/api/src/app.ts` — the
human approval route — fills them in. `runAutopilot` never did. So the
limits protected the path where a person is already looking at the message,
and not the unattended one that sends at volume.

Production shows the seam exactly. Every duplicate predates the fix
except one: `info@thebarcelonafeeling.com` was written to at 14:03, #34
merged at 17:45, and autopilot wrote to it again at 18:39 — hours after
the fix was live, through the half of the code the fix never reached.

Optional inputs are why this was quiet. Omitting them is not a type error
and not a runtime error; it is a gate that silently evaluates to "no
opinion". The same shape will hide the next one, so the fields are now
passed unconditionally rather than spread in behind a check — there is
always an address by this point, since `pickEmailRecipient` returning
nothing already skipped the row.

Counted from `interactions.contact_address` — the mailbox a message
reached — rather than from `actions`, which records who it was addressed
to. For a shared inbox those are different questions and only the first can
see fourteen colleagues arriving at one address. It also means a message
sent by hand from the approval queue binds the automated path and vice
versa: a mailbox does not care which half of the product wrote to it.

Three tests, built from the production shape — two colleagues at one
company, neither with a personal address, both inside their own per-person
limits:

  - one message goes out, not two, and the one held back explains itself in
    terms of the address rather than the person
  - two people with their own addresses still get two messages, so this
    does not quietly become "one email per company"
  - an interaction recorded by the manual route stops the automated one

933 tests pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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