Content Checks

Antispam Settings

Antispam Settings

Admin path: Content Checks > Antispam Settings (view_antispam_maintenance.cfm, inc/get_spam_settings.cfm, inc/spam_settings_save.cfm, inc/update_amavis_config_files.cfm, inc/update_spamassassin_config_files.cfm, inc/restart_amavis.cfm, inc/restart_spamassassin.cfm, inc/antispam_init_pyzor.cfm, inc/antispam_init_razor.cfm, inc/antispam_clear_bayes.cfm).

This page configures the SpamAssassin engine that Amavis calls inside hermes_mail_filter for every message that clears the SMTP-time perimeter, plus the Amavis-level handling policies that decide what happens to a message once it has been scored or otherwise classified. Per-rule weight adjustments live on Score Overrides; this page is engine settings and quarantine destiny only.

Where SpamAssassin sits in the flow

                  +-----------------------------------+
   inbound msg -->| Perimeter Checks pass             |
                  +---------------+-------------------+
                                  |
                                  v
                  +-----------------------------------+
                  |  Postfix smtpd_proxy_filter       |
                  |    -> hermes_mail_filter:10024    |
                  +---------------+-------------------+
                                  |
                                  v
                  +-----------------------------------+
                  |  Amavis (hermes_mail_filter)      |
                  |   - ClamAV virus scan             |
                  |   - SpamAssassin scoring          |
                  |       DCC / Razor / Pyzor net DBs |
                  |       Bayes statistical engine    |
                  |       custom rules + scores       |
                  |   - banned-file checks            |
                  |   - final_*_destiny -> quarantine/DSN/discard
                  +---------------+-------------------+
                                  |
                                  v
                  +-----------------------------------+
                  |  Re-inject -> hermes_postfix_dkim:10026
                  +-----------------------------------+

A virus verdict from ClamAV always pre-empts the spam score; the final_virus_destiny setting on this page decides what Amavis does with that already-classified virus. The final_spam_destiny, final_banned_destiny, and final_bad_header_destiny settings work the same way for the other three Amavis verdict categories.

Container and tool placement

Component Detail
Container hermes_mail_filter (IPv4 .105)
Engine SpamAssassin (spamd / Mail::SpamAssassin Perl modules called from Amavis)
Amavis config /etc/amavis/conf.d/50-user (rendered from /opt/hermes/conf_files/50-user.HERMES on every save)
SpamAssassin config /etc/spamassassin/local.cf (rendered from /opt/hermes/conf_files/local.cf.HERMES on every save)
Bayes DB Lives in the SpamAssassin user dir inside hermes_mail_filter (sa-learn --dump magic reports the actual path)
Network plugin state /etc/razor/identity (Razor), Pyzor's per-user config dir, DCC's local socket — all inside hermes_mail_filter
Reload mechanism spamassassin --lint + docker container restart hermes_mail_filter on every save

The container exposes no host ports — Amavis is reached only by Postfix internally at hermes_mail_filter:10024 and re-injects to hermes_postfix_dkim:10026.

Spam Detection Plugins card

Three boolean toggles enable third-party network-aware spam DBs. Storage: spam_settings.value for parameters use_dcc, use_razor2, use_pyzor (each row keyed by parameter, value 0 or 1).

