Skip to content

Detections & Verdicts

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.

Every message gets exactly one verdict, and the verdict always carries its reasons. This page explains how the reasons are produced, what the rules can see, and how to tune it.

The verdict

The optional mail_type classification describes apparent purpose, such as marketing or correspondence, independently of this verdict. A purpose label does not establish safety or consent. mail_type/type=unknown is a classification abstention; an absent mail_type means not classified. See Messages & Triage for viewing it and the rule reference for its paths.

Verdict Meaning
malicious Score at or above the malicious threshold
suspicious Score at or above the suspicious threshold
graymail Bulk or marketing mail: neither an attack nor wanted
benign Below the suspicious threshold, and nothing said "graymail"
unknown No judgement was reached
error Judging failed

The verdict object on a message carries:

Field Meaning
verdict The class above
score 0–100
top_signals Up to five contributing rules, heaviest first, each with rule_id, name and weight. This is the "why this verdict" block
matched_signals Every rule id that matched, including suppressed ones — the hunting surface
tags The deduplicated, sorted tags of the rules that actually contributed
engine_version SHA-256 fingerprint of the scoring rules, scoring policy and engine build
decided_at When
mode Who last decided: auto (the rule pack), analyst (a signed-in person), api (an API key, which includes a D&R rule acting through the extension) or detonation (link detonation). Older records can read ai. See Revising a verdict
campaign_id The campaign this message was clustered into, if any

A number alone is never the answer

A non-benign verdict always populates top_signals. The console renders it as Why this verdict in the message drawer, and the API returns it on both the index row (the single heaviest signal) and the detail response (the full list). A score with no explanation would not be actionable and is not offered.

The verdict is also an event

Every verdict this product reaches is emitted as an EMAIL_VERDICT event, so a rule that acts on verdicts is written once:

revision/seq revision/mode What it is
0 auto What the rule pack decided, emitted at ingest immediately after the message's EMAIL_MESSAGE
1, 2, … analyst A signed-in person overrode it
api An API key overrode it: a script, the AI triage agent, or a D&R rule through the extension
detonation Link detonation found something at the other end and overrode it

Records written before the mode came from the credential can also read ai.

The seq 0 event repeats a verdict that is already inside EMAIL_MESSAGE, and that duplication is deliberate: without it, "tell me when a message is judged malicious" would be two rules against two paths on two event types — one for the engine's opinion and one for everything that happened after it — that you would have to keep in agreement forever.

EMAIL_MESSAGE is still emitted once per message and is still immutable. The verdict history is the sequence of EMAIL_VERDICT events, which is what lets a hunt reconstruct what was known at any point in time.

The seq 0 event is an event and nothing else. It is not an entry in the message's revision history, and a message nobody has overridden reports zero revisions in the API and the console.

See Events & Automation for the payload and Custom Rules for a rule that uses it.

Revising a verdict

The engine's call is the first word, not the last. A person, an API key, an AI triage agent or a D&R rule can replace it, and the replacement is appended rather than written over the top.

limacharlie mailsec message revise <msg_uuid> \
  --verdict malicious --rationale "confirmed credential harvest" --oid $OID
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 reasons. A class with no reason is a naked verdict, and the same explainability contract applies to a revision as to the engine
score Optional. Omit it and the engine's score stays beside the new class, rather than a made-up number landing in the column a backtest reads

rationale is bounded rather than refused: at most 10 bullets of 280 characters, clipped on a character boundary with rationale_truncated set. A verdict is not thrown away over a long explanation.

Who revised, and the mode, are stamped from your authenticated identity and are never read from the request. A signed-in person is recorded as analyst and any API key as api, including the Email Security extension when a D&R rule revises the verdict. There is no mode field to send. The API ignores one in a request body, and the extension's revise_verdict action rejects it as an unknown parameter.

