Skip to main content

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_milter and hermes_openarc both listen on internal port 8893, but they are separate containers with their own network namespaces. Postfix reaches each by container name (inet:hermes_body_milter:8893 vs inet: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:

  1. Pre-modification sealing at :25 → broken seal at the recipient.
  2. Double-sealing: mail going through :25 → amavis → :10026 would be sealed twice by the same gateway (i=1 at :25, i=2 at :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 parameters as order1=3.1 so it sits AFTER OpenDKIM signer (0.5) and OpenDMARC. The retro-fix UPDATE in updates/hermes-260119/sql/schema_updates.sql corrects existing installs that had 0.5 for 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 and smtp:-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_BOUNCE virus/banned, D_DISCARD spam/bad_header per 50-user config).
  • Whitelisted senders bypass via BYPASSALLCHECKS at :10030.

Architectural principle: Hermes is the auth boundary (#229)

Hermes is the authoritative auth / security boundary for every domain it relays for. Inbound auth checks (DKIM, SPF, DMARC, ARC verify, spam, virus) happen at Hermes. Body modifications (External Sender Banner, disclaimer, signature insertion, encryption) also happen at Hermes. Customer downstream mail servers (the relay-target MX) must be configured to trust Hermes implicitly: allowlist Hermes by IP or hostname, accept forwarded mail without re-running DKIM / SPF / DMARC / ARC checks. This is the same deployment model Mimecast, Proofpoint, and Barracuda customers use — the SEG IS the trust boundary.

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:

  1. 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.
  2. 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 :10026 sign-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 auditDocker/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 driftconfig/hermes/opt/hermes/conf_files/master.cf still has the pre-#232 no_milters token on :10026 and smtpd_milters=:8891 for :10026. Fresh installs would regress until this template is brought into line with the active runtime config.