Skip to content

API Reference

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.

All Email Security routes live under https://api.limacharlie.io/v1/mailsec/{oid}/… and appear in the public OpenAPI spec at /openapi. Authentication is the standard Authorization: Bearer <JWT> header.

Permissions & enable gate

Every route requires the organization to be subscribed to ext-email-security — a 403 on any route means subscribe first. The oid is always taken from the authorized path.

Reads and the read-only POSTs (analyze, rules/validate, rules/backtest) require mailsec.get. Resolving a report requires mailsec.set. Anything that touches live mail — the action routes and the connection test — requires mailsec.act. Downloading raw message bytes requires mailsec.get and mailsec.get.eml.

The tenant purge routes are the exception to all of that. Both GET /tenant and DELETE /tenant require Owner-level authority — mailsec.act and billing.ctrl and user.ctrl together.

Connections, policy and custom rules are not /mailsec routes: their CRUD goes through Hive (mailsec_provider, mailsec_policy, dr-mail).

Shared behaviours:

  • Repeatable filters are passed as repeated query keys — ?verdict=malicious&verdict=suspicious. OR within a key, AND across keys.
  • Boolean selectors are tri-state. An absent parameter means "not filtered", which is not the same as passing false.
  • Keyset pagination. Pages carry next_cursor; pass it back as ?cursor=. An empty next_cursor is the last page. A cursor is bound to the filter set that minted it — changing a filter mid-walk fails the next page rather than resuming at a position that means something else. Restart the walk.
  • Times are RFC3339 or unix seconds on input.
  • A miss is not an error. An unknown or expired message, campaign or report id returns a null object rather than a 404: the index has a 35-day retention and a miss is a normal outcome.

Reads

Route Returns
GET /coverage Mailboxes discovered / protected / excluded / in error, message volume and the verdict funnel over the window, the parse-degradation rate, backfill progress, the emission backlog, per-connection health, and the overview block (open reports, active campaigns, resolved automation mode, and processing_latency_p95 — see Time to verdict). Params: since, until, window_days. With no window at all the default period is served from a short-lived server-side memo; naming an explicit range or a window_days always computes that exact period. window_days is the whole-days shorthand the CLI's --window-days uses (1-35, counted back from now); it cannot be combined with since/until, and its ceiling is the platform's maximum message retention. A window reaching past the organization's own retention horizon returns volume.truncated: the counts are of what is really stored, and the flag says the period asked about is longer than the period kept. An explicit window (including window_days) is recomputed rather than memoized, so it is subject to the read budget; the default no-window call is not
GET /messages {messages, next_cursor} — the message index. Filters: mailbox, sender_email, sender_root_domain, campaign_id, link_domain, attachment_sha256, verdict[], state[], direction[], lane, user_reported, min_score, q, since, until, cursor, limit. q is a free-text match evaluated 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; it is capped at 512 characters; and, unless it rides one of those index filters, it is subject to the read budget
GET /messages/{msg_uuid} {message, mdm, mdm_source} — the index row, the full signal rationale, the action timeline, and the Message Data Model. mdm_source is stored (the model the collector judged with, enrichments included) or eml_reparse (a fresh parse of the original bytes, no enrichments). mdm_unavailable_reason replaces the model when neither is available. message.judged_via is live or backfill — a backfilled message is judged and deliberately emits nothing and acts on nothing, so it can carry a malicious verdict beside an empty action timeline (see the historical backfill); an absent or null value means the read did not carry the field and must not be read as live
GET /messages/{msg_uuid}/similar {messages, since} — recent messages sharing at least one clustering key, each with the matched_keys that matched, plus the lookback window that was searched. Candidates, not a cluster
GET /messages/{msg_uuid}/revisions {revisions, revisions_truncated} — one message's whole verdict-revision history, oldest first: who decided (actor, mode), when, the structured rationale, and the prior state each one displaced. The first revision's prior is the engine's own verdict and the pack version that produced it. Not paginated — revisions are few by nature — but an optional limit is accepted and revisions_truncated reports the pathological history that exceeded the backend's ceiling. Gated on mailsec.get: a revision is the product's structured record of a decision about a message you can already open
GET /actions/bulk/{bulk_id} The running truth of a bulk remediation — see Bulk Remediation. An unknown bulk id, or an ordinary action_id passed here, returns a typed not-found rather than a partial answer
GET /campaigns {campaigns, next_cursor}. Filters: state[], verdict[], min_members, since, until, cursor, limit. Every campaign has at least two members, so min_members only narrows past that; values below 2 have no effect
GET /campaigns/{campaign_id} {campaign} — span, membership, verdict, and the keys that bound the messages together
GET /reports The user-report queue. Params: status[] (open, triaging, resolved), oldest_first, cursor, limit
GET /reports/{report_id} One report: who reported it, the message they reported, the original once located across the tenant's mailboxes, and its triage state
GET /senders/{key} The accumulated profile for one correspondent. key is qualified (email:someone@corp.example or domain:corp.example) or a bare address or domain. A key with no profile says so explicitly rather than returning a zeroed profile
GET /actions/{action_id} {action} — one audit entry expanded, including the JSON request payload the message timeline omits. For a raw-message download that payload carries the access justification. Gated on mailsec.get: reading who did what to a message is part of reading the product
GET /onboarding {scopes, steps, script} — the setup steps, OAuth scopes and gcloud commands for connecting a tenant, for rendering in a setup flow. Each step carries a console and, where verifiable, a verified_by naming the connection-test check that proves it. Params: provider (gworkspace default, or m365), project_id, sa_email, topic, subscription — supply them and the commands come back ready to run rather than templated
GET /tenant {confirmation, expires_in_seconds, warning} — the tenant-purge preview. Returns the warning describing exactly what a purge removes, and mints the single-use confirmation token that DELETE /tenant requires. It changes nothing. The token expires after expires_in_seconds (300). Requires Owner-level authority, not mailsec.get

