Skip to content

Messages & Triage

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. The MailSec CLI is currently available from the Python SDK's master branch, ahead of a PyPI release. Install it in a virtual environment before running the CLI examples:

python3 -m venv .venv-mailsec
source .venv-mailsec/bin/activate
python -m pip install --upgrade 'git+https://github.com/refractionPOINT/python-limacharlie.git@master'
limacharlie mailsec --help

Use that same installation for hive and secret commands. Credential-file examples also require jq. For repeatable scripts, replace master with the tested commit SHA; python -m pip freeze records the installed revision. Re-read these pages after upgrading.

Talk to us before relying on it in production.

Messages is the queue: every message the product has seen, filtered down to the ones that need a person. This page covers the queue, the drawer, the actions and the audit trail they leave.

The queue

Filtering is entirely server-side — every filter below narrows the query in the backend, so a filtered page is a statement about your whole mail history, not about the rows a browser happened to have loaded.

Filter Notes
verdict Repeatable: malicious, suspicious, graymail, benign, unknown
state Repeatable: delivered, quarantined, trashed, restored, bannered, spam
direction Repeatable: inbound, outbound, internal
lane live for ordinary incoming mail or backfill for the initial history walk; omit for either
mailbox One protected mailbox address
sender_email One sender address
sender_root_domain One sender registrable domain
campaign_id The members of one campaign
link_domain Messages linking to this registrable root domain (evil.example, not login.evil.example)
attachment_sha256 Messages carrying an attachment with this hash
user_reported Tri-state — see below
min_score Messages scoring at least this much
q Free-text over the message's subject and sender address, up to 512 characters. The subject is matched in both its raw and its normalized form, so a hit can be on text the row does not display. It is matched row by row rather than looked up, so it must be accompanied by something that bounds the read: a since, or one of mailbox / sender_email / campaign_id / link_domain / attachment_sha256, or a single verdict. On its own it is refused — see Free text needs a window
since / until RFC3339 or unix seconds

Repeatable filters OR within a key and AND across keys: verdict=suspicious plus verdict=malicious plus mailbox=cfo@corp.example means "suspicious or malicious, delivered to that mailbox".

The processing-lane filter is available on time-window, verdict, IOC-pivot and sender_root_domain queries. It cannot be combined with mailbox, sender_email, or campaign_id because those indexes do not carry the lane dimension. The API refuses such a combination with the typed, non-retryable lane_unsupported error and names the conflicting dimension; the console clears and disables the lane control while one of those filters is active.

limacharlie mailsec message list --verdict suspicious --verdict malicious \
  --mailbox cfo@corp.example --since "$(date -d '7 days ago' +%s)" --oid $OID

Tri-state booleans: absent is not false

Omitting user_reported means the dimension is unconstrained. Setting it to false selects mail nobody reported, which is a different and much larger set than "all mail".

Free text needs a window

Most of the filters in the table above are a lookup: mailbox, sender_email, campaign_id, link_domain, attachment_sha256 and a single verdict each pick the read index, so the backend seeks straight to the matching rows. q is not one of them. It is matched literally and case-insensitively against each candidate row's sender address and subject as the index is walked, so its cost follows how much of the index gets read rather than how many rows come back — and the most expensive q is the one that matches nothing, because nothing fills the page and the walk runs to the end of your retention.

So a q on its own is refused, and it has to name something that bounds the walk:

  • a since — an until alone does not count, because the walk is newest-first, so until moves where it starts and since is where it stops;
  • or one of mailbox, sender_email, campaign_id, link_domain, attachment_sha256;
  • or a single verdict. Two or more verdicts is not a lookup either, so it does not count.

state, direction, user_reported, min_score and sender_root_domain narrow the answer rather than the scan, so they do not satisfy the requirement.

# refused
GET /v1/mailsec/$OID/messages?q=invoice

# bounded by time
GET /v1/mailsec/$OID/messages?q=invoice&since=1757116800

# bounded by a lookup, any time
GET /v1/mailsec/$OID/messages?q=invoice&mailbox=cfo@corp.example

The subject is matched in two forms: as received, and in the normalized form campaign clustering derives from it — reply and forward prefixes stripped, digit runs collapsed. A row can therefore match on a subject that is not the one shown in the result.