Every caller that holds mailsec.act has the same effect: the verdict, the sender-history credit or repair, and the way a revision settles a message that was flagged only because a user reported it are identical for a person, a key and a rule. The mode is provenance for audit, not a level of authority.

A no-op is a success, not an error

Re-recording the class, score and mode a message already carries changes nothing: applied: false, already_current: true, and revision_seq names the revision that already says it. Re-wording the rationale alone is not a change. A person confirming an API key's call is one, because the recorded mode moves from api to analyst.

Revising takes mailsec.act, not mailsec.set — the only place on this surface where the permission is not read off the "does it touch a mailbox" line. A revision can move a message into the flagged set, which promotes its evidence from the 35-day lane to the 400-day one, and that promotion is a ratchet with no demotion. It also emits an EMAIL_VERDICT that fires every matching rule. That is the product doing things on the organization's behalf, and mailsec.act is the single grant an operator revokes to stop an autonomous caller doing them.

The decision modes

mode Who
auto The rule pack, at ingest. Only the engine writes this one
analyst A person signed in, through the console or the CLI
api An API key: a script, an AI triage agent, or a D&R rule acting through the extension
ai Legacy. Records from the time callers chose a mode. Nothing writes it now
detonation Link detonation came back with something the static pass could not know

The history

The revisions are an append-only sequence. Each entry carries its seq, the verdict and mode, the actor, decided_at, the rationale, and the prior state it displaced — verdict, score, mode and engine version. The first revision's prior is the engine's own verdict, so the chain reaches back to what the pack originally said without a separate lookup.

limacharlie mailsec message revisions <msg_uuid> --oid $OID --output yaml

GET /messages/{msg_uuid}/revisions serves the whole chain, oldest first. It is deliberately not paginated — a message's revisions are few by nature — but revisions_truncated reports the pathological history that exceeded the backend's ceiling. It is gated on mailsec.get: a revision is the product's own structured record of a decision about a message you can already open, not the original bytes mailsec.get.eml guards.

The message drawer inlines the most recent revisions, which is what an analyst reads; the route exists for the rare message with more than the drawer inlines, and for an audit export that wants the chain entire. A message nobody has overridden reports revision_count: 0 and an empty history — the engine's own verdict is on the message, not in its history of disagreements.

Nothing is rewritten. EMAIL_MESSAGE still stands as the record of what the engine decided at ingest, the row carries the current answer, and each revision ships an EMAIL_VERDICT at the next seq.

Scoring

Each matching scoring rule carries a weight (1–100, how much this evidence is worth) and a confidence (0–100, how often it is right when it fires). The score combines them with diminishing returns rather than a sum:

score = 100 × ( 1 − Π (1 − wᵢ/100 × cᵢ/100) )

Rules with the same non-empty shared_fact contribute only their strongest weighted evidence to this formula. Rules without a group contribute separately; suppressed matches contribute nothing. Graymail rules use a separate lane.

Each signal removes a fraction of the remaining headroom. A sum would let five weak signals outscore one strong one and would need clamping at 100, which makes every heavily-signalled message look identical. One rule at weight N and confidence 100 scores exactly N, which is the identity every threshold is reasoned about against.

Threshold Default Where to change it
malicious_min 85 mailsec_policy/thresholds
suspicious_min 45 mailsec_policy/thresholds

malicious_min must stay above suspicious_min. Composed policy that inverts the pair is refused, and an inverted pair reaching the scorer any other way falls back to the defaults rather than making every suspicious message malicious.

The graymail lane

Graymail is a lane, not a score band. Rules classed graymail contribute no score at all; they set the verdict to graymail only when the score did not reach suspicious_min and no detection-class rule fired.

That last clause is the point: a newsletter that also carries a credential-phish link is a phish that happens to look like a newsletter, and filing it under "marketing" is filing it where nobody looks.

Exclusions

Exclusions are applied before scoring, so a suppressed rule contributes nothing to the score and never appears in top_signals. It still appears in matched_signals, so a suppression is auditable rather than invisible.

