Skip to content

Release held mail through one dialog, and decide who may release and trust - #149

Merged
heliocosta-dev merged 16 commits into
mainfrom
feat/quarantine-release
Oct 17, 2026
Merged

heliocosta-dev merged 16 commits into
mainfrom
feat/quarantine-release

Conversation

@heliocosta-dev

Copy link
Copy Markdown
Member

Releasing held mail took one click on the loudest button on screen, with no confirmation. Any member, full-access key or MCP token could also trust the sender, which allowlists it: its later authenticated mail skips the prompt-injection and phishing checks, and the agent may start conversations with it and send it codes. This PR makes release a reviewed decision and puts the same rules on every channel.

Dashboard

  • One quiet "Release" per surface: the thread pane's held card (after the email text), each Needs review quarantine row, and the Received email page's held banner. Nothing in a held context is bone, and "Release and trust sender" is gone.

  • One dialog for every release (wide on desktop, stacked on phones). It shows:

    • why it was held: the reason, the risk scores, the screening reasons, and whether hidden text was removed;
    • what releasing does, listing only the lines that apply to that inbox;
    • the email's text.

    Buttons: Keep in quarantine and a destructive Release to agent (Release to inbox on people inboxes).

  • Friction sized to the hold:

    • Prompt injection, phishing, virus and failed authentication need "I've read this email and it's safe for my agent." ticked first.
    • Other holds confirm directly.
    • Also trust {address} appears only on a "not on the allowlist" hold from a verified sender, only for owners and admins, unticked, and with its real consequences listed.
  • Released mail keeps a "Released by {who}" badge in the thread. Its tooltip gives the reason, the score and when.

  • Fixes:

    • a success toast;
    • readable errors instead of raw codes;
    • the Received page's missing error handling;
    • the spinner showing on the button that wasn't clicked;
    • the people-inbox wording.

Rules, on every channel

  • Who may release:
    • People in the dashboard and full-access team keys can release any hold.
    • MCP tokens can't release content holds (injection, phishing, virus, failed authentication). Their other releases only go through on a judge's verdict.
    • Inbox-scoped keys still can't release, as before.
  • Who may trust: trust is allowed only for a "not on the allowlist" hold from an authenticated sender, by owners, admins and full-access keys. MCP tokens never trust.
  • Screening on release: holds that came before screening ran are screened at release, with the same judge, caps and 80-point line as inbound mail. A risky one stays held (held_again; REST 409 held_by_screening).
    • When the judge can't run, a person's or full-access key's release proceeds, because a person decided.
    • An MCP release is refused instead: 503 screening_unavailable, with a Retry-After.
    • The judge runs before the database transaction, with a 5-second bound.
  • A release names the hold it showed, so a release from a dialog opened before screening re-held the mail is refused rather than delivered.
  • REST: 403 forbidden, 403 trust_not_allowed and 409 held_by_screening, and 503 screening_unavailable. The MCP release_email tool no longer takes trust_sender; its schema is strict.
  • Webhook events for released mail now list their attachments: the keyring is passed on every release path.
  • Docs (en and pt-BR): agent safety, inboxes, MCP, and the API error tables.

Tests

  • core: the full trust matrix (reason × sender trust × role and channel), MCP on every hold reason (judge clean, risky, timed out, missing, capped), screening on release (held again, clean, timeout, no judge, cap, never re-screened, races), and attachments in webhooks.
  • api: the refusal mapping, and an MCP end-to-end refusal.
  • web: the dialog variants, the tick gate, keyboard and the support view, toasts and errors, the badge, and the screens.

🤖 Generated with Claude Code

heliocosta-dev and others added 16 commits October 17, 2026 15:20
The release dialog reads inboxes.quarantine.preview and
receiving.preview, both releases answer with an outcome, and the thread
wire carries who released a message and when. None of that exists on
the server yet, so this stand-in declares the agreed shapes (the
previews throw NOT_IMPLEMENTED) for the dashboard to typecheck on its
own. The server side of the contract replaces this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A release is about to ask the inbound judge before it delivers a hold
that came before screening, so the judge's run moves out of the
received.screen worker into core: one judged message of the day's caps
spent (team, instance and inbox, all or nothing), the message opened and
read into the judge's block, the judge asked with its throttles retried.
heldReason applies the line both share, injection first and then
phishing at 80 or more. The worker keeps what is only its own: the rules
without a judge, the suspicious mark and the spam folder, and how each
failure reads as a hold.

releaseScreen builds the judge a release asks from ABUSE_JUDGE's config,
bounded like the outbound screening call because a person waits on it.
Reading the monitor settings with or without the process's reader moves
from the inbox send path into monitor-settings, where both now find it.

