Events & Automation¶
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.
Email Security is not a side-car product with its own event bus. Everything it sees becomes ordinary LimaCharlie telemetry, in the same lake as your endpoint, cloud and identity data — which is what makes "a phish was delivered, and then that user's endpoint ran a new binary" one rule instead of two products and a spreadsheet.
The sensor¶
Each mail connection appears as one cloud sensor on platform email. The
mailbox is a field on the event, not an identity: a ten-thousand-mailbox tenant
is one sensor, not ten thousand.
The events¶
| Event | Emitted when |
|---|---|
EMAIL_MESSAGE |
Once per message, at ingest. Carries the whole parsed model — headers, sender, recipients, body, links, attachments, authentication, hops — plus the enrichments and the verdict. It is the record that this mail arrived |
EMAIL_VERDICT |
On every verdict decision: the rule pack's own, at ingest right after the EMAIL_MESSAGE (revision/seq: 0, revision/mode: auto), and then once per override afterwards (seq: 1…, mode analyst, ai or detonation) |
EMAIL_ACTION |
On every remediation outcome, including failures and skips, and on every raw-message download (action: get_eml), served or refused. Who asked, what was attempted, what happened |
EMAIL_USER_REPORT |
When a message reaches the abuse mailbox and becomes a report |
EMAIL_INGEST_ERROR |
When a message could not be fetched or processed. Coverage honesty: failures are visible, never silent |
EMAIL_MESSAGE is emitted once and is immutable. When a verdict changes, the
original event is never rewritten — a new EMAIL_VERDICT is emitted instead, so
the verdict history is the sequence of those events and a replay can reconstruct
what was known at any point in time.
EMAIL_VERDICT¶
The body carries the message's identity plus one revision block, and it is the
same shape whichever decision it reports — which is the point: one rule reads all
of them.
| Path | |
|---|---|
event/msg_uuid |
The durable handle every typed action takes. A provider message id is folder-scoped and stops resolving the moment the message is quarantined; this does not |
event/mailbox/address, event/provider |
Whose mail, from where |
event/internet_message_id |
The cross-mailbox join key: one message sent to forty people is forty msg_uuids and one of these |
event/sender_email, event/sender_root_domain, event/subject |
Enough to match on without a lookup |
event/ts |
The message's delivery time. The event's own timestamp is when the decision was made, so a hunt can window on either |
event/campaign_id |
The campaign, if the message clustered into one |
event/campaign_joined_late |
Present and true only when this event exists because the campaign above was identified after the message had already been delivered and reported. See A message that joins a campaign late |
event/cluster_reason |
The cluster keys that agreed — why this message is in that campaign. Carried on a late join, which is the event the question gets asked about |
event/revision/seq |
0 for the rule pack's verdict, 1… for each override |
event/revision/mode |
auto, analyst, ai or detonation |
event/revision/verdict, event/revision/score |
The decision |
event/revision/rationale |
The reasons, strongest first. For an override these are what the analyst or agent wrote; for seq 0 they are the names of the signals that fired — so it is absent on a benign message that matched nothing, which is the common case |
event/revision/top_signals, event/revision/engine_version |
seq 0 only — the rule ids that fired and the engine that decided (rule-pack version plus the library build it ran with). An override has neither: nothing matched, somebody decided |
event/revision/actor |
Who decided. Empty on seq 0: the rule pack is not a person, and engine_version is what identifies it |
event/revision/prior |
What the override displaced — verdict, score, mode and engine version. On seq 0 it is present but empty (verdict: "", score: 0), because the engine's first call displaced nothing — so match on revision/seq or revision/mode to tell the two apart, not on prior |
The MDM is deliberately not repeated here: it is already in the immutable
EMAIL_MESSAGE, and copying it into every verdict change would multiply a year
of telemetry by how often people change their minds.
A message that joins a campaign late¶
Clustering runs while a message is being ingested, so two copies of one attack
that arrive in the same instant each look for a campaign-mate before the other
has been written down — and both are stored attributed to nothing. When a
campaign one of them belongs to is identified afterwards, its membership is
recorded and an EMAIL_VERDICT is emitted carrying
campaign_joined_late: true.
# Detect: mail we already delivered has been shown to be part of a campaign.
op: and
rules:
- op: is
path: routing/event_type
value: EMAIL_VERDICT
- op: is
path: event/campaign_joined_late
value: true
It is a flag on the existing event rather than an event type of its own,
deliberately: a rule already written against EMAIL_VERDICT keeps working and
simply starts seeing the campaign, which is the one fact a campaign-wide response
needs.
The revision block on this event is a restatement, not a new decision.
Nothing was re-judged — the verdict is exactly what it was — so:
| Path | On a late join |
|---|---|
revision/seq |
The message's current revision sequence: 0 when the engine's verdict has never been overridden, which is the usual case, and 1… when it has. A consumer de-duplicating on (msg_uuid, seq) therefore reads this as a decision it already holds, now carrying a campaign |
revision/decided_at |
The clock of the decision being restated. On a message whose engine verdict was never overridden — the usual case — that is when the join happened, because the join is the only thing this event reports as new. On an already-overridden message it is the timestamp of the override being restated — the message's current one, which is the original when there has only ever been one — because restating an analyst's decision under a clock they did not choose would be worse. So match late joins on campaign_joined_late, never on a time window |
revision/verdict, revision/mode, revision/actor |
Whatever the message already carried. An overridden message restates its analyst's or agent's decision verbatim, attribution included |
The original EMAIL_MESSAGE is never rewritten, here as everywhere: it stands as
the record of what was known at ingest, and this is the record of what was learned
afterwards.
Why you want to act on this
A campaign-wide quarantine reaches the members the product knows about. A message that was never attributed to its campaign is one the sweep does not touch and one the campaign's member count does not include — so this event is how a response you have already run learns that it missed something.
Match on the flag rather than on a time window, per the decided_at note
above: on an already-overridden message the event carries the original
decision's timestamp, so a rule scoped to "the last hour" would miss it.
It roughly doubles your mail event volume
EMAIL_VERDICT at seq 0 is emitted for every ingested message, not only
for flagged ones, so a protected mailbox now produces about two events per
message instead of one. These are ordinary telemetry: they are evaluated by
your D&R rules and they land in your retention like any other event. The
bodies are small — identity plus a verdict, never the parsed message — so the
byte volume moves far less than the count.
Everything the rule needs is in the event
The verdict, the top signals and the enrichments the pipeline resolved are
all in EMAIL_MESSAGE. A rule never has to call back for enrichment, and a
replay of the event sees exactly what the pipeline saw.
Events arrive on the default D&R target, so a rule matching them needs no
target: line.
Acting on mail from a D&R rule¶
# Detect
op: and
rules:
- op: is
path: routing/event_type
value: EMAIL_MESSAGE
- op: is
path: event/verdict/verdict
value: malicious
- op: is
path: event/direction
value: inbound
# Respond
- action: report
name: email-malicious-delivered
- action: extension request
extension name: ext-email-security
extension action: quarantine_message
extension request:
msg_uuid: '{{ .event.msg_uuid }}'
The typed actions available to extension request are the same six the console
and the CLI use: quarantine_message, trash_message, move_to_spam,
restore_message, banner_message, unbanner_message. They route to the same
executor, so the organization's alert_only / enforce mode, the audit row and
idempotency all apply unchanged — there is exactly one remediation path in this
product.
A rule does not supply banner HTML: banner_message uses the organization's own
banner from its banners policy record, rendered
server-side into a fixed escaped template. Automated bannering also requires
enabled on the banners record; without it a rule's
banner_message is decided and audited but the mailbox is not touched
(alert_only). Bannering asked for by a person — console, API, CLI — is not
gated by that switch.
Actions dispatched this way are attributed with source: dr in the audit trail.
Which seat should this rule sit in?
A rule that should change the verdict belongs in dr-mail as a
pre_verdict signal — see Custom Rules. A rule that
should do something once the verdict exists can sit in either seat: in
dr-mail as post_verdict if it only needs mail actions, or here as an
ordinary D&R rule if it needs the platform's full response arsenal, Outputs,
Cases, or correlation with non-mail telemetry.
Reacting to a user report¶
From there the detection flows into Cases, Outputs and everything else that consumes detections. A report is the highest-signal thing your users will ever hand you, so treating it as a first-class detection is usually right.
Watching your own coverage¶
EMAIL_INGEST_ERROR is the event to alert on. A mail security product that
quietly stops seeing a mailbox is worse than one that is visibly down, so
failures are emitted rather than swallowed:
Pair it with the coverage call, which reports mailboxes in error, the
parse-degradation rate and the emission backlog. See
Getting Started.
Poll it without a window. With no since/until/window_days the answer is
eligible for a short-lived server-side memo and is not counted against the
read budget; naming a window recomputes the
period on every call and is counted. The memo lives 60 seconds, so polling faster
than once a minute buys you nothing — and a once-a-minute poll lands on the
expiry most times, so it is not free either. It is simply not budgeted.
Querying mail with LCQL¶
EMAIL_* events are queryable like any other telemetry in the Query Console and
through limacharlie search, over the platform's normal retention rather than
the 35-day product index. That makes it the right tool for questions that reach
further back than the queue does.
Use limacharlie ai generate-query to build the query and
limacharlie search validate before running it — LCQL is validated against
org-specific schemas and hand-written queries fail or mislead. See
Data & Queries.
For the everyday "who else got this" question, the message index pivots are faster and are purpose-built.
Outputs¶
Because the events are ordinary telemetry, every
Output works without any mail-specific
configuration: stream EMAIL_MESSAGE to a data lake, forward detections to a
SIEM, or push EMAIL_ACTION into an audit pipeline.
Configuration as code¶
Every piece of Email Security configuration is a Hive record, so a tenant's whole mail posture is a directory of YAML:
| Hive | Holds |
|---|---|
secret |
The provider credential |
mailsec_provider |
The connection |
mailsec_policy |
Automations, exclusions, VIPs, thresholds, banners, retention, reporter replies |
dr-mail |
Custom mail rules |
lookup |
VIP lists referenced by vips.list_refs |
dr-general |
The D&R rules on EMAIL_* events |
limacharlie hive set --hive-name mailsec_policy --key 50-finance-vips \
--input-file policy/50-finance-vips.yaml --enabled --oid $OID
limacharlie hive list --hive-name mailsec_policy --oid $OID --output yaml
limacharlie hive get --hive-name mailsec_policy --key 50-finance-vips --oid $OID
Two conventions make this pleasant to keep in git:
- Records compose in name order, so number your records (
00-baseline,50-team-x,99-override) when precedence matters. - Unknown fields are refused, so a typo fails the write rather than silently
disabling a control. Validate a rule with
limacharlie hive validateorlimacharlie mailsec rule validatebefore committing it.
Onboarding a new tenant is then: subscribe the extension, write the secret, write the provider record, apply the policy directory, run the connection test.