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.
Companion to docs/handoff-#232-multi-instance-opendkim.md.
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.