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 emptynext_cursoris 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 /coveragewith an explicit window — asince/untilpair, or awindow_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 /messageswithq, 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. Aqaccompanied bymailbox,sender_email,campaign_id,link_domainorattachment_sha256is an index lookup instead and is not counted at all. A bareverdictdoes not exempt it: the verdict index is keyed by verdict then time, soverdict=benignlooks 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_read — not 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 historyGET /hunts/{hunt_id}— a hunt's progress and its matchesPOST /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.
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_MESSAGEunderevent/verdict/…and again as theseq 0EMAIL_VERDICTunderevent/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 whyEMAIL_VERDICTcan be treated as the complete verdict stream for a message. - The
seq 0event is not a revision record.GET /messages/{msg_uuid}reportsrevision_count: 0and 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.