Command Line Interface¶
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.
The limacharlie mailsec command group covers the Email Security API surface:
the coverage screen, the message index and drawer, the audited raw-EML download,
verdict revisions, campaigns and campaign-wide sweeps, bulk remediation over a
selection you name, sender profiles, the action audit trail, the abuse-mailbox
report queue, custom-rule validation and backtest, the connection preflight, the
served onboarding guide, and the tenant purge.
Commands take the global options (--oid,
--output json|yaml|toon|csv|table|jsonl, --filter <jmespath>,
--fields <names>), and every command and subgroup answers --ai-help with
task-oriented guidance.
Every command requires the org to be subscribed to the extension:
Provider connections and policy are Hive records — manage them with the
standard limacharlie hive commands (mailsec_provider, mailsec_policy,
dr-mail). This group is the query, triage and remediation surface. See
Policy Reference for the record contracts and
API Reference for the routes these commands call.
Permissions¶
Four, rather than the usual get/set pair, because Email Security asks to be trusted with four separable things — plus one command that is not any of them:
| Permission | Covers |
|---|---|
mailsec.get |
Read the product's own view: queue, drawer, campaigns, senders, audit trail |
mailsec.set |
Change triage state — resolving a user report |
mailsec.act |
Remediate live mail at the provider |
mailsec.get.eml |
Download the original bytes of a message; requires a logged justification |
mailsec.act and billing.ctrl and user.ctrl |
tenant purge, in both its preview and its destructive form. Owner-level authority, the same trio deleting the organization requires — there is no separate "owner" permission |
mailsec.get.eml is separate on purpose: opening the drawer shows you the
product's structured view of a message, while downloading the EML takes a
person's actual mail out of your tenant. Gating them identically would mean
typing a justification to look at the queue.
At a glance¶
# Coverage
limacharlie mailsec coverage --window-days 30
# The triage queue
limacharlie mailsec message list --verdict suspicious --verdict malicious
limacharlie mailsec message list --mailbox cfo@corp.example --since 2026-08-01
limacharlie mailsec message list --user-reported # a human flagged these
limacharlie mailsec message list --link-domain evil.example # IOC pivot
limacharlie mailsec message list --attachment-sha256 <sha> # IOC pivot
limacharlie mailsec message get <msg_uuid>
limacharlie mailsec message similar <msg_uuid> # who else got it
limacharlie mailsec message eml <msg_uuid> --justification "INC-4471"
# Re-judging a message, and reading how its verdict moved
limacharlie mailsec message revise <msg_uuid> --verdict malicious --rationale "confirmed credential harvest"
limacharlie mailsec message revisions <msg_uuid>
# Remediation
limacharlie mailsec message action <msg_uuid> --action quarantine_message --reason "confirmed phish"
limacharlie mailsec message action <msg_uuid> --action restore_message
# Bulk remediation over a selection you name: previews without --confirm
limacharlie mailsec message bulk-action --action quarantine_message --input-file uuids.txt
limacharlie mailsec message bulk-action --action quarantine_message --input-file uuids.txt --confirm <token> --reason "INC-4471"
limacharlie mailsec message bulk-status <bulk_id>
# Campaigns: one attack, triaged once
limacharlie mailsec campaign list --min-members 3
limacharlie mailsec campaign get <campaign_id>
limacharlie mailsec campaign action <campaign_id> --action quarantine_message # preview
limacharlie mailsec campaign action <campaign_id> --action quarantine_message --confirm <token>
# Senders and the audit trail
limacharlie mailsec sender get cfo@corp.example
limacharlie mailsec sender get domain:corp.example
limacharlie mailsec action get <action_id>
# Abuse-mailbox reports
limacharlie mailsec report list --status open --oldest-first
limacharlie mailsec report get <report_id>
limacharlie mailsec report resolve <report_id> --disposition true_positive
limacharlie mailsec report reopen <report_id>
# Custom rules
limacharlie mailsec rule validate --file rule.json --rule-id custom-lookalike
limacharlie mailsec rule backtest --file rule.json --since 2026-08-01
# Analysis and setup
limacharlie mailsec analyze --file suspect.eml --org-domain corp.example
limacharlie mailsec connection test gws-exp
limacharlie mailsec onboarding --provider gworkspace
# Delete everything Email Security holds for this org — previews without --confirm
limacharlie mailsec tenant purge
--window-days accepts 1-35 (the platform's maximum message retention) and cannot be combined with an explicit --since/--until. Out-of-range values for --limit, --min-score and --min-members are refused with an error naming the flag rather than silently clamped or ignored.
Things worth knowing before you script this¶
Campaign actions preview by default¶
campaign action reports what it would do and changes nothing unless you pass
--confirm. That is deliberate for an operation whose blast radius is every
mailbox that received an attack.
The preview returns the members, the distinct mailboxes it would touch, and a
confirm token derived from that exact member set. Pass the token back —
not the campaign id, which is refused — so a campaign that absorbed new
members while you were reading the preview fails the confirmation rather than
sweeping a set nobody approved.
PREVIEW=$(limacharlie mailsec campaign action "$CAMPAIGN" \
--action quarantine_message --output json)
echo "$PREVIEW" | jq '{member_count, mailbox_count}'
TOKEN=$(echo "$PREVIEW" | jq -r .confirm)
limacharlie mailsec campaign action "$CAMPAIGN" \
--action quarantine_message --confirm "$TOKEN"
Sweeps are capped at 500 members: above that the answer is a person deciding, not a bigger dialog. See Campaigns.
--reason is recorded on every member's audit row and on the sweep's own
row, which comes back as action_id and reads through
limacharlie mailsec action get. It is bounded at 1024 characters and refused
rather than truncated, and it is not part of the confirmation token — rewording
it after the preview does not invalidate the token.
--attempt asks for a deliberate second run. Re-running a sweep is
idempotent per member — the per-member audit key is the campaign itself, so a
double click, or a retry of a request whose response you never saw, collapses
onto the row each member already has instead of claiming a move that happened
once as two. A new --attempt composes with the campaign, minting a new action
id per member and a new sweep record, so a re-run after a provider outage is
recorded beside the run that failed rather than over it; the same value twice
collapses again. It is an opaque handle you mint, not prose: at most 128
characters, refused rather than truncated, because a clipped idempotency token
is a different token, and it is checked on the preview leg too.
Like --reason, it is not part of a campaign sweep's confirmation — unlike a
bulk action's --attempt below,
which is. The two tokens answer different questions: a bulk token derives the
job's own identity, so the attempt is part of what it names, while a sweep's
token authorizes a member set and nothing else. Repeat a bulk --attempt on the
execute; add or change a sweep's freely.
--attempt on a campaign sweep needs a recent limacharlie release; an older
one refuses the flag as unknown, and the field can be sent directly to the API
in the meantime (see
Campaigns).
limacharlie mailsec campaign action "$CAMPAIGN" \
--action quarantine_message --confirm "$TOKEN" \
--reason "re-running after the provider outage" \
--attempt after-the-outage
alert_only is a success, not a failure¶
An action's result can come back as alert_only, meaning the action was
decided and deliberately not performed because your organization is not in
enforce mode. Do not treat it as an error — it is the product doing what you
configured, reported honestly rather than dressed up as ok.
Filters are tri-state¶
Leaving a boolean filter unset means the dimension is unconstrained, which is
not the same as false:
limacharlie mailsec message list # every message
limacharlie mailsec message list --user-reported # only reported mail
limacharlie mailsec message list --no-user-reported # only unreported mail
The EML download is audited¶
message eml requires --justification, and it is written to the access audit
with your identity. There is no way to fetch raw mail without leaving a record
of why.
limacharlie mailsec message eml <msg_uuid> \
--justification "INC-4471, user reported credential harvest" \
--out-file suspect.eml
Reports have an SLA ordering¶
--oldest-first is what makes the report queue an SLA surface rather than a
feed. "The oldest thing nobody has looked at" is the question a queue exists to
answer, and it is not answerable from a newest-first page.
Each report also carries original_found. A report whose original was never
indexed is a real state — the mail predates the connection, or landed in a
mailbox outside your scope — and it is shown as a gap rather than as a blank
field.
Backtest tells you what it could not see¶
rule backtest reports skipped_no_raw, skipped_unparse and truncated
alongside the match count, because a precision figure whose denominator quietly
shrank is a number that looks like a measurement and is not one. Its
coverage_note states the window it actually examined.
precision comes back as null, not 0, when nothing it matched has an
analyst disposition yet. Zero would read as "everything it matched was wrong"
and would have you discard a good rule.
Bulk remediation previews by default too¶
message bulk-action is the campaign sweep's discipline over a selection you
name: omit --confirm and it previews, pass the token back and it executes. The
selection can come from --msg-uuids, --input-file, or standard input — which
is what lets the queue pipe into it:
limacharlie mailsec message list --verdict malicious --output json \
| jq -r '.messages[].msg_uuid' \
| limacharlie mailsec message bulk-action --action quarantine_message --input-file -
The execute must repeat the identical --action, --msg-uuids and
--attempt the preview was minted with — the token is derived from those three,
not issued as a nonce. --reason is not one of them, and is supported: it is
recorded on the job's audit row and on every message's.
The job runs in the background, --wait is the default, and the exit code
carries the outcome — 0 only when the job completed and something was acted
on. The full contract, including every state, result and count, is in
Bulk Remediation.
Revising a verdict is mailsec.act, not mailsec.set¶
message revise records a human disposition over the scorer's, appending to the
message's history rather than overwriting it. --rationale is required and
audited — at least one, at most ten, each 280 characters or fewer.
limacharlie mailsec message revise <msg_uuid> \
--verdict benign --rationale "internal test send" --rationale "sender verified"
limacharlie mailsec message revisions <msg_uuid> --output yaml
The CLI always revises as analyst, because the operator of a CLI is a person.
An autonomous agent revises with its own key and mode: ai through the API,
so the audit can always say whether a person or a model decided.
applied: false is an honest outcome and not an error: the message already
carried that verdict and nothing changed. See
Detections & Verdicts.
The tenant purge is irreversible¶
tenant purge permanently deletes everything Email Security holds for the
organization — the message index and the long-term evidence lane, campaigns,
sender profiles, the action audit trail, user reports, stored raw messages and
their parsed copies, and link-detonation results — and removes the provider
connection and policy records, which stops the provider sending any further
notifications. There is no undo and no smaller scope than the whole tenant.
So it is two calls. With no --confirm the command previews: it prints the
warning and mints a confirmation token, and destroys nothing.
# 1. Preview. Prints the warning and a single-use token; changes nothing.
PREVIEW=$(limacharlie mailsec tenant purge --oid "$OID" --output json)
echo "$PREVIEW" | jq -r .warning
# 2. Purge, within 5 minutes, quoting that token.
TOKEN=$(echo "$PREVIEW" | jq -r .confirmation)
limacharlie mailsec tenant purge --oid "$OID" \
--confirm "$TOKEN" \
--reason "Tenant offboarded"
The token is single-use and expires 5 minutes after it is minted, so a purge
cannot be replayed and cannot be scripted without someone having been shown the
warning. A purge that comes back with complete: false did not finish and is
safe to repeat — but repeating it means starting again at step 1, because step 2
spent the token.
--reason is optional, capped at 1024 characters, and written to the
organization's audit log with your identity. Requires Owner-level authority. See
Data retention and deletion for exactly
what a purge removes, and for the deletion that happens on its own 30 days after
an organization unsubscribes.
Filtering and pagination¶
Repeatable filters OR within a key and AND across keys:
# suspicious OR malicious, AND delivered to that mailbox
limacharlie mailsec message list \
--verdict suspicious --verdict malicious \
--mailbox cfo@corp.example
Cursors are opaque and are passed back verbatim. They encode which index the walk is pinned to and are bound to the filter set that minted them — changing a filter mid-walk is an error rather than a page that silently means something else.
PAGE=$(limacharlie mailsec message list --limit 100 --output json)
NEXT=$(echo "$PAGE" | jq -r .next_cursor)
[ -n "$NEXT" ] && limacharlie mailsec message list --limit 100 --cursor "$NEXT"
An empty next_cursor means the last page.
Scripting¶
# Every suspicious message that reached a VIP mailbox in the last day
limacharlie mailsec message list \
--verdict suspicious \
--mailbox ceo@corp.example \
--since "$(date -d '1 day ago' +%s)" \
--output json --filter 'messages[].{id: msg_uuid, subject: subject}'
# Quarantine every member of a campaign, after reading the preview
PREVIEW=$(limacharlie mailsec campaign action "$CAMPAIGN" --action quarantine_message --output json)
limacharlie mailsec campaign action "$CAMPAIGN" --action quarantine_message \
--confirm "$(echo "$PREVIEW" | jq -r .confirm)"
# Resolve the oldest open report
REPORT=$(limacharlie mailsec report list --status open --oldest-first --limit 1 \
--output json | jq -r '.reports[0].report_id')
limacharlie mailsec report resolve "$REPORT" --disposition true_positive
Because the CLI is the whole surface, it is also how an AI triage agent reaches Email Security — there is no separate integration for agents to learn.