Numeric and boolean parameters are validated. limit is 1-1000 (the backend serves 200 by default), min_score is 0-100, min_members is 0 or more, and user_reported and oldest_first take true/false. A value that is unparseable, out of range, or given more than once is refused with 400 and a response body naming the parameter, for example {"parameter": "limit", "error": "limit: \"all\" is not an integer"}. It is not silently dropped, so a filter you sent is always a filter that was applied.

lane selects where a message was judged: live for ordinary incoming mail or backfill for the initial history walk. Omit it to include either. It works with time-window, verdict, sender_root_domain, link_domain and attachment_sha256 queries. A lane cannot be combined with mailbox, sender_email, or campaign_id; those combinations return a non-retryable typed refusal with error_code: "lane_unsupported" and name the conflicting_dimension rather than returning an unfiltered page.

GET /messages/{msg_uuid}/eml

The justified raw download. Requires mailsec.get and mailsec.get.eml, and the justification query parameter is required.

Param
justification Why these bytes are being accessed. Recorded against your authenticated identity in the organization's action audit and retained for 400 days — a failed attempt is recorded too. Stored verbatim; the backend enforces a minimum and a maximum length and refuses an over-long reason rather than truncating

Raw copies expire with their retention lane — message_days (up to 35) for the message index, flagged_days (up to 400) once a message is flagged, see retention — after which this returns a typed expiry error while the index row stays readable.

Every attempt is telemetry. Served or refused, each call emits an EMAIL_ACTION event with action: get_eml on the connection's sensor, carrying the actor, the message, the mailbox, the stated justification, and — on a served download — the bytes handed over. That is what makes the download alertable rather than merely recorded: see Detections & Verdicts for a rule that fires on volume. A refused attempt carries result: refused and a refused_reason:

refused_reason Meaning
permission_denied The caller holds mailsec.get but not mailsec.get.eml
quota_exceeded The organization's download budget for the window is spent
quota_unavailable The budget could not be evaluated (a 503, not a 429 — nothing was exceeded)
justification_missing / justification_too_short / justification_too_long No usable reason was supplied
message_not_found The msg_uuid matched no indexed message
eml_never_stored The message exists but no raw copy was written at ingest
eml_expired The raw copy aged out of its retention lane
read_failed The object is there and could not be read
eml_store_not_configured This deployment has no raw-message store
internal_error The service could not complete the read (an index-store failure, not an object failure)

The response's audited block echoes the action_id, the recorded actor and justification, and event_emitted — which is false when the organization has no live mail connection to ship the event on. Expand the action_id through GET /actions/{action_id} to read the justification back.

