Skip to content

Apply the shared-inbox limits to the path that actually sends - #48

Merged
ralyodio merged 1 commit into
mainfrom
fix/shared-inbox
Aug 17, 2026
Merged

ralyodio merged 1 commit into
mainfrom
fix/shared-inbox

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

#34 added address-keyed rate limits 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 somebody is already looking at the message, and not the unattended one that sends at volume.

The seam is visible in production

inbox messages window
support@canny.io 14 13:16 → 17:16
support@featurebase.app 4 same second
support@savvycal.com 2
info@thebarcelonafeeling.com 2 14:03 and 18:39

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

Why it was quiet

Omitting an optional input is not a type error and not a runtime error. It is a gate that silently evaluates to "no opinion". Nothing anywhere reports a limit that did not run.

The same shape will hide the next one, so the fields are now passed unconditionally rather than spread in behind a presence check. There is always an address by that point — pickEmailRecipient returning nothing has already skipped the row — so the conditional was only ever protecting against a case that cannot happen, at the cost of making absence look normal.

Counting

From interactions.contact_address — the mailbox a message actually 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.

Tests

Built from the production shape — two colleagues at one company, neither with a personal address, both comfortably 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 ("Only 0h since this address was last contacted", not a limit on a prospect who has never been contacted)
  • 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, format and typecheck clean.

Note on the existing damage

This stops it happening again; it does not un-send anything. support@canny.io has had 14 messages and support@featurebase.app 4. Worth deciding separately whether those domains should be suppressed for a while.

🤖 Generated with Claude Code

…ly sends

#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>
@ralyodio
ralyodio merged commit 04e8774 into main Aug 17, 2026
4 checks passed
ralyodio added a commit that referenced this pull request Aug 17, 2026
The address limits shipped in #48 work, and that is the problem. Production's
queue holds 28 pending email cards that resolve to four shared inboxes —
support@userlist.com alone stands in for 17 people — and all four were mailed
within the last day. Every one of those cards showed as Ready, because `ready`
only ever meant "an outbound action with a draft written". Clicking any of
them answered "Only 12h since this address was last contacted; the cooldown is
72h", which reads as a broken queue when the person on the card has never been
written to. The only way to learn that nothing was approvable was to click all
twenty-eight.

The queue now runs the two address gates ahead of time and says so on the card:
which mailbox the message would reach, that it is the company's rather than the
person's, and when the hold lifts. The Ready tab says how many of its cards can
actually be sent.

The gates are extracted into `evaluateAddressLimits` and the engine calls it
too, so the preview and the refusal are the same code and cannot drift into
naming different reasons for the same card. A test asserts they agree. This is
still only a preview: approving re-runs the whole engine against current state,
as it must, and that refusal remains the authority.

Reading it costs two queries for the whole page, not four per card. The
approvals page fetches up to 200 rows, so the per-card version would have been
eight hundred sequential Turso round trips to render one screen.

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

Production holds 213 prospects and not one personal address. Every message
therefore resolves to the company's shared inbox, the address limits added in
#48 correctly refuse it, and the approvals queue reads as broken. The limits
are not the problem. There is nowhere else to send.

The obvious routes were tried and do not work. `/team`, `/about` and
`/contact` were fetched for eight of these companies and every published
address was a role mailbox — modern SaaS does not put staff addresses on the
marketing site, so crawling deeper finds nothing. Commit metadata is worse:
the addresses are public and the platforms publishing them specifically forbid
using them for unsolicited mail. A licensed provider works and costs money per
lookup, which is not this commit's decision to make; `PersonEnrichmentProvider`
and the waterfall already exist for it and it drops in beside this.

What is left is to learn the shape a company writes addresses in from one
address already known to be right, and apply it to colleagues. The distinction
this rests on is between deriving and guessing, and it is kept visible
throughout: a candidate derived from a confirmed address at the domain carries
its basis and ranks first, while a candidate built from priors alone is marked
a guess and capped below any confidence that could be acted on.

The safety property is that nothing here can send anything. Proposals live in
`email_candidates`; the sender reads `social_identities`, which only
`confirmCandidate` writes to, and only when a human has said yes. A derived
address is a question put to the operator, never an answer the machine acts on.

Confirming an address the operator simply knows is accepted even though nothing
proposed it. That is deliberate and it is the most valuable input available:
one confirmation at a company turns its colleagues from guesses into
derivations, so the 17 people behind one shared inbox become 17 one-click
decisions, each sharpening the next.

Also fixes something nothing had noticed: `first_name` and `last_name` were
null for 212 of 213 people, the whole name living in `display_name`. Splitting
is what address derivation needs, so the stage records the parts as it goes.

Dry-run against production, read-only: 195 people across 35 domains would be
offered candidates.

Co-authored-by: Claude Opus 5 (1M context) <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