Message Rules
Message Rules
Admin path: Content Checks > Message Rules
(view_message_rules.cfm,
inc/get_message_rules.cfm,
inc/apply_message_rules.cfm,
inc/update_spamassassin_config_files.cfm,
inc/restart_mail_filter.cfm).
This page maintains a catalogue of custom SpamAssassin rules
that score against a regex match in a specific part of the message
(header, body, raw body, full message, or URI). Every rule a row on
this page produces is appended verbatim to SpamAssassin's local.cf
as a <type> <name> <regex> line, paired with a score <name> <value> line, and (optionally) a describe <name> <text> line.
SpamAssassin then runs the rule on every message that reaches the
SpamAssassin pass inside Amavis. The cutoff that turns a final score
into a tag / quarantine action is set globally on
Anti-Spam Settings; this page only writes
the rules themselves.
Message Rules is the body/header equivalent of what
File Extensions does for attachment names.
Both ride into local.cf / 50-user on save, both are validated
with spamassassin --lint before the mail filter restarts, but
File Extensions matches the trailing extension of an attachment
filename while Message Rules matches arbitrary regex against text
inside the message.
Where Message Rules sits
+---------------------------------------+
Message Rules | message_rules table |
(this page) -----> | id, rule_name, rule_type, header, |
| regex, score, rule_desc, applied |
+---------------+-----------------------+
|
v
+---------------------------------------+
| update_spamassassin_config_files.cfm |
| renders every row as |
| <type> <name> <regex> |
| score <name> <value> |
| describe <name> <desc> |
| substituted at ##CUSTOM-MESSAGE-RULES|
+---------------+-----------------------+
|
v
+---------------------------------------+
| apply_message_rules.cfm |
| spamassassin --lint |
| restart_mail_filter.cfm |
| (docker container restart |
| hermes_mail_filter) |
+---------------+-----------------------+
|
v
+---------------------------------------+
| /etc/spamassassin/local.cf in |
| hermes_mail_filter; rules contribute|
| to every message's total score |
+---------------------------------------+
A row added here only affects the SpamAssassin pass — it does not reject at SMTP-time, it does not modify headers directly, and it does not bypass content filtering for any recipient. It just adds or subtracts from the final score, and whether that final score crosses a quarantine threshold is decided by the recipient's SVF Policy.
Rule types
| Type | What it matches | Cost |
|---|---|---|
header |
A specific message header (Subject, From, Return-Path, ...) or any header when ALL is set |
Very cheap; runs against parsed header values |
body |
The decoded plain-text body | Cheap |
rawbody |
The raw/HTML body before SpamAssassin decodes it (good for catching CSS tricks, hidden text, encoded payloads) | Cheap |
full |
The entire raw message including all MIME parts and headers | Most expensive; use sparingly |
uri |
URIs extracted from the message body | Cheap; ideal for catching suspicious link patterns |
The Page Guide on the page calls out full as resource-intensive
because SpamAssassin runs the regex against the whole raw blob; a
greedy or expensive regex in a full rule can noticeably slow
every scan. Prefer body, rawbody, or uri where they cover
the case.
Score semantics
The score value behaves identically to a
Score Overrides weight, except this page
creates the rule from scratch instead of overriding a shipped rule:
| Score | Effect |
|---|---|
Positive (5, 20, etc.) |
Adds to the spam score on match. Higher values push the message toward tag / quarantine |
0 |
Rule still runs but contributes nothing — useful for keeping the rule in place during a tuning pass without firing it |
Negative (-3, -10) |
Subtracts from the score on match — effectively whitelists messages matching the pattern |
The validation accepts any numeric value in the range -999 .. 999
(per the input's step="0.01" and min/max attributes). The
SVF policy assigned to the recipient determines what total score
threshold triggers tag / quarantine — see SVF Policies.
The page
A Page Guide callout, a collapsible Regex Helper card (three tools: rule builder, common-pattern picker, regex tester — all client-side JavaScript that just populates the Add form), an Add Message Rule card, and an Existing Message Rules DataTable.
Add Message Rule card
| Field | Stored as | Notes |
|---|---|---|
| Rule Name | message_rules.rule_name |
Required. Letters, numbers, dashes, underscores only — no spaces. Must be unique. SpamAssassin uses this as the rule identifier in logs (X-Spam-Status header reports rule names that fired) |
| Rule Type | message_rules.rule_type |
Required. One of header, body, rawbody, full, uri |
| Header | message_rules.header |
Required when Rule Type is header. Letters, numbers, dashes, underscores only. Datalist suggests common headers (Subject, From, Return-Path, ...) plus the special ALL token to match any header. For non-header rules the field is force-cleared on save |
| Regex Pattern | message_rules.regex |
Required. A SpamAssassin-format regex like /keyword/i. For header rules a ~ prefix is auto-added on save (this is the SpamAssassin =~ operator notation header_name =~ /pattern/) and stripped on display so the operator sees only the regex |
| Score | message_rules.score |
Required, numeric, -999 .. 999 |
| Description | message_rules.rule_desc |
Optional. Surfaced into the rendered local.cf as a describe line, which feeds the rule into SpamAssassin's "why was this scored" explanations |
The handler validates each field in order and returns to the page
with session.m_rules = <code> for the first failure (form values
are preserved through session.form_* so the operator doesn't
re-type). Successful insert sets applied = 2 (pending) before
the apply chain runs, then bulk-updates all rows to applied = 1
once spamassassin --lint and the restart succeed.
Regex Helper card
Pure client-side, no server roundtrip:
| Tool | What it does |
|---|---|
| Build a Rule | Pick "match in body / header / raw / URIs," choose Contains / Exact / Starts / Ends / Any-of, type the text, click Build. JavaScript escapes regex metacharacters and assembles a /pattern/i string |
| Quick Select Common Patterns | A <select> of pre-built rules for typical spam patterns ("Subject contains lottery winner", "URI: URL shortener", "HTML: hidden text"). Picking one populates the Add form below |
| Test a Pattern | Paste a /regex/flags and a sample string; the helper runs JavaScript's RegExp against it and reports Match / No match / Invalid regex |
This is operator convenience — none of it touches the database or
SpamAssassin. The pattern that lands in message_rules.regex is
exactly what the operator submits, even if it came from one of
these helpers.
Existing Message Rules DataTable
| Column | Source |
|---|---|
| (checkbox) | Selection for bulk Delete Selected |
| Rule Name | message_rules.rule_name |
| Type | message_rules.rule_type rendered as a coloured badge per type |
| Header | message_rules.header for header rules; N/A otherwise |
| Regex | message_rules.regex (with the auto-prefixed ~ stripped for header rules so the display matches what the operator typed) |
| Score | message_rules.score |
| Description | message_rules.rule_desc |
| Actions | Per-row Edit and Delete buttons |
Edit reuses the same validation as Add. Rule Name is shown read-only
in the modal — to rename, delete and re-add (renaming would orphan
any X-Spam-Status historical correlation anyway).
Save and apply flow
1. View page submits action="add_rule" | "edit_rule" |
"delete_rule" | "bulk_delete"
2. Action handler validates input, INSERT/UPDATE/DELETE on
message_rules (applied flag set to '2' = pending on
add/edit; no applied flag manipulation on delete)
3. cfinclude apply_message_rules.cfm:
a. cfinclude update_spamassassin_config_files.cfm:
- Read /opt/hermes/conf_files/local.cf.HERMES (template)
- Substitute USE-DCC, USE-PYZOR, USE-RAZOR2, USE-BAYES,
BAYES-AUTO-LEARN, BAYESAUTOLEARN-SPAM, BAYESAUTOLEARN-HAM
from spam_settings (the Anti-Spam Settings rows)
- Append per-rule score overrides
(#CUSTOM-TESTS placeholder, from spam_settings
rows where spamfilter=1)
- Append every message_rules row as
"<type> <name> <regex>"+"score <name> <value>"
[+"describe <name> <desc>" if non-blank]
(#CUSTOM-MESSAGE-RULES placeholder)
- Back up /etc/spamassassin/local.cf ->
local.cf.HERMES.BACKUP, move rendered file into place
b. Write a temp shell script wrapping
docker exec hermes_mail_filter \
/usr/bin/spamassassin --lint 2>/dev/null
exit 0
(stderr redirected to /dev/null and trailing `exit 0` —
Lucee otherwise throws on stderr warnings; the lint return
code is captured into lintOutput)
c. cfinclude restart_mail_filter.cfm:
docker container restart hermes_mail_filter
d. UPDATE message_rules SET applied = '1'
(mark every row as live)
4. session.m_rules = 1|2|3 -> green alert
5. cflocation back to view_message_rules.cfm
A few things worth knowing about this chain:
- Lint failures do not stop the restart. The lint output is
captured into
lintOutputbut the include does not branch on it; the next step (restart_mail_filter.cfm) runs unconditionally. An invalid regex in a new rule will surface in the restarted container's logs (Amavis will refuse to load SpamAssassin, or SpamAssassin will skip the broken rule depending on the failure mode), not in the green alert on the page. applied = '1'is set for every row, not just the new one. The flag tracks "has every rule been pushed to the running SpamAssassin?" rather than "is this specific rule live?" — after the restart, every row is by definition live.- The full container is restarted (
docker container restart hermes_mail_filter), notforce-reloadd. This is the same restart that Anti-Spam Settings does; outbound mail queues briefly during the restart (typically a few seconds) and Postfix retries.
Failure semantics
| Alert | Trigger |
|---|---|
m_rules = 1 |
Add Rule succeeded; SpamAssassin validated and reloaded |
m_rules = 2 |
Delete (single or bulk) succeeded; SpamAssassin validated and reloaded |
m_rules = 3 |
Edit Rule succeeded; SpamAssassin validated and reloaded |
m_rules = 10 |
Rule Name is empty |
m_rules = 11 |
Rule Name contains characters other than letters, numbers, dashes, underscores |
m_rules = 12 |
A rule with that name already exists |
m_rules = 13 |
Header field is empty for a header rule |
m_rules = 14 |
Header field contains invalid characters |
m_rules = 15 |
Regex/Pattern is empty |
m_rules = 16 |
Score is empty |
m_rules = 17 |
Score is not numeric |
m_rules = 18 |
Rule Type is not one of header, body, rawbody, full, uri |
The validation order is sequential — the first failure wins and
the rest of the validation does not run. Form values are preserved
into the next page render via session.form_* so the operator
sees their submission intact when the error renders.
Files and containers touched
| Path | Owner | Role |
|---|---|---|
config/hermes/var/www/html/admin/2/view_message_rules.cfm |
hermes_commandbox |
The page (validation + Add / Edit / Delete / Bulk Delete + Regex Helper) |
config/hermes/var/www/html/admin/2/inc/get_message_rules.cfm |
hermes_commandbox |
Loads the full rules list and a count of applied = 2 (pending) rows |
config/hermes/var/www/html/admin/2/inc/apply_message_rules.cfm |
hermes_commandbox |
Orchestrates the render + lint + restart + mark-applied chain |
config/hermes/var/www/html/admin/2/inc/update_spamassassin_config_files.cfm |
hermes_commandbox |
Renders local.cf from template, appends every message_rules row and every spamfilter=1 spam_settings row |
config/hermes/var/www/html/admin/2/inc/restart_mail_filter.cfm |
hermes_commandbox |
docker container restart hermes_mail_filter |
config/hermes/opt/hermes/conf_files/local.cf.HERMES |
template (read) -> hermes_mail_filter (live /etc/spamassassin/local.cf) |
Receives the rendered rules at the #CUSTOM-MESSAGE-RULES placeholder |
/etc/spamassassin/local.cf.HERMES.BACKUP |
hermes_mail_filter |
Pre-write backup of the prior live local.cf, refreshed each save |
message_rules table |
hermes_db_server (hermes DB) |
Source of truth for every rule on this page |
hermes_mail_filter container |
-- | Hosts SpamAssassin under Amavis; full container restart on every save |
Related
- SVF Policies -- the per-recipient policy that
decides what threshold a rule's score has to push the running
total above before Amavis tags or quarantines. A rule scored at
5does nothing on a recipient whose SVF policy hasspam_kill_level = 12 - Score Overrides -- overrides for the
weights of SpamAssassin's shipped rules; this page creates
rules, that page reweights existing ones. Both ride into
local.cfon the same save chain - Anti-Spam Settings -- engine-wide
toggles (Bayes, DCC, Razor, Pyzor) and the global
final_*_destinyquarantine actions. Subject tagging (sa_spam_subject_tag) is also set here - Antivirus Settings -- the ClamAV pass runs before SpamAssassin in the same Amavis call; a virus verdict pre-empts any spam score this page contributes
- File Extensions -- the attachment-name
equivalent of this page; both write to
hermes_mail_filteron save and share the lint-then-restart pattern (File Extensions usesforce-reloadinstead of full restart because no SpamAssassin state is touched) - File Rules -- bundles extensions into named rulesets that bind to SVF policies; orthogonal to message rules but lives in the same Amavis pass
- Perimeter Checks -- nothing on this page matters for messages rejected at SMTP-time; rules here only see the traffic that already passed the perimeter
- Message History -- a quarantined message surfaces the matched rule names in the SpamAssassin verdict details, which is the canonical way to see whether a custom rule fired
- System Logs -- the
amavis[...]line for a scored message reportstests=followed by every rule that fired with its weight, including custom rules from this page