Exclusions can be scoped by rule id, sender address, sender domain or mailbox, require a written reason, and can carry an expiry — after which they become inert without anyone deleting them. See Policy Reference → Exclusions.

What the rules can read

Rules are standard D&R detect blocks evaluated against the Message Data Model — the parsed message — plus the enrichments the pipeline stamped onto it. Enrichments are fields in the message, so a rule reads them as ordinary paths. A replay of the emitted event can read its original stamps; the MailSec rule backtest instead re-parses stored EMLs and does not reconstruct those enrichments. See the Rule Reference for JSON fields, types, and presence rules.

The parsed message

headers (including the raw header list, decomposed sender and recipient addresses, and every domain and IP found), sender (with reply_to_mismatch, free-mail and disposable flags), recipients, subject, body (HTML and plain, extracted display text, thread segmentation into the current reply and previous quoted threads, hidden-text detection), links, attachments, auth (parsed SPF / DKIM / DMARC / ARC results with alignment) and hops (the parsed Received chain).

A message has two renderings, and your rules read both

A message usually carries its content twice — once as text/plain and once as text/html — and nothing in the mail standards makes the two agree. A sender who writes a decoy into one part and the real message into the other would otherwise choose what your rules get to read.

In the stored message (and so in the EMAIL_MESSAGE event), body/current_thread/text is the newest segment of both renderings: the plain one first, then the text a reader would see rendered from the HTML, separated by a blank line. The HTML half is left out when it is empty or when it already appears word for word in the plain part, which is the case for most ordinary mail. body/current_thread/visible_text is the same newest segment narrowed to the rendering a reader is shown: the text extracted from the HTML when the message has an HTML part, the plain part otherwise. Each rendering is also listed separately under body/current_thread/renderings, with its kind (plain or html), its text and its links.

In an Email Security rule (a dr-mail record, a managed rule, or a rule backtest), a rule that reads any body/current_thread/… path is evaluated once per rendering when the message has two. In each pass text and visible_text are both that one rendering's text, and links and renderings are narrowed to it too. The rule fires if any pass matches, and the signal records which renderings matched in renderings (see TopSignal). So you do not have to defend against a decoy yourself: a clause that switches a rule off ("fire on X unless the message also says Y") can only stand down the rendering it is found in, and the other rendering is still judged on its own.

Two things follow for a rule author:

  • Anything that reads the stored message rather than running as an Email Security rule, such as a D&R rule on the EMAIL_MESSAGE event, sees the combined text. Match there with substring or regex patterns rather than whole-value equality, since on a message whose two renderings differ the field says the same thing twice. A clause that switches such a rule off should read body/current_thread/visible_text, so that it cannot be satisfied by text in a part the reader is not shown.
  • These are matching surfaces, not display ones. To show a person the message, read body/html/display_text or body/plain/raw.

The quoted history is excluded from all of them — that is the point of current_thread — and it is available separately under body/previous_threads. body/current_thread/visible_chars is not split per rendering: it always counts the characters in the reader-shown text.

Enrichments