Plugin What it does Maintenance action
DCC (Distributed Checksum Clearinghouse) Fuzzy-checksum bulk-mail detection; matches a message against a network of receivers' checksum counters None — cdcc runs as part of the SpamAssassin call chain
Razor2 (Vipul's Razor v2) Collaborative spam catalog; checksum + signature lookup against the Razor network Initialize Razor (see Maintenance) before first use
Pyzor Collaborative digest-based spam detection Initialize Pyzor before first use

Each toggle substitutes into local.cf via the placeholders USE-DCC, USE-PYZOR, USE-RAZOR2 -> use_dcc 0|1, use_pyzor 0|1, use_razor2 0|1.

Operational consequence — network DB connectivity. All three plugins make outbound queries (DCC over UDP, Razor and Pyzor over TCP) at scan time. If outbound to the public Internet is blocked from hermes_mail_filter, the plugins quietly time out per message and add measurable per-scan latency. Disable plugins the gateway cannot actually reach.

Subject Tagging card

Single field, sa_spam_subject_tag in spam_settings. Substitutes into 50-user via the sa-spam-subject-tag placeholder, which sets Amavis's $sa_spam_subject_tag. Default [SUSPECTED SPAM]. Required (empty value rejected with error 2). Only applied when sa_spam_modifies_subj = 1 (a fixed value in spam_settings, not exposed in the UI).

Message Handling Policies card

Four radio pairs, one per Amavis verdict category. Each row stores D_DISCARD or D_BOUNCE in spam_settings.value and substitutes into 50-user via final-<category>-destiny. Amavis acts on the value as follows:

Setting DB row "Quarantine Only" (D_DISCARD) "Quarantine & Send DSN" (D_BOUNCE)
Virus Messages final_virus_destiny Message goes to quarantine; no DSN Message goes to quarantine; DSN sent to envelope sender
Banned File Messages final_banned_destiny Same as above for banned-file matches DSN sent
Spam Messages final_spam_destiny Quarantined silently DSN sent
Bad-Header Messages final_bad_header_destiny Quarantined silently DSN sent

The labels are deliberately conservative — D_DISCARD does not delete the message, it routes it to Amavis's quarantine where Message History can review and release it. Defaults: virus + banned send DSN; spam + bad-header quarantine silently.

Operational consequence — Send DSN on spam. Setting final_spam_destiny = D_BOUNCE means Hermes will deliver a non-delivery report to the envelope sender of every quarantined spam. Because the envelope sender is almost always forged on spam, the DSN will either bounce, contribute to backscatter against innocent third parties, or land in a victim's spam folder. The safe default for spam is D_DISCARD; reserve DSN for virus and banned-file (where the sender is more likely to be legitimate).

Bayes Database card

SpamAssassin's per-installation statistical learning engine. Three controls, stored in spam_settings:

Field DB row Substitution placeholder Effect
Enable Bayes Database use_bayes USE-BAYES -> use_bayes followed by 0 or 1 Master switch; when off, Bayes rules contribute no score
Enable Auto-Learning bayes_auto_learn BAYES-AUTO-LEARN -> bayes_auto_learn followed by 0 or 1 When on, SpamAssassin trains the Bayes DB automatically based on the message's final score relative to the thresholds below
Spam Threshold bayes_auto_learn_threshold_spam BAYESAUTOLEARN-SPAM -> bayes_auto_learn_threshold_spam <value> Final score above which auto-learn treats the message as spam. Must be numeric and in the range 0.01 .. 999
Non-Spam Threshold bayes_auto_learn_threshold_nonspam BAYESAUTOLEARN-HAM -> bayes_auto_learn_threshold_nonspam <value> Final score below which auto-learn treats the message as ham. Must be numeric and in the range -999 .. -0.01

The thresholds are SpamAssassin's bayes_auto_learn_threshold_spam and bayes_auto_learn_threshold_nonspam directives. JavaScript on the page collapses the thresholds when Bayes or auto-learning is disabled.

Operational consequence — Bayes poisoning. Auto-learning trusts the final score (which already includes Bayes's own contribution) to decide whether to train. A bad spam wave that sneaks past the score threshold can train Bayes to think more spam is ham, which lowers detection on the next batch. If detection quality regresses noticeably after enabling auto-learning, use the Clear Bayes Database action and re-train manually or via a known-good corpus before re-enabling.

Save flow

1. View page submits action="save_settings" (all four cards in one POST)
2. spam_settings_save.cfm validates:
     - sa_spam_subject_tag non-empty (error 2)
     - if bayes_auto_learn=1:
         spam threshold numeric (error 5), > 0 and <= 999 (error 4),
                 non-empty (error 3)
         non-spam threshold numeric (error 10), < 0 and >= -999 (error 8),
                 non-empty (error 7)
3. On valid input, UPDATEs 13 rows in spam_settings (sa_spam_subject_tag,
   four final_*_destiny, use_bayes, bayes_auto_learn, both thresholds,
   use_dcc, use_razor2, use_pyzor)
4. cfinclude update_amavis_config_files.cfm:
     - Reads /opt/hermes/conf_files/50-user.HERMES
     - Substitutes SERVER-NAME, SERVER-DOMAIN, sa-spam-subject-tag,
       final-{virus,banned,spam,bad-header}-destiny,
       enable-dkim-{verification,signing},
       HERMES-USERNAME, HERMES-PASSWORD,
       FILE-RULES-GO-HERE (from file_rule_components table),
       DKIM-KEYS-GO-HERE (from dkim_sign table)
     - Backs up /etc/amavis/conf.d/50-user -> 50-user.HERMES.BACKUP
     - Moves rendered file into place
5. cfinclude update_spamassassin_config_files.cfm:
     - Reads /opt/hermes/conf_files/local.cf.HERMES
     - Substitutes USE-DCC, USE-PYZOR, USE-RAZOR2, USE-BAYES,
       BAYES-AUTO-LEARN, BAYESAUTOLEARN-SPAM, BAYESAUTOLEARN-HAM
     - Appends per-rule score lines (from spam_settings where spamfilter=1)
     - Appends custom message rules (from message_rules table)
     - Backs up /etc/spamassassin/local.cf -> local.cf.HERMES.BACKUP
     - Moves rendered file into place
6. cfinclude restart_amavis.cfm -> restart_mail_filter.cfm:
     - docker container restart hermes_mail_filter
7. cfinclude restart_spamassassin.cfm:
     - docker exec hermes_mail_filter /usr/bin/spamassassin --lint
     - docker container restart hermes_mail_filter
8. session.m = 1 -> green "Anti-spam settings have been saved and applied" alert
9. cflocation back to view_antispam_maintenance.cfm

The same container is restarted twice (once for Amavis, once for SpamAssassin) because the restart includes are intentionally independent helpers used elsewhere; both calls resolve to the same docker container restart hermes_mail_filter. Outbound mail queues briefly during the restart cycle (typically a few seconds); Postfix will retry.

Maintenance card group

Three buttons, each running a single docker exec against hermes_mail_filter and surfacing stdout/stderr to the operator.

Initialize Pyzor

Action handler: antispam_init_pyzor.cfm

docker exec hermes_mail_filter /usr/bin/pyzor ping

Pings the Pyzor servers; success is detected by the literal string 200 in the output. The command both verifies connectivity and writes the per-user Pyzor config the first time it runs. Required before use_pyzor = 1 returns meaningful results.

Initialize Razor

Action handler: antispam_init_razor.cfm

docker exec hermes_mail_filter /bin/bash -c \
  'rm -f /etc/razor/identity && razor-admin -create && razor-admin -register'

Deletes the existing Razor identity, creates a fresh config, and registers the gateway with the Razor network. Success is detected by Register successful or created in the output. Re-run if Razor queries start failing (typically after the identity is rotated or the network rejects the existing identity).

Clear Bayes Database

Action handler: antispam_clear_bayes.cfm

docker exec hermes_mail_filter /usr/bin/sa-learn --clear

Wipes the learned spam/ham corpus. SpamAssassin will need to re-learn from scratch before Bayes rules contribute meaningful scores again. Use only when the database is known-poisoned or when migrating between servers without preserving training. The button is gated behind a JavaScript confirm() and renders inside a yellow warning card.

Failure semantics

Failure Behavior
Empty sa_spam_subject_tag session.m=2, red alert, no save
Bayes spam threshold empty session.m=3
Bayes spam threshold not numeric session.m=5
Bayes spam threshold <= 0 or > 999 session.m=4
Bayes non-spam threshold empty session.m=7
Bayes non-spam threshold not numeric session.m=10
Bayes non-spam threshold >= 0 or < -999 session.m=8
Any cfcatch during the save -> apply chain session.m=9, red alert with session.saveError showing cfcatch.message
spamassassin --lint failure during restart error.cfm cfabort with the lint failure message; the rendered local.cf is already in place but Amavis is not restarted further
Pyzor ping output without 200 session.m=12, red alert; full output shown in a <pre> for diagnosis
Razor init output without Register successful or created session.m=14, similar surfacing
Bayes clear cfcatch session.m=16 with the catch message

spamassassin --lint is the canonical pre-restart sanity check — when a custom rule (added via Score Overrides or message rules) has invalid syntax, the lint catches it before the container restart finishes and prevents Amavis from starting against a broken config.

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_antispam_maintenance.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/spam_settings_save.cfm hermes_commandbox Validation + UPDATE + apply chain
config/hermes/var/www/html/admin/2/inc/get_spam_settings.cfm hermes_commandbox Loads current spam_settings rows
config/hermes/var/www/html/admin/2/inc/update_amavis_config_files.cfm hermes_commandbox Renders 50-user from template + DB
config/hermes/var/www/html/admin/2/inc/update_spamassassin_config_files.cfm hermes_commandbox Renders local.cf from template + DB
config/hermes/var/www/html/admin/2/inc/restart_amavis.cfm / restart_spamassassin.cfm / restart_mail_filter.cfm hermes_commandbox docker container restart hermes_mail_filter
config/hermes/var/www/html/admin/2/inc/antispam_init_pyzor.cfm / antispam_init_razor.cfm / antispam_clear_bayes.cfm hermes_commandbox Maintenance docker-exec helpers
config/hermes/opt/hermes/conf_files/50-user.HERMES template (read) -> hermes_mail_filter (live /etc/amavis/conf.d/50-user) Amavis directives template
config/hermes/opt/hermes/conf_files/local.cf.HERMES template (read) -> hermes_mail_filter (live /etc/spamassassin/local.cf) SpamAssassin directives template
/etc/amavis/conf.d/50-user.HERMES.BACKUP hermes_mail_filter Pre-write backup, refreshed each save
/etc/spamassassin/local.cf.HERMES.BACKUP hermes_mail_filter Pre-write backup, refreshed each save
spam_settings table hermes_db_server (hermes DB) Source of truth for every UI value on this page; also holds per-rule scores (spamfilter=1 rows) for Score Overrides
message_rules table hermes_db_server Custom header/body/full message rules; rendered into local.cf
file_rule_components / files tables hermes_db_server Banned-file rules; rendered into 50-user
dkim_sign table hermes_db_server Per-domain DKIM keys; rendered into 50-user for outbound signing

Antivirus Settings

Antivirus Settings

Admin path: Content Checks > Antivirus Settings (view_antivirus_settings.cfm, inc/get_antivirus_settings.cfm, inc/antivirus_set_settings.cfm, inc/antivirus_add_whitelists.cfm, inc/antivirus_delete_entry.cfm, inc/generate_antivirus_configuration.cfm, inc/restart_clamav.cfm).

This page configures the ClamAV antivirus engine that runs inside hermes_mail_filter and is called by Amavis on every message that clears the SMTP-time perimeter. Two cards: the main settings card (sixteen toggles that map to clamd.conf directives) and a Pro-only AV Signature Whitelist for suppressing known-bad-signature false positives. Refreshing third-party signature feeds (Sanesecurity, SecuriteInfo, MalwarePatrol, etc.) is configured separately on Malware Feeds; this page configures the engine itself.

Where antivirus sits in the flow

                  +-----------------------------------+
   inbound msg -->| Perimeter Checks pass             |
                  +---------------+-------------------+
                                  |
                                  v
                  +-----------------------------------+
                  |  Postfix smtpd_proxy_filter       |
                  |    -> hermes_mail_filter:10024    |
                  +---------------+-------------------+
                                  |
                                  v
                  +-----------------------------------+
                  |  Amavis (hermes_mail_filter)      |
                  |   - SpamAssassin scoring          |
                  |   - ClamAV antivirus  <---- this page configures this engine
                  |   - banned-file checks            |
                  +---------------+-------------------+
                                  |
                                  v
                  +-----------------------------------+
                  |  Re-inject -> hermes_postfix_dkim:10026
                  +-----------------------------------+
                                  |
                                  v
                  +-----------------------------------+
                  |  OpenDKIM sign, ARC seal, deliver |
                  +-----------------------------------+

Amavis calls ClamAV over the local socket; the verdict determines whether Amavis quarantines, blocks, or passes the message. Amavis's own action policy (the final_*_destiny settings — quarantine vs DSN vs discard) lives in Antispam Settings and the per-domain policy table, not on this page. This page is engine knobs only.

Container and socket placement

Component Detail
Container hermes_mail_filter (IPv4 .105)
Engine clamd daemon, Unix socket inside the container
Daemon config /etc/clamav/clamd.conf (volume-mounted from ./config/mail_filter/etc/clamav/clamd.conf)
Signature dir /var/lib/clamav/ (Docker named volume mail_filter_data_clamav)
Signature whitelist /var/lib/clamav/local.ign2 (regenerated from parameters2 WHERE module='clamav-bypass' on every save)
Third-party feeds /etc/fangfrisch/fangfrisch.conf + /var/lib/fangfrisch/signatures/ (see Malware Feeds)
Base signature refresh freshclam (official ClamAV CVD updates, default 1h)
Feed refresh fangfrisch refresh on a 10-minute Ofelia job (hermes-fangfrisch-refresh)

The container exposes no host ports — Amavis is reached only by Postfix internally at hermes_mail_filter:10024 and re-injects to hermes_postfix_dkim:10026.

ClamAV Antivirus Settings card

Sixteen toggles, each rendered from the avSettings array in view_antivirus_settings.cfm with an inline hint and a "Recommended" label on the safer default. Every toggle writes parameters2.value2 = 'true' | 'false' for module = 'clamav'; on save, generate_antivirus_configuration.cfm selects every active row and emits one <directive> <value> line per toggle into a temp file, substitutes the temp file into the HERMES_ANTIVIRUS_SETTINGS_GO_HERE placeholder of clamd.conf.HERMES, backs up the live config to clamd.conf.HERMES, and moves the rendered file into place.

UI Toggle clamd.conf directive Recommended Notes
Scan Email Attachments ScanMail Enabled Master switch for inbound attachment scanning
Scan Archives ScanArchive Enabled Recurse into ZIP, RAR, 7z, etc. Without this, only the archive wrapper is scanned
Mark Encrypted Archives as Viruses ArchiveBlockEncrypted Disabled Aggressive; commonly false-positives on legitimate password-protected files
Scan Portable Executables ScanPE Enabled Windows PE format; required for decompression of UPX / FSG / Petite packers
Scan OLE2 Files ScanOLE2 Enabled MS Office .doc/.xls/.ppt and .msi
Block OLE2 VBA Macros OLE2BlockMacros Disabled Blocks ALL macro-enabled documents regardless of intent (detected as Heuristics.OLE2.ContainsMacros); useful in strict environments, breaks legitimate macros otherwise
Scan PDF Files ScanPDF Enabled PDF embedded JS, exploit detection
Scan HTML/JavaScript Content ScanHTML Enabled HTML normalization + JavaScript/ScriptEncoder decryption; phishing + script-exploit detection
Algorithmic Detection AlgorithmicDetection Enabled Engine-level heuristics for complex malware and graphic-file exploits
Scan ELF Files ScanELF Enabled Linux/Unix executable format
Phishing Signature Detection PhishingSignatures Enabled ClamAV's phishing signature DB
Scan Email URLs for Phishing PhishingScanURLs Enabled URL extraction + phishing URL DB lookup
Block SSL Mismatches in URLs PhishingAlwaysBlockSSLMismatch Disabled False-positives on CDN and redirect URLs
Block Cloaked URLs PhishingAlwaysBlockCloak Disabled False-positives on URL shorteners and marketing-tracker links
Detect Potentially Unwanted Applications DetectPUA Enabled Adware, dialers, non-malicious-but-unwanted software
Heuristic Scan Precedence HeuristicScanPrecedence Enabled When on, heuristic hits stop the scan immediately (saves CPU). When off, scanning continues so a signature-based hit can override a heuristic match

Operational consequence — disabling ScanMail. This effectively turns off antivirus for inbound mail. Amavis will still consult ClamAV for ban-pattern decisions but the engine will skip the attachment scan. Leave on except for very short-term diagnostics.

Operational consequence — OLE2BlockMacros = true. Every macro-enabled Office document is blocked as Heuristics.OLE2.ContainsMacros, including documents from your own users. Most organizations get better results with macro-blocking enforced at the endpoint (Microsoft 365 Protected View, Group Policy) rather than at the gateway. Turn on only after warning users and ensuring you have a release workflow.

AV Signature Whitelist card (Pro)

When ClamAV produces a false positive on a known-safe file, the admin enters the exact ClamAV signature name (e.g. Heuristics.OLE2.ContainsMacros) and Hermes appends it to /var/lib/clamav/local.ign2. ClamAV reads local.ign2 at engine start and suppresses any detection whose signature name matches a line in the file.

Storage: parameters2 WHERE module = 'clamav-bypass' (one row per signature name, parameter column holds the signature string). On every save and on every delete, generate_antivirus_configuration.cfm rewrites the whole local.ign2 from the table, runs dos2unix to scrub line endings, backs up the current file to local.ign2.HERMES, and moves the new file into place. ClamAV is then restarted via restart_clamav.cfm to pick up the change.

How to find a signature name

The in-card info box gives admins the lookup steps:

  1. From Message History, find the blocked message (Type column shows Virus or Banned)
  2. Grep the mail-filter log for the message ID: docker logs hermes_mail_filter 2>&1 | grep <mail_id>
  3. The log line shows the signature in parentheses, e.g. Blocked INFECTED (Heuristics.OLE2.ContainsMacros)
  4. Or scan a file directly: docker exec hermes_mail_filter clamscan /path/to/file

Operational consequence — whitelisting is by signature name, not by file hash. If you whitelist Heuristics.OLE2.ContainsMacros, you have effectively turned off macro detection globally. Prefer narrow signature names (specific malware family) over heuristic families when possible.

Signature refresh

Two independent refresh loops keep the engine current:

Source Mechanism Cadence Database
Official ClamAV (main.cvd, daily.cvd, bytecode.cvd) freshclam daemon inside hermes_mail_filter Default 1h (configurable in /etc/clamav/freshclam.conf) /var/lib/clamav/
Third-party feeds (Sanesecurity, SecuriteInfo, MalwarePatrol, etc.) fangfrisch refresh via Ofelia job hermes-fangfrisch-refresh Every 10 minutes (only feeds whose own publish cycle has elapsed actually re-download) /var/lib/fangfrisch/signatures/ then linked into /var/lib/clamav/ by setup-clamav-sigs

fangfrisch is the small Python tool that handles auth, cadence control, and integrity verification for third-party feeds; the feed list and per-feed enable/disable lives on Malware Feeds. Enabling premium feeds (SecuriteInfo paid, MalwarePatrol paid) requires Pro licensing — the feed list itself is gated on the same page.

Resource footprint

Loading the full signature database into RAM costs roughly 1.5–2 GB of memory. If hermes_mail_filter is under-provisioned (e.g. shared host with 4 GB total), clamd will fail to start, mail will queue behind Amavis, and the only sign in the UI is a quiet rise in deferred queue depth. Plan for at least 4 GB dedicated to the hermes_mail_filter container on systems with all third-party feeds enabled.

The default ClamAV file-size cap is 25 MB (MaxFileSize 25M in clamd.conf). Messages larger than this are passed without scan and flagged with a Heuristics.Limits.Exceeded indicator. Raising the cap requires editing clamd.conf.HERMES directly; the UI does not expose it because raising it disproportionately increases RAM and CPU per scan.

Save flow

1. View page submits action="AV Settings" (sixteen booleans),
                       action="Add AV Whitelist" (textarea),
                       action="Delete Entry" (id list)
2. view_antivirus_settings.cfm validates every avFields entry exists and is true|false
   (any failure -> error.cfm + cfabort)
3. antivirus_set_settings.cfm UPDATEs parameters2.value2 for each toggle
   (16 UPDATEs, module='clamav')
4. generate_antivirus_configuration.cfm:
     a. SELECT active='1' rows from parameters2 module='clamav' -> temp avsettings file
     b. dos2unix the temp file
     c. Substitute into clamd.conf.HERMES placeholder HERMES_ANTIVIRUS_SETTINGS_GO_HERE
     d. Back up /etc/clamav/clamd.conf -> clamd.conf.HERMES, move new file into place
     e. Rebuild /var/lib/clamav/local.ign2 from parameters2 module='clamav-bypass'
     f. dos2unix, back up local.ign2 -> local.ign2.HERMES, move new file into place
     g. cfinclude restart_clamav.cfm (docker container restart hermes_mail_filter ClamAV process)
5. session.m = 9 -> green "Antivirus Settings were saved successfully" alert

generate_antivirus_configuration.cfm also runs on whitelist add/delete — every change to either card triggers the same full regen + ClamAV restart cycle. The page does not return until the restart has completed (timeout per cfexecute).

Failure semantics

Failure Behavior
Toggle form missing a required boolean field m = "Antivirus Settings: form.<f> does not exist", error.cfm, cfabort
Toggle value not in true,false m = "Antivirus Settings: form.<f> is not true or false", error.cfm, cfabort
Delete clicked with no selection session.m = 11
Add Whitelist with empty textarea session.m = 13
dos2unix failure on the temp avsettings or local.ign2 file error.cfm + cfabort with the failing path in the message
cp /etc/clamav/clamd.conf -> .HERMES failure error.cfm + cfabort
mv <tmp>_clamd.conf -> /etc/clamav/clamd.conf failure error.cfm + cfabort
restart_clamav.cfm failure Surfaces as cfcatch from the docker restart step

The save is not transactional across the steps — if the SQL updates succeed but the ClamAV restart fails, the DB state has already advanced. The next save will re-render and re-apply because every save regenerates the entire file from the current row state (no incremental writes).

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_antivirus_settings.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/antivirus_*.cfm hermes_commandbox Validate / save / regenerate / restart
config/hermes/var/www/html/admin/2/inc/get_antivirus_settings.cfm hermes_commandbox Loads current parameters2 module='clamav' values
config/hermes/opt/hermes/conf_files/clamd.conf.HERMES hermes_commandbox (read) -> hermes_mail_filter (live /etc/clamav/clamd.conf) Canonical template with HERMES_ANTIVIRUS_SETTINGS_GO_HERE placeholder
config/mail_filter/etc/clamav/clamd.conf hermes_mail_filter (live config, bind-mounted) Read by clamd at start
/var/lib/clamav/local.ign2 hermes_mail_filter (Docker named volume mail_filter_data_clamav) Signature whitelist; rewritten on every save
/var/lib/clamav/*.cvd, *.cld, *.ndb, etc. hermes_mail_filter Signature databases (official + third-party)
parameters2 table, module='clamav' hermes_db_server (hermes DB) Source of truth for the sixteen toggles
parameters2 table, module='clamav-bypass' hermes_db_server (hermes DB) Source of truth for the AV Signature Whitelist
malware_databases table hermes_db_server (hermes DB) Third-party feed list (configured on Malware Feeds)
ofelia_jobs row hermes-fangfrisch-refresh hermes_db_server 10-minute feed refresh scheduler
hermes_mail_filter container clamd, freshclam, fangfrisch, Amavis, SpamAssassin

ARC Settings

ARC Settings

Admin path: Content Checks > ARC Settings (view_arc_settings.cfm)

What ARC does

ARC (Authenticated Received Chain, RFC 8617) preserves authentication results across forwarding gateways. Each gateway that handles a message can add a sealed record of the authentication state it observed, so a downstream verifier can trust the cumulative chain even when an intermediate gateway modifies the message body (adding disclaimers, banners, forwarding annotations, etc.) — body modification would otherwise invalidate the original sender's DKIM signature and lose DMARC alignment.

Hermes participates in ARC at two roles:

  1. As an originating sealer for mail submitted by authenticated Hermes users to external recipients — Hermes is the first hop in the chain (i=1; cv=none).
  2. As a forwarding sealer for inbound mail being relayed to a downstream MX (relay-mode domains) — Hermes adds a seal at i=N+1 referencing the upstream chain.

Container and milter placement

Component Detail
Container hermes_openarc (separate service, IPv4 .114)
Listen inet:8893
Source flowerysong/OpenARC v1.3.0, built from release tarball
Milter chain master.cf :10026 only (post-amavis re-injection, after OpenDKIM signer at :8892)
NOT in main.cf default smtpd_milters — sealing at :25 over the pre-modification body would produce an invalid seal once body_milter and CipherMail change the bytes

Modes

Mode Effect
s (sign only) Adds Hermes's seal but does not validate upstream chains
v (verify only) Records inbound chain validity in Authentication-Results headers; does not add a seal
sv (sign + verify) The gateway default; validates upstream then seals over the final body

The ARC Settings page slider auto-syncs Mode between sv (enabled) and v (disabled). The master arc_signing_enabled flag controls whether the daemon adds anything at all — when disabled, OpenARC operates in pass-through mode (every peer in PeerList, no headers added).

Single signing identity per gateway

Unlike DKIM (which uses per-sender-domain keys), ARC uses a single signing identity per gateway — Gmail seals everything with d=google.com, Microsoft 365 with d=outlook.com, and Hermes with whatever domain you generate the key for. Pick a domain you control (typically your own organization's primary domain). The selector follows the same DNS publication pattern as DKIM: <selector>._domainkey.<domain> with value v=DKIM1; k=rsa; p=<public-key>.

Hermes is the auth boundary — what cv=fail means and doesn't mean

Hermes is the authoritative auth / security boundary for every domain it serves. Inbound DKIM, SPF, DMARC, ARC verify, spam, virus checks all happen at Hermes. Body modifications (External Sender Banner, disclaimer, signature insertion, encryption) also happen at Hermes. Customer downstream mail servers are expected to be configured to trust Hermes implicitly: allowlist Hermes by IP / hostname, accept forwarded mail without re-running upstream auth checks. This matches how Mimecast, Proofpoint, and Barracuda customers deploy those products — the SEG IS the trust boundary.

When Hermes modifies a message body (banner, disclaimer, etc.), any cryptographic signature whose body hash was computed over the original bytes will no longer body-validate against the current bytes. This affects:

  1. The original sender's DKIM-Signature body hash
  2. The upstream ARC-Message-Signature body hash for each prior i=

Hermes's own outbound seal at i=N+1 is mathematically valid (it is computed over the modified body), but the cv= field on that seal must honestly report whether the upstream chain passed when Hermes received the message AND remains body-valid in the message it is about to send. Once Hermes modifies the body, the upstream bh= no longer matches the current body, so cv=fail is the correct (and only defensible) value.

This is by design. A correctly-configured customer downstream MX allowlists Hermes and does not re-check auth on Hermes-forwarded mail; the cv=fail and broken DKIM signals never gate delivery. If a customer reports forwarded mail being rejected by their downstream MX due to ARC / DKIM / DMARC failure, the fix is to allowlist Hermes on their MX, not to silence Hermes.

Removing Hermes's seal does not help: the verifier walks the chain back to i=1 and recomputes each prior body hash against the current body independently of our seal. Stripping the entire upstream chain would require Hermes to rewrite the From: header (mailing-list style) to maintain DMARC alignment with a domain Hermes controls — this is a significant UX cost that all major SEG vendors (Mimecast, Proofpoint, Barracuda) have chosen not to pay.

Default Hermes behavior

Scenario Behavior
Inbound mail with NO upstream ARC chain → any local recipient Banner injects; Hermes seals at i=1; cv=none; chain is clean
Inbound mail with upstream ARC → local mailbox recipient Banner injects; Hermes seals at i=N+1; cv=fail; message ends at Hermes (no downstream chain to protect — cv=fail is just bytes in the user's inbox)
Inbound mail with upstream ARC → relay-mode recipient Banner injects; Hermes seals at i=N+1; cv=fail; downstream MX (which should be allowlisting Hermes) accepts and delivers regardless
Outbound from local Hermes user → external Hermes is the first sealer; i=1; cv=none; clean chain to downstream

There is no toggle, no conditional skip, no per-domain override. Hermes always behaves the same way and reports the chain state honestly. Customer-side trust configuration is the responsibility of the customer's MX administrator.

When a Trusted ARC Sealer configuration helps

Trusted ARC Sealer configuration on the customer side is useful in cross-org scenarios that aren't direct relay-to-customer-MX — for example, when a Hermes-served domain is part of a chain that forwards through other gateways, or when Hermes is forwarding to a third-party tenant the customer doesn't control. See the Trusted ARC Sealers — M365 guide for the M365 PowerShell configuration. For the standard Hermes-as-relay-MX-to-customer-mail-server case, IP allowlisting on the customer's MX is simpler and sufficient.

When to ask receivers to trust Hermes as a sealer

For customers running strict downstream verifiers (Microsoft 365 tenants that DMARC-enforce, Gmail Workspace receivers that escalate on arc=fail, etc.), the chain-integrity limitation can cause relay-out delivery issues even on benign inbound that happens to come through an upstream sealer. The standard industry remedy is for the receiver to add Hermes to its Trusted ARC Sealers list.

For Microsoft 365 customers, follow the Trusted ARC Sealers — M365 guide which covers the PowerShell command, identity requirements, and verification steps.

Key management workflow

  1. Click Add ARC Key in the Gateway ARC Signing Identity card
  2. Enter the signing domain (must validate as bob@<domain>) and selector (DNS-safe label, e.g. arc1)
  3. Choose key size (RSA 1024 or 2048)
  4. Hermes generates the key pair in /opt/hermes/arc/keys/
  5. Copy the public key TXT record and publish at <selector>._domainkey.<domain> in your authoritative DNS
  6. Verify DNS propagation, then click the slider to enable signing

Without an active key, Mode is forced to v (verify only) regardless of the saved Mode setting.

Troubleshooting

Symptom Likely cause
Gmail "Show original" shows arc=fail (signature failed) on outbound from a local Hermes user DNS for selector not published, propagated incorrectly, or wrong key
Downstream MX rejects forwarded mail from M365 sender with arc=fail Expected when upstream ARC + body modification meet on relay-out; either ensure the conditional banner skip is active (/etc/hermes/body_milter/relay_domains is populated) or ask the receiver to configure Hermes as a Trusted ARC Sealer
OpenARC fails to start with key data is not secure The signing key file ownership is not openarc:openarc or permissions are too loose; check the entrypoint chown step
ARC headers absent from outbound entirely arc_signing_enabled = 0 (master off), or no enabled key exists for the configured arc_mode

BCC Maps

BCC Maps

Admin path: Content Checks > BCC Maps (view_bcc_maps.cfm, inc/add_bcc_map_action.cfm, inc/edit_bcc_map_action.cfm, inc/delete_bcc_map_action.cfm, inc/get_bcc_map_json.cfm, inc/get_mailbox_bcc_count.cfm).

This page manages silent message copies at the SMTP envelope layer. Each entry maps an envelope address (sender or recipient, chosen per row) to a BCC target; when mail matching the address flows through Postfix, an additional copy is generated and routed to the target. The original delivery is unaffected; neither the original sender nor the original recipient sees any indication that a copy was made.

BCC Maps is the sibling envelope-level rule table to Global Sender Rules. Where Global Sender Rules decide whether a message is allowed in or blocked, BCC Maps decides whether an additional copy is created — both work on the envelope, before the message body is parsed.

How Postfix BCC works

Postfix has two distinct directives for envelope-level BCC injection:

Directive Lookup key Adds BCC when... Typical use
sender_bcc_maps Envelope sender (MAIL FROM) The matched address is the one sending the message Journaling outbound mail from an executive, monitoring a compromised account
recipient_bcc_maps Envelope recipient (RCPT TO) The matched address is the one receiving the message Compliance journaling of mail to a regulated mailbox, legal-hold copies

The two maps are queried independently on every message — a single delivery can hit both if both a sender BCC and a recipient BCC match. The BCC happens once Postfix has accepted the message; the original envelope is preserved and the additional copy is queued separately.

Hermes wires both directives to MySQL-backed lookup tables in /etc/postfix/main.cf:

sender_bcc_maps    = mysql:/etc/postfix/mysql-sender-bcc-maps.cf
recipient_bcc_maps = mysql:/etc/postfix/mysql-recipient-bcc-maps.cf

Each .cf file holds a SQL query that selects bcc_to from bcc_maps where the address column matches and the row is enabled.

-- mysql-sender-bcc-maps.cf
SELECT bcc_to FROM bcc_maps
WHERE address='%s' AND bcc_type='sender' AND enabled=1

-- mysql-recipient-bcc-maps.cf
SELECT bcc_to FROM bcc_maps
WHERE address='%s' AND bcc_type='recipient' AND enabled=1

No reload required. Unlike hashed check_sender_access lookups (used by Global Sender Rules), MySQL lookups are evaluated live against the database on every message — there is no postmap step, no postfix reload. Adding, editing, disabling, or deleting a row takes effect on the next inbound message. The UI surfaces this implicitly: the success alerts say "entry created/updated/deleted" without the "Postfix reloaded and Amavis restarted" suffix that other envelope pages append.

The page

A single info callout, an Add button that opens a modal, and one DataTable.

Add BCC Map modal

Field Stored as Notes
Address bcc_maps.address The envelope address to watch. Full email (user@domain.tld) or @domain.tld for domain-wide. Lower-cased on save
Type bcc_maps.bcc_type sender (outbound mail from this address) or recipient (inbound mail to this address)
BCC To bcc_maps.bcc_to The address that receives the silent copy. Single email only; not a pattern. Lower-cased on save
Description bcc_maps.description Free-text label (e.g. "Legal compliance — exec journaling"); nullable

The handler validates Address against IsValid("email", ...) for full addresses and against a @domain pattern check for domain-wide rows. BCC To must be a valid email address — domain patterns are not accepted here, only a concrete delivery target. The (address, bcc_type) pair is UNIQUE in the schema, so attempting to add a second row with the same address and type returns alert m = 14 and rejects the insert.

BCC Maps (DataTable)

Column Source
Actions Edit (modal, AJAX load via get_bcc_map_json.cfm), Delete (confirm modal)
Address bcc_maps.address
Type bcc_maps.bcc_type -> Sender badge (primary) or Recipient badge (info)
BCC To bcc_maps.bcc_to
Status bcc_maps.enabled -> Enabled badge (green) or Disabled badge (grey)
Description bcc_maps.description (em-dash if empty)

Edit constraints

The Edit modal makes Address and Type read-only — they are the natural key of the row (UNIQUE (address, bcc_type)) and changing them would semantically be a different rule. To re-target a watched address, delete the row and add a new one. Only BCC To, Status (enabled / disabled), and Description can be changed in place.

The Status toggle is the right tool for pausing surveillance briefly without losing the row — e.g. a compliance journaling rule that should be off during a planned mail-flow test.

The bcc_maps table

Column Purpose
id Auto-increment primary key
address The watched envelope address (full email or @domain.tld)
bcc_to The silent-copy target address
bcc_type sender or recipient
enabled 1 = active, 0 = paused (row preserved, no BCC generated)
description Optional free-text label
created_at Auto-populated timestamp on insert
UNIQUE KEY (address, bcc_type) — same address can have one sender BCC AND one recipient BCC, but not two of either

BCC mail still goes through content filtering

Important behavior to understand: the BCC copy that Postfix generates is a real message in its own right, with the BCC target as its recipient. That copy traverses the same pipeline as any other inbound delivery — it goes through Amavis, SpamAssassin, ClamAV, the Sender/Recipient Rules for the BCC target, and any per-recipient quarantine policy.

The consequences:

The page's info callout flags the SPF case explicitly. For a journaling / compliance use case where loss of a copy is unacceptable, the BCC target should be a local mailbox on the same Hermes instance — the message stays inside the gateway, the external-receiver policy issue does not arise, and any spam-tier issue is visible to the local mailbox owner.

Privacy and compliance

BCC Maps is a surveillance feature. The original sender and the original recipient are never notified that a copy was made; that is the point.

Operationally that means:

Cascading delete on mailbox removal

When a mailbox is deleted from Mailboxes, inc/delete_mailbox_action.cfm (step 4b) issues:

DELETE FROM bcc_maps
 WHERE address = :deleted_mailbox
    OR bcc_to  = :deleted_mailbox

That is — every BCC rule referencing the deleted mailbox is removed, whether the mailbox was the watched address or the BCC target. Because the live MySQL lookup re-reads on every message, the change takes effect immediately; no postmap or reload runs.

The same delete handler calls the AJAX endpoint inc/get_mailbox_bcc_count.cfm from the confirmation modal before the deletion fires, so the admin sees the number of BCC rows that will be cascaded ("This mailbox is watched by 2 BCC rules and is the target of 1 BCC rule") and can cancel.

Domain-pattern rows (@domain.tld) are not cascaded by mailbox deletion — they reference a domain, not a specific mailbox, and remain in place until the whole domain is removed or the row is deleted manually.

Failure semantics

Alert Trigger
m = 1 / 2 / 3 Add / Edit / Delete success
m = 10 Address field blank on Add
m = 11 Address fails email-or-@domain syntax check
m = 12 BCC To blank on Add or Edit
m = 13 BCC To is not a valid email address
m = 14 An entry with the same (address, bcc_type) already exists
m = 20 Missing required form field on Edit / Delete (no bcc_id)
m = 21 Edit / Delete target row no longer exists

There is no session.m = 4 "Apply Failed" path because there is nothing to apply — the next message Postfix processes will read the new row from MySQL directly.

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_bcc_maps.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/add_bcc_map_action.cfm hermes_commandbox Validate + INSERT
config/hermes/var/www/html/admin/2/inc/edit_bcc_map_action.cfm hermes_commandbox Validate + UPDATE (only bcc_to, enabled, description)
config/hermes/var/www/html/admin/2/inc/delete_bcc_map_action.cfm hermes_commandbox DELETE single row
config/hermes/var/www/html/admin/2/inc/get_bcc_map_json.cfm hermes_commandbox AJAX endpoint for the Edit modal
config/hermes/var/www/html/admin/2/inc/get_mailbox_bcc_count.cfm hermes_commandbox AJAX endpoint for the mailbox-delete confirmation modal
config/postfix-dkim/etc/postfix/mysql-sender-bcc-maps.cf hermes_postfix_dkim MySQL lookup definition for sender_bcc_maps
config/postfix-dkim/etc/postfix/mysql-recipient-bcc-maps.cf hermes_postfix_dkim MySQL lookup definition for recipient_bcc_maps
bcc_maps table hermes_db_server (hermes DB) Source of truth
hermes_postfix_dkim container Reads MySQL lookups live on every message

DKIM Settings

DKIM Settings

Admin path: Content Checks > DKIM Settings (view_dkim_settings.cfm, inc/get_dkim_settings.cfm, inc/dkim_save_settings.cfm, inc/dkim_set_settings.cfm, inc/dkim_generate_config_file.cfm, inc/dkim_generate_keytable.cfm, inc/dkim_generate_signingtable.cfm, inc/dkim_generate_hosts.cfm, inc/dkim_generate_domains.cfm, inc/restart_opendkim.cfm, inc/generate_postfix_configuration.cfm).

This page controls inbound DKIM verification and the OpenDKIM runtime configuration that also drives outbound signing. DKIM (RFC 6376) lets a sending domain attach a cryptographic signature (DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=mail1; ...) covering selected headers and a hash of the message body; receivers fetch the public key at <selector>._domainkey.<domain> in DNS and verify the signature. Unlike SPF, DKIM survives most forwarding — the signature stays attached to the message and verifies wherever the body and signed headers remain unchanged.

Per-domain key generation (selector, RSA 1024 / 2048, DNS TXT record to publish) is managed elsewhere — on the Email Server Domains page via edit_domain_dkim.cfm, which writes rows into the dkim_sign table. This Settings page configures the OpenDKIM daemon's runtime behavior and maintains the verification-side bypass lists.

Two OpenDKIM instances, one config page

To avoid the body-modification trap that breaks any signer running after a body-modifying milter, Hermes (issue #232) runs two separate OpenDKIM instances inside hermes_postfix_dkim:

Instance Config Socket Mode Role
Primary /etc/opendkim.conf inet:8891@0.0.0.0 sv (sign + verify) Verifies inbound DKIM at smtpd :25; signs outbound at :587 / :465 (submission ports — pre-Amavis, pre-CipherMail)
Sign-only /etc/opendkim-sign.conf inet:8892@127.0.0.1 s (sign only) Signs at the :10026 re-injection port after Amavis, CipherMail, and the body milter have finished modifying the body. Never adds an Authentication-Results header

Both instances share the same key tables (/opt/hermes/dkim/KeyTable, /opt/hermes/dkim/SigningTable) and the same trusted-hosts / exempt-domains lists; the page below feeds both. The reason for two instances: a single sv-mode OpenDKIM on :10026 would verify the post-modification body of inbound mail flowing through the re-inject port and emit a spurious dkim=fail Authentication-Results header. Sign-only mode at :10026 produces the final outbound signature over the byte sequence the receiver will actually see.

Where DKIM sits in the flow

+--------------------------+
| Remote SMTP peer         |
+-----------+--------------+
            |
            v
+-----------+--------------------------------+
| smtpd :25 (hermes_postfix_dkim)             |
|   smtpd_milters = inet:127.0.0.1:8891, ...  |
|     primary OpenDKIM (sv) verifies inbound  |
|     DKIM-Signature, adds                    |
|     Authentication-Results: dkim=pass/...   |
|     (consumed downstream by OpenDMARC)      |
+-----------+--------------------------------+
            |
            v
        Amavis :10024 (content scoring, CipherMail)
            |
            v (reinject)
+-----------+--------------------------------+
| smtpd :10026 (post-content, post-body-mod)  |
|   smtpd_milters = inet:127.0.0.1:8891       |
|     sign-only OpenDKIM at :8892 actually    |
|     signs the final outbound body           |
|     (KeyTable selects per-domain key by     |
|      "*@<domain>" SigningTable match)       |
+-----------+--------------------------------+
            |
            v
        OpenARC seal (if enabled)
            |
            v
        Outbound to receiver

The actual signing decision happens against the SigningTable:

# /opt/hermes/dkim/SigningTable
*@example.com       mail1._domainkey.example.com
*@partner.org       k2024._domainkey.partner.org

…joined to the KeyTable:

# /opt/hermes/dkim/KeyTable
mail1._domainkey.example.com  example.com:mail1:/opt/hermes/dkim/keys/mail1_example.com.dkim.private
k2024._domainkey.partner.org  partner.org:k2024:/opt/hermes/dkim/keys/k2024_partner.org.dkim.private

Both files are regenerated from the dkim_sign table on every key add / enable / disable / delete on the per-domain page.

The two cards on the page

1. DKIM Settings (master toggle + OpenDKIM runtime controls)

DKIM Enabled flips the child row in parameters whose parameter matches inet:%:8891 under the smtpd_milters parent (and the same under non_smtpd_milters). Disabling DKIM here also disables DMARC, mirroring the SPF-disable behavior — DMARC needs at least one of the two to align against. The in-page callout warns about this dependency.

When enabled, nine controls are written to parameters2 rows in the dkim module, then substituted into the OpenDKIM template at /opt/hermes/conf_files/opendkim.conf.HERMES:

Control OpenDKIM directive Effect
Body Canonicalization Canonicalization (body half) relaxed (recommended) ignores trailing whitespace and end-of-line changes; simple requires byte-exact body. Most relays touch line endings, so relaxed is the only practical choice unless you fully control every downstream hop
Headers Canonicalization Canonicalization (header half) relaxed lowercases header names and folds whitespace; simple requires headers unchanged. Same reasoning — relaxed survives normal relay reformatting
Default Message Action On-Default Catch-all for verification outcomes not covered by the more specific actions below. accept is the recommended default
Bad Signature Action On-BadSignature Signature present, present-and-valid in syntax, but verification fails (body or signed-header bytes changed). accept (recommended) lets DMARC + spam scoring make the call
DNS Error Action On-DNSError The selector's _domainkey TXT record is unreachable or returned SERVFAIL. accept (recommended) — DNS instability is the sender's problem, not yours; do not block real mail on transient resolver failures
Internal Error Action On-InternalError OpenDKIM ran out of resources or hit an unexpected runtime error. accept (recommended) prevents silent mail loss when the verifier itself fails
No Signature Action On-NoSignature Message arrived unsigned. Many legitimate senders still don't sign — DMARC enforcement is the correct gate for "must be signed", not this knob. accept (recommended)
Security Concern Action On-Security Signature references a weak algorithm or unusually short key. accept (recommended) — score downstream rather than reject at the milter
Signature Algorithm SignatureAlgorithm rsa-sha256 (current standard, recommended) or the deprecated rsa-sha1. Many receivers reject rsa-sha1 outright; do not change unless you know why

Each "Action" option set is: accept, discard, reject, tempfail, quarantine. The save handler validates that submitted values are members of this set before writing.

Operational consequence — accept everywhere is intentional. The recommended baseline accepts on every error and every failure condition because DKIM at the milter is not a delivery gate. The verification result is meant to be consumed by DMARC and by spam scoring, not to drop mail. Setting any of these to reject means a single sender DNS hiccup or a single intermediate relay rewriting a header can cause real mail to bounce. Leave them at accept and let DMARC enforcement (which considers the sender-published policy) make the discard decision.

2. Whitelisted Domains and Trusted Hosts

Two row-per-entry lists that together drive three OpenDKIM directives:

Entry type OpenDKIM directive(s) File on disk Table
Whitelisted Domain ExemptDomains /opt/hermes/dkim/ExemptDomains dkim_bypass (entry, note)
Trusted Host InternalHosts + ExternalIgnoreList /opt/hermes/dkim/TrustedHosts dkim_trusted_hosts (host, note)

Whitelisted Domain exempts the listed sender domain from inbound DKIM verification entirely — OpenDKIM logs the bypass and does not fetch the selector record. Use for known-broken signers whose mail you still need to receive (some legacy mailing-list infrastructure, specific government endpoints with unmaintained selectors).

Trusted Host is dual-purpose. The same entries are written to both InternalHosts (mail from these hosts is considered locally originated and will be DKIM-signed on the way out) and ExternalIgnoreList (mail from these hosts skips inbound DKIM verification). Accepts IP addresses, CIDR ranges, hostnames, and bare domain names. The Docker subnet (172.16.32.0/24 by default) is pre-populated so the post-Amavis re-inject from 127.0.0.1 and the inter-container hops are correctly treated as internal.

The DataTable supports add (textarea — one entry per line, deduplicated), inline edit, single delete, and bulk delete; the row checkboxes carry an id|type composite value so the bulk handler can route each delete to the right table.

What this page does NOT control

Per-domain key rotation pattern

A working selector-rotation looks like this (operator-side, not a single button on the page):

1. On edit_domain_dkim.cfm, generate a new key with a new selector
   (e.g. existing "mail1" -> new "mail2"). Mark NEW key disabled.
2. Publish the new key's TXT record at
   mail2._domainkey.example.com in authoritative DNS. The old
   mail1._domainkey.example.com record STAYS published.
3. Verify DNS propagation globally.
4. Enable the new key (disables the old one in dkim_sign atomically).
   KeyTable + SigningTable regenerate; OpenDKIM reloads.
5. Outbound mail now signs with mail2; mail signed with mail1 while
   in flight still verifies because the mail1 TXT record is still
   live.
6. Wait through the typical re-delivery window (24-72 hours).
7. Delete the old mail1 row in dkim_sign; remove the
   mail1._domainkey.example.com TXT record.

Selectors are arbitrary DNS labels — mail1, 2026q1, hermes, etc. — and there is no DKIM-defined upper bound on how many you publish concurrently.

Save flow

1. Validate form fields exist (when enabling DKIM)
   - Missing or out-of-set values -> session.m = 20, redirect, no DB write
2. cfinclude dkim_set_settings.cfm
     a. UPDATE parameters child rows for the smtpd_milters / non_smtpd_milters
        :8891 entries (on or off)
     b. UPDATE parameters2 rows for the nine OpenDKIM runtime directives
     c. cfinclude dkim_generate_config_file.cfm — read
        /opt/hermes/conf_files/opendkim.conf.HERMES, REReplace the
        Canonicalization / On-* / SignatureAlgorithm placeholders, write
        /etc/opendkim.conf
     d. cfinclude dkim_generate_hosts.cfm — regenerate
        /opt/hermes/dkim/TrustedHosts from dkim_trusted_hosts
     e. cfinclude dkim_generate_domains.cfm — regenerate
        /opt/hermes/dkim/ExemptDomains from dkim_bypass
     f. cfinclude dkim_generate_keytable.cfm + dkim_generate_signingtable.cfm
        — rebuild from dkim_sign
     g. cfinclude restart_opendkim.cfm — docker exec inside
        hermes_postfix_dkim to restart BOTH opendkim instances
3. cfinclude generate_postfix_configuration.cfm — regenerate main.cf
   (smtpd_milters list reflects DKIM on/off) and reload Postfix
4. If DKIM was DISABLED: also flip off OpenDMARC milter rows, clear
   FailureReports, deactivate the DMARC report Ofelia job, regenerate
   opendmarc.conf, restart OpenDMARC
5. session.m = 9 -> green "DKIM settings saved" alert on redirect

Add / Edit / Delete on the second card calls dkim_generate_hosts.cfm or dkim_generate_domains.cfm (whichever applies) plus restart_opendkim.cfm inline — Postfix is not reloaded since the milter chain itself did not change.

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_dkim_settings.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/get_dkim_settings.cfm hermes_commandbox Loads current parameters / parameters2 / bypass / trusted-host values
config/hermes/var/www/html/admin/2/inc/dkim_save_settings.cfm hermes_commandbox Validates form, calls set + generate + restart chain; disables DMARC if DKIM off
config/hermes/var/www/html/admin/2/inc/dkim_set_settings.cfm hermes_commandbox UPDATEs the parameters / parameters2 rows, regenerates all four config files, restarts OpenDKIM
config/hermes/var/www/html/admin/2/inc/dkim_generate_config_file.cfm hermes_commandbox Renders /etc/opendkim.conf from the template + DB
config/hermes/var/www/html/admin/2/inc/dkim_generate_keytable.cfm hermes_commandbox Rebuilds /opt/hermes/dkim/KeyTable from dkim_sign
config/hermes/var/www/html/admin/2/inc/dkim_generate_signingtable.cfm hermes_commandbox Rebuilds /opt/hermes/dkim/SigningTable from dkim_sign
config/hermes/var/www/html/admin/2/inc/dkim_generate_hosts.cfm hermes_commandbox Rebuilds /opt/hermes/dkim/TrustedHosts from dkim_trusted_hosts
config/hermes/var/www/html/admin/2/inc/dkim_generate_domains.cfm hermes_commandbox Rebuilds /opt/hermes/dkim/ExemptDomains from dkim_bypass
config/hermes/opt/hermes/conf_files/opendkim.conf.HERMES hermes_commandbox (read) → hermes_postfix_dkim (live /etc/opendkim.conf) Template with HEADER-CANONICALIZATION, BODY-CANONICALIZATION, DEFAULT-ACTION, etc. placeholders
config/postfix-dkim/etc/opendkim-sign.conf hermes_postfix_dkim Static config for the sign-only instance at :8892 (no placeholders — relaxed/relaxed + rsa-sha256 are fixed for the re-injection signer)
parameters table (inet:%:8891 rows under smtpd_milters and non_smtpd_milters) hermes_db_server (hermes DB) DKIM milter on/off
parameters2 table (rows where module='dkim') hermes_db_server (hermes DB) The nine OpenDKIM runtime settings
dkim_sign, dkim_bypass, dkim_trusted_hosts tables hermes_db_server (hermes DB) Per-domain keys, exempt-domain list, trusted-host list
hermes_postfix_dkim container Runs both OpenDKIM instances and hosts the live config + key files
hermes_unbound container Resolves every <selector>._domainkey.<domain> lookup

Failure semantics

Failure Behavior
Missing form fields when enabling DKIM session.m = 20, redirect, no DB write
Out-of-set value submitted for an Action / Canonicalization / Algorithm field session.m = 20, redirect, no DB write
Empty entry on Add session.m = 13, redirect, no DB write
Invalid syntax on Add / Edit session.m = 17, redirect, no DB write
Duplicate entry on Add session.m = 14, redirect, no DB write
dkim_generate_config_file.cfm write fails Surfaces as cfcatch from the inline include — save aborts
restart_opendkim.cfm fails Same path — Postfix is reloaded anyway in step 3, but DKIM service is left in the prior runtime state
KeyTable / SigningTable missing because no dkim_sign rows exist yet OpenDKIM starts but signs nothing — outbound mail goes out unsigned

DMARC Settings

DMARC Settings

Admin path: Content Checks > DMARC Settings (view_dmarc_settings.cfm, inc/get_dmarc_settings.cfm, inc/dmarc_save_settings.cfm, inc/dmarc_set_settings.cfm, inc/dmarc_generate_config_file.cfm, inc/dmarc_generate_reports_script.cfm, inc/restart_opendmarc.cfm).

This page controls Hermes's OpenDMARC milter — both whether DMARC is evaluated on inbound mail and, when enabled, what happens to verdicts and whether daily aggregate reports are generated for the domains that publish a DMARC record. DMARC (RFC 7489) is the policy layer that sits on top of SPF and DKIM; a sender publishes a _dmarc.<domain> TXT record telling receivers what to do when neither SPF nor DKIM aligns with the From: header domain. Hermes is the receiver that does the work.

How DMARC fits the auth stack

                  +--------------------+
   inbound msg -->| SPF check          |  passes/fails on envelope-from IP
                  +---------+----------+
                            |
                            v
                  +--------------------+
                  | DKIM verify        |  passes/fails on each signature
                  +---------+----------+
                            |
                            v
                  +--------------------+
                  | OpenDMARC          |  reads SPF + DKIM AR headers,
                  |   :54321 milter    |  fetches _dmarc.<from-domain>
                  +---------+----------+  evaluates alignment + policy
                            |
                            v
                  +--------------------+
                  | RejectFailures?    |
                  | -> reject / accept |
                  +--------------------+

A message aligns when its From: header domain matches the SPF-pass envelope-from domain OR the DKIM-pass d= domain. Relaxed alignment (the default) accepts org-domain match (example.com aligns with mail.example.com); strict alignment requires exact match. OpenDMARC reads the alignment results that SPF and DKIM have already written into the Authentication-Results header — both checks must therefore be active before DMARC is useful. The UI enforces this: enabling DMARC with SPF or DKIM disabled returns error 1.

Container and milter placement

Component Detail
Container hermes_dmarc (separate service, IPv4 .111)
Listen inet:54321@[0.0.0.0] (Socket directive in opendmarc.conf)
Source OpenDMARC daemon (Trusted Domain Project), packaged in the hermes-dmarc image
Milter chain Postfix smtpd_milters AND non_smtpd_milters parents, child row inet:<container>:54321 — toggle flips enabled on that row
DMARC report DB opendmarc database on hermes_db_server, credentials in system_settings rows mysql_username_opendmarc / mysql_password_opendmarc
History file /etc/opendmarc/opendmarc.dat inside hermes_dmarc (volume-mounted from ./config/opendmarc/etc/opendmarc/)

The container exposes no host ports — Postfix reaches OpenDMARC internally at inet:hermes_dmarc:54321. The whitelist file path referenced by DomainWhitelistFile resolves to /etc/opendmarc/whitelist.domains, written by inc/dmarc_generate_domains.cfm from the dmarc_domains table on every save.

DMARC Settings card

Six controls drive opendmarc.conf directly via placeholder substitution into /opt/hermes/conf_files/opendmarc.conf.HERMES.

UI Control opendmarc.conf directive What it does
DMARC Enabled (YES/NO) Milter chain toggle Enables the inet:%:54321 child row under smtpd_milters and non_smtpd_milters; OpenDMARC stops being consulted entirely when disabled
Reject Failures RejectFailures (true/false) When true, messages failing DMARC evaluation are rejected (or temp-failed if evaluation could not complete). When false, the message is accepted and only an Authentication-Results header records the verdict
Hold Quarantine Policy Messages HoldQuarantinedMessages (true/false) When true, messages from domains publishing p=quarantine that fail DMARC are routed to the Postfix hold queue for manual release/delete. When false (recommended), quarantine-policy messages are delivered with an Authentication-Results annotation and downstream scoring handles them
Generate Daily Failure Reports FailureReports (true/false) When true, OpenDMARC writes failure records to the history file and the daily Ofelia job converts them to RFC 6591 aggregate reports
Failure Reports From E-mail --report-email flag on opendmarc-reports RFC 6591 envelope From: for the outgoing report — must be a valid email address (validated by IsValid("email", ...))
Failure Reports Reporting Organization --report-org flag Identifies your gateway as the report source — alphanumeric only (validation regex: [^A-Za-z0-9])

OpenDMARC's FailureReports triggers reports only for domains that publish p=quarantine or p=reject (it never auto-reports for p=none unless FailureReportsOnNone is also set — Hermes does not expose that directive).

The "Reject Failures" UI hint and the OpenDMARC docs use the same language: messages that fail are rejected when policy is reject, delivered with header when policy is none, and either held or flagged when policy is quarantine (depending on HoldQuarantinedMessages).

Operational consequence — RejectFailures = true. When this is on, OpenDMARC will respond 550 5.7.0 to messages from domains publishing p=reject that fail evaluation, and Postfix will refuse the message in-band. This catches forged messages but also catches legitimate forwarded mail from senders whose original SPF / DKIM chain breaks at an upstream forwarder. If you start seeing legitimate forward-from-mailing-list mail bounce, the fix is to add the originating domain to the Whitelisted Domains card below — not to disable Reject Failures globally.

Whitelisted Domains card

Rows from the dmarc_domains table (id, domain, note, type) write to /etc/opendmarc/whitelist.domains. OpenDMARC reads that file via DomainWhitelistFile and bypasses DMARC evaluation entirely for any matching From: domain — no alignment check, no policy enforcement, no failure report. Use for trusted senders with known broken DMARC, partner domains that forward through aggregators that strip headers, or legacy mailing lists.

Only domain names are accepted; IP addresses are rejected by the add handler. Domains are validated by the same regex used elsewhere in Hermes (e.g. error 17: "The entry is not a valid domain"). Bulk add is supported one-per-line in the textarea.

DMARC report generation (daily aggregate / RUA)

When Generate Daily Failure Reports is enabled, dmarc_set_settings.cfm calls dmarc_generate_reports_script.cfm which renders /opt/hermes/scripts/dmarc_report_script.sh with credentials and identifiers substituted into placeholders (DATABASE-SERVER, DATABASE-USER, DATABASE-PASSWORD, REPORTING-EMAIL, REPORTING-ORGANIZATION, POSTMASTER-EMAIL) and writes the result to /opt/hermes/schedule/dmarc_report_script.sh (chmod +x).

An Ofelia job named hermes-dmarc-report runs the script daily at 02:30:

[job-exec "hermes-dmarc-report"]
schedule:  0 30 02 * * *
container: hermes_dmarc
command:   /opt/hermes/schedule/dmarc_report_script.sh

The script does three things in sequence:

  1. opendmarc-import — drains /etc/opendmarc/opendmarc.dat (the per-message verdict log OpenDMARC writes) into the opendmarc MariaDB database
  2. opendmarc-reports — generates RFC 6591 aggregate XML reports for the prior 24h interval and emails one report per sender domain to the rua= address that domain published in DNS
  3. opendmarc-expire — drops records older than the retention window from the database

The script also emits a Net::SMTP success/failure notification to the postmaster address (from system_settings). The Perl one-liner passes the postmaster address through an environment variable rather than direct string interpolation — Perl's default array sigil @ treats @deeztek.net as an array dereference and silently loses the domain part. Passing via $ENV{POSTMASTER_ARG} avoids the trap (the fix landed as issue #215). The notification is also skipped entirely when postmaster is not a valid email address (e.g. bare local-part like postmaster) — this prevents queue pollution with undeliverable bounces.

SMTP delivery uses hermes_postfix_dkim:10026 (the post-amavis re-injection port) — using :25 would re-process the report through the inbound pipeline and could re-trigger DMARC evaluation on the report itself.

When Generate Daily Failure Reports is disabled (or DMARC itself is disabled), the save handler:

Forensic (RUF) reports

Forensic (per-failure) reports are intentionally not generated by Hermes. They are privacy-noisy (they include redacted copies of failing messages), receivers rarely publish a ruf= address, and the modern operational consensus is that aggregate (RUA) reports give operators the visibility they need without the per-message exhaust. The FailureReportsBcc / FailureReportsSentBy / CopyFailuresTo directives in opendmarc.conf.HERMES are left commented and not exposed in the UI.

ARC interaction

Hermes also runs an ARC sealer (hermes_openarc) on the same authentication stack. When Hermes modifies a message body (External Sender Banner, disclaimer injection, signature injection, S/MIME or PGP rewrap), the original sender's DKIM body hash no longer matches the current body — DMARC alignment is lost on the modified copy. ARC preserves the pre-modification verdict in a sealed chain so downstream receivers configured to trust Hermes can still rescue DMARC alignment. See ARC Settings and the Trusted ARC Sealers — M365 guide for the receiver-side configuration. Hermes is the authoritative auth boundary for every domain it serves; customer downstream MX allowlisting is the standard remedy when ARC trust is not in play.

Save flow

1. View page submits action=save_settings or add_domain / edit_domain / delete_domain
2. dmarc_save_settings.cfm validates:
     - SPF + DKIM both enabled (error 1 if not)
     - rejectfailures / holdquarantinedmessages / failurereports are true|false (error 20)
     - if failurereports=true: report_email present + valid (errors 2, 3)
                               report_org present + alphanumeric (errors 4, 5)
3. dmarc_set_settings.cfm UPDATEs:
     - parameters.enabled on the inet:%:54321 child row (smtpd + non_smtpd)
     - parameters2.value2 on FailureReports / RejectFailures / HoldQuarantinedMessages
       (module = 'dmarc')
     - parameters2.value2 on report_email / report_org (when reports enabled)
4. dmarc_generate_config_file.cfm:
     - Copies opendmarc.conf.HERMES to /opt/hermes/tmp/<trans>_opendmarc.conf
     - Substitutes FAILURE-REPORTS, REJECT-FAILURES, HOLD-QUARANTINE-MESSAGES placeholders
     - Backs up /etc/opendmarc/opendmarc.conf -> opendmarc.HERMES
     - Moves the rendered file into place
5. dmarc_generate_reports_script.cfm (if reports enabled):
     - Renders dmarc_report_script.sh, chmod +x
     - Enables ofelia_jobs row for hermes-dmarc-report, regenerates Ofelia config
   (else: deletes the script, disables the Ofelia row)
6. restart_opendmarc.cfm: docker container restart hermes_dmarc
7. generate_postfix_configuration.cfm: postconf -e the milter list, postfix reload
8. session.m = 9 -> green "DMARC settings saved successfully. Postfix reloaded." alert

Failure semantics

Failure Behavior
SPF or DKIM not enabled when DMARC=YES session.m = 1, redirect, no DB write
report_email empty session.m = 2
report_email invalid session.m = 3
report_org empty session.m = 4
report_org contains non-alphanumeric session.m = 5
Missing required form fields session.m = 20
Delete Domains clicked with nothing selected session.m = 11
Add Domain with empty Domain field session.m = 13
Add Domain with invalid format session.m = 17
Add Domain with duplicate session.m = 14 (single) or _exists alert (bulk)

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_dmarc_settings.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/dmarc_*.cfm hermes_commandbox Validate / save / generate / restart
config/hermes/opt/hermes/conf_files/opendmarc.conf.HERMES hermes_commandbox (read) -> hermes_dmarc (live /etc/opendmarc/opendmarc.conf) Canonical template
config/hermes/opt/hermes/scripts/dmarc_report_script.sh hermes_commandbox (read) -> rendered into /opt/hermes/schedule/ (executed in hermes_dmarc) Daily aggregate report script
/etc/opendmarc/whitelist.domains hermes_dmarc Generated from dmarc_domains table on every save
/etc/opendmarc/opendmarc.dat hermes_dmarc Per-message verdict history; drained nightly by opendmarc-import
opendmarc MariaDB DB hermes_db_server Holds imported verdicts that opendmarc-reports reads
parameters / parameters2 tables (module='dmarc') hermes_db_server (hermes DB) Source of truth for every directive
system_settings rows mysql_username_opendmarc / mysql_password_opendmarc hermes_db_server DB creds for the report script (managed via update_opendmarc_db_creds.cfm)
ofelia_jobs row hermes-dmarc-report hermes_db_server Daily report scheduler entry

File Expressions

File Expressions

Admin path: Content Checks > File Expressions (view_file_expressions.cfm, inc/get_file_expressions.cfm, inc/update_amavis_config_files.cfm).

This page maintains the catalogue of regex patterns that Amavis can match against attachment filenames. Where File Extensions is a one-extension-per-row list (.exe, .docm, .iso), File Expressions is the free-form regex sibling — any Perl-compatible pattern that should fire on the attachment name: double-extension traps (^.+\.(exe|scr)\.[a-z0-9]+$), disguised-archive patterns (^invoice.*\.pdf\.zip$), or any project-specific filename signature an extension list can't express. The page itself does not block anything — it only registers patterns. The block / allow decision is taken by a File Rule that bundles expressions (and extensions, file types, MIME types) into a named ruleset, which is then bound to recipient traffic via an SVF policy on Anti-Spam Settings.

The expression catalogue is entirely operator-driven — Hermes ships no system-managed expressions. The shipped High-Risk catch-all ("Double Extensions in File Name") and the Windows Class ID block live on the File Extensions page as type = 'FILE-HIGH' rows. Everything on the File Expressions page is something the operator added.

Where File Expressions sits

                       +---------------------------------------+
   File Expressions    |  files table                          |
   (this page)  -----> |   id, file ("\.exe$"),                |
                       |   description ("Executable files"),   |
                       |   type ("CUSTOM-EXPRESSION"),         |
                       |   system ("NO"),                      |
                       |   allow ("[qr'\.exe$'i => 0]"),       |
                       |   ban   ("[qr'\.exe$'i => 1]")        |
                       +---------------+-----------------------+
                                       |
                                       v
                       +---------------------------------------+
                       |  File Rules                           |
                       |   bundle expressions + extensions     |
                       |   into named rulesets with per-item   |
                       |   allow / ban / priority              |
                       +---------------+-----------------------+
                                       |
                                       v
                       +---------------------------------------+
                       |  Anti-Spam Settings (SVF Policies)    |
                       |   bind a File Rule to recipient(s)    |
                       |   via policy.banned_rulenames         |
                       +---------------+-----------------------+
                                       |
                                       v
                       +---------------------------------------+
                       |  Amavis 50-user.HERMES                |
                       |   @banned_filename_re emitted per     |
                       |   rule on every save chain            |
                       +---------------------------------------+

The rendered @banned_filename_re block is enforced at content-filter time inside hermes_mail_filter. A matched expression triggers Amavis's final_banned_destiny action (D_BOUNCE, D_DISCARD, or D_PASS — set globally on Anti-Spam Settings).

How the pattern is wrapped

The textarea takes a raw Perl regex. On save the handler wraps it into Amavis's qr// syntax with the i (case-insensitive) modifier and stores both the allow and ban form on the row:

[qr'\.exe$'i => 0]      (allow form, stored in files.allow)
[qr'\.exe$'i => 1]      (ban form,  stored in files.ban)

Whether the allow or ban form gets rendered into Amavis's @banned_filename_re is decided at File Rule time, not here. The File Expressions page does not have an allow/ban toggle — both forms are stored so the same expression can serve allow-rules and ban-rules without re-typing.

There is no case-sensitive variant on this page. Every File Expression is stored with the i modifier. Operators who need strict case have to drop down to the File Rule's per-component selection or use a regex character class on the pattern itself (\.[Ee][Xx][Ee]$).

The page

A page guide callout, an Expression Helper card (build / pick / test), an Add Expressions card with a bulk textarea, and a single DataTable listing every custom expression. The DataTable is flat — system vs. custom does not apply because the catalogue is all-custom by design.

Expression Helper card

A three-section utility, collapsed by default, that exists so operators don't need to know regex to add common patterns.

Section Purpose
Build an Expression Pick a match mode (Ends with / Starts with / Contains / Exact), enter plain text, click Build. The helper regex-escapes the input, wraps it with the appropriate anchors (^…, …$, ^…$), and shows the generated pattern with a plain-English explanation
Quick Select Common Patterns A dropdown of pre-built patterns (\.exe$, \.bat$, ^invoice, \.(exe|bat|cmd|scr|pif)$, etc.) — click Use to drop the pattern into the Add form
Test a Pattern A pattern + filename pair with a Test button — runs new RegExp(pattern, 'i').test(filename) in the browser and reports Match / No match / Invalid regex. Lets the operator sanity-check before saving

The Build helper escapes . * + ? ^ $ { } ( ) | [ ] \ in the user input before wrapping, so a builder entry of invoice.pdf becomes invoice\.pdf$, not invoice.pdf$.

Add File Expressions card

Field Stored as Notes
File Expressions files.file (the regex) + files.description One per line; format is regex_pattern description where the first space separates pattern from label. A pattern with no space becomes its own description (useful for self-documenting patterns like \.docm$)

The handler line-splits the textarea on LF or CRLF, strips whitespace, and inserts each non-blank entry. Per entry it checks one thing: that no row already exists in files with the same file value under type = 'CUSTOM-EXPRESSION'. Duplicates are skipped and surfaced in the partial-success alert ("Duplicate: \.exe$"); the rest still insert.

There is no regex-validity check on save — the regex is stored as-typed and any syntax error is exposed at Amavis reload time, not in the alert. Use the Test a Pattern section of the helper before saving to catch malformed patterns first.

File Expressions DataTable

Column Source
(checkbox) Selection for bulk Delete Selected
Regex Pattern files.file (rendered inside a <code> block)
Description files.description
Actions Per-row Delete button (single-row confirm)

The DataTable shows only type = 'CUSTOM-EXPRESSION' rows. No edit-in-place — to change a pattern the operator deletes it and re-adds.

Foreign-key guard on delete

A custom expression cannot be deleted while it is referenced by any File Rule. The single-row Delete handler runs:

SELECT COUNT(*) AS cnt FROM file_rule_components
WHERE file_id = :id

If cnt > 0, the delete is refused with alert m = 40 and the DataTable shows the offending rule name(s) ("This expression is referenced by the following File Rule(s): Block-Disguised-Exe"). The operator's path is to open File Rules, remove the expression from the rule, then come back here and delete it.

Bulk Delete applies the same guard per-id and accumulates partial results — alert m = 41 reports "N deleted, M blocked" with the blocked rows' pattern and rule names attached, so the operator knows exactly what to unwire first.

Save and apply flow

1. View page submits action="add_entries" | "delete" | "bulk_delete"
2. For each valid entry:
     a. Generate ban  string: "[qr'<pattern>'i => 1]"
     b. Generate allow string: "[qr'<pattern>'i => 0]"
     c. INSERT INTO files (file, description, type, system, allow, ban)
        with type='CUSTOM-EXPRESSION' and system='NO'
3. If at least one row was added or deleted:
     a. update_amavis_config_files.cfm:
          - Read /opt/hermes/conf_files/50-user.HERMES (template)
          - Substitute the SERVER/destiny/DKIM/MySQL-credential
            placeholders from spam_settings and creds files
          - Render every File Rule's components into an
            @banned_filename_re block (per-rule, in priority order,
            using the allow/ban regex stored on each files row -
            including the CUSTOM-EXPRESSION rows this page creates)
          - Back up /etc/amavis/conf.d/50-user -> 50-user.HERMES,
            move rendered file into place
     b. docker exec hermes_mail_filter /etc/init.d/amavis force-reload
        (30-second timeout)
4. session.m = 1 (add) | 2 (single/bulk delete) | 30 (empty submit)
   | 40 (FK refused) | 41 (bulk partial)

Amavis is reloaded with force-reload rather than restarted — the daemon re-reads 50-user without dropping connections, and mail in flight is not interrupted. The reload step is wrapped in cftry/cfcatch and the catch block is intentionally silent: if the reload itself fails the DB rows are already in place, and the next save (or a manual force-reload) will re-render. The page does not roll back on reload failure.

Failure semantics

Alert Trigger
m = 1 Add Expressions completed (with entries_added / entries_skipped / entry_errors set on session for the per-row breakdown)
m = 2 Single Delete succeeded; Amavis reloaded
m = 30 Add submitted with an empty textarea
m = 31 Pattern field empty (legacy edit path, no longer reachable from the current UI)
m = 32 Duplicate pattern (legacy edit path)
m = 40 Single Delete refused — the expression is wired into at least one File Rule (rule names surfaced in the alert)
m = 41 Bulk Delete partial — deleted_count rows removed, blocked_count rows refused (the per-row pattern + rule-name list is HTML-rendered into the alert body)

The per-row error list is HTML-rendered into alert m = 1 so the operator sees every duplicate at once. No row is silently dropped without an explanation.

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_file_expressions.cfm hermes_commandbox The page (add + delete + bulk delete + Expression Helper + Amavis reload)
config/hermes/var/www/html/admin/2/inc/get_file_expressions.cfm hermes_commandbox Loads type = 'CUSTOM-EXPRESSION' rows into the DataTable
config/hermes/var/www/html/admin/2/inc/update_amavis_config_files.cfm hermes_commandbox Renders 50-user from template + File Rules (called on every change here too — expression edits affect rendered @banned_filename_re blocks)
config/hermes/opt/hermes/conf_files/50-user.HERMES hermes_commandbox (read) -> hermes_mail_filter (live /etc/amavis/conf.d/50-user) Canonical Amavis template; receives the rendered @banned_filename_re blocks
/etc/amavis/conf.d/50-user hermes_mail_filter Live Amavis config; reloaded with force-reload on every save
files table, type = 'CUSTOM-EXPRESSION' hermes_db_server (hermes DB) Source of truth for the expression catalogue
file_rule_components table hermes_db_server (hermes DB) Cross-reference checked by the delete guard
hermes_mail_filter container Hosts Amavis; receives force-reload (not restart) on every change

Operational consequences

File Extensions

File Extensions

Admin path: Content Checks > File Extensions (view_file_extensions.cfm, inc/get_file_extensions.cfm, inc/update_amavis_config_files.cfm).

This page maintains the catalogue of attachment file extensions that Amavis can match on. Each entry is a single extension such as .exe, .docm, or .iso paired with a description and a sensitivity flag (Standard vs. High Risk). The page itself does not block anything — it only registers extension candidates. The block / allow decision is taken by a File Rule that bundles extensions into a named ruleset, which is then applied to recipients via an SVF policy on Anti-Spam Settings. File Extensions is the building-block page; File Rules and SVF Policies are where the ruleset is composed and bound to traffic.

The extension catalogue ships with a system-managed list of common high-risk types (.exe, .scr, .pif, .com, .bat, .vbs, .js, .jar, .ps1, and dozens more) that cannot be deleted from the UI. Operators add custom extensions on top — typically Office macro-enabled types in environments that don't allow macros, archive formats they want to surface separately, or new attack-surface file types as they appear in the wild.

Where File Extensions sits

                       +---------------------------------------+
   File Extensions     |  files table                          |
   (this page)  -----> |   id, file ("exe"), description,      |
                       |   type ("EXT" | "EXT-HIGH"),          |
                       |   system ("YES"/"NO"),                |
                       |   allow ("[qr'.\.(exe)$'i => 0]"),    |
                       |   ban   ("[qr'.\.(exe)$'i => 1]")     |
                       +---------------+-----------------------+
                                       |
                                       v
                       +---------------------------------------+
                       |  File Rules                           |
                       |   bundle extensions into named        |
                       |   rulesets with per-extension         |
                       |   allow / ban / priority              |
                       +---------------+-----------------------+
                                       |
                                       v
                       +---------------------------------------+
                       |  Anti-Spam Settings (SVF Policies)    |
                       |   bind a File Rule to recipient(s)    |
                       +---------------+-----------------------+
                                       |
                                       v
                       +---------------------------------------+
                       |  Amavis 50-user.HERMES                |
                       |   @banned_filename_re emitted per     |
                       |   rule on every save chain            |
                       +---------------------------------------+

Amavis enforces the resulting @banned_filename_re regex sets at content-filter time inside hermes_mail_filter. A matched extension triggers Amavis's final_banned_destiny action (D_BOUNCE, D_DISCARD, or D_PASS — set globally on Anti-Spam Settings).

What "matched" means in Amavis

The stored allow / ban snippets are case-insensitive regexes anchored to the end of the filename:

[qr'.\.(exe)$'i => 1]      (ban; case-insensitive)
[qr'.\.(exe)$'x  => 0]     (allow; case-sensitive)

This means:

The double-extension confusion case (invoice.pdf.exe) is the historic reason this list exists. Amavis sees the real trailing extension; the user sees only the displayed-name prefix and a familiar icon.

The page

A page guide callout, an Add Extensions card with a bulk textarea, a Custom File Extensions DataTable (editable / deletable), and a Read-Only System File Extensions DataTable (the shipped list).

Add File Extensions card

Field Stored as Notes
File Extensions files.file + files.description One per line; format .ext description. The leading dot is stripped on save (so the row stores exe, not .exe); the description is auto-prefixed with (.ext) so the DataTable shows (.docm) Microsoft Word Macro-Enabled Document regardless of how the operator typed it
Extension Type files.type EXT (Standard) or EXT-HIGH (High Risk). Purely a classification tag for the UI badges — Amavis treats both the same
Case Sensitivity drives which template is rendered into files.allow / files.ban Insensitive (default, recommended) uses _insense templates with the i regex modifier; sensitive uses _sense templates with x only — for environments where you want .EXE to differ from .exe

The handler line-splits the textarea on either LF or CRLF, strips whitespace, validates each entry, and inserts the valid ones. Per entry it checks:

Each rejected line is collected into a per-row error list that surfaces in the partial-success alert; the valid entries still insert. The (.ext) prefix on the description is auto-prepended so the catalogue stays self-describing regardless of how the operator typed the row.

Custom File Extensions DataTable

Column Source
(checkbox) Selection for bulk Delete Selected
Extension .<files.file> (the leading dot is displayed in the UI even though it isn't stored)
Description files.description
Actions Per-row Delete button (single-row confirm)

The DataTable shows only rows with system = 'NO' and excludes type = 'CUSTOM-EXPRESSION' rows (those belong to File Expressions, which uses the same files table with a different type discriminator).

System File Extensions DataTable (read-only)

The shipped catalogue — every row from files where system = 'YES' and type IN ('EXT', 'EXT-HIGH'). These rows are filtered out of every DELETE path on this page (AND system = 'NO' is part of every DELETE query). The UI gives them no checkbox and no Delete button; attempting a forged POST that targets a system row surfaces alert m = 11 and is rejected.

Standard rows get an "Info" badge, High Risk rows get a "Danger" badge. The badge is cosmetic — Amavis treats both the same as banned-extension candidates once they're wired into a File Rule.

Foreign-key guard on delete

A custom extension cannot be deleted while it is referenced by any File Rule. The single-row Delete handler runs:

SELECT COUNT(*) AS cnt FROM file_rule_components
WHERE file_id = :id

If cnt > 0, the delete is refused with alert m = 10 and the DataTable shows the offending rule name(s) ("This file extension is used in the following File Rule(s): HighRisk-block"). The operator's path is to open File Rules, remove the extension from the rule, then come back here and delete it.

Bulk Delete applies the same guard per-id and accumulates partial results — the success alert reports "N deleted, M skipped" with the skipped rows' rule names attached so the operator knows exactly what to unwire first.

Save and apply flow

1. View page submits action="add_entries" | "delete" | "bulk_delete"
2. For each valid entry:
     a. Read the case-sensitive/insensitive allow + ban templates
        from /opt/hermes/scripts/file_allow_{sense|insense} and
        file_deny_{sense|insense}
     b. Substitute THE-EXTENSION placeholder with the (dot-stripped)
        extension name
     c. INSERT INTO files (file, description, type, system, allow, ban)
3. If at least one row was added or deleted:
     a. update_amavis_config_files.cfm:
          - Read /opt/hermes/conf_files/50-user.HERMES (template)
          - Substitute SERVER-NAME, SERVER-DOMAIN, sa-spam-subject-tag,
            final-virus-destiny, final-banned-destiny, final-spam-destiny,
            final-bad-header-destiny, enable-dkim-verification,
            enable-dkim-signing placeholders from spam_settings
          - Render every File Rule's components into an
            @banned_filename_re block (per-rule, in priority order,
            using the allow/ban regex stored on each files row)
          - Substitute HERMES-USERNAME / HERMES-PASSWORD from
            /opt/hermes/creds/ for the Amavis MySQL lookup
          - Back up /etc/amavis/conf.d/50-user -> 50-user.HERMES,
            move rendered file into place
     b. docker exec hermes_mail_filter /etc/init.d/amavis force-reload
        (30-second timeout)
4. session.m = 1 (add) | 2 (single delete) | 12 (bulk delete)

Amavis is reloaded with force-reload rather than restarted — the daemon re-reads 50-user without dropping connections, and mail in flight is not interrupted. The full container restart that Anti-Spam Settings and Score Overrides trigger is not needed here because no SpamAssassin state is being touched.

The reload step is wrapped in cftry/cfcatch with comment "Log but don't block — extensions were added" — if the reload itself fails, the DB rows are already in place and the next save (or manual force-reload) will re-render. The page does not roll back on reload failure.

Failure semantics

Alert Trigger
m = 1 Add Extensions completed (with entries_added / entries_skipped / entry_errors set on session for the per-row breakdown alert)
m = 2 Single Delete succeeded; Amavis reloaded
m = 10 Single Delete refused — the extension is wired into at least one File Rule (rule names surfaced in the alert)
m = 11 Attempt to delete a system row (system = 'YES') — refused at the DB query
m = 12 Bulk Delete completed (with bulk_deleted / bulk_skipped / bulk_errors set on session)
m = 30 Add submitted with an empty textarea

The per-row error list is HTML-rendered into the alert body so the operator sees every rejection at once ("Must start with dot: foo", "Invalid characters: .x@y", "Description required: .docm", "Duplicate: .exe"). No row is silently dropped without an explanation in the alert.

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_file_extensions.cfm hermes_commandbox The page (validation + bulk add + DataTables + Amavis reload)
config/hermes/var/www/html/admin/2/inc/get_file_extensions.cfm hermes_commandbox Loads custom + system rows for the two DataTables
config/hermes/var/www/html/admin/2/inc/update_amavis_config_files.cfm hermes_commandbox Renders 50-user from template + File Rules (called on every change)
config/hermes/opt/hermes/scripts/file_allow_insense / file_allow_sense hermes_commandbox Allow-regex templates with THE-EXTENSION placeholder
config/hermes/opt/hermes/scripts/file_deny_insense / file_deny_sense hermes_commandbox Ban-regex templates with THE-EXTENSION placeholder
config/hermes/opt/hermes/conf_files/50-user.HERMES hermes_commandbox (read) -> hermes_mail_filter (live /etc/amavis/conf.d/50-user) Canonical Amavis template; receives the rendered @banned_filename_re blocks
/etc/amavis/conf.d/50-user hermes_mail_filter Live Amavis config; reloaded with force-reload on every save
files table, type IN ('EXT','EXT-HIGH') hermes_db_server (hermes DB) Source of truth for the catalogue (system + custom)
file_rule_components table hermes_db_server (hermes DB) Cross-reference checked by the delete guard
hermes_mail_filter container Hosts Amavis; receives force-reload (not restart) on every change

File Rules

File Rules

Admin path: Content Checks > File Rules (view_file_rules.cfm, inc/get_file_rules.cfm, inc/update_amavis_config_files.cfm).

This page is the bundling layer that turns the raw catalogues on File Extensions and File Expressions into named, prioritised rulesets that Amavis can actually enforce. A File Rule is a named group of file-type components (extensions, file types, MIME types, high-risk variants of each, and custom regex expressions) plus a default action (Ban or Allow) that the operator binds to recipient traffic via an SVF Policy under Anti-Spam Settings. Without a File Rule wrapping them, no row on the catalogue pages does anything to mail.

Hermes ships one system rule, SYSTEM_DEFAULT, populated with a broad ban list (executables, scripts, Windows-class-IDs, double-extension trap, archive formats, dangerous MIME types). It is read-only — it can be copied, but not edited or deleted. Every custom rule the operator creates lives alongside it in the same DataTable, marked No in the System Rule column.

Where File Rules sits

          File Extensions          File Expressions
                |                          |
                v                          v
     +-----------------------+    +-----------------------+
     | files table           |    | files table           |
     | type IN ('EXT',       |    | type =                |
     |          'EXT-HIGH',  |    |   'CUSTOM-EXPRESSION' |
     |          'FILE',      |    +----------+------------+
     |          'FILE-HIGH', |               |
     |          'MIME',      |               |
     |          'MIME-HIGH', |               |
     |          'OTHER')     |               |
     +-----------+-----------+               |
                 |                           |
                 +-------------+-------------+
                               |
                               v
                +-----------------------------+
                |  File Rules (this page)     |
                |                             |
                |  file_rule_components:      |
                |   rule_id, rule_name,       |
                |   file_id (FK -> files.id), |
                |   description, type ('ban'  |
                |   or 'allow'), priority,    |
                |   system (1=shipped,        |
                |           2=custom)         |
                |                             |
                |  file_rules (legacy index): |
                |   rule_id, rule_name,       |
                |   system                    |
                +--------------+--------------+
                               |
                               v
                +-----------------------------+
                |  Anti-Spam Settings         |
                |   SVF Policy row            |
                |   policy.banned_rulenames   |
                |   = '<rule_name>'           |
                +--------------+--------------+
                               |
                               v
                +-----------------------------+
                |  Amavis 50-user.HERMES      |
                |   per-rule @banned_         |
                |   filename_re block, with   |
                |   the rule's components in  |
                |   priority order            |
                +-----------------------------+

A File Rule that is created but not bound to an SVF Policy is inert. The rule renders into Amavis's config (50-user carries every defined rule), but no recipient policy points at it, so nothing in @banned_filename_re fires for traffic.

The two backing tables

Table Role
file_rule_components The real source of truth. One row per (rule, file-type) pair. Carries rule_id, rule_name, file_id (FK -> files.id), description, type (ban or allow), priority, system (1 = shipped, 2 = custom)
file_rules A legacy index table holding only rule_id, rule_name, system. Hermes ships a single row in it (SYSTEM_DEFAULT, system=1) — the page's CRUD operations write to file_rule_components directly and the Delete handler also clears file_rules for the matching rule_id. New rules are NOT inserted into file_rules; rule existence is determined entirely by DISTINCT rule_id on file_rule_components

The system value is the system / custom discriminator and is the guard for every modify path:

The action column is named type (not action) on file_rule_components and is per-component: a single rule can mix ban and allow components, although the page's UI surfaces "Default Action" as a single radio button and assigns the same value to every component on save. Mixing ban and allow on the same rule is possible only by direct SQL.

The page

A page guide callout, a single DataTable listing every rule (system and custom together), and three modals: Create Custom File Rule (Add), Edit File Rule, and Copy File Rule.

File Rules DataTable

Column Source
Rule Name file_rule_components.rule_name (distinct)
Type Rendered from the first component's type<span class="badge bg-danger">Ban</span> or <span class="badge bg-success">Allow</span>
File Types Every component's description as a list of bg-secondary badges, each suffixed with (ban) or (allow)
System Rule Yes (info badge, system=1) or No (warning badge, system=2)
Actions Copy (always present) + Edit + Delete (only when system=2)

Default sort is System Rule asc, Rule Name asc, so the shipped rule sinks below the custom ones once any exist (custom = system=2 sorts above shipped = system=1? No — 2 > 1, but the column order asc is intentional: shipped first, then custom alphabetised). The DataTable carries stateSave: true, so the operator's sort / search / page-size choices persist across page loads.

Create Custom File Rule modal (Add)

Field Stored as Notes
Rule Name file_rule_components.rule_name Regex-validated against [^_a-zA-Z0-9-] — letters, numbers, dashes, underscores only. No spaces, no punctuation. Max length 50. Duplicates across both system and custom rules are rejected (m = 22)
Default Action file_rule_components.type on every inserted component Radio: ban (default) or allow
File Type checkboxes One INSERT per checked box into file_rule_components Eight grouped cards: High Risk Extensions, High Risk File Types, High Risk MIME Types, File Extensions, File Types, MIME Types, Other Types, Custom Expressions. Each card has a "select-all" master checkbox and a scrollable list of every files row of that type. At least one file type must be selected (m = 23)

The handler computes the next rule_id as MAX(rule_id) + 1 (scoped across file_rule_components, not file_rules), assigns priority sequentially as components are inserted (1, 2, 3, … in submission order), and marks each row system = 2.

Edit File Rule modal

Opens preloaded with the current rule's name, default action, and checkbox selections — the JavaScript reads a ruleComponents map written into the page at render time and ticks the matching checkboxes across all eight category cards.

Save is destructive-then-rebuild: the handler DELETEs every file_rule_components row for the rule_id, then re-INSERTs from the new form selection. The same name / action / file-types validation as Add applies, plus:

Copy File Rule modal

The only path to derive a new rule from SYSTEM_DEFAULT. Asks for a new name (same [a-zA-Z0-9_-]+ validation, same duplicate check, same 50-char max), then INSERTs a fresh set of file_rule_components rows under a new rule_id with all the source rule's file_id, description, type, and priority values preserved. The copy is always system = 2 regardless of the source's flag — so a copy of SYSTEM_DEFAULT becomes a fully editable custom rule.

The default new-name in the modal is <source>_copy, so the operator can hit Copy on SYSTEM_DEFAULT and immediately get SYSTEM_DEFAULT_copy ready to edit.

Policy-binding guard on delete

A custom rule cannot be deleted while any SVF Policy points at it. The Delete handler runs:

SELECT policy_name FROM policy
WHERE banned_rulenames = '<rule_name>'

If any row comes back, the delete is refused with alert m = 25 and the policy name(s) are surfaced in the alert ("You cannot delete a file rule that is assigned to SVF Policy: Default,Inbound-Strict. Remove the assignment first under Content Checks > SVF Policies.").

This is the symmetric counterpart to the FK guard on File Extensions and File Expressions — those pages refuse to delete a row that is bundled into a rule; this page refuses to delete a rule that is bundled into a policy.

Save and apply flow

1. View page submits action="add_rule" | "edit_rule" | "delete_rule"
                          | "copy_rule"
2. Validate name (non-empty, regex-clean, non-duplicate, non-system
   on edit/delete), validate file_ids (non-empty)
3. For Add / Edit / Copy:
     a. Determine rule_id (next MAX+1 for Add/Copy, form value for Edit)
     b. (Edit only) UPDATE policy.banned_rulenames if rule_name changed
     c. (Edit only) DELETE existing file_rule_components for rule_id
     d. INSERT one file_rule_components row per checked file_id, with
        priority assigned sequentially (1..N) and system='2'
   For Delete:
     a. DELETE FROM file_rules WHERE rule_id = :id
     b. DELETE FROM file_rule_components WHERE rule_id = :id
4. update_amavis_config_files.cfm:
     - Read /opt/hermes/conf_files/50-user.HERMES (template)
     - Substitute SERVER/destiny/DKIM/MySQL-credential placeholders
     - Loop every DISTINCT rule_id in file_rule_components
       and emit a per-rule @banned_filename_re block in
       priority order, using each component's allow or ban
       regex from files.allow / files.ban
     - Back up /etc/amavis/conf.d/50-user -> 50-user.HERMES,
       move rendered file into place
5. docker exec hermes_mail_filter /etc/init.d/amavis force-reload
   (60-second timeout - longer than the catalogue pages because
    every rule re-renders)
6. session.m = 1 (add) | 2 (edit) | 3 (delete) | 4 (copy)
              | 10 (reload error) | 20-25 (validation refusals)

Amavis is reloaded with force-reload rather than restarted. If the reload itself fails, the rule rows are already committed — alert m = 10 ("Configuration Error") fires but the DB is not rolled back. The next successful save (or a manual force-reload) will re-render.

Failure semantics

Alert Trigger
m = 1 Rule created. The alert also nudges the operator to assign the rule to a policy under SVF Policies — without that binding the rule is inert
m = 2 Rule updated; Amavis reloaded
m = 3 Rule deleted; Amavis reloaded
m = 4 Rule copied. Same nudge as m = 1 — the copy is inert until bound to an SVF Policy
m = 10 Amavis reload error — the DB write succeeded but force-reload returned non-zero. Open Anti-Spam Settings and save once to re-trigger the render + reload, or restart hermes_mail_filter manually
m = 20 Rule name field empty
m = 21 Rule name contains characters outside [a-zA-Z0-9_-] (spaces, dots, slashes, etc.)
m = 22 Duplicate rule name — checked against both system and custom rules
m = 23 No file types selected — at least one checkbox across the eight category cards is required
m = 24 Attempted to edit or delete a system rule (system=1) — refused. The operator's path is to Copy first, then edit the copy
m = 25 Delete refused — the rule is bound to one or more SVF Policies (policy names surfaced in the alert)

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_file_rules.cfm hermes_commandbox The page (CRUD + Copy + DataTable + three modals + Amavis reload)
config/hermes/var/www/html/admin/2/inc/get_file_rules.cfm hermes_commandbox Loads the rule list + every files row grouped by type for the modal cards (get_files_ext_high, get_files_file_high, …, get_files_custom_expr)
config/hermes/var/www/html/admin/2/inc/update_amavis_config_files.cfm hermes_commandbox Renders 50-user from template + every File Rule's components
config/hermes/opt/hermes/conf_files/50-user.HERMES hermes_commandbox (read) -> hermes_mail_filter (live /etc/amavis/conf.d/50-user) Canonical Amavis template; receives the per-rule @banned_filename_re blocks
/etc/amavis/conf.d/50-user hermes_mail_filter Live Amavis config; reloaded with force-reload on every save
file_rule_components table hermes_db_server (hermes DB) The real rule store — one row per (rule, file-type) pair
file_rules table hermes_db_server (hermes DB) Legacy index — only SYSTEM_DEFAULT lives here; custom rules are NOT mirrored. Cleared on delete for the matching rule_id
files table hermes_db_server (hermes DB) Source of the file-type checkboxes; FK target of file_rule_components.file_id
policy table, banned_rulenames column hermes_db_server (hermes DB) Where SVF Policies record their rule binding; renamed in step with rule renames, checked by the delete guard
hermes_mail_filter container Hosts Amavis; receives force-reload (not restart) on every change

Operational consequences

Global Sender Rules

Global Sender Rules

Admin path: Content Checks > Global Sender Rules (view_global_sender_block_allow.cfm, inc/get_global_sender_block_allow.cfm, inc/global_sender_add_entries.cfm, inc/global_sender_edit_entry.cfm, inc/global_sender_delete_entry.cfm, inc/global_sender_write_and_reload.cfm).

This page manages system-wide envelope-sender rules that apply regardless of recipient. Every entry on this page is a single sender pattern (full address, exact domain, or domain + subdomains) paired with an action — Block or Allow. The rules are evaluated by Postfix at MAIL FROM time, before the message body is read; an Allow match additionally bypasses Amavis content filtering for that sender.

Global Sender Rules are the system-wide counterpart to Sender/Recipient Rules. A Global rule matches all recipients in the system; a Sender/Recipient rule requires both a sender and a recipient to match. A Global entry takes precedence over any Sender/Recipient entry for the same sender.

Where Global Sender Rules sit in the flow

+-------------------+
| Remote SMTP peer  |
+---------+---------+
          |
          v
+-----------------------------------------------+
|  postscreen :25 (perimeter / RBL scoring)     |
+---------+-------------------------------------+
          |
          v
+-----------------------------------------------+
|  smtpd :25                                    |
|   smtpd_sender_restrictions =                 |
|     check_sender_access                       |
|       hash:/etc/postfix/amavis_senderbypass   |
|                                               |
|   match -> REJECT (block)                     |
|   match -> FILTER amavis:[127.0.0.1]:10030    |
|             (allow -> route past content      |
|              filtering)                       |
|   no match -> fall through to recipient rules |
+---------+-------------------------------------+
          |
          v
+-----------------------------------------------+
|  Amavis (white.lst / black.lst consulted      |
|  again at content-filter tier)                |
+-----------------------------------------------+

The same rule set is written to two places on each save: the Postfix check_sender_access table (/etc/postfix/amavis_senderbypass, postmaped into a Berkeley DB) and the Amavis whitelist/blacklist files (/etc/amavis/white.lst, /etc/amavis/black.lst). Block entries surface at the Postfix tier — the connection is rejected at MAIL FROM and Amavis is never invoked. Allow entries route past Amavis content scoring via the FILTER transport hint, and are also written to Amavis's own whitelist as a safety net for any mail path that does reach Amavis (locally-injected, alias-rewritten, etc.).

Pattern formats

The page accepts three pattern formats. The save handler validates each line and auto-prepends @ to bare domains so the stored row is always in one of the three canonical forms:

Format Example Matches
Full email user@example.com A single envelope sender
Exact domain (@) @example.com Every sender on example.com only — subdomains do not match
Domain + subdomains (.) .example.com example.com and every subdomain (sub.example.com, mail.sub.example.com, ...)

Bare-domain input (example.com) is treated as a typo for @example.com and rewritten on insert. Email-syntax validation runs on the host portion of every pattern; entries that fail validation are collected into a "Invalid Entries" alert and the rest of the batch is still processed.

The page

A single warning callout, a multi-line Add form, and one DataTable.

Add Sender Entries

A textarea (one entry per line) plus a Block/Allow radio. The form processes the entire batch in one round-trip:

The redirected page surfaces three separate inline alerts (green success, red invalid, red duplicate) so a mixed batch reports clearly on what happened to every line.

A small inline JS check flips a warning banner under the textarea when the operator types a domain (no @) — the consequence of allow-listing or block-listing an entire domain is significant enough to warrant the extra nudge.

Global Sender Entries (DataTable)

Searchable, sortable, paginated, with bulk-delete checkboxes and per-row Edit / Delete buttons.

Column Source
Sender amavis_sender_bypass.sender
Format Derived from the leading character — @ -> Domain badge, . -> Domain + Subdomains badge, otherwise Email badge
Action amavis_sender_bypass.type -> Allow (green) or Block (red)
Actions Edit (modal), Delete (confirm)

Bulk delete posts a comma-separated list of row IDs from the wrapping form. Single Edit and Delete use separate hidden forms so they don't collide with the bulk submit handler.

Save flow

Every Add, Edit, and Delete runs the full regeneration path inline:

1. Validate input + INSERT / UPDATE / DELETE on amavis_sender_bypass
2. cfinclude global_sender_write_and_reload.cfm:
     a. SELECT all type='allow' rows (with transport column)
     b. SELECT all type='block' rows
     c. Write /etc/postfix/amavis_senderbypass    (allow rows + transport)
     d. Write /etc/amavis/white.lst               (allow rows, one per line)
     e. Write /etc/amavis/black.lst               (block rows, one per line)
     f. docker exec hermes_postfix_dkim postmap /etc/postfix/amavis_senderbypass
     g. docker exec hermes_postfix_dkim chown root:root <file + .db>
     h. docker exec hermes_postfix_dkim postfix reload
     i. docker exec hermes_mail_filter /etc/init.d/amavis force-reload
3. session.m = 1 / 2 / 5 (Added / Deleted / Updated)
   On failure -> session.m = 4 ("Apply Failed")

The Postfix postmap step is what makes Block entries actually take effect — check_sender_access reads the hashed .db file, not the plain-text source. Skipping the postmap (e.g. by editing the source file out-of-band) is a common cause of "I added a block but mail is still getting through".

Why both Postfix and Amavis get the list. The Postfix tier handles the common case — Block rejects before DATA, Allow routes past Amavis via the FILTER transport. The Amavis-side white.lst / black.lst files are a defence in depth: any mail path that does reach Amavis (locally-injected mail, mail that was alias-rewritten after the sender check, mail from permit_mynetworks sources that skipped sender restrictions) still gets the same allow/block treatment at the content-filter tier. The two layers are kept in sync by the single save flow.

The amavis_sender_bypass table

Column Purpose
id Auto-increment primary key
sender The pattern (user@example.com, @example.com, or .example.com)
transport For Allow rows: FILTER amavis:[127.0.0.1]:10030. Empty for Block rows
action Always NONE for active rows; reserved for future scheduled-action use
type allow or block
applied 1 once the row is live; future use for deferred apply

The duplicate check on insert is an exact string match on sender, so @example.com and .example.com are treated as separate (and both can legitimately coexist — they match different sets of addresses).

Failure semantics

Failure Behavior
Empty textarea session.m = 30, redirect, no DB write
Invalid email/domain on a line Line skipped, accumulated into the Invalid Entries alert; other valid lines still processed
Exact-string duplicate on a line Line skipped, accumulated into the Duplicate Entries alert; other valid lines still processed
cffile / postmap / reload failure session.m = 4 ("Apply Failed"); inserted rows remain in the DB and will be re-applied on the next successful save
Postfix container down Reload fails -> session.m = 4; mail flow continues with the previously-loaded Berkeley DB until the container is back

The save is not transactional across the DB + file-write + reload steps. If the DB insert succeeds but the postmap or reload fails, the next Add/Edit/Delete will regenerate from the full DB state and reapply.

Operational guidance

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_global_sender_block_allow.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/get_global_sender_block_allow.cfm hermes_commandbox Loads the active row set for the DataTable
config/hermes/var/www/html/admin/2/inc/global_sender_add_entries.cfm hermes_commandbox Batch validation + INSERT loop
config/hermes/var/www/html/admin/2/inc/global_sender_edit_entry.cfm hermes_commandbox Single-row UPDATE + regen
config/hermes/var/www/html/admin/2/inc/global_sender_delete_entry.cfm hermes_commandbox Single or bulk DELETE + regen
config/hermes/var/www/html/admin/2/inc/global_sender_write_and_reload.cfm hermes_commandbox Writes the three files, runs postmap, reloads Postfix and Amavis
amavis_sender_bypass table hermes_db_server (hermes DB) Source of truth
/etc/postfix/amavis_senderbypass (+ .db) hermes_postfix_dkim Postfix check_sender_access lookup
/etc/amavis/white.lst, /etc/amavis/black.lst hermes_mail_filter Amavis sender whitelist / blacklist
hermes_postfix_dkim container Runs postmap + postfix reload
hermes_mail_filter container Runs amavis force-reload

Malware Feeds

Malware Feeds

Admin path: Content Checks > Malware Feeds (view_malware_feeds.cfm, inc/get_malware_feeds_settings.cfm, inc/malware_feeds_save_global.cfm, inc/malware_feeds_add_feed.cfm, inc/malware_feeds_edit_feed.cfm, inc/malware_feeds_delete_feed.cfm, inc/malware_feeds_toggle_feed.cfm, inc/malware_feeds_save_urls.cfm, inc/generate_malware_feeds_configuration.cfm).

This page manages the third-party ClamAV signature feeds that supplement the stock freshclam definitions on Antivirus Settings. The feed manager is Fangfrisch, a small Python tool that handles per-feed authentication, cadence control, integrity verification, and post-download deployment. Hermes ships ten built-in feed definitions (free and commercial), exposes a custom-feed form for additional sources, and a per-feed URL editor for signature file selection. Refresh runs as an Ofelia job inside hermes_mail_filter.

This page replaced an earlier view_antivirus_signature_feeds.cfm page (orphan cleanup tracked as issue #257); any sidebar bookmark or external link pointing at the old page should be updated.

How feeds reach ClamAV

                  +-------------------------------------------+
                  |  hermes-fangfrisch-refresh (Ofelia job)   |
                  |    inside hermes_mail_filter              |
                  |    schedule: @every <refresh_interval>    |
                  +----------------+--------------------------+
                                   |
                                   v
                  +-------------------------------------------+
                  |  /usr/bin/fangfrisch refresh              |
                  |    reads /etc/fangfrisch/fangfrisch.conf  |
                  |    iterates enabled feeds                 |
                  |    skips feeds whose own interval has not |
                  |    elapsed                                |
                  +----------------+--------------------------+
                                   |
                                   v
                  +-------------------------------------------+
                  |  Per-feed download                        |
                  |    auth via API key / customer_id /       |
                  |    serial_key when required               |
                  |    integrity check (sha256, md5, off)     |
                  |    -> /var/lib/fangfrisch/signatures/     |
                  +----------------+--------------------------+
                                   |
                                   v
                  +-------------------------------------------+
                  |  on_update_exec=/usr/local/bin/setup-     |
                  |  clamav-sigs (post-update hook)           |
                  |    validates each file with `clamscan`    |
                  |    copies valid files to /var/lib/clamav/ |
                  |    signals clamd to reload                |
                  +-------------------------------------------+

The page emits /etc/fangfrisch/fangfrisch.conf (an INI file) on every save. Fangfrisch itself is invoked on a fixed Ofelia schedule; the schedule is regenerated from ofelia_jobs.schedule and reflects the Global Settings > Refresh Interval picker.

Container and tool placement

Component Detail
Container hermes_mail_filter (IPv4 .105, same container as ClamAV, Amavis, SpamAssassin)
Feed manager fangfrisch (Python, third-party ClamAV signature aggregator)
INI config /etc/fangfrisch/fangfrisch.conf (bind-mounted, owned root:clamav, mode 640)
State DB sqlite:////var/lib/fangfrisch/db.sqlite (per-feed last-refresh, integrity hashes)
Download dir /var/lib/fangfrisch/signatures/ (raw downloaded files)
Deploy dir /var/lib/clamav/ (validated files, ClamAV signature store)
Post-update hook /usr/local/bin/setup-clamav-sigs (validates with clamscan, copies to deploy dir, signals reload)
Scheduler Ofelia job hermes-fangfrisch-refresh row in ofelia_jobs
Default cadence @every 10m (Fangfrisch then honors per-feed interval = to decide what to actually re-fetch)

Global Settings card

Four controls write to parameters2 WHERE module = 'malware_feeds'. The first three substitute into the [DEFAULT] section of fangfrisch.conf on every save; the fourth updates the Ofelia row that schedules the refresh job.

Field Storage INI / scheduler effect Notes
Log Level parameters2.value2 (log_level) [DEFAULT] log_level = ... debug,info,warning,error,fatal; logs go to docker logs hermes_mail_filter
Default Max Size parameters2.value2 (max_size) [DEFAULT] max_size = ... Per-file cap. Regex anchors a number followed by KB, MB, M, or B (e.g. 5MB, 10M, 250KB). Inherited by feeds that don't set their own
Update Timeout (sec) parameters2.value2 (on_update_timeout) [DEFAULT] on_update_timeout = ... Bounded 1-300. Caps how long setup-clamav-sigs is allowed to run
Refresh Interval parameters2.value2 (refresh_interval) AND ofelia_jobs.schedule Ofelia @every <interval> Allowed values: 5m,10m,15m,30m,1h,2h,4h. Fangfrisch's own per-feed interval = still gates whether each feed actually re-downloads on a given run

The post-update hook path is hard-coded to /usr/local/bin/setup-clamav-sigs and shown read-only beneath the form as [DEFAULT] on_update_exec. The hook lives inside the hermes_mail_filter image and validates each downloaded file with clamscan before copying it to /var/lib/clamav/; a file that fails validation is left in the Fangfrisch download dir and not deployed.

Malware Feeds card

Rows from malware_feeds_config populate a DataTable; per-row form posts toggle, edit, manage URLs, and (custom feeds only) delete. The schema:

Column Role
id Surrogate key
section_name INI section header, [<section_name>]. Lowercase alphanumeric + underscore (^[a-z0-9_]+$). Cannot change after creation. Unique.
display_name Card label, free text
enabled tinyint(3), 0/1. Sliders here flip this. enabled = yes/no line in INI
is_builtin tinyint(3), 0/1. Built-in rows cannot be deleted (the Delete action button is suppressed in the UI and the delete handler refuses)
prefix ${prefix} interpolation source for URL entries. Optional
interval_value Per-feed cadence (e.g. 1h, 4h, 1d); blank = inherit @every <refresh_interval>
max_size Per-feed cap; blank = inherit Global Default Max Size
integrity_check sha256, md5, disabled, or NULL (default sha256)
api_key_1_name / api_key_1_value Optional auth key (e.g. customer_id, receipt). Value stored AES-encrypted with key /opt/hermes/keys/hermes.key
api_key_2_name / api_key_2_value Second auth key (e.g. MalwarePatrol's product). Same encryption
description Free text
sort_order Display order; custom-add inserts at 100

Built-in feed catalog (factory rows)

Feed Type Default state Auth Notes
SaneSecurity Free Enabled None Broad zero-day coverage; mirror https://ftp.swin.edu.au/sanesecurity/
URLhaus Free Enabled None Malicious URL signatures from abuse.ch
MalwarePatrol Commercial Enabled receipt, product IDs Configure both keys via Edit; subscription IDs are documented in the in-card help
MalwareExpert Commercial Enabled serial_key URL template embeds the serial in the path
SecuriteInfo Commercial Enabled customer_id Free tier available; paid tier unlocks extra URLs
TwinWave Free Enabled None Public GitHub-hosted signatures
ClamPunch Free Enabled None Heuristic family signatures
RFXN Free Enabled None R-fx Networks Linux Malware Detect signatures
InterServer Free Enabled None Hash + URL signatures
Ditekshen Free Enabled None YARA/ClamAV detection rules

A commercial feed is "enabled" only in the sense that its row is marked enabled = 1; without API keys the feed is configured but will not actually fetch (the in-card help describes the per-vendor key requirements and the table icon shows a yellow warning triangle on commercial rows missing keys).

Add Custom Feed modal

Free-form add for any feed source not in the built-in catalog. Validation:

Field Rule
Section Name ^[a-z0-9_]+$, required, must not already exist
Display Name Required
URL Prefix Optional, becomes the prefix = line and the substitution source for ${prefix} in URL entries
Update Interval Optional, number followed by m (minutes), h (hours), or d (days). Examples: 10m, 1h, 1d
Max Size Optional, number followed by KB, MB, M, or B. Examples: 5MB, 250KB
Integrity Check Dropdown: default (sha256), sha256, md5, disabled
Description Optional free text

A new custom feed is inserted with enabled = 0 and is_builtin = 0; the admin then opens the URL manager to register at least one URL before turning the row on.

Manage URLs modal (per-feed)

Rows from malware_feed_urls keyed by feed_id. Each URL becomes a line in the corresponding [<section_name>] block of fangfrisch.conf:

url_<url_key> = <url_value>
filename_<url_key> = <filename_override>   ## only when filename_override set

When a URL is toggled off, the url_ prefix is replaced with !url_ to inactivate the line without losing the configuration. ${prefix} in the URL value is expanded against the feed's prefix = at fetch time.

Field Rule
Name (url_key) ^[a-z0-9_.]+$, must be unique within the feed (UNIQUE KEY uq_feed_url(feed_id, url_key))
Download URL (url_value) Full URL, or ${prefix}<path> shorthand when the feed has a prefix
Save As (filename_override) Optional. Renames the downloaded file locally; useful when the source filename is too generic
Toggle Per-URL on/off. Disabled URLs are skipped without being deleted

Built-in feeds may have URLs that Fangfrisch maintains internally — the in-modal note explains that an empty URL table for a built-in feed means it is using its packaged defaults, not that it is broken.

Save flow

1. View page submits action= save_global | add_feed | edit_feed
                          | delete_feed | toggle_feed | url_action
2. malware_feeds_*.cfm validates and UPDATEs/INSERTs/DELETEs the row(s)
3. generate_malware_feeds_configuration.cfm runs on EVERY action:
     a. SELECT module='malware_feeds' rows from parameters2 -> globalSettings
     b. SELECT malware_feeds_config -> all feed rows
     c. SELECT malware_feed_urls -> all URLs grouped by feed_id
     d. Build [DEFAULT] section + one [<section_name>] block per feed
     e. Decrypt api_key_*_value with AES + /opt/hermes/keys/hermes.key
        (key emitted as `<api_key_*_name> = <plain>`)
     f. Write temp file -> /opt/hermes/tmp/<trans>_fangfrisch.conf
     g. dos2unix (tolerated if missing)
     h. cffile write -> /etc/fangfrisch/fangfrisch.conf
     i. docker exec hermes_mail_filter chown root:clamav + chmod 640
        (tolerated if container is down)
     j. cfinclude ofelia_generate_config.cfm
        (rewrites /etc/ofelia/config.ini if any schedule changed)
4. cflocation back to view_malware_feeds.cfm
5. session.m + session.alerttype + session.alertmsg drives the alert banner

Every UI action -- including a single-row enable/disable toggle -- runs the full INI regen, ownership fix, and Ofelia config regen. There is no incremental write path; the INI is always rendered from the current database state. This means manual edits to /etc/fangfrisch/fangfrisch.conf are lost on the next save -- store all configuration in the database.

API key encryption

The api_key_1_value and api_key_2_value columns store AES-Base64 ciphertext using the key in /opt/hermes/keys/hermes.key. The edit modal shows a masked preview (20 asterisks + last 4 chars of the plaintext) for visual confirmation without exposing the full key. Decryption happens only in generate_malware_feeds_configuration.cfm at the moment the INI is rendered; a decryption failure replaces the key line with a commented ## <name> = [decryption error] marker rather than aborting the save.

The encryption key file is mounted into hermes_commandbox only; neither hermes_mail_filter nor any other service reads it. This keeps the plaintext key out of the running config on disk for as short a window as possible (write -> chmod 640 root:clamav -> next Fangfrisch run reads -> file remains until next save replaces it).

Manual refresh

The Ofelia job runs on schedule, but the same command can be invoked manually from a host shell:

docker exec hermes_mail_filter fangfrisch --conf /etc/fangfrisch/fangfrisch.conf refresh

Fangfrisch is conservative — it will still skip feeds whose own per-feed interval = window has not elapsed. To force a re-download of a single feed regardless of cadence, the Fangfrisch state DB can be cleared for that feed:

docker exec hermes_mail_filter sqlite3 /var/lib/fangfrisch/db.sqlite \
  "DELETE FROM refreshlog WHERE source = '<section_name>';"

Then re-run the refresh. The post-update hook re-validates with clamscan and deploys to /var/lib/clamav/. To inspect downloaded files:

docker exec hermes_mail_filter ls -la /var/lib/fangfrisch/signatures/

Failure semantics

Failure Behavior
Global save with non-allowlisted log_level / max_size / timeout / interval session.m=malware_feeds_error, alerttype=danger, alertmsg explains; no DB write
Add Custom Feed with duplicate section name session.m=error, alertmsg names the conflict; INSERT not attempted
Toggle/edit on non-existent feed_id session.m=error "Feed not found"; no UPDATE
Delete attempted on a built-in feed UI suppresses the button; handler refuses the row
API key decryption error at INI regen INI line replaced with ## <name> = [decryption error]; save still completes; Fangfrisch will treat the auth as missing on the next run
Container down during chown/chmod cftry swallows the exec failure; INI is still written to the bind mount and the chown is applied next save when the container is back up
dos2unix binary missing Tolerated via cftry; INI is written without the line-ending normalization step

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_malware_feeds.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/malware_feeds_*.cfm hermes_commandbox Validate / save / regen per action
config/hermes/var/www/html/admin/2/inc/generate_malware_feeds_configuration.cfm hermes_commandbox Renders the INI from the DB; runs on every action
/etc/fangfrisch/fangfrisch.conf hermes_mail_filter (bind-mounted, root:clamav, 640) Live Fangfrisch config
/var/lib/fangfrisch/db.sqlite hermes_mail_filter Per-feed last-refresh state
/var/lib/fangfrisch/signatures/ hermes_mail_filter Raw downloads (pre-validation)
/var/lib/clamav/ hermes_mail_filter (Docker named volume mail_filter_data_clamav) Validated signature store; ClamAV reads from here
/usr/local/bin/setup-clamav-sigs hermes_mail_filter (image-baked) Post-update validation + deploy hook
/opt/hermes/keys/hermes.key hermes_commandbox only AES key for api_key_*_value columns
malware_feeds_config table hermes_db_server (hermes DB) Per-feed row state
malware_feed_urls table hermes_db_server Per-feed URL list (FK cascade delete on feed delete)
parameters2 rows module='malware_feeds' hermes_db_server Global Settings card
ofelia_jobs row hermes-fangfrisch-refresh hermes_db_server Schedule (auto-updated when Refresh Interval changes)

Message History

Message History

Admin path: Content Checks > Message History (view_message_history.cfm, view_message.cfm, inc/messages_release_message.cfm, inc/messages_block_sender.cfm, inc/messages_allow_sender.cfm, inc/messages_train_ham.cfm, inc/messages_train_spam.cfm, inc/messages_forget_bayes.cfm, inc/messages_sa_learn_sync.cfm).

This is the operator inspection surface for everything that has flowed through the content filter. Every message Amavis processes lands as one row in msgs (per-message metadata) plus one row per recipient in msgrcpt (per-recipient disposition). This page is the joined view over those two tables, with a date range filter, a content-type filter, a delivery-status filter, and per-row actions to release from quarantine, train Bayes, or block/allow the sender.

Pairs with System Logs and Mail Queue. System Logs shows the raw syslog stream (connection negotiation, milter results, queue lifecycle). Mail Queue shows what Postfix is currently holding. Message History shows what the content filter saw, what verdict it produced, and what landed where -- and lets the admin act on those rows.

The same msgs table feeds the Messages Processed donut on System Status; the per-user self-service version of this view lives at /users/2/view_message_history.cfm and is scoped to the logged-in recipient only.

How a message gets into msgs and msgrcpt

        SMTP in                postfix              amavisd-new
   ──────────────────►  hermes_postfix_dkim ────► hermes_mail_filter
                            (port 25)              (port 10024)
                                                        │
                                                        │  scan: ClamAV,
                                                        │  SpamAssassin,
                                                        │  banned-files
                                                        ▼
                                            ┌─────────────────────┐
                                            │ amavis SQL backend  │
                                            │ datasource: hermes  │
                                            ├─────────────────────┤
                                            │  msgs    (1 row /   │
                                            │           message)  │
                                            │  msgrcpt (1 row /   │
                                            │           recipient)│
                                            │  maddr   (sender +  │
                                            │           rcpt addr │
                                            │           dedup)    │
                                            └──────────┬──────────┘
                                                       │
                                  ┌────────────────────┼────────────────────┐
                                  │                    │                    │
                                  ▼                    ▼                    ▼
                          ds=P (Pass)         ds=D (Discard)        ds=B (Bounce)
                          delivered to        quarantined +         rejected with
                          downstream MTA      no further delivery   DSN to sender
                                              (quar_loc set on
                                               msgs row)

The ds ("disposition") column on msgrcpt is the per-recipient verdict. The content column on msgs is the per-message why -- virus, spam, banned attachment, bad header, oversized, clean, etc. Together they answer "did this message get through, and if not, what blocked it?"

The Search Messages card at the top of the page is the filter set; all fields are submitted as URL params so any search is bookmarkable and back-button safe.

Field URL param Effect
Start Date/Time startdate Lower bound on msgs.time_iso. Defaults to 24 hours ago. Validated as a date by isValid("date", ...); invalid values short-circuit to the error template
End Date/Time enddate Upper bound on msgs.time_iso. Defaults to now. Same validation as startdate
Search Results Limit limit LIMIT clause on the join query. One of 1000, 1500, 2500, 5000, 10000, 15000 -- the dropdown is the allowlist, anything else aborts. Defaults to 1000. The form text warns: setting limit to 10000+ significantly increases page load time
Type content_filter Multi-select against msgs.content -- the per-message content type (see table below). Empty = all types. Tom Select widget with remove and clear buttons
Action action_filter Multi-select against msgrcpt.ds. Empty = all actions. Three options: P Delivered, D Blocked (Discarded), B Blocked (Bounced)

The date pickers are Tempus Dominus widgets bound to the start/end inputs at page load; they emit yyyy-MM-dd HH:mm:ss into the form fields so the validation regex matches whether the admin types the date or picks it.

The msgs.content codes -- "what was this?"

These are the values rendered by the Type column and the values used by the Type multi-select. They come from the msg_content_type table (seeded at install time):

Code Description Meaning
V Virus ClamAV (or another configured scanner) hit a signature
B Banned A File Rule regex matched an attachment name, MIME type, or archive member
U Unchecked Amavis received the message but didn't scan (bypass policy, scanner failure, oversized, etc.)
S Spam Quarantined SpamAssassin score reached spam_kill_level per the recipient's SVF Policy
M Bad-Mime MIME structure invalid in a way that broke the parser
H Bad-Header Header malformed per RFC; subject to per-policy bad_header_lover
O Oversized Message exceeded the configured size limit
T Mta Error Downstream MTA rejected the release / delivery attempt
C Clean Scanned, no findings, delivered
Y Spam Tagged Score reached spam_tag2_level (tagged with header) but stayed below spam_kill_level (delivered)
s Spam Tagged (OLD) Legacy lowercase variant; preserved for back-compat with older msgs rows

The score column shown on the table is msgs.spam_level -- the raw SpamAssassin score from the scan, not the per-policy threshold. A row tagged S with score 7.2 means the recipient's SVF policy has a spam_kill_level of 7.2 or lower.

The msgrcpt.ds codes -- "where did it go?"

ds is one character per recipient row:

ds Column header Meaning
P Delivered Pass -- handed to the downstream MTA (Postfix re-injection on port 10025 for relay topology, LMTP to Dovecot for mailbox topology)
D Blocked Discard -- not delivered, quarantined on disk under /mnt/data/amavis/<quar_loc>
B Blocked Bounce -- rejected at SMTP time with DSN to sender
anything else N/A Unexpected disposition; usually means amavis was killed mid-handoff or the row is partial

Per-recipient is the key: a single message with three recipients can have one P, one D, and one B row in msgrcpt. The table renders each msgrcpt row separately even though they share a mail_id.

The results table

The DataTable below the search card is sortable, paginated (50 / 75 / 100 / All rows per page), and exportable (Copy, CSV, Excel, PDF, Print buttons rendered by the DataTables Buttons extension). Default sort is Date/Time descending.

Column Source Notes
Checkbox msgs.mail_id Selects the row for the Message Actions modal. Select All in the header checks every checkbox on the current page
View -- Magnifier button; opens view_message.cfm?mid=<mail_id> with the same startdate / enddate / limit so the back link round-trips correctly
Archived msgs.archive Y if the quarantine file has been moved to the long-term archive mount, N if it's still in the live amavis quarantine. Drives where view_message.cfm reads the EML from
Date/Time msgs.time_iso Indexed (idx_msgs_time_iso); this is the column the date range filters on. Rendered yyyy-mm-dd HH:mm:ss
Sender IP msgs.client_addr The client IP that handed the message to Postfix. For inbound that's the upstream MTA; for outbound it's the relay submitter
Return-Path maddr.email via msgs.sid The envelope sender (MAIL FROM); resolved via the maddr address-dedup table
From msgs.from_addr The header From: -- which is what users see and what DMARC aligns to
To maddr.email via msgrcpt.rid The envelope recipient. Per-recipient -- one table row per msgrcpt row
Subject msgs.subject Decoded subject header
Score msgs.spam_level Numeric score from SpamAssassin; formatted with 2 decimal places
Type msg_content_type.description Translated from msgs.content -- see the code table above
Action derived from msgrcpt.ds Delivered / Blocked / Blocked / N/A

If the date range returns zero rows, the table is replaced by an info alert ("No messages were found for the selected date range").

The View action -- view_message.cfm

Clicking the magnifier opens the per-message detail page. What that page can show is gated by two install-time toggles in /opt/hermes/config/security.conf:

Toggle Default Effect
ALLOW_MESSAGE_CONTENT=yes off Show the decoded message body (HTML + text). When off, only headers are rendered
ALLOW_ATTACHMENT_DOWNLOAD=yes off Render the attachment list with a download button per attachment. When off, attachments are silently not listed

Both default off because viewing a quarantined message body is a privileged operation -- it's the difference between "the admin can see a message was rejected" and "the admin can read a user's mail." Sites that need release-decision support enable ALLOW_MESSAGE_CONTENT; sites that need forensic attachment extraction enable ALLOW_ATTACHMENT_DOWNLOAD. The fast path reads only the raw MIME headers via a buffered Java reader so the headers page loads cheaply even on huge quarantine files; full-body parsing only happens when the toggle is on.

The EML is read from one of two paths depending on msgs.archive:

If the file no longer exists on disk, the page aborts to the error template instead of returning a partial render.

Message Actions -- the bulk-action modal

Above the results table, the Message Actions button opens a modal that applies one of six actions to every row whose checkbox is ticked. The action runs in a CFML loop over the comma-delimited mail_id list; each iteration includes the matching action template per-message.

Action Include What it does
Block Sender inc/messages_block_sender.cfm Adds the envelope sender to the Amavis WB-list as B for the recipient of that message. Honors virtual-recipient validation -- bulk attempts against unknown recipients land in failureinvalidrecipient_email
Allow Sender inc/messages_allow_sender.cfm Same as Block Sender but writes W (whitelist). The recipient's future mail from that sender bypasses spam scoring
Release Message(s) to Recipient inc/messages_release_message.cfm Calls docker exec hermes_mail_filter /usr/sbin/amavisd-release <quar_loc> <secret_id> <recipient>. Re-injects the message from the quarantine file into Postfix for delivery. Success detected by parsing 250 2.0.0 out of the amavisd-release stdout
Train Message(s) as Spam inc/messages_train_spam.cfm Runs sa-learn --spam against the quarantine EML so Bayes learns that pattern as spam
Train Message(s) as Ham (NOT Spam) inc/messages_train_ham.cfm Runs sa-learn --ham so Bayes learns that pattern as legitimate. Use this on the false positives released from quarantine
Remove Message(s) Previous Training inc/messages_forget_bayes.cfm Runs sa-learn --forget to undo a prior --spam or --ham call against the same message

After any of the three Bayes actions, the page calls inc/messages_sa_learn_sync.cfm (which docker execs sa-learn --sync to flush the in-memory token store to the Bayes database) and then runs /opt/hermes/scripts/bayes_chown_amavis.sh so the freshly written Bayes files stay owned by the amavis UID inside the content-filter container. Don't skip the sync -- without it, scoring decisions based on the new training only land after amavis's next periodic auto-sync, which is up to an hour out.

The release-message path is the most operationally important: it requires the quarantine file still exists on disk (the message hasn't been pruned by the cleanup job), amavisd-release exits with a 250, and the downstream MTA accepts the re-injection. Any of those failing puts the row in failurereleasemessage_email and surfaces a red alert.

By design. Releasing a message does not automatically train it as ham. If a quarantined spam is actually legitimate, run Release Message and Train as Ham as separate bulk actions so Bayes learns the false positive.

Status alerts -- the m flow

The page uses a session.m integer to pipe action-outcome alerts between the action-handler block (top of file) and the alert renderers (also top of file, after parameter setup). The handler sets session.m = <code> and cflocations back to the same URL with the filter params preserved; the alert renderer reads session.m, emits the matching alert, and clears the variable.

m Triggering action Alert
1 Submit clicked with no rows ticked "You must first select message(s) before clicking the Message Actions button"
3 Block Sender success / warning
4 Allow Sender success / warning
5 Release Message(s) success / warning
6 Train Ham success / warning
7 Train Spam success / warning
8 Forget (remove training) success / warning

The "warning" path fires when some rows in the bulk action failed -- the page lists both the successful and the failed subjects so the admin can re-target the failures.

Retention -- the message lifecycle

This page is not the retention surface; it is the read/action surface against rows that the retention pipeline maintains. Two scheduled jobs (registered as Ofelia jobs against hermes_commandbox) own the message lifecycle:

Schedule Endpoint Job
0 30 01 * * * (01:30 daily) schedule/message_cleanup.cfm Prunes msgs + msgrcpt rows past the configured retention window and deletes the matching quarantine files from /mnt/data/amavis/
@every 60s schedule/quarantine_notify.cfm Reads the idx_msgrcpt_notify index, sends recipient-facing quarantine notifications for new ds=D rows that haven't been notified yet, and flips notification_sent=1

Both are managed from Scheduled Tasks; retention thresholds and per-content-type quarantine targets are configured on Anti-Spam Settings. The cleanup job is the reason a Release Message action can fail with "quarantine file does not exist" -- if you wait past the retention window, the EML is gone and only the msgs row remains as a record.

Performance notes

The base join (msgs INNER JOIN msgrcpt ON msgs.mail_id = msgrcpt.mail_id) is hit on every page load with a WHERE msgs.time_iso BETWEEN ? range. idx_msgs_time_iso is the index that makes the date range cheap; without it the query degrades to a full table scan and pages with limit=15000 would time out on a busy gateway. The per-row sub-queries (getfromaddr, gettoaddr, gettype) fire once per result row because they were originally written with N+1 semantics; on limit=15000 that's 60K+ extra queries plus 15K DataTable rows being rendered into the DOM. The "10000+ significantly increases page load time" warning on the form is calibrated against that reality.

Don't widen the date range and crank the limit at the same time when debugging a specific incident. Narrow the window first, then widen the limit only if you have to.

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:

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

Network Block/Allow

Network Block/Allow

Admin path: Content Checks > Network Block/Allow (view_network_block_allow.cfm, inc/get_network_block_allow.cfm, inc/network_add_entries.cfm, inc/network_edit_entry.cfm, inc/network_delete_entry.cfm, inc/generate_postscreen_access.cfm).

This page manages the operator-curated CIDR list that Postfix's postscreen daemon consults at TCP-accept time, before any DNSBL scoring or SMTP handshake. Each entry pairs a single IP or CIDR with an action — permit (allow / RBL bypass) or reject (block) — and the list is written verbatim to /etc/postfix/postscreen_access.cidr on every save. The directive that wires it in lives in main.cf:

postscreen_access_list = permit_mynetworks, cidr:/etc/postfix/postscreen_access.cidr

This is the third-party-list override for the perimeter — the place an admin overrides a misfiring RBL hit without disabling the RBL itself, and the place a known-bad source is dropped before it can even attempt SMTP.

Where this list sits in the flow

+-------------------------+
|  Inbound TCP connect    |
+-----------+-------------+
            |
            v
+-------------------------------------------------+
|  postscreen :25 (hermes_postfix_dkim)           |
|                                                 |
|  1. postscreen_access_list                      |
|     permit_mynetworks                           |
|     cidr:/etc/postfix/postscreen_access.cidr    |
|     -> permit  -> hand off to smtpd, skip all   |
|                    scoring (RBL, greet, etc.)   |
|     -> reject  -> 550, connection closed        |
|     -> no hit  -> fall through                  |
|                                                 |
|  2. postscreen_dnsbl_sites (RBL scoring)        |
|     -> threshold met -> 550                     |
|                                                 |
|  3. pipelining / non-SMTP / bare-newline        |
|     (if enabled on Perimeter Checks)            |
|                                                 |
+-----------+-------------------------------------+
            | passes -> hand to smtpd
            v
+-------------------------------------------------+
|  smtpd :25  (smtpd_*_restrictions)              |
+-------------------------------------------------+

The position of cidr:/etc/postfix/postscreen_access.cidr matters: because it sits before postscreen_dnsbl_sites in postscreen_access_list, a permit entry here causes postscreen to short-circuit and skip every DNSBL lookup for that source. A reject entry closes the connection with no further checks at all.

Distinction from Relay Networks

This page is easy to confuse with Relay Networks — both store IPs and CIDRs against Postfix. They are not the same:

Page Postfix destination What an entry does
Network Block/Allow (this page) cidr:/etc/postfix/postscreen_access.cidr, consulted by postscreen_access_list permit = skip RBL scoring for this IP. reject = 550 at TCP accept. No trust granted — the source still passes through smtpd_recipient_restrictions and content scanning
Relay Networks mynetworks directive in main.cf, also Amavis @inet_acl Sets permit_mynetworks — sender is fully trusted: bypasses RBL, SPF, sender/recipient checks, and is allowed to relay outbound to any destination

A wrong entry on Relay Networks creates an open relay. A wrong entry here at worst lets a few extra messages through the perimeter into content scanning, where Amavis + SpamAssassin + ClamAV still apply. The two pages serve different jobs — gate the source vs. trust the source — and the postfix directives they write to are distinct.

When to add a permit entry

Scenario Why allow here instead of Relay Networks
Trusted partner whose IP is listed in an RBL You want their mail through, but you do not want to grant them open relay; the RBL bypass is enough
Shared-hosting sender whose IP also hosts a spammer Same as above — bypass RBL scoring, let content checks still apply
Microsoft 365 outbound ranges EOP IPs are already in the shipped seed list as permit (151 rows on a fresh install). They are inbound mail sources — they don't need relay trust
Internal monitoring sender whose IP randomly appears in CBL RBL false positives caught by IP age or shared CGN

When to add a reject entry

Scenario Why reject here instead of waiting for content scoring
Persistent spam source that consistently slips past RBLs Cheapest possible reject — no DATA accepted, no Amavis cycles
Compromised CIDR block that the operator wants closed off entirely One CIDR row handles a whole /24, /16, or /8
Manual ban after a Fail2ban-or-equivalent decision is escalated to permanent A reject here outlasts any IP-table or jail-based ban

The two cards on the page

1. Add IP/Network

A textarea for bulk entry — one per line, IP_or_Network [Note]. The note is everything after the first space on each line; the IP/CIDR is everything before it. If a line has no space, the entry is its own note.

Validation runs per line:

The single Action radio applies to the whole textarea — every line in one submit gets the same permit or reject. To mix actions, submit twice.

On submit: rows are INSERT-ed into postscreen_access with applied=1, action2='NONE', then generate_postscreen_access.cfm is included to write the new CIDR file and reload Postfix in the same request. The green "Entries Added" alert summarizes added, skipped, and any per-line errors.

2. Network Entries (DataTable)

Searchable, sortable, paginated; bulk-delete checkboxes, per-row Edit / Delete buttons.

Column Source
IP/Network postscreen_access.sender
Note postscreen_access.note (free text from the second half of each Add line)
Action postscreen_access.action rendered as a green "Allow" or red "Block" badge
Actions Edit (modal), Delete (confirm)

The Edit modal lets the operator change the IP, the action (Allow / Block), or the note in one form post.

Save flow

Add / Edit / Delete
    |
    v
INSERT / UPDATE / DELETE on postscreen_access (datasource: hermes)
    |
    v
cfinclude generate_postscreen_access.cfm
    1. SELECT all enabled rows ORDER BY sender ASC
    2. Write /etc/postfix/postscreen_access.cidr
         <sender>\t<action>\n   per line
    3. docker exec hermes_postfix_dkim /usr/sbin/postfix reload  (30s timeout)
    |
    v
session.m = 1 / 2 / 5 (Added / Deleted / Updated)
On failure -> session.m = 4 ("Configuration Error")

The file is written via a direct cffile action="write" from the CommandBox container — possible because /etc/postfix/ is a host-bind-mounted volume shared between hermes_commandbox and hermes_postfix_dkim. The reload then runs inside the postfix container via docker exec. No postmap is required for cidr: tables — Postfix reads them as text at load time.

The postscreen_access table

Column Type Role
id int AUTO_INCREMENT Primary key (used as form delete_id / edit_id)
sender varchar(255) The IP or CIDR string (the column is named sender for historical reasons — it is not an envelope sender)
action varchar(255) permit or reject
action2 varchar(255) Always NONE — legacy two-phase apply column kept for compatibility
applied int 1 once the row is live in the generated .cidr file
note varchar(255) Free-text label shown in the table

Engine is MyISAM (matches other operator-curated tables in the schema); collation latin1_swedish_ci. The shipped seed includes a large block of Microsoft 365 / Exchange Online Protection ranges as permit so EOP-fronted senders are never RBL-scored on a fresh install.

Failure semantics

Failure Behavior
Empty textarea on Add session.m = 30, redirect, no DB write
Invalid IP or CIDR on a line Line skipped, entries_skipped incremented, error appended; other lines still process
Duplicate against existing sender Same as invalid — skipped with a Duplicate: error line
cffile cannot write /etc/postfix/postscreen_access.cidr cfcatch -> session.m = 4 ("Configuration Error")
postfix reload fails inside the container Same session.m = 4 path

If the SQL inserts succeed but the file write or reload fails, the database state has advanced but the live CIDR file lags. The next successful save (or any Edit / Delete) re-renders the file from the current table contents, so the page does not strand split-brain state permanently.

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_network_block_allow.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/get_network_block_allow.cfm hermes_commandbox Loads active rows
config/hermes/var/www/html/admin/2/inc/network_add_entries.cfm hermes_commandbox Per-line validate, INSERT, regen + reload
config/hermes/var/www/html/admin/2/inc/network_edit_entry.cfm hermes_commandbox UPDATE, regen + reload
config/hermes/var/www/html/admin/2/inc/network_delete_entry.cfm hermes_commandbox DELETE single or bulk, regen + reload
config/hermes/var/www/html/admin/2/inc/generate_postscreen_access.cfm hermes_commandbox Rewrites /etc/postfix/postscreen_access.cidr and reloads Postfix
postscreen_access table hermes_db_server (hermes DB) Source of truth
/etc/postfix/postscreen_access.cidr (volume mount) hermes_postfix_dkim Live CIDR file consumed by postscreen
hermes_postfix_dkim container Where postfix reload runs

Perimeter Checks

Perimeter Checks

Admin path: Content Checks > Perimeter Checks (view_perimeter_checks.cfm, inc/get_perimeter_checks.cfm, inc/perimeter_save_settings.cfm, inc/generate_postfix_configuration.cfm).

This page collects every SMTP-time check Hermes can apply before the message body is even read. Each control here writes a row (or toggles enabled) in the parameters table; on save, the generate_postfix_configuration.cfm include rebuilds main.cf from those rows via postconf -e and runs postfix reload inside hermes_postfix_dkim. There is no message-content inspection on this page — content scoring lives in Anti-Spam Settings and Anti-Virus Settings, and runs only after the perimeter checks accept the connection.

Where perimeter checks sit in the flow

+-------------------+
| Remote SMTP peer  |
+---------+---------+
          |
          v
+-----------------------------------------------+
|  postscreen :25 (hermes_postfix_dkim)         |
|   - postscreen_access.cidr  (whitelist/block) |
|   - DNSBL scoring -> postscreen_dnsbl_sites   |
|   - pipelining / non-SMTP / bare-newline      |
+---------+-------------------------------------+
          | passes -> hand off
          v
+-----------------------------------------------+
|  smtpd :25                                    |
|   - smtpd_helo_required                       |
|   - smtpd_client_restrictions                 |
|   - smtpd_helo_restrictions                   |
|   - smtpd_sender_restrictions                 |
|   - smtpd_recipient_restrictions              |
|     (permit_mynetworks, permit_sasl_auth,     |
|      reject_unauth_destination,               |
|      reject_invalid_hostname, ...,            |
|      reject_rbl_client / DNSBL,               |
|      check_policy_service for SPF)            |
|   - message_size_limit                        |
+---------+-------------------------------------+
          | passes -> DATA accepted
          v
+-----------------------------------------------+
|  Amavis / SpamAssassin / ClamAV (content)     |
+-----------------------------------------------+

Perimeter Checks owns the postscreen knobs and the smtpd_*_restrictions toggles. RBL list membership is split out to its own page — RBL Configuration — because the list is row-per-entry data, not a fixed set of switches.

The four cards on the page

1. Postscreen Settings

postscreen is Postfix's pre-queue connection filter — it sits in front of smtpd on port 25 and runs cheap protocol checks before any SMTP state machine is built. Three switches:

Switch parameters row Postfix directive What it catches
Pipelining Detection postscreen_pipelining_enable postscreen_pipelining_enable = yes/no Clients that send EHLO + MAIL FROM + RCPT TO in one TCP write before the server has finished its greeting — classic spambot shortcut
Non-SMTP Command Detection postscreen_non_smtp_command_enable same Clients that send something other than the SMTP verbs (typically HTTP GET from a misdirected scanner, or shellcode)
Bare Newline Detection postscreen_bare_newline_enable same Clients that terminate lines with a bare \n instead of \r\n — RFC 5321 violation, very common in homebrew bot SMTP libraries

Operational consequence. Enabling any of these activates greylisting-style deferral for unknown clients. Mail from a well-behaved peer is delayed by one retry on first contact; mail from a peer that retries incorrectly (or not at all) is lost. The in-page callout warns about this explicitly. Leave these off until you have a reason to turn them on.

2. Message Limits

A single control: Maximum Message Size (MB). The page displays the value in megabytes; on save it is multiplied by 1024*1024 and the integer byte count is written to the child row under the message_size_limit parent. Postfix enforces this at DATA-accept time and rejects with 552 5.3.4 if the message exceeds the limit.

Validation rejects zero, negative, and non-numeric input (session.m = 3).

3. SMTP Restrictions

The bulk of the page. The HELO toggle and seven recipient-side rejects each map to a child row under one of two parent parameters:

Toggle Parent Postfix directive Rejects when...
Require HELO/EHLO smtpd_helo_required smtpd_helo_required = yes Client tries to send MAIL FROM without first issuing HELO or EHLO
Reject Unauthorized Destination smtpd_recipient_restrictions reject_unauth_destination Recipient domain is not a relay or hosted domain (open-relay protection — leave on)
Reject Unauthorized Pipelining smtpd_recipient_restrictions reject_unauth_pipelining Client pipelines commands without EHLO advertising support
Reject Invalid Hostname smtpd_recipient_restrictions reject_invalid_hostname HELO/EHLO name is syntactically invalid (e.g. no dot)
Reject Non-FQDN Sender smtpd_recipient_restrictions reject_non_fqdn_sender MAIL FROM: address has no fully-qualified domain
Reject Unknown Sender Domain smtpd_recipient_restrictions reject_unknown_sender_domain Sender domain has neither MX nor A record in DNS
Reject Non-FQDN Recipient smtpd_recipient_restrictions reject_non_fqdn_recipient RCPT TO: address has no fully-qualified domain
Reject Unknown Recipient Domain smtpd_recipient_restrictions reject_unknown_recipient_domain Recipient domain has neither MX nor A record in DNS

The DNSBL Threshold field in the same card writes postscreen_dnsbl_threshold — the combined score that any single connecting IP must reach across all enabled DNSBL zones before postscreen rejects it. The shipped baseline is 3. Per-zone weights are configured on RBL Configuration; the threshold here is what those weights add up against. Validation requires an integer (session.m = 2).

Order matters in Postfix. The save routine does not let an admin reorder restrictions — the order1 column in parameters is seeded at install time so that permit_mynetworks and permit_sasl_authenticated come first, then the reject_unauth_destination open-relay guard, then sender / recipient validation, then policy services. This is the canonical order; the UI only toggles which entries are active, not where they sit in the list.

4. Email Authentication (read-only status)

Three badges (SPF, DKIM, DMARC) showing whether each authentication service is wired into smtpd_milters / smtpd_recipient_restrictions, each with a small "Configure..." link to its dedicated page. This card is informational — toggling SPF/DKIM/DMARC on or off happens on:

The DMARC row carries an additional note: DMARC requires SPF and DKIM to both be active. If either is disabled, the card surfaces "Requires both SPF and DKIM" inline.

Save flow

A single Save & Apply Settings click runs:

1. Validate dnsbl_threshold (integer) and message_size_limit (positive float)
   - Fail -> session.m = 2 or 3, cflocation back, no DB write
2. UPDATE parameters child rows for all toggles + values (applied = 2)
3. cfinclude generate_postfix_configuration.cfm
     a. Copy /opt/hermes/conf_files/main.cf.HERMES -> /etc/postfix/main.cf
     b. SELECT all enabled parents (child=2), join children (child=1)
     c. Write /opt/hermes/tmp/<trans>_postconf.sh with one
        `postconf -e "<directive> = <values>"` line per parent
     d. Append `postfix reload`
     e. docker exec hermes_postfix_dkim /bin/bash <script>
     f. UPDATE parameters SET applied=1, action='NONE' WHERE applied=2
4. session.m = 1 -> green "Settings Saved" alert on redirect
   On failure -> session.m = 4 with cfcatch detail surfaced in the alert

The reload is in-band — the page does not return until Postfix has reloaded (timeout: 240s).

The parameters dual-row pattern (perimeter-specific)

Every Postfix directive in Hermes is stored as two-or-more linked rows in the parameters table:

child Role What the parameter column holds
2 Parent (directive name) The Postfix directive name (e.g. smtpd_recipient_restrictions)
1 Child (directive value) One value the directive should emit (e.g. reject_unauth_destination, or yes)

Rows are linked by parent_name (child's parent_name matches parent's parameter) or by numeric parent (child's parent matches parent's id). The order1 column sequences children inside a parent so the generated postconf -e line emits values in a predictable order.

For perimeter checks, that means:

Failure semantics

Failure Behavior
Invalid dnsbl_threshold session.m = 2, redirect, no DB write
Invalid message_size_limit session.m = 3, redirect, no DB write
generate_postfix_configuration.cfm throws session.m = 4; session.postfix_error is set to cfcatch.message & cfcatch.detail and surfaced under a small "Detail:" line in the red alert
postfix reload fails inside the container Surfaces as a cfcatch from the cfexecute of the temp script — same session.m = 4 path
main.cf.HERMES template missing in /opt/hermes/conf_files/ cfcatch on the template copy step — same path

The save is not transactional across the steps — if the SQL updates succeed but the reload fails, the DB state advances to applied=2 and the next save attempt will pick those rows up and re-apply. The page does not strand partial state.

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_perimeter_checks.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/get_perimeter_checks.cfm hermes_commandbox Loads parent IDs + current child values
config/hermes/var/www/html/admin/2/inc/perimeter_save_settings.cfm hermes_commandbox Validates form, updates parameters, calls the generator
config/hermes/var/www/html/admin/2/inc/generate_postfix_configuration.cfm hermes_commandbox Writes a temp postconf -e shell script, executes inside the postfix container, reloads Postfix
config/hermes/opt/hermes/conf_files/main.cf.HERMES hermes_commandbox (read) → hermes_postfix_dkim (live /etc/postfix/main.cf) Canonical template copied on every regen
parameters table hermes_db_server (hermes DB) Source of truth for every restriction and toggle
hermes_postfix_dkim container Where postconf -e + postfix reload execute

RBL Configuration

RBL Configuration

Admin path: Content Checks > RBL Configuration (view_rbl_configuration.cfm, inc/get_rbl_configuration.cfm, inc/rbl_add_entry.cfm, inc/rbl_edit_entry.cfm, inc/rbl_delete_entry.cfm, inc/rbl_test_entry.cfm, inc/generate_postfix_configuration.cfm).

This page manages the DNSBL (block) and DNSWL (allow) lists that Postfix's postscreen daemon consults before a connection is even handed off to smtpd. Each enabled entry contributes a weighted score for the connecting IP; when the running total crosses the threshold set on Perimeter Checks, postscreen rejects the connection with 550 5.7.1. Allow-list entries subtract from that score and can rescue a sender that one or two block lists flag.

The list is row-per-entry data — add, edit, delete, and live-test operations all happen on this page. The numerical threshold those weights are compared against is a single integer on the Perimeter Checks page (postscreen_dnsbl_threshold, default 3).

How postscreen scoring works

Inbound TCP -> postscreen :25
                  |
                  v
        For each enabled DNSBL site:
          dig <reversed-client-ip>.<rbl-zone>
          if A record returned (and matches optional =127.x.x.x filter):
            add (or subtract) the entry's weight
                  |
                  v
        Sum >= postscreen_dnsbl_threshold ?
          yes -> reject 550 5.7.1
          no  -> pass to smtpd for the rest of the perimeter checks

The decision is made against a single connecting IP in a single postscreen session. Postscreen does this in parallel across every enabled zone and waits up to a few seconds for responses.

Block vs. Allow

Type Stored weight DNS contribution Typical use
Block List (DNSBL) Positive integer (+1+8 typical) Adds to the score on hit zen.spamhaus.org, bl.spamcop.net, b.barracudacentral.org
Allow List (DNSWL) Negative integer (-2-8 typical) Subtracts from the score on hit list.dnswl.org, wl.mailspike.net, hostkarma.junkemailfilter.com=127.0.0.1

The UI presents two radio buttons (Block List / Allow List) and a positive weight; the save handler signs the weight automatically (positive for block, negative for allow) and stores both the signed integer in the weight column and a string representation in the parameter column (<host>*<weight> for block, <host>*-<abs(weight)> for allow).

Return-code filtering

Many DNSBL providers publish different return codes for different sub-lists inside a single zone. Spamhaus ZEN is the canonical example: 127.0.0.2 for SBL, 127.0.0.3 for the CSS sub-list, 127.0.0.4-7 for XBL, 127.0.0.10-11 for PBL. Postfix lets you match a subset of those codes with the <hostname>=127.x.x.x syntax (and =127.0.0.[N..M] / =127.0.0.[N;M;O] for ranges and unions). This lets an admin assign a different weight to each sub-list:

zen.spamhaus.org=127.0.0.2        weight 3   (SBL — moderate confidence)
zen.spamhaus.org=127.0.0.3        weight 4   (CSS)
zen.spamhaus.org=127.0.0.[4..7]   weight 6   (XBL — exploit list)
zen.spamhaus.org=127.0.0.[10;11]  weight 8   (PBL — policy list)

The shipped baseline includes exactly this kind of staged Spamhaus configuration plus per-code weights for several other providers; see the RBL Entries table after a fresh install.

The two cards on the page

1. Add RBL Entry

Four inputs: hostname (with optional =127.x.x.x filter), type (Block / Allow), positive weight, and submit. The hostname is validated by stripping any =... suffix and running the bare host through IsValid("email", "test@" & hostPart) — a permissive syntactic check that accepts valid DNS labels and rejects empty strings, whitespace, and obvious garbage.

Duplicates are blocked via a LIKE '%<host>%' lookup on the parameters table before insert; the page surfaces a "Duplicate Entry" warning if a row already contains the hostname (including existing entries with different =127.x.x.x filters — be aware that the substring check will treat zen.spamhaus.org=127.0.0.2 and zen.spamhaus.org=127.0.0.3 as duplicates of each other, so add sub-list variants by editing the existing row's filter rather than inserting a second).

On success: INSERT into parameters under the postscreen_dnsbl_sites parent, immediately call generate_postfix_configuration.cfm, redirect with session.m = 1 (green "Entry Added" alert). The full RBL list takes effect on the next inbound connection.

2. RBL Entries (DataTable)

Searchable, sortable, paginated table with bulk-delete checkboxes, per-row Test / Edit / Delete buttons, and a Test All action.

Column Source
Hostname parameter column with the trailing *<weight> stripped for display
Type Derived from sign of weight — positive = Block, negative = Allow
Weight Abs(weight)
Status Live AJAX result of the per-row DNS test (see below); starts as "Not Tested"
Actions Test (vial icon), Edit, Delete

The DataTable is wrapped in a <form> whose submit target is the bulk delete handler; per-row Delete and Edit use separate hidden forms outside the DataTable so they don't collide with the bulk form.

The live RBL test

The vial-icon button on each row triggers view_rbl_configuration.cfm?action=test_entry&id=<id> — an AJAX-only branch that runs before any HTML output and returns JSON. The handler performs a two-stage DNS probe from inside the same container Postfix uses for its real DNSBL queries:

Stage Query Pass criterion
1. Test-data lookup dig +short A 2.0.0.127.<zone> (the IP 127.0.0.2 reversed, prefixed onto the zone — the universal DNSBL "test record") Response starts with 12 (i.e. a 127.x.x.x answer) → zone is actively publishing data
2. SOA fallback dig +short SOA <zone> Non-empty response → zone infrastructure exists even if the test record was not returned

Both dig invocations run via docker exec hermes_postfix_dkim dig +short +time=3 +tries=1 ... inside a cfthread with a 10-second join timeout. This matters for two reasons:

  1. Same resolver as Postfix. The CommandBox JVM's DNS resolver cannot reliably reach DNSBL zones; querying from the postfix container guarantees the test sees what the live mail flow sees.
  2. Same source IP as Postfix. Many DNSBL providers throttle or refuse responses to public-resolver IPs (Cloudflare, Google, Quad9). The test must originate from the same egress IP as the real queries to give a meaningful result. This is the central reason Hermes ships its own DNS Resolver; if that resolver is flipped to forwarding mode through a public provider, both the live tests and real DNSBL traffic will degrade.

Result encoding:

JSON status Badge Meaning
ok (stage 1 hit) Green "Zone Active" with the returned IP in the tooltip Zone is publishing test data and reachable
ok (stage 2 hit) Green "Zone Active" with "Zone active (SOA)" tooltip Zone infrastructure exists; test record not returned (common — many providers block data-center IPs from test queries)
error Red "Error" No DNS response, NXDOMAIN, or NS delegation only with no SOA
timeout Red "Unreachable" The 10-second thread join expired

Green only confirms zone infrastructure — not that the list is actively publishing data. Many DNSBL providers (Barracuda is the common example) block data-center IP ranges from running live data queries. A stage-2-only green from such a provider is the expected healthy result, not a problem — the live mail-flow queries are coming from the same blocked IP, so they will also miss, and the provider in that case isn't actually contributing to scoring.

Why dead RBLs are dangerous in both directions

The in-page callout flags this explicitly:

The live tests catch zones that are flat-out unreachable; they cannot catch zones that are actively publishing wrong answers. The operational mitigation is to keep the weight on any single entry small enough that one misbehaving zone cannot single-handedly cross the threshold — the shipped weights are set with this in mind (per-zone weights of 2-8 against a threshold of 3 means at least two corroborating hits are required for a block).

Edit and delete

The Edit modal preserves the same Block / Allow toggle + positive weight UX as Add; on save it rewrites both the parameter string and the signed weight integer. Single-row delete uses a confirm prompt + hidden <form> POST; bulk delete posts a comma-separated list of parameters.id values from the wrapping DataTable form. All three (add, edit, delete) call generate_postfix_configuration.cfm inline and reload Postfix in the same request.

Save flow

1. (Add / Edit / Delete) Validate input, INSERT / UPDATE / DELETE
   on the `parameters` table under postscreen_dnsbl_sites parent
2. cfinclude generate_postfix_configuration.cfm
     - SELECT all enabled children of every enabled parent,
       including the full ordered list of postscreen_dnsbl_sites
     - Render a temp postconf -e script + `postfix reload`
     - docker exec hermes_postfix_dkim /bin/bash <script>
     - UPDATE parameters SET applied=1 WHERE applied=2
3. session.m = 1 / 2 / 5 (Added / Deleted / Updated)
   On failure -> session.m = 4

The parameters rows for DNSBL sites

Column Value (block-list example) Value (allow-list example)
parameter zen.spamhaus.org=127.0.0.[4..7]*6 list.dnswl.org=127.0.[0..255].3*-8
parent_name postscreen_dnsbl_sites postscreen_dnsbl_sites
weight 6 (positive integer) -8 (negative integer)
child 1 (it's a child of the directive parent row) 1
order1 Sequence within the directive (auto-incremented on Add) Same
enabled 1 to include in the live postscreen_dnsbl_sites value 1
applied 1 once Postfix has been reloaded against this row, 2 while pending Same

The generator joins the children into a single comma-separated value for the postscreen_dnsbl_sites directive — the live Postfix configuration ends up as one long line of <zone>=<filter>*<weight> tokens.

Failure semantics

Failure Behavior
Empty hostname on Add session.m = 10, redirect, no DB write
Invalid hostname syntax (Add or Edit) session.m = 11, redirect, no DB write
Duplicate hostname (Add) session.m = 12, redirect, no DB write
generate_postfix_configuration.cfm throws session.m = 4, red "Configuration Error" alert
dig inside hermes_postfix_dkim times out (test only) JSON {"status":"timeout"} → red "Unreachable" badge; live mail flow is unaffected
hermes_postfix_dkim not running (test only) JSON {"status":"error"} → red "Error" badge

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_rbl_configuration.cfm hermes_commandbox The page (with the early action=test_entry AJAX intercept)
config/hermes/var/www/html/admin/2/inc/get_rbl_configuration.cfm hermes_commandbox Loads the postscreen_dnsbl_sites parent ID + all active children
config/hermes/var/www/html/admin/2/inc/rbl_add_entry.cfm hermes_commandbox Validate, INSERT, regen + reload
config/hermes/var/www/html/admin/2/inc/rbl_edit_entry.cfm hermes_commandbox Validate, UPDATE, regen + reload
config/hermes/var/www/html/admin/2/inc/rbl_delete_entry.cfm hermes_commandbox DELETE (single or bulk), regen + reload
config/hermes/var/www/html/admin/2/inc/rbl_test_entry.cfm hermes_commandbox Two-stage DNS probe via docker exec into the postfix container
config/hermes/var/www/html/admin/2/inc/generate_postfix_configuration.cfm hermes_commandbox Rebuilds main.cf from parameters and reloads Postfix
parameters table (rows under parent postscreen_dnsbl_sites) hermes_db_server (hermes DB) Source of truth
hermes_postfix_dkim container Runs dig for the live tests and postscreen for the real DNSBL traffic
hermes_unbound container The recursive resolver every dig (test) and every postscreen (live) query flows through

Future work

A scheduled RBL health checker that runs the per-entry test on a timer and emails the admin when a zone goes dark — including auto-disable of consistently-failing entries — is planned (tracked on the GitHub issue tracker). Until that ships, the Test All button on this page is the manual equivalent; it triggers every per-row test in parallel and refreshes the Status column in place.

Score Overrides

Score Overrides

Admin path: Content Checks > Score Overrides (view_score_overrides.cfm, inc/update_spamassassin_config_files.cfm, inc/update_amavis_config_files.cfm, inc/restart_spamassassin.cfm, inc/restart_amavis.cfm).

This page tunes the per-rule scores that SpamAssassin contributes to each message's total. SpamAssassin ships with thousands of named rules; each rule that matches a message adds (or subtracts) a default score, and the message is tagged or quarantined when the running total crosses the global threshold configured on Anti-Spam Settings. Score Overrides is where the operator says "this rule should weigh more / less / not at all for our mail." The threshold itself is not changed here.

Every entry written on this page lands in SpamAssassin's local.cf as a score <RULE_NAME> <value> line. SpamAssassin reads local.cf on daemon start, and the override takes precedence over the shipped default the rule was defined with.

Where Score Overrides sits

                       +---------------------------------------+
   inbound msg ------->|  Amavis content-filter pass           |
                       |   - ClamAV (virus verdict pre-empts)  |
                       |   - SpamAssassin SCORING              |
                       |        rule_A 0.3                     |
                       |        rule_B 1.2                     |
                       |        rule_C 4.0     <-- per-rule    |
                       |        ...                weights set |
                       |        SUM = N                here    |
                       +---------------------------------------+
                                       |
                                       v
                       +---------------------------------------+
                       |  Anti-Spam Settings thresholds        |
                       |   sa_tag_level                        |
                       |   sa_tag2_level    <-- cutoff points  |
                       |   sa_kill_level         set there     |
                       +---------------------------------------+

Score Overrides tunes the contributions; Anti-Spam Settings tunes the cutoffs. A message reaches quarantine because the sum of contributions crosses the cutoff — moving either side of that equation changes behavior, and they are independent knobs.

What an override actually changes

Override value Effect on the rule Use it when
Positive (e.g. 3.5) Adds more to the spam score on match A rule catches a genuine pattern your senders see often but the default score is too low to flag
0 Rule still runs but contributes nothing A rule produces too many false positives in your mail mix and you want to neuter it without ripping it out of the database
Negative (e.g. -2.0) Subtracts from the spam score on match The rule indicates legitimacy in your environment (e.g. a trusted-relay heuristic) and you want it to act as a bonus

Setting a score to 0 is the safe equivalent of "disable this rule" — SpamAssassin still evaluates it (so the test name still appears in X-Spam-Status and you can confirm it fired), but the message total is unaffected. Removing the override does not delete the underlying SpamAssassin rule; it only stops Hermes's local.cf from overriding the shipped default.

The page

A collapsible scoring helper (the same text the operator gets in the in-page guide), a hard-locked "DKIM and SPF rules are not evaluated" warning, an Add Override modal, a DataTable of current overrides, and an Edit / Delete modal pair.

Add Override modal

Field Stored as Notes
Test Name spam_settings.parameter The SpamAssassin rule name, uppercase with underscores (e.g. BAYES_99, HTML_MESSAGE, FREEMAIL_FROM)
Score spam_settings.value Numeric, validated -999 <= value <= 999. Set to 0 to neuter the rule
Description spam_settings.description Free-text label that surfaces in the DataTable; optional

Add validates: Test Name non-blank, Score numeric and in range, the (parameter) natural key not already present, and the rule name not in the SPF / DKIM / ADSP plugin family (see warning below). On success: INSERT row with spamfilter='1', active='1', applied='1'; then immediately regenerate local.cf and reload the engine — same chain Save uses.

Score Overrides DataTable

Column Source
(checkbox) Selection for bulk Delete Selected
Test Name spam_settings.parameter
Score spam_settings.value
Description spam_settings.description
Edit Per-row pencil button -> Edit modal

System-managed rows (system_managed = 1) get a lock icon instead of a checkbox, a "System-managed" badge next to the test name, and a disabled Edit button. They are filtered out of any DELETE generated by the page even if a forged POST targets them (AND system_managed = 0 is part of the delete query). The lock exists for rules that encode a Hermes architectural decision — for example, the per-rule scores Hermes maintains for the trusted-relay Return Path lookups.

Edit Modal

Test Name is read-only — changing it is semantically a different rule and would orphan the override. Only Score and Description are editable. Save runs the same regen + reload chain as Add.

DKIM / SPF / ADSP overrides are silently meaningless

The page mounts a warning callout flagging that any override targeting a DKIM, SPF, or ADSP rule has no effect in Hermes, and the Add handler rejects them with alert m = 13. The rule families covered:

The SpamAssassin DKIM and SPF plugins are intentionally not loaded in Hermes's init.pre — the authoritative DKIM verdict is the Authentication-Results: header that OpenDKIM writes at :25, and the authoritative SPF verdict is the Received-SPF: header that postfix-policyd-spf-python writes at envelope time. SpamAssassin's in-content re-check would otherwise produce false-positive failures against Hermes-modified bodies (External Sender Banner, disclaimer, signature insertion) and could pick up the wrong upstream IP from the Received chain in multi-hop scenarios (federal mail, M365 GOV cloud, etc.). Letting an operator write an override for a rule that literally cannot fire would silently mislead them, so the guard runs at the Add handler.

The block is case-insensitive (UCase + Left / FindNoCase) so mixed-case rule names cannot sidestep it.

Save and apply flow

1. View page submits action="add" | "edit" | "delete"
2. view_score_overrides.cfm validates the row (per-action rules above)
3. INSERT / UPDATE / DELETE on spam_settings (spamfilter='1'),
   guarded by system_managed=0 on UPDATE and DELETE
4. update_spamassassin_config_files.cfm:
     a. Read /opt/hermes/conf_files/local.cf.HERMES (template)
     b. Substitute USE-BAYES, USE-DCC, USE-PYZOR, USE-RAZOR2, and
        bayes_auto_learn placeholders from their own spam_settings rows
     c. SELECT every spamfilter='1' active='1' row -> tmp/_sa_tests file:
          score <parameter> <value>
          (one line per row)
     d. Substitute the #CUSTOM-TESTS placeholder in local.cf with the
        rendered score list
     e. Render Message Rules into the #CUSTOM-MESSAGE-RULES placeholder
     f. Back up /etc/spamassassin/local.cf -> local.cf.HERMES.BACKUP,
        move the rendered file into place
     g. UPDATE spam_settings SET applied='1' WHERE applied='2'
5. update_amavis_config_files.cfm:
     - Regenerate Amavis 50-user from template (subject tags, destinies,
       DKIM-verification toggle, file rules) so a SA setting change that
       also affects Amavis takes effect in the same write
6. restart_spamassassin.cfm:
     - docker exec hermes_mail_filter /usr/bin/spamassassin --lint
       (validation; abort on failure)
     - Then docker container restart hermes_mail_filter
7. restart_amavis.cfm: same docker container restart hermes_mail_filter
   (idempotent; the engine is back from step 6)
8. session.m = 1 / 7 / 8 -> success alert with "regenerated" wording

The restart in step 6 is a full container restart — hermes_mail_filter runs SpamAssassin, ClamAV, Amavis, and Fangfrisch, all of which re-initialize together. Inbound mail held in Postfix's queue during the restart is retried on the next queue run; no message is lost.

Failure semantics

Alert Trigger
m = 1 Add succeeded and SpamAssassin reloaded
m = 2 Test Name blank
m = 3 Test Name already exists
m = 4 Score out of -999..999 range
m = 5 Score blank
m = 6 Score not numeric
m = 7 Edit succeeded and SpamAssassin reloaded
m = 8 Delete succeeded and SpamAssassin reloaded
m = 10 Delete clicked with no rows selected
m = 11 The Apply chain (regen + restart) threw — DB write may already have happened
m = 12 Attempt to edit or delete a system_managed = 1 row (forged POST defense; the UI hides the action)
m = 13 Add of a DKIM / SPF / ADSP family rule — rejected because the underlying plugin is disabled

m = 11 is the partial-failure case: the DB row has already been inserted / updated / deleted but local.cf regen or the lint / restart step failed. The page does not roll back the DB write — the next successful save will re-render local.cf from the current table state, so the system is self-healing on the next click.

Finding rule names

The page guide gives the lookup steps that work for any received message:

  1. From Message History, open any message and view headers; the X-Spam-Status: header lists every rule that fired and its score
  2. SpamAssassin rule names are uppercase with underscores (e.g. BAYES_99, HTML_MESSAGE, FREEMAIL_FROM, RDNS_NONE, URIBL_BLOCKED)
  3. To see the default score and description for a rule: docker exec hermes_mail_filter spamassassin --debug rules 2>&1 | grep -i <RULE_NAME>

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_score_overrides.cfm hermes_commandbox The page (validation + alerts + DataTable)
config/hermes/var/www/html/admin/2/inc/update_spamassassin_config_files.cfm hermes_commandbox Renders local.cf from template + score rows + message rules
config/hermes/var/www/html/admin/2/inc/update_amavis_config_files.cfm hermes_commandbox Re-renders Amavis 50-user (called in the same chain to keep SA-related Amavis flags in sync)
config/hermes/var/www/html/admin/2/inc/restart_spamassassin.cfm hermes_commandbox Lints the new local.cf then restarts hermes_mail_filter
config/hermes/var/www/html/admin/2/inc/restart_amavis.cfm hermes_commandbox Calls restart_mail_filter.cfm
config/hermes/opt/hermes/conf_files/local.cf.HERMES hermes_commandbox (read) -> hermes_mail_filter (live /etc/spamassassin/local.cf) Canonical template with ##CUSTOM-TESTS and ##CUSTOM-MESSAGE-RULES placeholders
/etc/spamassassin/local.cf hermes_mail_filter Live file SpamAssassin reads at engine start
/etc/spamassassin/local.cf.HERMES.BACKUP hermes_mail_filter Pre-write backup taken every save
spam_settings table, spamfilter = '1' hermes_db_server (hermes DB) Source of truth for every override (and for the Bayes / DCC / Razor / Pyzor / threshold values used by Anti-Spam Settings)
hermes_mail_filter container Hosts SpamAssassin, ClamAV, Amavis, Fangfrisch — restarted as a unit on every save

Sender/Recipient Rules

Sender/Recipient Rules

Admin path: Content Checks > Sender/Recipient Rules (view_sender_recipient_block_allow.cfm, inc/get_sender_recipient_block_allow.cfm, inc/sender_add_entry.cfm, inc/sender_edit_entry.cfm, inc/sender_delete_entry.cfm).

This page manages per-recipient envelope-sender filters — pairs of (sender, recipient) that Amavis honors when it scores an inbound message. Each row says "when this sender writes to this recipient, apply this rule" — ALLOW (skip spam scoring) or BLOCK (quarantine / reject). The rules live in Amavis's native wblist table and are read live on every message, so saves take effect on the next inbound delivery with no service reload.

This is the envelope-level half of the inbound-control story. Pairs with Network Block/Allow, which is the IP-level half evaluated much earlier in the SMTP pipeline.

Where this list sits in the flow

+---------------------------+
|  Inbound TCP / SMTP       |
+-------------+-------------+
              |
              v
+-------------------------------------------------+
|  postscreen / smtpd  (postfix perimeter checks) |
|  - Network Block/Allow  (CIDR)                  |
|  - RBL / DNSBL                                  |
|  - SPF / sender hostname / recipient domain     |
+-------------+-----------------------------------+
              | DATA accepted
              v
+-------------------------------------------------+
|  amavis :10024  (hermes_mail_filter)            |
|                                                 |
|  Per-recipient lookup:                          |
|  $sql_select_white_black_list                   |
|    SELECT wb FROM wblist, mailaddr, recipients  |
|    WHERE recipients.id = wblist.rid             |
|      AND mailaddr.id   = wblist.sid             |
|      AND mailaddr.email IN (%k)                 |
|                                                 |
|  -> wb = 'W'  -> SKIP spam scoring              |
|                 (viruses + banned files +       |
|                  bad headers STILL apply)       |
|  -> wb = 'B'  -> mark as spam / quarantine      |
|  -> no row    -> normal scoring path            |
+-------------------------------------------------+

The lookup is keyed on the envelope-sender address (mailaddr.email) after Amavis has already accepted the message from Postfix and started its scoring pass. That is the central operational fact: this page does not stop mail at SMTP time — it only changes how Amavis treats it once received.

Distinction from sibling pages

Three pages share overlapping vocabulary; they apply at three different points in the pipeline.

Page Layer Match key Effect
Network Block/Allow postscreen (TCP / pre-SMTP) Source IP / CIDR 550 or RBL bypass; no content-layer effect
Global Sender Rules Amavis (per-message) Envelope sender only Allow / block from this sender to every recipient on the system
Sender/Recipient Rules (this page) Amavis (per-message) Envelope sender and specific recipient Allow / block from this sender to one recipient (or one recipient-domain)

Order of precedence within Amavis: a Global Sender Rules entry takes precedence over a per-recipient entry on this page — the in-page callout on Global Sender Rules states this explicitly. Use this page when the policy needs to be scoped to a specific person or mailbox; use Global Sender Rules only when the policy must apply to everyone.

ALLOW does not bypass virus, banned files, or bad headers

The in-page callout makes this explicit:

Allow entries only bypass Spam checks. Emails with Viruses, Banned Files, and Bad Headers will still be blocked.

That is a property of Amavis itself — wb='W' in the wblist table short-circuits the SpamAssassin score path but does not exempt the message from virus scanning (ClamAV), banned-file extension rules (@banned_filename_re), or RFC-violation header checks. The operational consequence is that an ALLOW here is much narrower than the permit action on Network Block/Allow — there, RBL is skipped and the message enters Amavis on the same path as any other; here, only the spam-score gate is removed.

Sender match formats

The sender field accepts three formats, all distinguished by the position of @:

What you type Stored as Matches
user@example.com user@example.com A single full envelope-sender address
example.com @example.com Any envelope sender on example.com (the bare domain — exact match, no subdomains)
.example.com @.example.com example.com and any subdomain (mail.example.com, sub.sub.example.com, …)

The page accepts the bare domain form for convenience and rewrites it with the leading @ before the mailaddr lookup. The leading-dot form is preserved as-is and stored as @.example.com — Amavis itself interprets the dot as the wildcard.

Recipient match formats

The recipient field is constrained to recipients already known to the system. It autocompletes from the recipients table via a <datalist> populated on page render. Two forms work:

What you type What the lookup does Effect
user@example.com Matches a single row in recipients One wblist row inserted (one rid)
@example.com Matches a domain-level row in recipients (where domain='1'); the handler then enumerates every individual recipient under that domain One wblist row per recipient in the domain — the rule fans out

If the typed recipient does not exist anywhere in recipients, the save fails with session.m = 34 ("specified recipient was not found in the system"). The page does not create recipients on the fly — add the recipient on Relay Recipients or as a Mailbox first.

Same-domain sender / recipient is rejected

A guard rejects entries where the sender domain and recipient domain are the same (session.m = 35). Inbound mail from user@example.com to boss@example.com is normally outbound or internal, not the inbound-filtering case this page is designed for, and an ALLOW across that boundary would be a routine misconfiguration.

The two cards on the page

1. Add Sender/Recipient Entry

Four inputs across one form: Sender Email or Domain, Recipient (autocomplete from recipients), Action (BLOCK / ALLOW radios), and submit. Validation order on submit:

  1. Sender non-empty (session.m = 30 on fail).
  2. Recipient non-empty (session.m = 31).
  3. Action is BLOCK or ALLOW (session.m = 32).
  4. Sender is a syntactically valid email or a syntactically valid domain — checked by IsValid("email", ...) against a stub address (session.m = 33).
  5. Recipient resolves to a row in recipients (session.m = 34).
  6. Sender domain != recipient domain (session.m = 35).
  7. Sender+recipient pair is not already in wblist (session.m = 36, "already exists or already staged for addition").

On success, the handler:

  1. Resolves or creates the mailaddr row for the sender (one row per distinct address — mailaddr is shared with the rest of the Amavis stack).
  2. Inserts the wblist row(s):
    • Specific recipient: one row.
    • Domain-wide recipient: one row per individual recipient in that domain (the rule fans out at insert time, not at lookup time).
  3. Sets wb = 'W' (ALLOW) or wb = 'B' (BLOCK).

There is no Postfix or Amavis reload — Amavis reads wblist live on every message via its SQL backend.

2. Sender/Recipient Entries (DataTable)

Searchable, sortable, paginated; bulk-delete checkboxes; per-row Edit / Delete buttons.

Column Source
Sender mailaddr.email joined via wblist.sid
Recipient recipients.recipient joined via wblist.rid
Type wblist.wb rendered as green "Allow" or red "Block" badge
Actions Edit (modal), Delete (confirm)

Each row's checkbox value is a composite rid:sid (the wblist table's natural primary key — no surrogate id column). The bulk delete handler splits each entry on : and deletes the matching wblist row directly.

The Edit modal keeps the recipient read-only (with the inline note "Recipient cannot be changed. Delete and re-add if needed") — changing the recipient would change rid, which is the row's identity. The sender and the BLOCK/ALLOW type are editable; the save handler deletes the original row and inserts a new one, using the sender email strings to find the old row (no integer ID is needed from the form).

Save flow

Add / Edit / Delete
    |
    v
INSERT / UPDATE / DELETE on wblist (and mailaddr for new senders)
  All queries datasource = "hermes"
    |
    v
(Delete only) Garbage-collect orphaned mailaddr rows:
  DELETE FROM mailaddr WHERE id NOT IN (SELECT DISTINCT sid FROM wblist)
    |
    v
session.m = 1 / 2 / 5  (Added / Deleted / Updated)
On validation failure -> session.m = 30..36

No file write, no postmap, no service reload. Amavis picks the new rules up on the next message.

Tables involved

Table Role Engine
wblist (rid, sid, wb) composite-key per-pair rule MyISAM, utf8mb3
mailaddr Distinct envelope-sender addresses; unique key on email MyISAM, utf8mb3
recipients Resolved at lookup time to find rid; populated from the rest of the system (Mailboxes, Relay Recipients, domain-level entries) MyISAM

wblist and mailaddr are Amavis's own native tables — Hermes pre-creates them in hermes_install.sql because Amavis would otherwise lazily create them on its first SQL-backend write, after the CFML pages that reference them have already started to render.

The composite key (rid, sid) is enforced at the database layer, so the page's duplicate guard (session.m = 36) and the database itself will both refuse a true duplicate. mailaddr carries a UNIQUE KEY on email, so concurrent sender adds cannot create duplicate rows even mid-race.

Relationship to user-portal sender filters

End users in the recipients table see and manage their own subset of wblist rules from the user portal (/users/2/) — the "Allow this sender" and "Block this sender" buttons on a quarantined message, plus the explicit Sender Filters page, both write rows into the same wblist table with the user's own recipient id as rid.

This admin page sees those user-trained rules in the same table — they are not flagged separately in the UI. Operators editing or deleting from this page can affect user-trained rules; that is by design (this page is the operator's view of the entire wblist table).

Failure semantics

Failure session.m Behavior
Empty sender 30 Redirect, no DB write
Empty recipient 31 Redirect, no DB write
Invalid action (neither BLOCK nor ALLOW) 32 Redirect, no DB write
Sender not a valid email or domain 33 Redirect, no DB write
Recipient not found in recipients 34 Redirect, no DB write
Same sender and recipient domain 35 Redirect, no DB write
Pair already in wblist 36 Redirect, no DB write

There is no equivalent of session.m = 4 ("Configuration Error") on this page — there is no Postfix / Amavis regen step that could fail. A SQL error would surface as an uncaught cfcatch and the standard 500-error page, not a friendly alert.

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_sender_recipient_block_allow.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/get_sender_recipient_block_allow.cfm hermes_commandbox Joins wblist + mailaddr + recipients for the table
config/hermes/var/www/html/admin/2/inc/sender_add_entry.cfm hermes_commandbox Validate, resolve/insert mailaddr, INSERT wblist (fans out for domain recipients)
config/hermes/var/www/html/admin/2/inc/sender_edit_entry.cfm hermes_commandbox DELETE original row by email-join, INSERT new row, garbage-collect orphan mailaddr
config/hermes/var/www/html/admin/2/inc/sender_delete_entry.cfm hermes_commandbox DELETE single or bulk by rid+sid, garbage-collect orphan mailaddr
wblist, mailaddr, recipients tables hermes_db_server (hermes DB) Source of truth
hermes_mail_filter container (Amavis) Consumes the rules live via $sql_select_white_black_list on every inbound message

SPF Settings

SPF Settings

Admin path: Content Checks > SPF Settings (view_spf_settings.cfm, inc/get_spf_settings.cfm, inc/spf_save_settings.cfm, inc/spf_generate_config_file.cfm, inc/spf_add_whitelist.cfm, inc/spf_edit_whitelist.cfm, inc/spf_delete_whitelist.cfm, inc/generate_postfix_configuration.cfm).

This page controls inbound SPF policy enforcement. SPF (RFC 7208) lets the owner of a domain publish, in DNS, the list of IP addresses authorized to send mail using that domain in the envelope MAIL FROM (and optionally the SMTP HELO). When Postfix accepts a connection, Hermes consults the published record for the connecting client and decides whether to accept, defer, or reject the message based on the result.

Hermes is responsible only for the verification side. Publishing your own organization's SPF record (the v=spf1 ... TXT record at your sending domain) is a one-time DNS operation done at your authoritative DNS host — it is not managed from this page.

Where SPF sits in the flow

+----------------------+
| Remote SMTP peer     |
+----------+-----------+
           |
           v
+----------+--------------------------------+
| smtpd :25 (hermes_postfix_dkim)            |
|   smtpd_recipient_restrictions = ...,      |
|     check_policy_service unix:private/     |
|       policy-spf                           |
|       |                                    |
|       v                                    |
|   Postfix spawns policyd-spf (python)      |
|   from master.cf "policy-spf unix" entry   |
|   - reads /etc/postfix-policyd-spf-python/ |
|       policyd-spf.conf                     |
|   - queries DNS for the sender's SPF TXT   |
|   - returns Pass / Fail / Softfail /       |
|     Neutral / None / TempError / PermError |
|   - returns Postfix action verb            |
|     (DUNNO / REJECT / DEFER_IF_REJECT)     |
+----------+--------------------------------+
           |
           v
+----------+--------------------------------+
| OpenDKIM milter :8891 (DKIM verify)        |
| OpenDMARC milter :54321 (DMARC eval)       |
+----------+--------------------------------+
           |
           v
   Amavis / SpamAssassin / ClamAV

The policy daemon is a Postfix policy delegate — a separate process that Postfix spawns from master.cf:

policy-spf  unix  -  n  n  -  -  spawn
            user=nobody argv=/usr/bin/policyd-spf

smtpd_recipient_restrictions invokes it via check_policy_service unix:private/policy-spf. The daemon's configuration file at /etc/postfix-policyd-spf-python/policyd-spf.conf is what this admin page writes; the entire file is regenerated on every save from the template at /opt/hermes/templates/policyd-spf.conf.HERMES.

SPF result classes and their typical meaning

Result Meaning Default Hermes behavior
Pass Connecting IP is in the published v=spf1 record Accept
Fail Sender has published -all; this IP is explicitly disallowed Reject
SoftFail Sender has published ~all; this IP is not authorized but the owner is in monitoring mode Reject (Hermes recommended) — see Operational consequence below
Neutral Sender published ?all; owner expresses no opinion Accept (treated as None)
None No SPF record exists for the sender Accept
TempError DNS timeout / SERVFAIL during the lookup Accept (treat as no record) — operator can switch to defer
PermError SPF record is malformed or exceeds the 10-DNS-lookup limit Accept (treat as no record) — operator can switch to reject

SPF is checked twice per message by the daemon: once against the SMTP HELO identity (before MAIL FROM), and once against the envelope sender domain after MAIL FROM. Each check has its own rejection policy on this page.

The two cards on the page

1. SPF Settings (master toggle + policy daemon controls)

The master SPF Enabled dropdown flips a single child row in the parameters table — the row whose parameter value is check_policy_service unix:private/policy-spf under the smtpd_recipient_restrictions parent. When SPF is disabled the page also forces DMARC off (DMARC requires both an SPF and a DKIM result; without SPF the DMARC milter has nothing to align against). The in-page callout warns about this dependency.

When SPF is enabled, the policy section exposes six controls, each written to a parameters2 row in the dkim/spf module rows:

Control policyd-spf.conf directive Effect
Logging Level debugLevel 04 verbosity; -1 disables logging. Higher levels log every DNS lookup and the full SMTP envelope data — useful for diagnosing federal / M365 GOV / Proofpoint Government chain issues
Test Mode TestOnly 1 adds the SPF result to message headers but never rejects, regardless of the rejection policies below. Use to evaluate impact before enforcing
HELO Check Rejection Policy HELO_reject What to do with the SPF result for the SMTP HELO/EHLO identity. Options: Fail, SPF_Not_Pass (Reject All), Softfail (Recommended), Null (reject HELO of null-sender bounces only), False (header only), No_Check
Mail From Check Rejection Policy Mail_From_reject Same option set, but applied to the envelope MAIL FROM domain
Permanent Error Policy PermError_reject True rejects when the published SPF record is broken; False (recommended) treats it as no record
Temporary Error Policy TempError_Defer True issues a 4xx defer on DNS timeout; False (recommended) accepts and continues

Operational consequence — single point of SPF truth. The Hermes baseline disables SpamAssassin's redundant SPF re-check. SA's in-process SPF scoring runs after Amavis has reinjected the message over a local hop, so SA sees an IP path that does not include the original sender — on government/M365 GOV/Proofpoint Government mail the wrong IP gets scored, producing false-positive SPF_SOFTFAIL hits. The policy daemon on this page is the single authoritative SPF verifier; it sees the real connecting client IP. To preserve the spam-coverage SA's SPF_SOFTFAIL rule provided, set both HELO and Mail From Check Rejection Policy to Reject SoftFail. This is the in-page recommendation and the shipped baseline.

2. SPF Whitelist Entries

Per-row bypass list written to four Whitelist directives in policyd-spf.conf:

Entry type policyd-spf.conf directive What it matches Typical use
IP / Network Address Whitelist The connecting client IP (single address or CIDR) Trusted secondary MX, known forwarders, partner relays
HELO/EHLO Host Name HELO_Whitelist The hostname announced in HELO/EHLO. Daemon DNS-checks the connecting IP against an A/AAAA for that name to prevent forgery Mailing-list providers that consistently HELO with their own domain
Domain Name Domain_Whitelist The envelope MAIL FROM domain Senders with broken ~all records whose mail you still need to receive
PTR Domain Domain_Whitelist_PTR The reverse-DNS (PTR) domain of the connecting IP Hosts whose forward DNS is unstable but whose reverse DNS is well-controlled

Entries are stored in the spf_bypass table (entry, entry_type, entry_note). The save handler joins all enabled rows of each type with commas and substitutes them into the template at IP-NETWORK-WHITELIST, HELO-WHITELIST, DOMAIN-WHITELIST, PTR-WHITELIST placeholders.

A whitelist hit completely skips SPF evaluation for that connection — the daemon returns Pass without consulting DNS. Use IP-based whitelisting when possible; HELO / Domain / PTR entries incur extra DNS lookups per message.

The DataTable supports add (textarea — one entry per line, validated and deduplicated), inline edit modal, single delete, and bulk delete via checkbox selection.

What this page does NOT control

Save flow

1. Validate form fields exist when SPF is being enabled
   - Missing fields -> session.m = 20, redirect, no DB write
2. UPDATE parameters child row for SPF on/off
3. UPDATE parameters2 rows for the six policy daemon directives
4. cfinclude spf_generate_config_file.cfm
     a. Read /opt/hermes/templates/policyd-spf.conf.HERMES
     b. REReplace placeholders (DEBUG-LEVEL, TEST-ONLY, HELO-REJECT,
        MAIL-FROM-REJECT, PERMERROR-REJECT, TEMPERROR-REJECT)
     c. SELECT all enabled spf_bypass rows by entry_type, comma-join,
        substitute *-WHITELIST placeholders
     d. Backup current /etc/postfix-policyd-spf-python/policyd-spf.conf
        as policyd-spf.conf.HERMES
     e. Move generated tmp file into place
5. cfinclude generate_postfix_configuration.cfm
     - Regenerates main.cf so smtpd_recipient_restrictions reflects
       SPF on/off
     - Reloads Postfix inside hermes_postfix_dkim
6. If SPF was DISABLED: also disable the OpenDMARC milter rows,
   clear FailureReports, deactivate the DMARC report Ofelia job,
   regenerate opendmarc.conf, restart OpenDMARC
7. session.m = 9 -> green "SPF settings saved" alert on redirect

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_spf_settings.cfm hermes_commandbox The page
config/hermes/var/www/html/admin/2/inc/get_spf_settings.cfm hermes_commandbox Loads current parameters / parameters2 / spf_bypass values
config/hermes/var/www/html/admin/2/inc/spf_save_settings.cfm hermes_commandbox Validates form, updates rows, calls config + Postfix regen; disables DMARC if SPF off
config/hermes/var/www/html/admin/2/inc/spf_generate_config_file.cfm hermes_commandbox Renders policyd-spf.conf from the template + DB
config/hermes/opt/hermes/templates/policyd-spf.conf.HERMES hermes_commandbox (read) → hermes_postfix_dkim (live /etc/postfix-policyd-spf-python/policyd-spf.conf) Canonical template with DEBUG-LEVEL, TEST-ONLY, etc. placeholders
parameters table (check_policy_service unix:private/policy-spf row) hermes_db_server (hermes DB) SPF on/off
parameters2 table (rows where module='spf') hermes_db_server (hermes DB) The six daemon settings
spf_bypass table hermes_db_server (hermes DB) Whitelist entries
hermes_postfix_dkim container Runs smtpd, spawns policyd-spf, hosts the live policyd-spf.conf
hermes_unbound container Resolves every SPF DNS query the daemon makes

Failure semantics

Failure Behavior
Missing form fields when enabling SPF session.m = 20, redirect, no DB write
spf_generate_config_file.cfm throws (template missing, write fails, etc.) Surfaces as a cfcatch from the inline include — the save aborts
Empty whitelist entry on Add session.m = 13, redirect, no DB write
Whitelist entry fails IP / hostname syntax check session.m = 17, redirect, no DB write
Duplicate whitelist entry session.m = 14, redirect, no DB write
postfix reload fails inside the container Standard generate_postfix_configuration.cfm failure path

SVF Policies

SVF Policies

Admin path: Content Checks > SVF Policies (view_svf_policies.cfm, inc/get_svf_policies.cfm, inc/update_amavis_config_files.cfm, inc/restart_amavis.cfm).

This page manages the SVF (Spam / Virus / File) policies that Amavis applies on a per-recipient basis. Each policy bundles four groups of decisions -- spam scoring thresholds, a banned-file ruleset name, four "accept" toggles (deliver instead of quarantine on virus / spam / banned-file / bad-header), four "bypass" toggles (skip the corresponding scan entirely), and three recipient notification toggles. When a message arrives, Amavis looks up the recipient in the recipients table, joins to the policy table on policy_id, and uses that policy's row to drive every per-message decision -- including which File Rule to enforce for attachments.

SVF policies are how the gateway expresses "marketing tolerates more spam than legal does," "abuse@ has to receive raw spam samples," or "this VIP mailbox skips banned-file checks because they trade .iso images legitimately." The global engine settings on Anti-Spam Settings and the per-rule weights on Score Overrides decide how a message is scored; the SVF policy assigned to the recipient decides what happens to that score.

Where SVF Policies sits

   incoming msg for                 +--------------------------+
   bob@example.com                  |  Amavis content-filter   |
   ----------------------+--------> |  pass (hermes_mail_filter)|
                         |          |   - ClamAV scan          |
                         |          |   - SpamAssassin scoring |
                         |          |     produces total score |
                         |          |   - banned-file regex set|
                         |          +------------+-------------+
                         |                       |
                         v                       v
   +---------------------+----------+   +-----------------------+
   | $sql_select_policy lookup      |   | resolved per-message: |
   | (in 50-user.HERMES):           |   |   spam_tag2_level     |
   |   SELECT *, recipients.id      |   |   spam_kill_level     |
   |   FROM recipients, policy      +-->|   virus_lover         |
   |   WHERE recipients.policy_id   |   |   spam_lover          |
   |       = policy.id              |   |   banned_files_lover  |
   |   AND recipients.recipient     |   |   bad_header_lover    |
   |       IN (%k)                  |   |   bypass_*_checks     |
   +--------------------------------+   |   banned_rulenames    |
                                        |   warn*recip          |
                                        +-----------+-----------+
                                                    |
                                                    v
                                        +-----------------------+
                                        |  per-recipient verdict|
                                        |  -> deliver / tag /   |
                                        |     quarantine /      |
                                        |     bypass / notify   |
                                        +-----------------------+

The recipient lookup is the policy resolver. Every recipient in the recipients table has a policy_id pointing at a row in the policy table; the spam_policies table is a thin index that adds system / custom / default_policy flags on top. A recipient with no matching row falls back to the default policy (spam_policies.default_policy = '1') -- the page enforces that exactly one default exists at all times.

What's actually in a policy

The policy table is the Amavis-shaped row; only the columns the UI exposes are documented here (policy has additional NULL columns inherited from Amavis's reference schema that this page doesn't touch).

Field DB column Effect
Policy Name policy.policy_name + spam_policies.policy_name Display name; visible in the recipient dropdown on Relay Recipients and Mailbox Recipients. Up to 32 chars; letters, numbers, spaces, underscores, hyphens, @, and periods only
Spam Tag Score policy.spam_tag2_level The Amavis $spam_tag2_level -- the score at which the spam header is added to the message (e.g. X-Spam-Status: Yes). Below this the message is delivered without a spam header. Range -999 .. 999
Spam Quarantine Score policy.spam_kill_level The Amavis $spam_kill_level -- the score at which the message is quarantined (or bounced, depending on final_spam_destiny on Anti-Spam Settings). Below this but above tag, the message is delivered with a spam header. Range -999 .. 999
File Rule policy.banned_rulenames The name of a File Rule (from file_rule_components.rule_name) -- Amavis maps this to the @banned_filename_re ruleset emitted into 50-user and applies that ruleset's allow / ban regex to every attachment for this policy's recipients
Accept Viruses policy.virus_lover (Y / N) When Y, virus-flagged messages are delivered (with a notation) instead of quarantined. Almost always N; exists for forensic mailboxes
Accept Spam policy.spam_lover When Y, spam-flagged messages are delivered instead of quarantined. Useful for abuse / postmaster mailboxes that need to see the raw spam
Accept Banned Files policy.banned_files_lover When Y, messages with banned attachments are delivered instead of quarantined
Accept Bad Headers policy.bad_header_lover When Y, messages with malformed headers (per RFC) are delivered instead of quarantined
Bypass Virus Checks policy.bypass_virus_checks When Y, skip ClamAV entirely for this policy's recipients. No scan happens; no virus score contributes
Bypass Spam Checks policy.bypass_spam_checks When Y, skip SpamAssassin entirely. No score; no rule contributions; no Bayes update
Bypass Banned Checks policy.bypass_banned_checks When Y, skip banned-extension matching. Attachments are not screened against any File Rule
Bypass Header Checks policy.bypass_header_checks When Y, skip bad-header detection. Malformed-header messages pass through
Notify on Banned File policy.warnbannedrecip When Y, the recipient receives an Amavis notification when a banned-file message is quarantined for them
Notify on Virus policy.warnvirusrecip Same, for virus quarantines
Notify on Bad Header policy.warnbadhrecip Same, for bad-header quarantines

policy.spam_modifies_subj is fixed to Y on add (the checkbox-equivalent isn't on the UI), which lets the subject tag configured on Anti-Spam Settings prepend to messages between tag and quarantine scores.

Operational consequence -- Accept vs Bypass. "Accept" still runs the check; the message is just delivered when it fires. "Bypass" doesn't run the check at all. Use Bypass when the recipient must not pay the scan cost (e.g. high-volume automated relay) and Accept when the recipient must see the message but also wants the verdict header for downstream filtering (e.g. a SIEM mailbox or a mailbox that runs its own filtering on the spam header).

Operational consequence -- Bypass disables the verdict entirely. Bypass Virus Checks means the message is never scanned by ClamAV; a virus reaching that recipient is not caught downstream by anything else in Hermes. Combine Bypass with a recipient-specific compensating control (e.g. quarantine at the destination mail server) or use Accept instead.

System vs custom vs default policies

Three orthogonal flags on spam_policies:

Flag Stored as Effect
system spam_policies.system = '1' Ships with the install. Cannot be deleted from the UI. Five system policies are seeded: No Antispam & No Antivirus, Antispam & Antivirus, Antispam Only, Antivirus Only, Default
custom spam_policies.custom = '1' Created by an operator on this page (or via Copy of a system policy). Can be renamed, edited, deleted (unless default or assigned -- see below)
default_policy spam_policies.default_policy = '1' The policy applied to any recipient whose recipients.policy_id does not resolve. Exactly one row in spam_policies has this flag; the edit handler toggles it atomically by setting every row to 2 then the target row to 1

The DataTable badges each row Yes/No for System and Default so the operator sees the flags at a glance. System rows lose their delete checkbox; the default row's "Default Policy" select is read-only in the edit modal with a hint to "set another policy as the default instead."

The page

A Page Guide callout, a collapsible Add SVF Policy card, and a DataTable of every existing policy (system + custom merged) with per-row Edit, Copy, and Delete actions.

Add SVF Policy card

A single form covering all four sections (basic + Accept + Bypass + Notifications). On submit:

  1. Validates policy_name non-blank, character-safe, and not a duplicate
  2. Validates spam_tag2_level and spam_kill_level as floats in -999 .. 999
  3. Validates banned_rulenames (File Rule) non-blank
  4. INSERTs into policy (with spam_tag_level hardcoded to -999 and spam_modifies_subj = 'Y')
  5. INSERTs into spam_policies with custom = '1', system = '2', default_policy = '2' and policy_id = <new policy.id>
  6. Runs the Amavis apply chain (see Save and apply flow below)

The Copy action duplicates an existing policy under the name Copy of <original> (with a date-time suffix if that name is already taken). Useful for branching a system policy into a custom variant without re-keying every toggle.

SVF Policies DataTable

Column Source
(checkbox) Selection for bulk Delete Selected. Disabled with a hover tooltip on system rows
Policy Name spam_policies.policy_name
System Yes/No badge driven by spam_policies.system
Default Yes/No badge driven by spam_policies.default_policy
Spam Tag policy.spam_tag2_level
Spam Quarantine policy.spam_kill_level
File Rule policy.banned_rulenames
Actions Edit, Copy, Delete (Delete hidden on system rows)

Edit reuses the same validation as Add. Renaming a policy propagates the new name into spam_policies.policy_name in the same UPDATE.

Deletion guards

A custom policy can only be deleted when all three guards pass:

Guard Source Alert
Not a system policy spam_policies.system <> '1' m = 10 -- "System policies cannot be deleted"
Not the default policy spam_policies.default_policy <> '1' m = 11 -- "The default policy cannot be deleted. Set another policy as the default first"
Not assigned to any recipient recipients.policy_id <> :id m = 12 -- "This policy is assigned to the following recipient(s): . Assign them to a different policy first"

Single delete reports the specific failure; bulk delete silently skips guarded rows and reports a per-batch count via m = 13 ("No policies were deleted") if zero deletes succeeded. The list of blocking recipients is surfaced in the single-delete failure alert so the operator can see exactly which entries need to be reassigned on Relay Recipients or Mailbox Recipients first.

Save and apply flow

1. View page submits action="add_policy" | "edit_policy" |
   "copy_policy" | "delete_policy" | "bulk_delete"
2. Action handler validates input, runs deletion guards,
   INSERTs / UPDATEs / DELETEs on the policy + spam_policies tables
3. cfinclude update_amavis_config_files.cfm:
     - Read /opt/hermes/conf_files/50-user.HERMES
     - Substitute SERVER-NAME, SERVER-DOMAIN, sa-spam-subject-tag,
       final-{virus,banned,spam,bad-header}-destiny,
       enable-dkim-{verification,signing},
       HERMES-USERNAME, HERMES-PASSWORD,
       FILE-RULES-GO-HERE (from file_rule_components table),
       DKIM-KEYS-GO-HERE (from dkim_sign table)
     - Back up /etc/amavis/conf.d/50-user -> 50-user.HERMES.BACKUP
     - Move rendered file into place
4. cfinclude restart_amavis.cfm:
     docker container restart hermes_mail_filter
5. session.m = 1|2|3|5 -> green alert ("Policy Added" / "Updated"
   / "Deleted" / "Copied")
6. cflocation back to view_svf_policies.cfm

A few important things about this chain:

Failure semantics

Alert Trigger
m = 1 Add Policy succeeded; Amavis updated and reloaded
m = 2 Edit Policy succeeded; Amavis updated and reloaded
m = 3 Delete Policy (single or bulk with at least one success) succeeded
m = 5 Copy Policy succeeded (no Amavis restart -- new copy is unassigned)
m = 10 Single delete refused: system policy
m = 11 Single delete refused: default policy
m = 12 Single delete refused: policy assigned to recipient(s) -- recipient list surfaced
m = 13 Bulk delete completed with zero successes (every row was protected)
m = 30 Policy name empty
m = 31 Policy name has invalid characters
m = 32 Policy name duplicates an existing policy
m = 33 Spam Tag Score empty or non-numeric
m = 34 Spam Tag Score outside -999 .. 999
m = 35 Spam Quarantine Score empty or non-numeric
m = 36 Spam Quarantine Score outside -999 .. 999
m = 37 File Rule not selected
m = 38 Copy: source policy not found
m = 40 Save succeeded but Amavis apply chain threw

Recipient assignment

SVF policies are bound to recipients on the Email Relay > Recipients page (view_internal_recipients.cfm) and the Email Server > Mailboxes page (view_mailboxes.cfm). Each page exposes a Policy dropdown populated from spam_policies. Assigning a policy writes the matching policy.id into recipients.policy_id, and Amavis picks it up on the next message to that recipient.

A recipient row with policy_id pointing at a row that no longer exists falls through to the default policy at scan time -- this is the same fall-through as a recipient with no row in the recipients table at all. The deletion guard on this page (which refuses delete while any recipient still references the policy) is the front-line defence against accidentally creating that fall-through.

Files and containers touched

Path Owner Role
config/hermes/var/www/html/admin/2/view_svf_policies.cfm hermes_commandbox The page (validation + Add / Edit / Copy / Delete / Bulk Delete)
config/hermes/var/www/html/admin/2/inc/get_svf_policies.cfm hermes_commandbox Loads system, custom, and combined policy lists plus the file-rule dropdown
config/hermes/var/www/html/admin/2/inc/update_amavis_config_files.cfm hermes_commandbox Renders 50-user from template + DB (file rules, DKIM keys, destinies)
config/hermes/var/www/html/admin/2/inc/restart_amavis.cfm hermes_commandbox docker container restart hermes_mail_filter
config/hermes/opt/hermes/conf_files/50-user.HERMES template (read) -> hermes_mail_filter (live /etc/amavis/conf.d/50-user) Holds $sql_select_policy which Amavis uses to resolve a recipient to a policy row at scan time
/etc/amavis/conf.d/50-user.HERMES.BACKUP hermes_mail_filter Pre-write backup of the prior live 50-user, refreshed each save
policy table hermes_db_server (hermes DB) Amavis-shape policy row -- the source of truth for every per-recipient verdict
spam_policies table hermes_db_server Thin index over policy with system / custom / default_policy flags
recipients table hermes_db_server recipients.policy_id is the foreign key Amavis joins on at scan time; the assignment is managed by Relay Recipients and Mailboxes pages
file_rule_components table hermes_db_server Source of the File Rule dropdown -- policy.banned_rulenames stores the chosen rule name
hermes_mail_filter container -- Hosts Amavis; restarted on add / edit / delete; reads policy directly per-message at scan time

Trusted ARC Sealers — Microsoft 365

Trusted ARC Sealers — Microsoft 365

When this guide applies

The standard Hermes-as-relay-MX deployment expects the customer's downstream mail server (the relay target) to allowlist Hermes by IP or hostname and accept Hermes-forwarded mail without re-running upstream auth checks. That's how Mimecast, Proofpoint, Barracuda customers deploy those products; Hermes works the same way. In that deployment model, you do NOT need a Trusted ARC Sealer configuration because the receiver doesn't run its own auth checks against Hermes-forwarded mail in the first place.

This guide applies when:

In that specific scenario, M365's Trusted ARC Sealers feature lets the M365 admin tell their tenant "accept Hermes's seal as authoritative even when the math fails" — which is the receiver-side equivalent of IP allowlisting for the auth check.

The same scenario is also relevant for cross-org forwarding cases where a Hermes-served message later hops through another Hermes-untrusting gateway before final delivery (e.g. customer A's Hermes forwards to customer B's M365 tenant, customer B's tenant doesn't allowlist customer A's Hermes IP).

Background: why this comes up

When Hermes modifies a message body — banner injection, disclaimer injection, S/MIME or PGP rewrap — the modification invalidates any cryptographic signature whose body hash was computed over the original bytes. This affects both the original sender's DKIM-Signature and any prior ARC-Message-Signature from upstream sealers (M365, Workspace, Mimecast, Proofpoint, Exclaimer, etc.). Hermes's own ARC seal at the post-content-filter re-injection point is mathematically valid (it's computed over the modified body) but honestly records cv=fail on the chain it can no longer body-validate.

A correctly-configured downstream MX allowlists Hermes and ignores these signals; this guide is for the cases where allowlisting isn't an option.

What this fixes (and what it doesn't)

Symptom Trusted ARC Sealer helps?
M365 receiver quarantines forwarded mail with arc=fail from Hermes Yes — M365 will accept Hermes's seal as authoritative
M365 receiver delivers but flags forwarded mail as spam due to DMARC fail-on-forward Yes — DMARC alignment is rescued via the trusted seal
Non-M365 downstream MX (Gmail Workspace, on-prem Exchange, third-party SEG) rejects No — those have their own trust mechanism (Gmail uses an internal list; on-prem typically has none)
Outbound mail from Hermes users to external recipients fails DKIM No — that's a DKIM key/DNS issue, not an ARC trust issue

Identity requirements

To add Hermes to the M365 Trusted ARC Sealers list, the receiving M365 tenant administrator needs to know the ARC signing domain Hermes uses — the d= value in Hermes's ARC-Seal: header. Find this in the Hermes admin UI under Content Checks > ARC Settings: it's the domain on the active row in the Gateway ARC Signing Identity card.

The domain must also have a valid public key published in DNS at <selector>._domainkey.<domain> (this is what M365 fetches to verify the seal signature before deciding whether to trust the seal). If DNS isn't right, the math fails before the trust check even runs.

Configuration steps (M365 admin)

Run in Exchange Online PowerShell connected to the tenant:

# Connect (if not already)
Connect-ExchangeOnline

# Inspect existing trusted sealers
Get-ArcConfig

# Add Hermes's signing domain to the trusted list
Set-ArcConfig -Identity Default `
  -ArcTrustedSealers "your-hermes-signing-domain.example.com"

If multiple gateways need to be trusted, comma-separate the list:

Set-ArcConfig -Identity Default `
  -ArcTrustedSealers "hermes.example.com","mimecast.example.com"

To remove a sealer, set the property to a comma-separated list that omits the entry.

Verification

After configuration:

  1. Send a test message from an ARC-sealing upstream system through Hermes (relay-mode domain) to a mailbox on the configured M365 tenant.
  2. Open the message in Outlook on the Web → ellipsis menu → View → View message source.
  3. Look for the Authentication-Results header chain that M365 added:
    • arc=pass with the oar= field referencing Hermes's signing domain confirms the trust list took effect.
    • arc=fail with a note about original-authres indicates the trust list did NOT match (most likely cause: domain mismatch or DNS not published).

Troubleshooting

Problem Check
Get-ArcConfig returns ArcTrustedSealers as empty after Set Confirm you're connected to the right tenant; verify with Get-OrganizationConfig | Select Identity
Test mail still shows arc=fail in M365 Wait up to 60 min for the trust config to propagate; recheck DNS for the Hermes selector
Hermes's seal shows cv=pass but M365 still rejects Not an ARC issue — check Connection Filter / Anti-spam policies on the M365 side