This route is rate-limited, and it is the only one that fails closed

Two budgets apply, both per rolling hour:

Budget Limit
Per API key (or user) per organization 120 downloads
Per organization, across every key 600 downloads

Exceeding either returns 429 with a body naming the budget. The organization-wide refusal is recorded in the action audit and emitted as an EMAIL_ACTION with refused_reason: quota_exceeded.

These budgets fail closed: if they cannot be evaluated, the download is refused with a 503 rather than served, and the refusal is recorded against the organization as refused_reason: quota_unavailable — in the audit trail, not in the response body, which carries only error. A budget that cannot be counted is not a budget, and this is the one route that hands original message bytes out of the platform.

Two reads are bounded as well — see Read budgets — but in the opposite direction and for a different reason. Those bound cost, not access, so they deliberately fail open: if the budget cannot be evaluated the read is served, because a counting outage must not take an organization's own dashboard down. This route is the only one where "we cannot count, so we refuse" is the safe answer.

Every other Email Security route returns the product's view of a message — the index row, the verdict, the parsed model — and reading those in bulk is what a dashboard does. This one returns the message, so a legitimate key doing it in bulk is exfiltration. There is no bulk EML export route, and the limits are sized for an analyst working a queue rather than for a scrape.

DELETE /tenant

The tenant purge. It 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, link-detonation results, and the organization's provider connection and policy configuration — and it stops the mail connections at the provider so no further notifications arrive. It cannot be undone, and there is no smaller scope than the whole organization.

Both this route and GET /tenant require Owner-level authority: mailsec.act, billing.ctrl and user.ctrl together — the same three permissions deleting the organization requires. There is no separate "owner" permission.

The confirmation token comes from GET /tenant, which returns the warning text and changes nothing. The token is single-use and expires 5 minutes after it is minted, so the destructive call cannot be replayed, and cannot be reached without the warning having been served first.

Param
confirmation Required. The token minted by GET /tenant. A token that has expired, has already been spent, or was minted for another organization is refused
reason Optional free text, 1024 characters maximum. Recorded in the organization's audit log next to your authenticated identity. An over-long reason is refused rather than truncated

The response reports what was removed rather than flattening it into success:

Field
complete true when nothing was left behind. false means part of the purge did not finish and the call should be repeated
objects_deleted Stored raw messages removed
mdms_deleted Parsed message copies (Message Data Models) removed
detonation_results_deleted Link-detonation results removed
detonation_results_skipped Link-detonation results left in place — reported rather than quietly counted as deleted
objects_failed Stored items that could not be removed. Any non-zero value is a reason complete is false
tables_purged The list of the product's data sets that were cleared, named so a partial purge shows which ones were reached
subscriptions_stopped Provider notification subscriptions stopped, after which the provider sends nothing further
subscriptions_failed Subscriptions the provider would not stop
mailboxes_walked Mailboxes visited while stopping notifications
provider_records_deleted Email Security provider connection records removed
policy_records_deleted Email Security policy records removed
connections_unreachable Connections that could not be reached at all — a revoked credential or a tenant that is already gone. Their data is still purged; only the provider-side stop could not be confirmed
rows_remained Rows still present when the pass ended. Non-zero means re-run

The call is re-runnable. A purge that returns complete: false has deleted whatever it could, and repeating it deletes what remained — nothing is double-counted and nothing is skipped for having been attempted. Mint a fresh confirmation token for each attempt: the previous one was spent.

Deletion also happens without this route being called — 30 days after an organization unsubscribes from Email Security, and immediately when the organization is deleted. See Data retention and deletion.

Read budgets

Some reads on this surface recompute something rather than serving a cached or seekable answer, and those are bounded per organization. There are two separate budgets, because the reads they cover are expensive in different resources: the two below are measured in database work, and the rule replays further down are measured in stored messages re-read. They are counted separately, refill on different clocks, and carry different rate_bucket values — so do not retry one on the other's advice.

The query budget