q is also capped at 512 characters. A search bounded only by time is counted against a per-organization read budget; one carrying a mailbox, sender_email, campaign_id, link_domain or attachment_sha256 is an index lookup and is not counted at all. A single verdict satisfies the requirement above but does not exempt the search: the verdict index is keyed by verdict and then time, so verdict=benign seeks into what is, for most organizations, all of their mail.

In the web console the search box supplies the bound for you: searching without any other filter searches everything retained, and the result summary says so.

The two IOC pivots

link_domain and attachment_sha256 are the incident-response pivots, and they are the reason the queue is not just a mailbox view. Given one confirmed phish, they answer "who else received this" across every protected mailbox — which is the question that decides whether you are handling one message or an incident.

limacharlie mailsec message list --link-domain evil.example --oid $OID
limacharlie mailsec message list --attachment-sha256 <sha256> --oid $OID

Pagination

Pages are keyset-paginated. next_cursor is opaque and is passed back verbatim; an empty one is the last page.

A message cursor is bound to the complete filter set that minted it: the token carries the chosen read index and a digest of your organization, the sort order and every filter — q, verdict, state, direction, lane, mailbox, sender address, sender root domain, campaign, user-reported, score floor, time window, link domain and attachment hash — so changing any of them mid-walk fails the next page (400, error_code: cursor_filter_changed, restart_walk: true) rather than silently resuming at the previous search's position. Filter values are not readable from the token; it is a hash, not a serialization. Restart the walk from the first page instead; a cursor minted before this binding existed is refused the same way once. A token that is structurally invalid — truncated, edited, from another endpoint — answers error_code: cursor_malformed with the same restart_walk: true, and the repair is identical.

Retention

The searchable message index keeps 35 days by default; flagged messages and their stored evidence are kept up to 400 days. Both are maxima you can lower — message_days and flagged_days in mailsec_policy/retention — and data past whichever horizon you set is deleted rather than merely hidden. A miss on an older id is a normal outcome and returns a null message rather than an error.

The drawer

Opening a row shows what the engine decided and why:

  • Why this verdict — the top signals with their weights, from the same top_signals the API returns.
  • The message itself — a sanitized rendering plus the parsed model: sender and recipients, authentication results, the links table with display/href mismatches highlighted, attachments and their explosion tree, and the thread segmentation.
  • The sender profile card — this organization's history with the sender.
  • The action timeline — every audited action on this message.
  • Remediation controls, driven by the message's current placement: the actions offered are the ones that make sense for where the message actually is.

Which model you are looking at

The detail response labels its source, because the two are not equivalent:

mdm_source What it is
stored The model the collector actually judged with — the enrichments it resolved at ingest (sender prevalence, lookalike distances, link features, domain age) and the verdict as stamped
eml_reparse Today's parser reading the original bytes. The same message, a different parse, and no enrichments at all

stored is served when it exists; eml_reparse is the fallback for mail ingested before stored models existed. An analyst deciding whether the engine was right needs to know which one they are reading, so the field is always present.

Neither requires a justification. The model is the product's own structured view; the original bytes are what is gated.

Which lane judged it

judged_via says whether this message's verdict came from live ingestion or from the connection's historical backfill.

judged_via What it means
live An ordinary ingest. The EMAIL_MESSAGE and EMAIL_VERDICT events shipped, and any policy automation that matched has run
backfill The message was already in the mailbox when the connection was made. It is judged with the same rules, and nothing was emitted and nothing acted on it
absent / null The read did not carry the field. Treat it as unknown — never as live

The distinction matters on exactly one screen, and it is worth stating plainly: a backfilled message can show a malicious verdict beside an empty action timeline and no telemetry. That is the lane working as designed, not a broken connection and not somebody suppressing an alert. See the ingest pipeline.

Similar messages

GET /messages/{id}/similar (limacharlie mailsec message similar <msg_uuid>) returns recent messages sharing at least one clustering key with this one, each row carrying the matched_keys that matched. These are candidates, not a cluster — deciding that two messages are the same attack belongs to the clustering engine. The response echoes the lookback window, because "no similar messages" only means something alongside the window it looked at.

