Rules
Table of contents
Routes only run a transform pipeline when their routing rule matches the incoming message. Rules are stored as arbitrary JSON, validated, and compiled into predicates. The notes below explain what data is available to a rule, how each field behaves, and how to combine matchers.
Use the MailWebhook agent skill to turn matching requirements into rule JSON or review an existing rule against sample emails. The setup guide includes installation and an example prompt.
What a rule sees
Before matching, the inbound email is normalized into a dictionary:
subject: lowercased string.to_emails,from_emails: lists of lowercased email addresses. Empty when the parsed message omitted the field.from_domains: the domain portion (after the@) for everyfrom_emailsentry that contains a domain.headers: map of unfolded header name to string value, with header names lowercased and missing values coerced to"".attachments_content_types: list of lowercased MIME types for every persisted attachment (e.g.,"image/png"). Empty when there are no attachments.
Most subject, address, domain, header-contains, and attachment comparisons are case-insensitive. Header names are lowercased. headers_equals compares the normalized header value exactly after string conversion and trimming, so value case and spacing matter. Leading/trailing whitespace is stripped by rule validators. Unknown keys are accepted but ignored by the compiler, allowing forward-compatible storage.
Leaf matchers
Each populated field is AND-ed at the top level (a message must satisfy all of them unless you wrap logic in any/all/none/negate). Empty arrays or objects mean “no constraint” for that field.
| Key | Type | Behavior |
|---|---|---|
subject_contains | list[str] | Passes when any entry is a substring of the subject. Comparisons are case-insensitive and operate on the normalized subject. |
subject_regex | list[str] | Python regexes tested (case-insensitive) against the subject. Patterns are linted via safe_regex; invalid expressions are skipped and logged. |
to_contains / from_contains | list[str] | Substring match against each recipient/sender email. If any address contains any substring, the predicate passes. |
to_emails / from_emails | list[str] | Exact (case-insensitive) match against normalized addresses. |
from_domains | list[str] | Exact match against any normalized sender domain. |
headers_equals | dict[str,str] | Header name comparison is case-insensitive. Header value comparison is exact after normalization. |
headers_contains | dict[str,str] | Header name comparison is case-insensitive. Header value must include the provided substring, also case-insensitively. Useful for prefixes like "x-priority": "high". |
attachments_mime | list[str] | Match attachment content types with glob patterns, enabling negation against */* for no-attachment matching. |
Boolean combinators
Rules can branch using nested boolean structures. Each child entry uses the same schema as a normal rule:
all: list of subrules. Equivalent to logical AND.any: list of subrules. Equivalent to logical OR; an empty list evaluates toTrue, so omit the key instead of sending[]when you want “no constraint.”none: list of subrules. Passes only if none of the child subrules match.negate: single subrule whose result is inverted.
These combinators can be nested arbitrarily, so constructs like “match invoices but exclude auto-replies” or “subject contains term and headers mention Plan X” are expressible.
Example rule JSON
{
"to_emails": ["alerts@example.com"],
"from_domains": ["vendor.com"],
"subject_contains": ["invoice", "receipt"],
"any": [
{ "headers_contains": { "x-priority": "high" } },
{ "subject_regex": ["(?i)urgent"] }
],
"none": [
{ "from_emails": ["bot@vendor.com"] }
]
}
With this rule saved on a route, only messages addressed to alerts@example.com and sent by vendor.com will reach the route’s transform pipeline. The any branch makes high-priority or “urgent” subjects eligible, while the none branch blocks a known autoresponder address.
Operational notes
- Storing a rule with zero populated fields yields a “match everything” route, which is useful when you only rely on downstream mappers.
- Regex patterns run through
safe_regex, so catastrophic backtracking is mitigated; nevertheless, keep expressions narrow and test them locally. - The UI and API return rules exactly as saved (
RouteOut.rule), so you can clone an existing route by copying its JSON block verbatim.