The worker's screening behaves as before; its tests are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Release delivered any hold to anyone who asked: any member, any
full-access key and any MCP token, the agent itself included, never
screening it, and "trust sender" put any From on the allowlist whatever
the hold, from a member or an agent too. releaseRights is now the one
rule for every channel. A person or a team-wide key releases any hold;
an agent releases nothing held for prompt injection, phishing, a virus
or failed authentication, since that mail is aimed at it. Trust is for a
not_allowlisted hold from an authenticated (external) sender, the one
case the allowlist would admit, and only for owners, admins and
team-wide keys, who may edit the allowlist itself.

A hold made before screening ran (not_allowlisted,
unauthenticated_sender, failed_auth) is screened when it is released,
by the same judge and line as inbound mail. At 80 or more it stays held,
now for injection or phishing with its scores (held_again); a judge
that can't answer leaves the person's decision standing. The judge runs
ahead of the release's transaction so no lock waits on it, and the
transaction keeps a verdict another release reached meanwhile. The call
is bounded at the outbound screening call's 5 seconds, so the dashboard
waits on the button instead of a "being screened" state.

Refusals reach every surface as their own errors: tRPC keys
trust_not_allowed, forbidden, not_quarantined and allowlist_full; REST
403 trust_not_allowed and forbidden, and 409 held_by_screening. The MCP
tool loses trust_sender and says a person reviews content holds. Every
release path now passes the keyring, so the events of released mail name
their attachments, which none did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dashboard's one release dialog needs, for every surface, what the
Needs review table never carried: the judge's reasons, hidden text
removed, the message's text and what a release would set off.
inboxes.quarantine.preview answers it from core's releasePreview: why
it was held, the cleaned read a person sees (reply, signature, quoted
history), whether this person may trust the sender or only an owner or
admin could (releaseRights again), which trust and release consequences
apply in that inbox (send policy, private codes, approvals, forward_to,
and an endpoint that would take the release's events, read through the
fan-out's own predicates), and whether the judge reads it first. With
inboxId null it previews a catch-all copy held for a virus. A support
view gets no text.

Thread messages now carry screening.released_by and released_at in the
dashboard, named as the Received page names a releaser, so a released
message keeps a trace in the thread. The public REST wire is unchanged:
agents have no use for who on the team let mail through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Release used to ship held mail on the first click, from a bone button
that sat above the text in the thread pane, last in each Needs review
row and first in the Received page's header, beside a "Release and
trust sender" offered whatever held the mail. Nothing said what
releasing does, the Received page failed silently, and refusals showed
raw codes.

Each surface now has one secondary Release, after the text it is
decided on, and every release goes through the same dialog: why the
mail was held (reason, risk, the screening's reasons, hidden text),
what releasing does (only the lines that apply to the inbox), and the
text itself. Content holds and failed authentication take an "I've
read this email" tick before Release to agent enables; trust is offered
only where the server says it may be, on a verified sender held off the
allowlist, and starts unticked. A release ends in a toast, a screening
that holds it again says so, and every refusal is worded. People
inboxes are told about the inbox, never an agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once released, held mail rendered like any other message, so whoever
read the thread later couldn't tell a person had let it past screening;
only the Received page's events strip kept the story. Its card now
carries a warn "Released by {who}" badge whose tooltip says what held
it, at what risk, and when it was released. It reads released_by and
released_at from the thread wire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs said held mail is one click from release, that any MCP client
releases and trusts with release_email, and that a release is never
screened. Agent safety now describes the one release dialog and its tick
for content holds, the screening a hold made before screening gets on
release, and who may trust a sender. The inboxes page and the API
reference list the release's refusals (403 forbidden and
trust_not_allowed, 409 held_by_screening), and the MCP page says
release_email leaves content holds and trust to a person. English and
Brazilian Portuguese alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	apps/web/src/server/inboxes.ts
#	apps/web/src/server/routers/inboxes.ts
#	apps/web/src/server/routers/receiving.ts
The dialog asked receiving.preview for a held catch-all row, which the
server never built: inboxes.quarantine.preview answers for those rows
too, with inboxId null. It also kept its own copies of two server rules,
which tick holds need ("I've read this email") and when a member is told
owners and admins can trust the sender. It now gates on core's
CONTENT_HOLDS, the list MCP releases are refused on, and on the
preview's trustNeedsAdmin, so the dialog can't drift from what the
server enforces.

Text cut at the read pipeline's caps now says so under it: ticking "I've
read this email" shouldn't rest on part of it. A catch-all row's preview
reports an agent inbox, as the dialog words it, since team keys and MCP
list the row once it is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Screening on release can keep an email held under a new reason, from
not_allowlisted to phishing, say. The preview stayed in the query cache
after the dialog closed, so opening it again showed the old hold, whose
release needs no tick, until a refetch landed. The preview now leaves
the cache with the dialog, and each opening reads the hold as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dialog opened on a not_allowlisted hold has no tick. If another
release's screening held the mail again for phishing meanwhile, clicking
Release in that open dialog delivered mail scored 92 that nobody had
read: the hold-changed check only ran when this request had asked the
judge itself. releaseQuarantined now takes the hold the person reviewed,
and a hold that differs from it, or from the one just judged, answers
held_again as it now stands. The check runs before the refusal check, so
an admin who also ticked trust gets the new hold, not trust_not_allowed.

Two failures no longer turn a release into something else. An attachment
list that can't be opened (corrupt, an unknown key version) rolled the
release back as a 500; its events now name no attachments, as they did
before releases carried the keyring. And one throttled answer from the
judge released the mail unscreened, where the inbound and outbound paths
retry; a release now asks once more after 250 ms.

holdRisk and JUDGED_HOLDS join the release rules, so the dashboard and
the API read the score behind a hold, and which holds a release never
screens again, from the same place the server does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oes unscreened

release_email's input schema stripped unknown keys, so a client that
still sent trust_sender got an irreversible release with no trust and no
error, where the same token on REST is refused with trust_not_allowed.
The schema is strict now: a stray argument fails the call and nothing is
released. The tool is marked destructive, as a release can't be undone,
so clients that confirm such calls ask first.

The docs said a release the judge couldn't answer goes through because
"a person decided", which isn't so for an MCP client or a key; they now
say it goes through unscreened, and the tool and REST descriptions say
screening runs first only when it can. They also promised forward copies
for every release, but infected mail is never forwarded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each dashboard release now carries the hold its dialog showed, so mail
that screening held again since the dialog opened stays held and the
person sees why, instead of releasing it on a reading of the old hold.

A preview that answers "not in quarantine" (released elsewhere
meanwhile) was retried three times, about 7 seconds of skeleton, then
read "Couldn't load this email." A conflict won't change on a retry, so
it now answers at once with its own words, "Email not found." after a
delete, and closing the dialog refreshes the screen behind, so the stale
Release button goes too.

Under a support view the confirm is dimmed for the mouse only; pressing
it from the keyboard sent the release, and the server's refusal added
"Couldn't release this email. Try again." to the read-only notice. The
dialog now stops there, as its ⌘↵ shortcut already did.

People inboxes are never screened, so their dialog no longer says a
release skips screening or that the mail was held before screening could
read it. A missing subject reads "(no subject)" unquoted, as the
Received page shows it, and the Released badge's tooltip reads "Held for
prompt injection, risk 97/100." as approved, keeping "Held: ..." for
reasons that don't follow "for".

The dialog reads the score behind a hold and the holds a release never
screens again from core's release rules, the thread pane's held card and
Released badge share one mapping of the wire's screening, and the tick
card takes the radio card's look from the stylesheet rather than a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An MCP token or an inbox-scoped key acts for the agent that held mail is
aimed at, so its release can't rest on the agent's word. A judge that
couldn't run let an MCP release of not_allowlisted or
unauthenticated_sender mail through unscreened, and unscreened or
screening_cap holds went out with no screening at all.

releaseRights now treats both as one agent credential, an inbox-scoped
key only in its own inbox, and marks its releases as needing a verdict.
releaseQuarantined screens every such release of a not_allowlisted,
unauthenticated_sender, unscreened or screening_cap hold with the judge,
line and caps of screening on release: under 80 it releases, at 80 or
more it stays held (held_again, REST 409 held_by_screening), and when no
verdict comes back (no judge, a failure, a timeout, a spent cap) it
refuses with screening_unavailable: REST 503 with retry-after, 60
seconds or until midnight UTC for a spent cap, as name_review_unavailable
answers a review that can't run. Content holds stay refused. People and
full-access team keys keep their behaviour, a judge that can't run
leaving their decision standing.

Inbox-scoped keys reach POST /inboxes/{own}/quarantine/{id}/release
under that rule; another inbox's key stays refused. The MCP tool
description, the route and key descriptions, the docs (en and pt-BR) and
the API reference error table state it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit let an inbox-scoped key release held mail in its own
inbox under the agent rule. That widened what a key scoped to one inbox
can do: such a key has never released quarantined mail, and the docs
and tests said so. Restore that boundary. The key allow-list leaves the
release route out again, and releaseRights refuses every inbox-scoped
key, so it no longer takes the hold's inbox.

MCP releases keep the verdict rule (needsVerdict, 503
screening_unavailable with retry-after), content holds stay refused to
MCP, agents never trust, and people and full-access keys are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@heliocosta-dev
heliocosta-dev merged commit 463695c into main Oct 17, 2026
5 checks passed
@heliocosta-dev
heliocosta-dev deleted the feat/quarantine-release branch October 17, 2026 21:06
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