Actions

Six typed actions apply to a single message. Each is idempotent, performed at the provider, and audited.

Action Effect
quarantine_message Out of the inbox into a product-owned quarantine location — restorable, invisible to the user
trash_message To the provider's recoverable trash
move_to_spam To the provider's junk/spam location
restore_message Back to where it was before we moved it, falling back to the Inbox when that is unknown
banner_message Prepend the organization's warning banner. Its wording comes from the banners policy record and is escaped into a fixed template — no caller supplies HTML
unbanner_message Remove it

The per-provider mechanics differ and are documented in Connecting Providers.

limacharlie mailsec message action <msg_uuid> \
  --action quarantine_message --reason "confirmed credential phish" --oid $OID

Actions require mailsec.act.

To act on many messages at once — a filtered page of this queue, or a selection you built elsewhere — see Bulk Remediation. It is the same executor and the same audit trail, with a preview and a confirmation over the set you named.

Outcomes are reported honestly

result Meaning
ok The provider was changed
skipped The desired state already held, so nothing was written. Recording this as success would make the audit claim a provider write that never happened
alert_only The action was decided and deliberately not performed, because the organization is not in enforce mode. Not an error
failed The provider refused or errored; error carries the reason
pending In flight

unbanner_message does not change placement

A message's state is a placement. Bannering is a modification, so a message that was quarantined and then un-bannered is still quarantined — writing delivered there would move it back in the UI without moving it at the provider.

Idempotency

Repeating an action collapses onto the existing attempt, so a redelivery, a double-click or a rule firing twice on one event is a no-op rather than two quarantines. To deliberately act again — re-running a quarantine after a provider outage — pass a new attempt token.

Enforcement

In an alert-only organization, actions from every source, including analysts, are recorded but withheld. The response reports force_required: true. To perform that action deliberately, repeat the request with JSON force: true, use the console's explicit override confirmation, or pass --force in the CLI:

limacharlie mailsec message action <msg_uuid> --action quarantine_message --force

Only a JSON boolean true overrides the mode; strings such as "true" do not. The override applies to this action, does not enable organization-wide automation, and is recorded separately from the withheld attempt in the audit trail. It still requires mailsec.act and the provider's required capabilities.

Automated actions are governed by policy.

The audit trail

Every action writes an audit row — including failures and skips, because "quarantined 412 of 418, 6 failed" is only answerable if the six are recorded — and emits an EMAIL_ACTION event.

Field Meaning
action_id The row's identity and its idempotency key
action, ts, result, error What was attempted, when, and what happened
actor Who asked — stamped by the server from the caller's authenticated claims. A request body cannot supply it
source analyst, automation, ai, api or dr
provider Which mail tenant it hit

The per-message timeline is deliberately narrow and does not carry the action's request payload. Expand one row to read it:

limacharlie mailsec action get <action_id> --oid $OID --output yaml

A null request on a timeline row means "not read", never "no parameters recorded" — an auditor asking whether a justification exists must expand the row rather than infer absence from the list.

Downloading the original message

This is a privileged read of a person's mail, and it is gated separately from the rest of the product. It requires both mailsec.get and mailsec.get.eml, plus a justification.

limacharlie mailsec message eml <msg_uuid> \
  --justification "INC-4471, user reported credential harvest" \
  --out-file suspect.eml --oid $OID
  • The justification is required. A blank or whitespace-only reason is refused.
  • It is recorded against your authenticated identity in the organization's action audit and retained for 400 days. A failed attempt is recorded too.
  • It is stored verbatim. The backend enforces a minimum and a maximum length, and refuses an over-long reason rather than truncating — silently clipping the record the gate exists to produce would corrupt it.
  • Raw copies expire 35 days after delivery (longer for flagged messages), after which this returns a typed expiry error while the index row stays readable.

Read a justification back with mailsec action get <action_id>.

Sender profiles

limacharlie mailsec sender get cfo@corp.example --oid $OID
limacharlie mailsec sender get domain:corp.example --oid $OID

A key with no profile means no history at all, and the response says so explicitly rather than returning a zeroed profile that would read as a known-but-quiet sender. Keys are lowercased, and a bare address or domain is resolved for you.