Path What it carries
enrichments/sender_profile This organization's history with the sender address: first_seen_ts, days_known, msg_count_30d, flagged_count_180d, prevalence (none / new / rare / common)
enrichments/domain_profile The same, keyed on the sender's registrable domain
enrichments/sender_domain The sender domain's registration age, from RDAP with a bounded global cache
enrichments/link_features[] Per link, aligned with links[]: domain, domain_age_days, popularity_bucket (top1k / top100k / top1m / unranked), in_urlhaus, mixed_script (a homograph label mixing writing systems), credentials_in_url (the https://apple.com@evil.example/ trick)
enrichments/lookalike vip_hit (display_name:<name> when the display name matches a VIP whose address does not, email:<addr> when the sender is the VIP), org_domain_distance and brand_domain_distance — edit distances against your own domains and known brands
enrichments/detonation What link detonation found at the other end of a link, when it ran: hops[] with each hop's resolved_ips, connected_addr and tls, a landing (effective_url, title, has_password_input, form_action_hosts, …), or a refusal (kind, reason). Added after the first verdict, so it is absent on a message that was never detonated — which is most of them
attachments[].explode Attachment explosion: recursive children with their own names, hashes, magic types and depth; archive (encrypted, file_count, max_depth_hit); vba (auto_exec, suspicious, hex_strings); qr[].url; ocr_excerpt; yara_matches; the scanners that ran

Attachment explosion is bounded — a per-message time budget and size and event caps — and can only ever fail toward "not scanned". explode.scanners names what actually ran for that attachment, so a rule that depends on a particular kind of evidence can tell "the scanner found nothing" from "that scanner did not run".

Absent is not benign

An enrichment that could not be resolved is absent, never a reassuring value. domain_age_days is missing when the registry lookup was unavailable, rate-limited or a cache miss — which is common — and a newly-registered-domain rule must therefore test presence as well as a threshold. Likewise popularity_bucket is empty when the lookup did not run, which is a different fact from unranked, a positive finding that the domain really is not in the list. A missing lookup must never become a suspicion.

The sender-history feedback loop, and why it is closed

Sender profiles are read before the message is counted, so the stamp answers "what did this organization know about this sender before this message arrived". Counting first would make msg_count_30d never zero and first contact indistinguishable from second contact.

More importantly, the profile's flagged_count_180d counter is only incremented for verdicts that flag independently of prevalence signals. Rules whose evidence is the accumulated history carry a prevalence tag, and the counter is computed with those rules removed.

The reason is a loop observed live before the contract existed: the sender-history rule alone can cross the suspicious threshold, and if a flagged verdict fed the counter, one false positive would re-flag that sender forever — each flag re-incrementing the counter that caused it, never decaying, and in enforce mode quarantining a legitimate sender silently. Counting only the independent lane makes a history rule an amplifier of other evidence and never of itself.

The default rules

LimaCharlie's default rules are installed into dr-mail when you subscribe. Vendor-tagged defaults receive later pack updates, preserving enabled/disabled choices. Disable an unwanted default; deleting it can let the next pack release recreate it. Copy or untag a rule before maintaining your own version. See default rule ownership. Email Security → Detection rules is the authoritative catalog for your organization: it shows the exact current conditions, weight, confidence, phase, tags and false-positive notes. Every default is editable, disableable and deletable.

Defaults cover impersonation, authentication and sender history, links, attachment threats, suspicious content, detonation evidence and graymail. They are ordinary D&R rules over the Message Data Model, not a separate engine. See Detection Rules for the format, IaC and explicit restoration.

Callback phishing and HTML smuggling

Two attack classes are invisible to a scanner that only looks at links and known-bad files, so the defaults read them from structure.

Callback phishing (telephone-oriented attack delivery) is an invoice, renewal or "your device is infected" notice whose only action is a phone number. The defaults read the number as a fact (PhoneNumbers) from the body, from the text of attached images, from numbers in a PDF, and from attached messages, and combine it with a call to action, billing vocabulary, how short the message is, and whether the sender looks odd (a free-mail address, a young domain, a failing DMARC result, a Reply-To elsewhere). A legitimate vendor's receipt carries the same words and a support number, which is why a sender oddity is required and an established sender is never read as a lure. The callback rules describe one observation and do not add up: a message that trips all of them scores as the strongest. A PDF's wording is not available to rules, so the PDF rule judges a short PDF by its shape and its numbers; numbers in a PDF are recognised for North American formats only.

HTML smuggling is a web page, often an .html or .svg attachment, that builds the real payload in the victim's browser. The parser scans every HTML-like attachment and HTML body in full and reports encoded data, the type that data decodes to, decoding and download primitives, redirects and password forms (HTMLIndicators). Defaults flag a page that decodes encoded data and saves it, a page whose encoded data is an archive or executable, a page that builds its own decoder, an HTML sign-in page delivered as a file, a tiny redirect page, an SVG that carries script, and a message body that runs a decoder. A single-file report or export tool that embeds data and offers a download button matches the same facts as a smuggling page and is scored as suspicious, not malicious, unless its data also decodes to a recognisable payload. Credential-page and tiny-redirect defaults require a browser-file extension; source templates and files with unconventional names can fall outside those two checks.

In managed pack 0.6.0, these 22 new rules carry explicit severity. Older managed rules currently use the informational fallback. Severity is independent of the verdict, so a malicious verdict from an older rule can still have informational severity.

Display-name brand impersonation ("PayPal Support" over an unrelated address), advance-fee and extortion text, voicemail and fax lures, free-hosting and open-redirector links, internationalised look-alike domains, OneNote files, locked PDFs with the password in the message, and web pages hidden inside archives from a stranger are covered by further defaults. Email Security → Detection rules shows every rule's conditions and false-positive notes.

A verdict's engine_version is a SHA-256 fingerprint of the scoring rules, resolved thresholds, exclusions, VIPs, threat-feed references and clustering policy, and linked parsing/enrichment library build. Changing rule content or scoring policy changes the fingerprint. It identifies the decision configuration; it is not a promise that an external lookup feed or other message enrichment is unchanged.

Outbound PII detections

The optional email DLP pack adds outbound detections for validated payment card numbers, IBANs and US Social Security numbers, plus a bulk detection when any one kind has at least ten distinct values. Bulk counts are per kind: four cards, four IBANs and four SSNs do not meet the bulk threshold. Bulk detections fire alongside the matching single-kind detection. These are platform D&R rules on EMAIL_MESSAGE, separate from the engine verdict; installing them does not change the verdict or automatically move mail.

The rules read PIIFindings, which stores counts only. A detection still carries the originating email event, whose body can contain the actual sensitive values; the count facts do not redact that body. Plan detection access and outputs accordingly. IBANs commonly occur on ordinary invoices, and dashed SSN-shaped internal IDs can match. Tune the optional rules for your organization rather than treating a match as proof of malicious intent. The detector covers message text, OCR and attached messages, but does not inspect text inside ordinary document, spreadsheet or PDF files. Counts remain lower bounds for these excluded sources even when enrichments/pii/truncated is absent. Known incomplete extraction or parsing, and inspection limits, set that flag; the counts still describe only the available text. Deferred attachment scans refresh the stored facts but do not replay the initial EMAIL_MESSAGE evaluation. A DLP match therefore describes evidence available when that event was emitted, rather than every later attachment result.

Static link features answer what a URL looks like. Detonation answers where it actually goes: the redirect chain, the address each hop really connected to, the landing page's certificate, and bounded signals from the page itself.

It is an enrichment, never a gate. No mail is held waiting for it. The message is judged, EMAIL_MESSAGE is emitted and the automations run first; detonation happens afterwards and, if what it finds changes the answer, it files a verdict revision in mode: detonation.

Availability is per region

Detonation needs an isolated, separately-governed analysis environment — the process that fetches attacker-chosen URLs and parses attacker-written HTML holds no mailbox credential, no organization key and no LimaCharlie identity at all. Where that environment is not deployed, detonation is simply absent: nothing is stamped, and it never degrades into a fetch from a service that can reach a mailbox.

What gets detonated

Four conditions, all of them required:

The verdict is suspicious Not malicious — that has already crossed the threshold that fires automations, so a fetch there takes a slot from the message where the answer would change something. Not benign, for the other end of the same argument
The message has at least one http/https link Other schemes are skipped before anything is queued
It is a first ingest, not a redelivery A re-delivered message has already had its turn
The environment is deployed in that region Otherwise absent

Up to five distinct URLs per message, taken in message order after duplicates are folded together. Five, because a phishing message's payload link is in the first few and a newsletter has hundreds — and stated honestly: a message whose sixth link is the malicious one is not fully examined.

The URLs come from the message's own link list, after a mail gateway's rewrite has been unwrapped. Detonating the wrapper would describe your mail plumbing rather than the attacker's infrastructure.

Two other things reach the same machinery: an analyst or a rule dispatching crawl_link, and an AI triage agent doing the same. Three invocations, one mechanism — a second detonation path would be a second answer for one message.

One fetch per URL, per organization

A URL is claimed before it is fetched, and the claim is organization-wide rather than per message. That is what makes a campaign cheap: four hundred messages carrying one link cost one fetch.

Outcome
Claimed This is the first caller. It fetches and writes the result back
Fresh A completed result already exists and is recent enough to reuse. Nothing is fetched
In flight Another caller holds a live claim. Nothing is stamped and nothing waits — mail is never held for detonation
Backoff This URL failed recently and is not retried yet

Everything about the lane sheds rather than blocks or grows: bounded workers, a bounded queue, and a per-organization cap on how many detonations are in flight at once so one organization's campaign cannot take every slot. A drop costs one message a better verdict, which is a different thing from losing mail, and the two do not share a policy.

A refusal is a result, not an error

"This link redirects to the cloud metadata address" is the single most valuable thing detonation produces, so a refusal is a successful detonation carrying a typed reason — never an error a caller would log as infrastructure and discard.

Reason
invalid_url The URL could not be parsed
scheme_not_allowed Not http or https
port_not_allowed Not a web port
credentials_in_url Credentials before the host — the https://apple.com@evil.example/ trick
blocked_address A hop resolved to an address that must never be connected to: loopback, link-local, private ranges, the cloud metadata address
no_usable_address The name resolved, and every address it resolved to was blocked. Deliberately its own finding: "this host now points only at an internal address" is not a DNS failure
dns_failure The name did not resolve
too_many_hops The redirect chain ran past its limit
redirect_loop The chain came back to somewhere it had been
missing_location A redirect status with no usable destination
budget_exceeded The target stalled past the time budget — itself evidence
transport_error The connection failed

(A thirteenth code, disabled, is reserved in the contract for "asked for where detonation is not deployed". Nothing stamps it on a message — in that situation nothing is stamped at all — so do not write a rule that waits for it.)

Each hop's real connected address is recorded, so a name that answered differently the second time cannot hide behind the first answer. TLS facts are recorded even when the certificate does not verify — refusing to look at a page because its certificate is untrusted would blind detonation to the pages most likely to be malicious. Oversized pages are truncated and reported as truncated, never refused: refusing would hand an attacker a one-line way to blind detonation by padding a harvest page.

mode: detonation

When the evidence changes the class, the message is re-judged in full — the enabled rule records, your policy, your thresholds — and the new class is filed as a revision in mode: detonation.

It has the lowest authority of the revising modes. It never overwrites a decision a caller made:

The message's current mode A detonation revision
auto, or a previous detonation Applies
api, analyst, or the legacy ai Refused. The evidence is still stamped on the message
Anything else Refused. The rule is an allow-list, so a mode this build does not recognize is not overwritten either

A machine does not overrule a person, or a key that already looked. The refusal is a satisfied outcome, not a failure: the detonation block lands on the message either way, and only the verdict is left alone.

detonation is not a mode any caller can claim. The revision API takes no mode at all; this one is stamped by the engine that produced it.

What is kept, and what is not

The page body never comes back. What is stored is a hash and size, a title, a bounded excerpt, structural signals, and the redirect chain with each hop's resolved and connected addresses and TLS facts.

Results are always sealed — there is no plaintext mode, and the lane refuses to start without a key. Each result is encrypted under a key derived from your organization's own key, stored under a path that leads with your organization id, and bound to that exact path so that a copied object cannot be opened elsewhere. The environment that does the fetching never holds your organization's key; it writes through a single-object, single-use signed URL and holds no credential of yours at all.

Detonation results are removed by a tenant purge and by the ordinary retention sweep, along with everything else the product holds for you.

Reading it

The detonation block lands on the message's enrichments and in the drawer, and rules read it as an ordinary path:

# The landing page asks for a password
op: is
path: enrichments/detonation/landing/has_password_input
value: true
# A link's chain ended somewhere it must never be connected to
op: is
path: enrichments/detonation/refusal/kind
value: blocked_address

The block carries url — which link this describes, and the only field that says so — plus hops[] (each with host, resolved_ips, connected_addr, status, next_url and tls), a landing (effective_url, status, title, body_sha256, body_bytes, body_complete, text_excerpt, has_password_input, form_count, form_action_hosts), a refusal (kind, reason) where there was one, elapsed_ms and detonated_at.

Most of these fields are absent rather than false or empty

A boolean like has_password_input, and refusal/kind itself, are omitted when they have no value — so "the page had no password field" and "no detonation ran" look identical to a rule that only tests for false. Test for presence first when the difference matters, exactly as you would for any other enrichment.

When several of a message's links were detonated, the drawer shows the most damning one — credential harvest first, then a refusal that found a private pivot, then any other refusal, then a landing with a bad certificate. The others are not discarded: each link carries its own resolved chain.

Watching the download itself

Detection does not stop at the mail. The most privileged thing anyone can do in this product is take a person's original message out of it, and that act is telemetry too: every call to GET /messages/{msg_uuid}/eml — served or refused — emits an EMAIL_ACTION with action: get_eml, carrying the actor, the mailbox, the stated justification, and the bytes handed over.

One download is an analyst doing their job. A hundred in an hour is not, and it is the volume that says so — which is why the byte count is on the event.

# Detect: one identity pulling raw mail in bulk
op: and
rules:
  - op: is
    path: routing/event_type
    value: EMAIL_ACTION
  - op: is
    path: event/action
    value: get_eml
  - op: is
    path: event/result
    value: ok
# Respond
- action: report
  name: mailsec-raw-download
  detect_data:
    actor: '{{ .detect.event.actor }}'
    mailbox: '{{ .detect.event.mailbox.address }}'
    bytes: '{{ .detect.event.bytes }}'
  suppression:
    is_global: true
    keys:
      - 'mailsec-raw-download'
      - '{{ .detect.event.actor }}'
    max_count: 25
    period: 1h

The suppression block is doing the real work: it lets twenty-five downloads an hour by one identity pass without a detection and reports the twenty-sixth, so the rule is quiet for normal use and loud for a scrape. Set max_count to what your team actually does in an hour, not to a number that feels safe. is_global: true keys the budget on the identity across the whole organization rather than per sensor — an analyst working two mail connections is still one analyst.

Templates in detect_data and metadata resolve against the detection, not the raw event, which is why the paths above are .detect.event.….

Alert on the refusals too — they are the earlier signal

A stolen or over-broad key usually fails before it succeeds. Match event/result = refused and read event/refused_reason: permission_denied means somebody without the mailsec.get.eml grant reached for raw mail, and quota_exceeded means the organization's download budget was hit. Both are worth a detection on the first occurrence, with no suppression at all.

# Detect: somebody reached for raw mail without the grant
op: and
rules:
  - op: is
    path: routing/event_type
    value: EMAIL_ACTION
  - op: is
    path: event/action
    value: get_eml
  - op: is
    path: event/refused_reason
    value: permission_denied

Both rules are ordinary platform D&R rules on the edr target, so the same response arsenal applies — page a channel, open a ticket, disable the key.

Two seats for rules

Signal rules run in the collector, before the verdict is emitted. Platform D&R rules run afterwards, on the emitted events, with the whole response arsenal. Same syntax, different seat. See Custom Rules and Events & Automation.