mailsec: bulk remediation across a caller-supplied selection - #352
Merged
Conversation
Binds the three bulk routes the gateway serves — preview, execute and status — as SDK methods and two CLI verbs, so any search result becomes provider-side action instead of a loop of single-message calls. The consent model is the whole feature. The preview's `confirm` token is DERIVED from the exact selection rather than issued as a nonce, so the CLI normalizes one list and reuses that same object for both calls: a selection cannot change between the screen a human approved and the request that runs. Execute is asynchronous by necessity (500 provider writes paced against Microsoft 365 / Google throttling do not fit one request), so --wait polls with a bounded loop that treats complete, interrupted, stalled and the deadline as four terminal states and narrates each. Narration goes to stderr so `--output json` still emits exactly one document across the three calls. Two deliberate omissions, both matching the wire rather than the sketch: there is no --reason (the gateway's execute allow-list does not carry one, and a justification dropped in transit is worse than an absent one) and no client-side 500 cap (the server refuses an oversized selection rather than truncating it, which a trimming client would hide). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lcbill
previously approved these changes
Sep 2, 2026
maximelb
marked this pull request as ready for review
September 2, 2026 13:39
…t code
Review found the verb had invented a third consent grammar. Its siblings
in this same file — `campaign action`, `hunt remediate` — spell it one
way: omit --confirm to preview, pass --confirm TOKEN to execute.
bulk-action had --preview-only, --yes, a prompt and a mutual-exclusion
guard on top of the same idea. All four are gone; the preview is now the
default and its document, token included, goes to stdout, which is what
makes the two-step usable from a script rather than only from a terminal.
The reason that mattered more than tidiness: the old default path
required a TTY, --quiet suppressed the very screen consent was given to,
and the isatty gate meant piping ids in — the advertised use — could
never reach an execute. (The tests that covered the prompt were inert:
CliRunner's stdin patch never took effect.)
THE EXIT CODE NOW CARRIES THE OUTCOME, because a runbook chains with &&
and reads nothing else. Measured, before: an execute answering
accepted:false exited 0 and silently downgraded to no-wait; a 502 on the
first poll exited 1 with an empty stdout and the bulk_id nowhere, which
orphans a job that is running. Now: the bulk_id is announced on stderr
BEFORE anything that can fail, a failed poll prints the resume command
and puts the acceptance (which carries the id) on stdout, and complete
exits 0 unless nothing was remediated (ok=0 with failures). Interrupted,
stalled and the timeout are non-zero, and the stall names the gateway's
own documented repair.
The wait loop moves to Mailsec.wait_for_bulk, mirroring Jobs.wait: three
terminal conditions, the last status document returned either way, no
timeout exception. --poll-interval and --timeout gained IntRange floors,
since --poll-interval 0 previously issued 30,580 requests in two seconds
and a negative one crashed AFTER the provider writes were ordered.
A selection is now shape-checked against the msg_uuid format. This is
usually the server's job, but not here, because its answer is
indistinguishable: a phantom member comes back from the preview as "NOT
IN INDEX", exactly like an id that expired past retention, so pasting a
CSV export (header row, second column) silently corrupts member_count
and mailbox_count — the two numbers a human is consenting over.
Reuse, per review: --input-file goes through _input_helpers' canonical
loader and accepts `-`, plus hive.py's read-the-pipe-if-nothing-is-named
idiom; _note becomes a shared note() beside command_output. The local id
splitter stays local but becomes comma-only, matching the twelve other
comma-splitters; a cross-module splitter would widen this PR.
The preview falls back to JSON when the format would be a table.
Measured: format_table renders `messages` as "[2 items]" and `summary`
as "{7 keys}", and those two ARE the preview. (The token itself was never
truncated — the gateway's is 32 hex characters and the cell width never
drops below 40 — but it is now also printed on stderr, so a copy-paste
does not depend on that arithmetic.)
Verified against the gateway rather than assumed: `banner` is the public
spelling that mailsecBulkExecuteArgs translates to `banner_html`, so that
pin stays — but its test comment claimed the allow-list would DROP a
literal banner_html, which is false (it forwards both), and a pin that
encodes a wrong belief is worse than none.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lcbill
approved these changes
Sep 2, 2026
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.
Binds the three bulk-remediation routes the API gateway serves as SDK methods and two CLI verbs. This is what turns any search result — the message index, a hunt, a shell pipeline — into provider-side action, instead of a loop of single-message calls.
The routes
POST /mailsec/{oid}/actions/bulk/previewmailsec.getconfirm. Changes nothing and creates no job. A POST only because it carries up to 500 ids.POST /mailsec/{oid}/actions/bulk/executemailsec.actbulk_idimmediately. The provider work runs on the collector that holds the org's lease.GET /mailsec/{oid}/actions/bulk/{bulk_id}mailsec.getstate, runningcounts, per-messageitemswith each one'saction_id, andstalled.Vocabulary:
quarantine_message,trash_message,move_to_spam,restore_message,banner_message,unbanner_message. Note it is not the per-message list — it addsmove_to_spamand dropssubmit_to_triage, which moves no mail.The property this is built around
The
confirmtoken is derived from the exact selection, not issued as a nonce: it hashes (oid, action, attempt, normalized member list). Any change to any of those invalidates it. So a client that re-ran its search between the preview a human read and the execute it sent is refused rather than acting on messages nobody approved.The CLI therefore normalizes one list — trim, drop blanks, dedupe, sort, mirroring the server's own normalization — and never rebuilds it. Because the token binds the attempt and the action too, the help text names all three (
--action,--msg-uuids,--attempt) rather than only the selection: previewing with--attempt inc-9and executing without it is a stale-token error pointing at a list the caller never changed.CLI
The grammar is the siblings' grammar.
campaign actionandhunt remediate, in this same file, spell the two-step one way: omit--confirmto preview, pass--confirm TOKENto execute.bulk-actionnow does the same.--confirmconfirmtoken included, goes to stdout for a script. Exit 0. Nothing is changed.--confirm TOKENThere is no
--yes, no--preview-only, no prompt and no mutual-exclusion guard. Note that within the mailsec group--confirmtakes a value, unlike the boolean--confirmused by 19 other command modules; a bare--confirmnow fails with click's "requires an argument", which is the safe direction.Selection input:
--msg-uuids(repeatable, comma-separated),--input-file(a JSON/YAML list or one id per line,-for stdin), or — following hive.py's idiom — piped stdin when neither is named.mailbox_countis shown beside the message count because "38 messages" and "38 people's inboxes" feel very different.complete,interrupted,stalledor the deadline stopped it.--output jsonkeeps its promise.The exit code carries the outcome
A runbook chains with
&&and reads nothing else, so the outcome has to be in the exit code:--no-wait,accepted: truecomplete, at least one message acted oncounts)completewithok == 0and failuresinterrupted/stalled/ still running at the timeoutaccepted != true, nobulk_id, or a poll that erroredThe stall message states the gateway's own documented repair: re-send the same execute request with the same confirmation token to finish it — every message already acted on collapses onto its existing action row rather than being acted on twice.
--quietsilences both streams, as it does everywhere in this CLI; with it, the exit code above andbulk-statusare how the result is read. The explain text says so.Two deliberate deviations from the obvious shape
No
--reason. The gateway's execute allow-list forwards onlyaction,msg_uuids,confirm,attemptand the banner. Areasonwould be accepted by this client and dropped in transit — a justification that silently never reaches the audit trail is worse than one the caller knows it cannot give. Documented in the explain text, which points atmailsec message action --reasonfor the per-message case.No client-side 500 cap. The server refuses an oversized selection rather than truncating it, and names the cap in the refusal (
error_code: bulk_too_large). A client that trimmed 900 to 500 would report success over a batch whose tail was silently left in inboxes nobody will look at.Honest outcomes, preserved
Expired ids and already-done messages are reported, not dropped and not errors — acting on a search result taken minutes ago legitimately includes both. They stay in the confirmed set and the provider re-checks each one, because
already_in_target_stateis read off the index and is advisory. Partial failure is reported beside what worked and is never a rollback: provider actions are not transactional, and "restoring" a quarantined message is a second visible move in somebody's mailbox rather than an undo.Review pass
Beyond the grammar and the exit codes, six things the review found and this now fixes:
--poll-interval 0issued 30,580 requests in two seconds, and a negative one crashed after the provider writes were already ordered. Both bounds are nowclick.IntRange, enforced before the execute fires.member_countandmailbox_countwere wrong in a way nobody could see. Ids are now shape-checked against the msg_uuid format (the backend derives them as v5-shaped UUIDs) and a bad one is refused by name.format_tablerendersmessagesas[2 items]andsummaryas{7 keys}, and those two are the preview. Now that the preview is this verb's default output, it falls back to JSON when the format would be a table. (The token itself was never truncated — the gateway's is 32 hex characters and the renderer's cell width never drops below 40 — but it is also printed on stderr, so a copy-paste does not depend on that arithmetic staying true.)Mailsec.wait_for_bulk, mirroringJobs.wait: three terminal conditions, the last status document returned either way, no timeout exception. The CLI half is now narration only. It uses the existing status route, so the route inventory stays at 28.--input-filegoes through_input_helpers' canonical loader (whose docstring mandates it for new code) and accepts-;_notebecomes a sharednote()besidecommand_outputin_output_helpers. The local id splitter deliberately stays local — a cross-module comma-split helper would widen this PR past its subject — but is now comma-only, matching the twelve existing comma-splitters.One thing the review flagged that turned out to be correct as written, checked against the gateway source: the SDK sends
banner, andmailsecBulkExecuteArgstranslates it to the collector'sbanner_html. The pin stays. Its test comment did not: it claimed the allow-list would drop a literalbanner_html, which is false (it forwards both spellings), and a pin that encodes a wrong belief about the gateway is worse than no pin. The comment now says what the allow-list actually does and why the documented public name is still the one to pin.Tests
tests/unit/test_cli_mailsec_bulk.py(54 tests) and theTestBulkSelectionNormalization/TestBulkRemediation/TestWaitForBulktrio intests/unit/test_sdk_mailsec.py(64 tests in the file). The CLI tests wirewait_for_bulkto the real SDK implementation and stub only the HTTP-facing status call, so the loop whose terminal semantics decide the exit code is the shipped one.Covered: preview-as-default and the token on stdout; the two-step sending a byte-identical selection across two invocations; every exit code in the table above; the first-poll exception leaving the bulk_id in both streams;
--poll-intervaland--timeoutrejections happening before the execute; stdin via-and via a bare pipe; JSON-array and YAML-list input files; the CSV-paste refusal naming its offending token; the table-format preview keeping its substance; andwait_for_bulk's three terminal conditions. The route-coverage inventory stays exact at 28.The old prompt-path tests are not carried forward: they were inert, because the
sys.stdin.isattypatch never took effect underCliRunner.Full suite: 4083 passed, 5 skipped (up from 4048; the 5 are pre-existing platform/docstring skips).