Bulk Remediation¶
Private beta
Email Security is in private beta. It is not generally available, and
access is enabled per organization — if the ext-email-security extension
is not in your catalog, this product is not turned on for you yet.
While it is in beta, expect the surface described here to move: commands, fields and event shapes may change between releases, and they may change in ways that are not backwards compatible. Pin a CLI version if you script against it, and re-read this page after upgrading.
Talk to us before relying on it in production.
A campaign sweep acts on a cluster the engine decided. Bulk remediation acts on a set you named — a filtered page of the queue, the result of an LCQL hunt, a list of ids in a file. It is what turns a search result into provider-side action.
It is deliberately the same preview-then-confirm discipline the campaign sweep uses, over a caller-supplied selection rather than a cluster, with one structural difference: the execute cannot finish inside a request. Up to 500 provider writes paced against Microsoft 365 and Google throttling do not fit in one call, so execute returns a handle and the work proceeds in the background.
The four steps¶
| Step | Route | Permission | |
|---|---|---|---|
| Preview | POST /actions/bulk/preview |
mailsec.get |
Reads the index, reports each message's current state, and mints the confirm token |
| Confirm | — | — | A human reads the preview. This is the step the design exists for |
| Execute | POST /actions/bulk/execute |
mailsec.act |
Takes the token and the same request back. Returns a bulk_id immediately |
| Status | GET /actions/bulk/{bulk_id} |
mailsec.get |
The running truth of the job, polled to completion |
The actions are the same frozen names every other remediation path uses:
quarantine_message, trash_message, move_to_spam, restore_message,
banner_message, unbanner_message. An unrecognized action is refused with the
whole supported set named, rather than with a bare error.
submit_to_triage is deliberately not among them. Submitting 500 messages to
an AI triage agent is a bulk spend, not a bulk remediation, and it does not
belong behind a dialog whose whole subject is where mail ends up.
Capped at 500, and a larger selection is refused rather than truncated
Acting on the first 500 of 900 leaves the rest in inboxes nobody will look at. Narrow your filters, or split the selection yourself.
Preview¶
{
"action": "quarantine_message",
"msg_uuids": ["0057db2b-…", "…"],
"attempt": "optional idempotency token"
}
Nothing is changed and no job is created. The preview reads the index and answers, per message:
| Field | |
|---|---|
msg_uuid |
The message |
exists |
false when the index no longer resolves the id — expired past retention, or never real |
verdict, state, mailbox, provider, subject |
Enough to recognize what you are about to act on |
already_in_target_state |
Advisory, from the index — see below |
and, in summary:
| Count | |
|---|---|
total |
The selection you sent |
found / missing |
How many the index resolved |
already_in_target_state |
How many the index already places where the action would put them |
mailbox_count |
The blast radius. "38 messages" and "38 people's inboxes" feel very different, and only one of them is the real exposure |
actionable |
An upper bound, not a prediction: how many are not already in the target state according to the index |
by_provider |
The selection split by mail tenant |
target_state is the placement the action would produce. It is present and empty
for an action with no target placement (unbanner_message modifies a message
without moving it), which is what has_target_state distinguishes from "the
backend forgot to say".
Expired ids and already-done messages are reported, not dropped
They stay in the confirmed set. already_in_target_state is read off the
index, which records where remediation last put a message — its owner may
have moved it since. The executor re-checks at the provider and answers
skipped, which is a truer answer than a client-side filter could give.
The confirmation is a derivation, not a nonce¶
The confirm token is derived from (action, attempt, the normalized member
list). It is not a random handle the server remembers, so the execute must send
the whole previewed request back beside it — the same action, the same
attempt, and the same msg_uuids.
Change any of the three and the backend re-derives a different token, does not match, and refuses the run rather than acting on a set nobody approved. A client that re-ran its search between previewing and executing is stopped by this.
The selection is deduplicated, trimmed and sorted before the token is derived, so re-ordering your own list is not a change.
attempt participates in the derivation on purpose. A new attempt over the
same selection is a new token, a new bulk_id and a deliberate second run —
which is the escape hatch for acting on the same messages again after a provider
outage. Omit it and a repeat collapses onto the job you already have.
reason is deliberately outside the derivation. It is your justification,
recorded on the job's audit row and on every message's, and rewording it
between previewing and executing neither invalidates the token nor starts a
second job over the same messages.
Execute¶
{
"action": "quarantine_message",
"msg_uuids": ["0057db2b-…", "…"],
"confirm": "3f31ed…",
"attempt": "optional",
"reason": "INC-4471, confirmed credential phish"
}
The response is an acceptance, not a completion:
| Field | |
|---|---|
bulk_id |
The handle. Poll it |
accepted, started |
Whether the job was taken, and whether this call started it |
already_running / already_complete |
The idempotent answers — the confirmation re-derives to a fixed bulk_id, so re-sending the same request adopts the same job rather than acting twice |
member_count, state, counts |
The job as it stands at acceptance |
Partial failure is a normal, honestly-reported outcome, never a rollback. Provider actions are not transactional and there is no undo.
Status¶
GET /actions/bulk/{bulk_id} is the running truth.
state |
Terminal | |
|---|---|---|
running |
Members are still being attempted | |
complete |
✅ | Every member settled |
interrupted |
✅ | The job was finalized before every member settled — a worker went away and the record was closed out |
interrupted is a terminal state, not a synonym for failure
The job stopped moving. The outcomes recorded against each member are real
and are the authority on what actually happened — read items[], do not
infer that nothing was done.
stalled is a different fact and is not a state: it is true when a
running job's record has not been touched for two minutes, which means the
worker that accepted it is gone. Re-send the same execute request with the same confirmation
to finish it; every message already acted on collapses onto its existing action
row rather than being acted on twice.
Each member appears in items[]:
items[].result |
|
|---|---|
ok |
A provider write that happened |
skipped |
The provider found the message already where the action would put it |
failed |
items[].reason carries the executor's bounded explanation |
alert_only |
The organization's policy declined to act — see automations |
not_found |
No row in the index for that member |
pending |
Not attempted yet |
items[].action_id is the authoritative audit row for that member, expandable
through GET /actions/{action_id} like any other action.
counts carries the same vocabulary as running totals — total, pending,
ok, skipped, failed, alert_only, not_found. alert_only is counted
separately from ok on purpose: one is a provider write and the other is a
decision the organization's policy withheld.
Read state to know whether to keep polling, and counts to know what
happened. They answer different questions and can disagree in the one way that
matters: a job that ends interrupted is terminal with members still pending,
because nothing will attempt them now. counts.pending at zero means every
member settled; it is not the definition of "stop polling".
Two reason fields, two authors
reason at the top level is the operator's justification for the whole
job. items[].reason is the executor's explanation of one member's
outcome. They are never the same sentence.
From the CLI¶
Two calls, exactly like a campaign sweep:
# 1. Preview. Prints the per-message report and the confirm token; changes nothing.
PREVIEW=$(limacharlie mailsec message bulk-action \
--action quarantine_message --input-file uuids.txt \
--oid $OID --output json)
echo "$PREVIEW" | jq '.summary'
# 2. Execute the exact selection that was previewed.
limacharlie mailsec message bulk-action \
--action quarantine_message --input-file uuids.txt \
--confirm "$(echo "$PREVIEW" | jq -r .confirm)" \
--reason "INC-4471, confirmed credential phish" \
--oid $OID
The selection can come from --msg-uuids (repeatable and comma-separated),
--input-file (a JSON/YAML list or one id per line), or standard input — which
is what makes the queue pipe into it:
limacharlie mailsec message list --verdict malicious --output json --oid $OID \
| jq -r '.messages[].msg_uuid' \
| limacharlie mailsec message bulk-action \
--action quarantine_message --input-file - --oid $OID
Ids must be message UUIDs. A selection carrying anything else — the header row and second column of a pasted CSV are the usual way this happens — is refused by name, because a phantom id comes back from the preview as "not in index", identical to a legitimately expired one, and the counts you would be consenting over would be wrong.
--reason is supported on the execute and reaches the audit trail.
The CLI's --ai-help text contradicts itself about --reason
limacharlie mailsec message bulk-action --ai-help ends with a paragraph
claiming "there is no --reason". That paragraph is stale: the flag is
registered, the gateway forwards it, and an earlier paragraph in the same
help text describes it correctly. Trust this page and --help; the help text
is being corrected.
The Python SDK's bulk_action_execute() docstring carries a related but
different caveat — that reason needs a gateway new enough to forward it.
That was true of older deployments and is not a contradiction; it is simply
no longer the situation on a current one.
Passing --reason to a preview does nothing, and the CLI says so rather
than accepting it quietly: the preview mints the confirmation and takes no
reason by design, so that rewording a justification can never invalidate a
selection somebody already approved.
The exit code carries the outcome¶
bulk-action waits for the job by default (--wait, with --timeout
seconds — 300 by default — and --poll-interval), so && means what it looks
like in a runbook:
| Exit | |
|---|---|
0 |
The job reached complete without the "nothing worked" case below — or you passed --no-wait, or you ran a preview and it produced a token |
| non-zero | The execute was not accepted; the job completed with zero successes and at least one failure; it ended interrupted; it is stalled; it was still running at the timeout; or a poll errored |
The non-zero rule is narrower than "nothing was remediated", deliberately. Per-member failures inside an otherwise successful batch are your data, not an error. Zero successes with at least one failure is an error: nothing was remediated and something broke, so a chained command must not run as though it had worked.
A batch that completes with no failures and no successes — every member
skipped because the provider already had them where you wanted them, every
member alert_only because the organization is not in enforce mode, or every
member not_found — exits 0. Nothing broke. If "at least one provider
write actually happened" is what your runbook needs, read counts.ok rather
than relying on the exit code.
The bulk_id is announced on stderr before the first poll, so a poll that
fails still leaves you holding the handle. With --quiet, the exit code and
limacharlie mailsec message bulk-status <bulk_id> are how you read the result.
From the console¶
The Messages queue and the Hunt screen both carry row selection and a bulk action bar. Selecting rows and choosing an action runs exactly the flow above: the dialog is the preview, with the blast radius and the per-message report, and the confirm button is the execute. The console then polls the status route and renders each member's outcome by name.
The console's bulk action list is a deliberate subset: banner_message is
offered (a bulk banner still uses the organization's own
banner policy; no client supplies HTML), and
unbanner_message is not — un-bannering is a per-message follow-up taken from a
bannered row's timeline, not a sweep. The API accepts all six.
When to use which¶
| One message | POST /messages/{msg_uuid}/actions — see Messages & Triage |
| Everything the engine attributed to one attack | Campaign sweep — the member set is the engine's, and the token is derived from it |
| A set you chose | Bulk remediation, this page |
All three end in the same executor, so alert_only / enforce, the audit row,
the EMAIL_ACTION event and idempotency apply identically. There is exactly one
remediation path in this product.