Skip to content

API Reference

Generally available

Email Security is generally available. Subscribe to the Email Security extension in your organization to purchase and enable it. Paid usage costs \(1 per protected mailbox per month**, billed daily at **\)1/30 per mailbox-day. Free-tier organizations receive a limited 14-day trial. See security product billing for pricing, trial limits and upgrading.

CLI examples require LimaCharlie CLI 5.7.0 or later, which includes the mailsec commands. Install or upgrade, then check:

python -m pip install --upgrade limacharlie
limacharlie mailsec --help

Credential-file examples also require jq.

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. For a 403, check both the subscription and the permission named in the error. 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 /submissions {enabled, available, submissions, next_cursor} — the samples your organization copied to LimaCharlie. Filters: category (missed_threat, false_positive, other), since, until (RFC 3339), limit (1-200, default 50), cursor. enabled is whether the organization opted in and available is whether the datacenter has a submissions store; both are always present. See Sample Submission. Requires mailsec.get
GET /submissions/{submission_id} {submission, reviews, reviews_truncated} — one submission and up to 200 review-access timestamps (never who); counts and latest-review time include all accesses. An unknown id is not an error: it returns {"submission": null, "reviews": []}. Requires mailsec.get
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
audit_write_failed The access record for this download could not be written (or an earlier record for it could not be read), so nothing was served
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

POST /rules/backtest re-reads stored originals, decrypts and decompresses them, parses them into the Message Data Model, and evaluates the candidate rule. Its cost follows how many messages are in the window, not how many match.

Historical mail searches use the platform's ordinary LCQL search service, with its own search limits. See Historical hunting.

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. 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, release_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; its optional text (plain text, at most 512 characters; refused on any other action) replaces the wording for that one banner. No caller supplies HTML. Requires mailsec.act
POST /messages/{msg_uuid}/actions with submit_sample or withdraw_sample Copy one message to LimaCharlie, or withdraw that copy. submit_sample requires category (missed_threat, false_positive, other) and a reason of 1-1024 characters; withdraw_sample takes an optional reason. Only a person can run either: D&R rules, automations and the AI agent are refused. Submission requires organization opt-in; withdrawal remains available after opt-out or provider disconnect. See Sample Submission. Requires mailsec.act
DELETE /submissions/{submission_id} Withdraw a submission: hard-deletes the stored copy and its metadata and returns {withdrawn: true, submission_id, action_id}. An unknown or already-deleted id returns {withdrawn: false, submission_id} with no action_id; an expired id is still cleaned up if metadata remains. 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 malicious, spam, graymail, benign, simulation. 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
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, and the mode, are stamped from your authenticated identity and are never read from the body. A signed-in user is recorded as analyst and any API key as api, which includes the Email Security extension acting for a D&R rule. There is no mode to send, and one in the body is ignored. Every caller that holds mailsec.act has the same effect. Older records can read ai.

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 API key'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 /banner/preview Render a candidate banners policy exactly as recipients would see it, without saving. Body: banner (the record's fields, without policy_type), optional verdict (which variant to preview) and optional text (an action's wording). Answers 200 with valid: true, the rendered html, supported colors and field limits, or valid: false and the validator's reason. Requires mailsec.get
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

Historical hunting

The console's Email Security → Hunt screen searches LCQL over EMAIL_MESSAGE using your organization's ordinary historical-event search permissions. Use the guided filters or open the query in the Query Console. The supported API workflow is the platform's search API; there is no separate MailSec hunt job API.

Search matches contain stable message UUIDs. To act on selected matches, use bulk remediation: preview the exact selection, review the current message states and affected mailboxes, then explicitly confirm execution. The preview is read-only. A search result alone does not authorize remediation.

Historical events follow the organization's telemetry retention. The message index and raw EML follow Email Security retention, which can be shorter. A historical match may therefore remain searchable after its current message row or raw copy has expired; the bulk preview reports whether that message can still be resolved for action. Messages processed only by the initial historical backfill have no EMAIL_MESSAGE event and are outside this LCQL search. Use the message index to inspect retained backfill rows.

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, api, detonation, and ai on older records)
EMAIL_ACTION Once per remediation outcome, including failures and skips
EMAIL_USER_REPORT Once per message that reaches the abuse mailbox
EMAIL_DISPOSITION Independent analyst/SOAR disposition changed or cleared; carries actor, source, note, server timestamp, prior value, and sequence
EMAIL_REPORT_RESOLVED Report resolved; carries report/message identities, recorded disposition and resolver, and mailbox when available
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.

Provider quarantine and release activity

GET /provider-quarantine and GET /release-requests require mailsec.get. They accept scalar connection, status, since, until, cursor and limit (1–1000). Delivery status values are quarantined, filteredAsSpam and failed; release status values are requested, released and denied. Responses include independent per-feed coverage; an empty list with not_granted, pending, stale or error coverage is not proof of zero blocked messages. See Provider Quarantine for the row contract.

Disposition and release

Route Body and behavior
POST /messages/{msg_uuid}/disposition disposition from the five-value vocabulary, optional note; or clear: true. Requires mailsec.set.
POST /messages/dispositions Same decision plus 1–500 unique msg_uuids. Returns per-message results, including partial failures. Requires mailsec.set.
GET /messages?disposition=<value> Filter by one disposition, or none for no current label.
POST /messages/{msg_uuid}/actions with action: release_message Restore, benign verdict revision, benign disposition, history repair. Optional reason, force, attempt. There is no mode: the recorded mode comes from your credential, as for a verdict revision. Requires mailsec.act.
POST /reports/{report_id}/resolve disposition; optional remediation with scope (message, group or campaign), action and optional confirm, reason, force, attempt (a required UUID for group scope, reused for preview, confirmation and polling; see Message Groups). Without confirm, remediation is previewed and the report stays open. Pure resolution requires mailsec.set; remediation also requires mailsec.act.

The five dispositions are malicious, spam, graymail, benign, and simulation. Disposition is separate from verdict. Message detail includes disposition_info: {disposition, note, actor, source, ts} and disposition_seq. Source is analyst, api, extension, report, or ai; attribution and time are assigned by the service. Bodies cannot forge them.