Two reads recompute a query rather than serving a cached or seekable answer.

  • GET /coverage with an explicit window — a since/until pair, or a window_days. With no window at all the answer comes from a short-lived server-side memo and is not budgeted; naming a window computes the period from scratch, which is a grouped scan of everything in it plus a bounded latency sample.
  • GET /messages with q, when the search is a walk — the free-text filter is matched row by row against the subject (raw and normalized) and the sender of every candidate, so its cost follows how much of the index is walked rather than how many rows come back. A q accompanied by mailbox, sender_email, campaign_id, link_domain or attachment_sha256 is an index lookup instead and is not counted at all. A bare verdict does not exempt it: the verdict index is keyed by verdict then time, so verdict=benign looks up a partition that is, for most organizations, all of their mail.

The budget is 7,200 of each per organization per hour, counted across every credential in the organization and decaying in one-minute steps. It is sized well above interactive use: the console's Overview sends no window and is never counted at all, and eight analysts searching continuously for an hour without pause comes to about 40% of it.

Over the budget the request answers 429 with:

{
  "error": "the email-security read budget for this organization (7200 in 1h0m0s) is spent for \"message_search\"; …",
  "rate_bucket": "mailsec_read",
  "route": "message_search",
  "quota": 7200,
  "period": "1h0m0s"
}

rate_bucket is mailsec_read, which is what distinguishes this refusal from the raw-download budget on GET /messages/{msg_uuid}/eml — both are 429s on this surface and they mean different things. route is coverage_window or message_search. A Retry-After header carries the number of seconds after which capacity can have returned (60 — the decay step, not the whole hour), and X-RateLimit-Quota / X-RateLimit-Period restate the budget as 7200 and 3600. The three headers are in seconds; the body's period is the same window written as a duration, so do not parse the two the same way.

If a read is refused, the two cheapest ways to get it served are to drop the coverage window (the default period is memoized) or to add a mailbox, sender_email, campaign_id or IOC filter to the search, which makes it an index lookup rather than a walk and takes it out of the budget entirely.

The replay budget

Two POST routes are reads as well, and they are the most expensive ones here.

  • POST /rules/backtest — replaying a candidate rule does not query an index, it re-reads your mail. For every message in the window it fetches the stored original, decrypts it, decompresses it, parses it back into the Message Data Model and evaluates the rule against it. Its cost therefore follows how many messages are in the window, not how many the rule matches.
  • POST /hunts — a retro-hunt is the same shape over a wider window. It is budgeted now, before it serves, so that the budget is not a change of contract later. See Registered, but not implemented yet.

