Repository navigation
Release held mail through one dialog, and decide who may release and trust - #149
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Buttons: Keep in quarantine and a destructive Release to agent (Release to inbox on people inboxes).
Friction sized to the hold:
Released mail keeps a "Released by {who}" badge in the thread. Its tooltip gives the reason, the score and when.
Fixes:
Rules, on every channel
held_again; REST409 held_by_screening).503 screening_unavailable, with a Retry-After.403 forbidden,403 trust_not_allowedand409 held_by_screening, and503 screening_unavailable. The MCPrelease_emailtool no longer takestrust_sender; its schema is strict.Tests
🤖 Generated with Claude Code