Hermes SEG Email Flow
Hermes SEG Email Flow
Reference diagram for the inbound, outbound, and CipherMail-originated mail paths inside the Docker stack. Includes every listening port, every container, the milter chain at each smtpd service, and where body modifications occur relative to DKIM signing.
Container + port map (at a glance)
| Container | Service / Daemon | Port(s) | Role |
|---|---|---|---|
hermes_postfix_dkim |
postfix smtpd (:25) |
25 | Inbound MX |
hermes_postfix_dkim |
postfix smtpd (submission) |
587 | Authenticated outbound (STARTTLS) |
hermes_postfix_dkim |
postfix smtpd (smtps) |
465 | Authenticated outbound (implicit TLS) |
hermes_postfix_dkim |
postfix smtpd (:10026) |
10026 | Re-injection (post-CipherMail) |
hermes_postfix_dkim |
postfix smtpd (:10027) |
10027 | CipherMail web-GUI originated mail |
hermes_postfix_dkim |
OpenDKIM primary (sv mode) | 8891 | Verify inbound / sign outbound at :25/:587/:465 |
hermes_postfix_dkim |
OpenDKIM sign-only (s mode) — #232 | 8892 | Sign post-CipherMail egress at :10026 only |
hermes_mail_filter |
amavisd-new (filter) | 10021 | SpamAssassin + ClamAV + policy |
hermes_mail_filter |
amavisd-new (pickup / bypass) | 10030 | BYPASSALLCHECKS lane |
hermes_body_milter |
Python pymilter (#214/#226/#228/#230) | 8893 | Disclaimer + signature + banner + CID inline |
hermes_dmarc |
OpenDMARC | 54321 | DMARC verify / SPF alignment header |
hermes_openarc |
OpenARC (#229, flowerysong v1.3.0 built from source) | 8893 | ARC sealing at :10026 only — RFC 8617 chain preservation across body mods |
hermes_ciphermail |
CipherMail SMTP | 25 | Encryption decisions + MIME rebuild |
hermes_dovecot |
LMTP | 24 | Local mailbox delivery |
Milter listening side: the OpenDKIM/OpenDMARC/body_milter/OpenARC daemons listen on
TCP and postfix smtpd connects to them per the smtpd_milters line in effect
for each port.
Note:
hermes_body_milterandhermes_openarcboth listen on internal port8893, but they are separate containers with their own network namespaces. Postfix reaches each by container name (inet:hermes_body_milter:8893vsinet:hermes_openarc:8893), so the shared port number causes no conflict.
Milter chains by smtpd port
The smtpd_milters chain order is set globally in main.cf (built from the
parameters DB table by generate_postfix_configuration.cfm) and overridden
per-service in master.cf for :10026 and :10027.
| smtpd port | Milter chain (in order) | Why |
|---|---|---|
:25 (inbound) |
OpenDKIM :8891 → OpenDMARC :54321 → body_milter :8893 | Verify DKIM, verify DMARC, then inject External Banner / disclaimer |
:587 :465 (submission) |
OpenDKIM :8891 → OpenDMARC :54321 → body_milter :8893 | Sign DKIM first (before body mods), then disclaimer/signature inject — wrong order; see #232 outbound fix history |
:10026 (re-inject) |
OpenDKIM :8892 sign-only → OpenARC :8893 (hermes_openarc) |
Re-sign body that CipherMail mutated, then ARC-seal the final form so downstream verifiers trust the cumulative auth chain even after body modification (#229) |
:10027 (CipherMail GUI) |
OpenDKIM :8891 | Sign GUI-originated mail; no body mods on this path |
Why OpenARC sits ONLY at :10026 (and NOT in main.cf)
OpenARC's ARC-Message-Signature includes a hash of the message body. If
ARC sealed at :25, the body would later be mutated by body_milter (:8893)
and CipherMail (MIME rebuild), so the seal's body hash would be invalid by
the time downstream verifiers received the message — cv=fail.
:10026 is the only point where the body is in its final form (all body
modifications + CipherMail MIME rebuild complete), so it's the only correct
hop to apply the ARC seal. Adding ARC to main.cf's default smtpd_milters
would cause two problems:
- Pre-modification sealing at
:25→ broken seal at the recipient. - Double-sealing: mail going through
:25→ amavis →:10026would be sealed twice by the same gateway (i=1at:25,i=2at:10026), producing redundant chain bloat / verification ambiguity.
ARC stays out of main.cf deliberately. Master.cf :10026 override is the
single point of truth.
The body_milter ordering is recorded in
parametersasorder1=3.1so it sits AFTER OpenDKIM signer (0.5) and OpenDMARC. The retro-fixUPDATEinupdates/hermes-260119/sql/schema_updates.sqlcorrects existing installs that had0.5for body_milter (which placed body mods BEFORE signing, the root cause of #232 outbound DKIM failures).
Inbound flow
External MTA → local mailbox.
┌─────────────────────────────────────────────────────────────────────┐
│ External Internet │
└─────────────────────────────────────────────────────────────────────┘
│
│ SMTP / DKIM-signed by sender
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_postfix_dkim ▸ :25 (postfix smtpd, inbound MX) │
│ ▸ smtpd_milters chain: │
│ 1. OpenDKIM primary inet:127.0.0.1:8891 (verify sender's DKIM) │
│ 2. OpenDMARC inet:hermes_dmarc:54321 (verify DMARC alignment)│
│ 3. body_milter inet:hermes_body_milter:8893 (External Banner) │
│ ▸ content_filter = amavis:[hermes_mail_filter]:10021 │
└─────────────────────────────────────────────────────────────────────────────┘
│ smtp
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_mail_filter ▸ :10021 (amavisd-new — main filter lane) │
│ ▸ SpamAssassin scoring (sees body, computes its own DKIM_INVALID │
│ since body_milter already injected banner) │
│ ▸ ClamAV virus scan │
│ ▸ Policy / quarantine decisions │
│ ▸ $forward_method = smtp:hermes_ciphermail:25 │
└─────────────────────────────────────────────────────────────────────────────┘
│ smtp
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_ciphermail ▸ :25 (encryption gateway) │
│ ▸ Encryption-mode decisions │
│ ▸ MIME rebuild (always — not byte-stable across this hop) │
│ ▸ Re-injects to → smtp:hermes_postfix_dkim:10026 │
└─────────────────────────────────────────────────────────────────────────────┘
│ smtp
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_postfix_dkim ▸ :10026 (postfix smtpd, re-injection) │
│ ▸ smtpd_milters = inet:localhost:8892,inet:hermes_openarc:8893 │
│ 1. OpenDKIM sign-only (s mode) #232 │
│ 2. OpenARC seal #229 │
│ │
│ For INBOUND mail, the From: domain is NOT in the local KeyTable. │
│ → sign-only instance does nothing (no key match → no header added) │
│ → critically: it also does NOT verify, so no Authentication-Results │
│ "dkim=fail" header gets written against the body-modified message. │
│ │
│ OpenARC then seals the FINAL body, recording the A-R header that │
│ OpenDKIM-primary + OpenDMARC wrote back at :25 (which still says │
│ "dkim=pass" against the unmodified original). │
│ │
│ ⚠ Chain-integrity caveat (#229): when the inbound message ALREADY │
│ carried an upstream ARC-Seal (M365 / Workspace / Mimecast / │
│ Proofpoint / Exclaimer) and Hermes modified the body at :25 │
│ (banner injection), OpenARC at :10026 writes cv=fail at i=2. │
│ The upstream i=1 body hash no longer matches. To prevent this for │
│ relay-out recipients, body_milter automatically skips banner │
│ injection in that narrow case — see "Conditional banner skip" below. │
└─────────────────────────────────────────────────────────────────────────────┘
│ transport_maps lookup
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_dovecot ▸ :24 (LMTP) │
│ ▸ Sieve filtering (vacation, file-into; redirect is same-domain only) │
│ ▸ Local mailbox delivery → /mnt/data/vmail │
└─────────────────────────────────────────────────────────────────────────────┘
Inbound paths that diverge
- Relay-only domains (in
transport_maps) skip Dovecot andsmtp:-forward to the next-hop MX listed in/etc/postfix/transport. - Quarantined / virus / bad-header mail is held by amavis at
:10021(final destinies:D_BOUNCEvirus/banned,D_DISCARDspam/bad_header per50-userconfig). - Whitelisted senders bypass via
BYPASSALLCHECKSat:10030.
Architectural principle: Hermes is the auth boundary (#229)
When the inbound message arrives carrying an upstream ARC-Seal:
header (M365, Workspace, Mimecast, Proofpoint, Exclaimer, etc.) and
Hermes modifies the body (banner, disclaimer), the upstream chain's
body hash is invalidated. OpenARC at :10026 honestly records
cv=fail on Hermes's own seal because it can no longer validate the
upstream chain against the modified body. The original sender's
DKIM-Signature body hash is also invalidated.
This is by design and is not a Hermes problem. A correctly-configured
customer downstream MX is allowlisting Hermes and not re-checking auth
on forwarded mail; the cv=fail and broken DKIM signal never gates
delivery. If a customer's downstream MX is doing redundant auth checks
on mail Hermes forwards, that's a misconfiguration on the customer's
end — the fix is to allowlist Hermes there, not to silence Hermes here.
For external receivers Hermes does NOT have a trust relationship with
(third-party MXes encountered via sieve redirect or alias forwarding,
should those leak past the same-domain validation), the cv=fail and
broken DKIM signals do reach a non-trusting receiver. That's why sieve
redirects from the user portal are validated to require a same-domain
target (see inc/sieve_user_rule_actions.cfm) — keeping forwarded mail
inside Hermes's auth boundary. Aliases configured by admins are
constrained to internal Hermes mailboxes by the existing CFML check
in inc/add_mailbox_alias_action.cfm.
Why not lift the chain by stripping the upstream ARC?
cv=fail is honest — Hermes correctly admits the chain we received
no longer body-validates against the message we're about to send.
The verifier walks the chain backward and recomputes the upstream
i=1 body hash against the current body, so stripping our i=2
admission does not repair anything. The only mechanisms that could
restore trust for body-modifying gateways are:
- Receiver-side trust configuration — Microsoft 365's "Trusted ARC Sealers" feature, Gmail's internal trust list, etc. Useful when forwarding to receivers OTHER than the customer's own MX. See the Trusted ARC Sealers — M365 guide for cross-org scenarios.
- Don't modify the body — defeats the purpose of having body modification features.
Multi-instance OpenARC (separate verify-only + sign-only daemons) does not help: OpenARC v1.3.0's sign-only mode re-validates the chain at sealing time and ignores AAR cached by the verify instance. This was empirically tested on DEV on 2026-05-14; see the closed #229 discussion.
Outbound flow
Authenticated local user → external recipient.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Mail Client (Outlook / Thunderbird / iOS Mail / Roundcube / NC Mail) │
└─────────────────────────────────────────────────────────────────────────────┘
│ SMTP submission
│ (SASL AUTH via Dovecot SASL :9587)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_postfix_dkim ▸ :587 (submission) OR :465 (smtps) │
│ ▸ smtpd_milters chain (same as :25): │
│ 1. OpenDKIM primary :8891 ◀─── signs DKIM here │
│ 2. OpenDMARC :54321 (record verify; outbound mostly noop) │
│ 3. body_milter :8893 ◀─── injects disclaimer/signature │
│ ▸ content_filter = amavis:[hermes_mail_filter]:10021 │
│ │
│ ⚠ #232 historical bug: with body_milter at order1=0.5 (before OpenDKIM), │
│ body_milter ran BEFORE signing, so DKIM signed the pre-modified body. │
│ Fixed by raising body_milter to order1=3.1 (after OpenDKIM signer). │
└─────────────────────────────────────────────────────────────────────────────┘
│ smtp
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_mail_filter ▸ :10021 (amavisd-new) │
│ ▸ MYNETS policy_bank (originating=1, higher spam_kill threshold) │
│ ▸ Virus / banned-file scan │
│ ▸ $forward_method = smtp:hermes_ciphermail:25 │
└─────────────────────────────────────────────────────────────────────────────┘
│ smtp
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_ciphermail ▸ :25 (S/MIME / PGP encryption) │
│ ▸ Encryption-mode lookup per recipient │
│ ▸ MIME rebuild (BREAKS the original DKIM bh= hash — receipt below) │
│ ▸ Re-injects to → smtp:hermes_postfix_dkim:10026 │
└─────────────────────────────────────────────────────────────────────────────┘
│ smtp
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_postfix_dkim ▸ :10026 (re-injection) │
│ ▸ smtpd_milters = inet:localhost:8892,inet:hermes_openarc:8893 │
│ 1. OpenDKIM sign-only (s mode) #232 │
│ 2. OpenARC seal #229 │
│ │
│ For OUTBOUND mail, the From: domain IS in the local KeyTable. │
│ → sign-only instance signs the post-CipherMail body. │
│ → fresh DKIM header replaces (or oversigns) the stale one from :587. │
│ │
│ OpenARC then seals the post-DKIM body, attaching ARC-Seal / ARC-Message- │
│ Signature / ARC-Authentication-Results headers. For outbound traffic │
│ originating from a relay user whose own MTA pre-signed DKIM, the ARC │
│ seal lets downstream verifiers trust the chain even though our body │
│ modification (disclaimer etc.) invalidated the relay's original DKIM. │
│ │
│ ⚠ Historical bug: master.cf had `no_milters` in receive_override_options │
│ at :10026 → suppressed all milters → no re-sign → Gmail rejected. │
│ Fixed by removing `no_milters` token (commit 7014285). │
└─────────────────────────────────────────────────────────────────────────────┘
│ smtp (smtp_milters chain on egress)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ External MX (recipient gateway) │
│ → DKIM verify against `From:` domain key (DNS TXT) │
│ → DMARC alignment │
│ → Deliver │
└─────────────────────────────────────────────────────────────────────────────┘
CipherMail web-GUI originated mail
When admins send mail directly from CipherMail's web GUI (rare), it enters
postfix at :10027, bypasses amavis content filtering entirely, and is signed
by the primary OpenDKIM (:8891) — not the sign-only instance — because
this path doesn't traverse any body-modification hop and the standard sv-mode
instance is appropriate.
┌─────────────────────────────────────────────────────────────────────────────┐
│ CipherMail web GUI (admin compose) │
└─────────────────────────────────────────────────────────────────────────────┘
│ smtp
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ hermes_postfix_dkim ▸ :10027 │
│ ▸ smtpd_milters = inet:localhost:8891 (OpenDKIM primary, sv mode) │
│ ▸ No content_filter — bypasses amavis │
└─────────────────────────────────────────────────────────────────────────────┘
│ smtp egress
▼
External MX (recipient gateway)
Why two OpenDKIM instances? (#232 architecture decision)
A single OpenDKIM instance in sv mode (verify + sign) cannot satisfy both
requirements at :10026:
| Requirement | Default sv instance at :8891 | Sign-only instance at :8892 |
|---|---|---|
Verify inbound at :25 |
✅ does this | ❌ wouldn't (correctly) |
Sign outbound at :587/:465 |
✅ does this | ❌ wouldn't (correctly) |
Sign outbound re-inject at :10026 (post-CipherMail body rebuild) |
✅ would sign | ✅ signs |
Skip verify on inbound re-inject at :10026 (post-body-milter banner) |
❌ verifies → dkim=fail Authentication-Results |
✅ never verifies |
OpenDKIM has no per-port mode override, and InternalHosts / ExternalIgnoreList
control signing-vs-verification only by sender IP — not by smtpd inet service.
Per OpenDKIM's own project guidance, running a second daemon with a different
config file is the supported way to get differential behavior on a single host.
The sign-only instance ignores inbound mail naturally: its KeyTable and
SigningTable only contain entries for local domains, so inbound mail (where
the From: domain matches no local key) is a no-op pass-through — it neither
signs nor adds Authentication-Results.
Receipts (forensic proof captured during #232 diagnosis)
| Symptom | Evidence | Root cause |
|---|---|---|
Gmail rejected outbound from tina@getwithme.com |
bounced .eml had bh=z0Xb... in DKIM header but body hashed to bh=vJCS... |
CipherMail MIME rebuild after :587 DKIM sign; :10026 had no_milters so no re-sign |
Inbound dkim=fail only when External Banner enabled |
Same gmail→tina test: banner-on dkim=fail, banner-off dkim=pass. CipherMail held constant. |
body_milter modifying body before the next milter hop saw it; primary OpenDKIM at :10026 re-verified the modified body |
| Spam score ~+1.3 on banner-injected inbound | DKIM_INVALID=0.1 + NML_ADSP_CUSTOM_MED=0.9 when banner on; DKIM_VALID*=-0.3 when banner off |
amavis runs its own SpamAssassin DKIM check on the post-body-milter body. Not fixed by multi-instance OpenDKIM — separate problem |
| Downstream forwarder DMARC fail | Recipient forwards to gmail; gmail re-verifies original sender DKIM against modified body → fail | Solved by ARC sealing (#229) |
Open follow-ups that touch this flow
- #228 External Sender Banner re-enable — blocked on this diagram's
:10026sign-only wiring landing. - #229 ARC sealing at perimeter — required so downstream forwarders trust Hermes' original-sender verdict.
- amavis SpamAssassin DKIM scoring (separate issue, not yet filed) — options A/B/C documented in the #232 handoff doc.
- Dead-weight config-file audit —
Docker/postfix_dkim/config/,Docker/mail_filter/config/,Docker/opendmarc/config/are shadowed by volume mounts at runtime; ~85 files to consolidate or relocate. - Install-template drift —
config/hermes/opt/hermes/conf_files/master.cfstill has the pre-#232no_milterstoken on:10026andsmtpd_milters=:8891for:10026. Fresh installs would regress until this template is brought into line with the active runtime config.