This budget is counted in messages re-read rather than in requests, and one call is charged the most it could re-read (2,000 — the backtest's own scan bound), because the platform cannot know how much of your window it will walk until it has walked it. The organization's allowance is 12,000 messages re-read per 10 minutes, decaying in one-minute steps, which is 6 rule backtests per 10 minutes across every credential in the organization.

That is sized for authoring a rule — write, backtest, read the report, adjust — and not for a loop. It is deliberately tighter than the query budget above: a single backtest can occupy a datacenter's mail-reading capacity for tens of seconds, where a coverage recompute is a bounded query.

Over the budget the request answers 429 with:

{
  "error": "the email-security read budget for this organization (6 in 10m0s) is spent for \"rule_backtest\"; …",
  "rate_bucket": "mailsec_post_read",
  "route": "rule_backtest",
  "quota": 6,
  "period": "10m0s"
}

rate_bucket is mailsec_post_readnot the mailsec_read above, and the difference matters to a client: the two budgets refill on different windows, so a client that treated them as one would retry a backtest on advice that does not apply to it. route is rule_backtest or hunt_create. Retry-After is 60 (the decay step), and X-RateLimit-Quota / X-RateLimit-Period restate the budget as 6 and 600 seconds.

Like the query budget, this one bounds cost and not access, so it fails open: if it cannot be evaluated the backtest is served.

If a backtest is refused, waiting is the answer — there is no narrower shape that takes it out of the budget, because the charge is the same whatever window you ask for. Asking for a narrower window does make the call itself cheaper and faster, which is worth doing for its own sake.

Writes

Route Does
POST /messages/{msg_uuid}/actions Perform a typed action on one message. Body: action (quarantine_message, trash_message, move_to_spam, restore_message, banner_message, unbanner_message), optional force (boolean; see alert-only overrides), optional reason, optional attempt (idempotency token — omit to collapse onto the existing attempt). banner_message uses the organization's own banner, rendered from its mailsec_policy record of type banners; the body's banner field is deprecated and ignored and will be removed. Requires mailsec.act
POST /campaigns/{campaign_id}/actions Sweep a campaign. Same body plus confirm. Without confirm this previews and changes nothing, returning the member ids, the distinct mailboxes, the counts and a confirm token derived from that exact member set. With confirm it executes exactly that set; a campaign that grew since the preview is refused. Capped at 500 members. reason is recorded on every member's audit row and on the sweep's own row (action_id in the response); attempt (bounded at 128 characters, refused not truncated) mints a new row per member, so a deliberate retry is recorded beside what it retried instead of over it. Neither is part of the confirm token. Requires mailsec.act
POST /actions/bulk/execute Execute a previewed bulk remediation. Returns a bulk_id immediately and the provider work proceeds in the background. Requires mailsec.act. See Bulk Remediation
POST /reports/{report_id}/resolve Record a triage outcome. Body: disposition — one of true_positive, false_positive, benign. Resolving an already-resolved report succeeds and reports already_resolved, so two analysts clicking at once is not an error. Requires mailsec.set
POST /reports/{report_id}/reopen Put a resolved report back in the queue — see POST /reports/{report_id}/reopen. Requires mailsec.set
POST /messages/{msg_uuid}/verdict Re-judge one message — see POST /messages/{msg_uuid}/verdict. Requires mailsec.act
POST /connections/{record}/test Probe a configured connection and report each requirement independently: the credential, each scope, a real directory read, and — for Google Workspace — the notification subscription and topic. Every check carries id, name, required, status, and on failure detail and remediation. A failed optional check leaves ok true. Body: include_watch (Workspace only; the one probe with a side effect — it establishes an idempotent, self-expiring push watch). Takes a record name, not a credential. Requires mailsec.act

POST /messages/{msg_uuid}/verdict

Record a re-judgement of one message, replacing the class the engine stamped.

Field
verdict Required. malicious, suspicious, graymail, benign or unknown. unknown is an honest abstention that escalates to a human queue. error is refused — it means judgement itself failed, which is an engine fact nobody decides
mode Required. Which seat decided: analyst for a person, ai for an autonomous triage agent calling with its own organization credentials. auto is refused; that is the scorer's own path and it does not override itself
rationale Required. A non-empty list of short strings. A class with no reason is a naked verdict, and the same explainability contract applies to a revision as to the engine. Bounded: at most 10 bullets of 280 characters, clipped on a character boundary with rationale_truncated set, rather than the verdict being refused over a long explanation
score Optional. Omitting it keeps the engine's score beside the new class

Who revised is stamped from your authenticated identity and is never read from the body. mode names the seat, not the person: a caller misstating it can only do so beside an actor it did not choose, where the two disagree visibly.

Requires mailsec.act, not mailsec.set, and that is the one permission choice on this surface that is not read off the "does it touch a mailbox" line. A revision can move the message into the flagged set, which promotes its evidence from the 35-day lane to the 400-day one — a ratchet with no demotion — and it ships an EMAIL_VERDICT that fires every matching D&R rule. That is the product doing something on the organization's behalf, and mailsec.act is the single grant an operator revokes to stop an autonomous caller doing it.

Response field
applied Whether a revision was written
already_current true when the message already carried this class, score and mode
revision_seq The revision this call produced — or, on a no-op, the one that already says it
prior What was displaced: verdict, score, mode, engine_version
newly_flagged Whether the message entered the flagged set
flagged_mirrored Whether its 400-day evidence row was updated in the same transaction
event_emitted, retained Whether the EMAIL_VERDICT shipped, and whether the evidence reached the retained lane
rationale, rationale_truncated, actor, verdict, mode, decided_at The revision as recorded

A no-op is a success, not an error

Re-recording the class, score and mode a message already carries changes nothing: the response is 200 with applied: false and already_current: true. Distinguish the two through the body, not the status code. Re-wording the rationale alone is not a change; a person confirming an agent's call is one, because the mode moves.

The revision is durable before the event and the evidence promotion are attempted, so a failure to ship telemetry comes back as a retryable error whose body still says applied: true. The recorded judgement is never silently discarded, and the event is never claimed when it did not ship. This route is not retried on your behalf: one billable verdict change must not become two because a response was lost — poll GET /messages/{msg_uuid}/revisions instead, or re-send it yourself.

POST /reports/{report_id}/reopen

Put a resolved user report back in the queue. Its status returns to open and it is worked again.

Takes no request body. Who reopened is stamped from your authenticated identity and is deliberately not supplied by the caller.

This is the escape hatch for a resolution nobody made: a report from an automated sender is born resolved and attributed to system:automated-sender, and that classifier is only defensible because it is reversible. It serves a wrong AI resolution and an analyst's mis-click equally; none of the three is special here.

The resolution columns are deliberately kept — only the status moves — so the row still reads "previously resolved benign by system:automated-sender" rather than erasing the very thing being disputed.

Reopening a report that is already open or being triaged succeeds and reports already_open, so two analysts clicking at once is not an error; reopened_from names the state it came out of. An unknown report id is an error rather than a silent success, because this route names one specific row to change.

Requires mailsec.set — exactly like the resolve it undoes. An analyst who can close a report must be able to reopen one, or a mis-click is permanent.

Read-only POSTs

Route Does
POST /analyze Parse a raw message into the Message Data Model and judge it with the organization’s enabled dr-mail rules and resolved scoring policy. Nothing is ingested or stored: no index row is written, no raw copy kept, and the organization's mail history is unchanged. Body: eml_b64 (preferred) or eml, plus optional org_domains and direction — one of inbound, internal, outbound; anything else is refused with a 400 rather than analysed, because many default detections apply to inbound mail only and a mistyped direction would silently answer a lower verdict. Omit it to judge with no direction. Tenant context it cannot have — your sender history, your VIP list — is named explicitly in the payload rather than silently missing. Requires mailsec.get
POST /actions/bulk/preview Preview a bulk remediation over a caller-supplied selection: reports each message's current state and the distinct-mailbox blast radius, and mints the confirm token derived from that exact selection. Nothing is changed and no job is created — it is a POST only because up to 500 message ids do not belong in a query string. Requires mailsec.get, like the campaign preview it mirrors. See Bulk Remediation
POST /rules/validate Compile a candidate dr-mail rule and report its errors without saving it. Body: rule (object), optional rule_id. Runs the same validator the dr-mail Hive applies on save, including lookup existence checks when the API's Hive metadata access is configured. Response blocks receive shape and size checks; full response compilation happens in the collector. See Custom Rules. An invalid rule is a 200 carrying valid: false and the reason, not an error response. Requires mailsec.get
POST /rules/backtest Evaluate a candidate pre_verdict rule over re-parsed stored messages. Original pipeline enrichments are not reconstructed. Lookups use current records when the Hive resolver is configured; post_verdict rules are refused. See backtest limitations. Body: rule, optional rule_id, since, until. Every response carries a coverage_note and counts what it could not examine (skipped_no_raw, skipped_unparse, truncated). precision is null — not 0 — when nothing it matched has an analyst disposition yet. Every message in the window is re-read from storage, which makes this the most expensive read on the surface: it is subject to the replay budget. Requires mailsec.get

Registered, but not implemented yet

Three retro-hunt routes appear in the public OpenAPI document. None of them is served.

  • POST /hunts — replay a detect block or an LCQL query over the message history
  • GET /hunts/{hunt_id} — a hunt's progress and its matches
  • POST /hunts/{hunt_id}/remediate — act across everything a hunt matched

They are registered now so that the URLs and their permission gates are frozen before any client ships against them, and so that calling one gives you a refusal rather than a 404 you cannot tell from a typo. The permission gates are live and are the same ones the served routes use; what is missing is the replay engine behind them.

POST /hunts is already counted against the replay budget, even while it refuses. That is deliberate: the budget exists to bound what a hunt will cost when it serves, and adding it on the day the engine lands would be a change of contract for clients that had already shipped.

Do not build against them yet — but the refusal itself is a contract you can branch on. All three answer a 400 carrying the same typed, non-retryable not_implemented. GET /hunts/{hunt_id} answers:

{
  "error": "get_hunt is not implemented until M7 (needs: the replay/retro-hunt engine)",
  "retry": false,
  "data": {
    "error_code": "not_implemented",
    "rpc": "get_hunt",
    "milestone": "M7",
    "needs": "the replay/retro-hunt engine"
  }
}

error_code is the only field to branch on. rpc names the route that refused and differs per route; needs is prose and also differs — the remediation route answers the replay/retro-hunt engine + the remediation executor. The error text is meant for a human reading a log and is free to change; the code is not. milestone is an internal build-order label, not a published schedule or a commitment to a date.

retry is false and means it: an unimplemented route does not become implemented inside a retry budget, so a client that retries pays for the same answer again. Branch on error_code to hide the feature today, and the same check keeps working when the engine lands.

Until they serve, mail hunting is LCQL over EMAIL_MESSAGE — which is what the console's Hunt screen runs — and acting on what you find is bulk remediation over the message ids you selected.

Explicit override in alert-only mode

For POST /messages/{msg_uuid}/actions, POST /actions/bulk/execute, and POST /campaigns/{campaign_id}/actions, send force: true to explicitly override an organization's alert-only mode. These paths use the MailSec organization prefix documented above. Without the override, execution is withheld and the response reports force_required: true when an action was withheld by the mode. For asynchronous bulk work, inspect job status and per-message outcomes.

{"action": "quarantine_message", "force": true}

Only a JSON boolean true is accepted as consent. The override does not change policy, bypass mailsec.act or provider capability checks, or replace a bulk or campaign confirmation token. Withheld and forced attempts have separate audit identities, so the original refusal remains visible. See enforcement.

Action results

Action routes report the outcome honestly rather than flattening it into success/failure:

result Meaning
ok The provider was changed
skipped The desired state already held; no provider write happened
alert_only 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

A campaign sweep returns attempted, succeeded, skipped (a subset of succeeded: members already in the target state, which cost no provider write), alert_only, a per-member failed map, and action_id — the sweep's own audit row, readable through GET /actions/{action_id}, carrying the operator's justification and the counts. It does not abort on the first error. If the collector begins shutting down mid-sweep (or the caller's request ends), the sweep stops between members and returns a retryable error carrying attempted, selected, action_id and interrupted_by (drain or caller); the audit row settles pending, and re-running the same confirmation finishes it.

Telemetry event contract

The routes above are the read/write surface. The event surface is ordinary LimaCharlie telemetry on the email platform, delivered to the default D&R target and to every configured Output, and it is a contract in its own right: these types and JSON paths are frozen and additive-only, because a year of stored telemetry and every customer rule is keyed on them.

Event Cardinality
EMAIL_MESSAGE Exactly once per message, at ingest, immutable. Carries the full Message Data Model
EMAIL_VERDICT Once per verdict decision. revision/seq: 0 with revision/mode: auto is the rule pack's own verdict, emitted at ingest immediately after that message's EMAIL_MESSAGE; seq: 1… is one per override (analyst, ai, detonation)
EMAIL_ACTION Once per remediation outcome, including failures and skips
EMAIL_USER_REPORT Once per message that reaches the abuse mailbox
EMAIL_INGEST_ERROR Once per message that could not be fetched or processed

Two consequences worth stating plainly:

  • The verdict appears twice at ingest — inside EMAIL_MESSAGE under event/verdict/… and again as the seq 0 EMAIL_VERDICT under event/revision/…. That is deliberate. It is what lets a rule about verdicts be written once instead of once for the engine and once for every later override, and it is why EMAIL_VERDICT can be treated as the complete verdict stream for a message.
  • The seq 0 event is not a revision record. GET /messages/{msg_uuid} reports revision_count: 0 and an empty revision history for a message nobody has overridden. The rule pack's verdict is on the message itself, not in its history of disagreements.

The EMAIL_VERDICT body is documented field by field in Events & Automation.

Attribution

actor and source on every audit row are stamped by the server from the caller's verified claims — a user token keeps its stable user id, and an authorized organization API key is attributed as api-key:<key-id>. A request body cannot supply by, actor or source: an audit trail is only worth having if it names who really asked.

SDK and CLI

The Python SDK exposes the same surface, and the CLI wraps it — see Command Line Interface. Both are generated against these routes, so anything documented here is reachable from either.