Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Inbound Auth Verification Plan

Context

The smtp-in tier is a pure relay. It accepts mail for hosted domains from the internet, routes it to the imap tier via mailertable, and performs no SPF, DKIM, or DMARC verification — docker/templates/in-sendmail.mc declares no INPUT_MAIL_FILTER at all. Meanwhile we hold senders to a strict standard: the control domain publishes p=reject with full SPF/DKIM/DMARC (terraform/infra/modules/app/global_dns.tf), and we ingest the aggregate reports other receivers send us (lambda/api/process_dmarc). We evaluate nothing in the other direction: a message claiming to be from paypal.com that fails every authentication check is delivered to the user’s inbox looking identical to one that passes.

This plan adds verification milters to smtp-in that evaluate SPF, DKIM, and DMARC on every inbound message and stamp the result as an Authentication-Results header (RFC 8601), then surfaces that result to the end user in the webmail and Apple clients. Verification is observe-only: no message is rejected, quarantined, or rerouted based on the result, regardless of the sender’s published policy. The user sees the finding and decides. This is deliberate — a small personal MX misclassifying one real message costs more than a hundred correctly-flagged spoofs are worth.

The stamped results are also the foundation for outbound DMARC aggregate report generation (reporting to other domains on how their mail authenticated here), which is scoped separately in docs/tentative/dmarc-outbound-reports-plan.md. That plan depends on this one; this one does not depend on it.

Four phases, strictly additive and independently shippable:

  1. Verification milters on smtp-in. OpenDKIM in verify-only mode plus OpenDMARC in monitor mode (with its built-in SPF evaluation), stamping Authentication-Results under our authserv-id. Includes stripping forged inbound Authentication-Results headers that claim our authserv-id.
  2. Lambda envelope surface. Fetch and parse the header in envelope_dict() and expose a compact auth_results field to both clients.
  3. React display. Failure indicator in the envelope row, per-method breakdown in the message reading view.
  4. Apple display. Same, through ApiEnvelope → Envelope → detail view.

Goals

Non-goals

Current state

Design

Verification must run on smtp-in

SPF evaluation needs the connecting client’s IP, HELO name, and envelope sender. Only smtp-in sees the internet peer; by the time the imap tier receives the message the peer is smtp-in itself. DKIM and DMARC could technically run anywhere, but they belong beside SPF so a single milter pass produces one coherent header.

Milter stack

Two milters, chained in declaration order in in-sendmail.mc:

INPUT_MAIL_FILTER(`opendkim', `S=inet:8891@localhost, F=T, T=R:2m')dnl
INPUT_MAIL_FILTER(`opendmarc', `S=inet:8893@localhost, F=T, T=R:2m')dnl

DNS resolution uses the VPC resolver as everything else does; on stage, DNSSEC validation (docs/dnssec.md) already covers these lookups.

Header hygiene: forged Authentication-Results

Anyone can send a message that already contains Authentication-Results: <control-domain>; dmarc=pass .... If that header survives to the mailbox, the Lambda parser and both clients would show a spoofed pass. The milter stage must therefore strip inbound Authentication-Results headers before stamping. OpenDKIM’s RemoveARFrom (scoped to all external hosts) is the intended mechanism — confirm the exact knob and semantics against the packaged version during implementation; if it proves insufficient, a two-line header check in the sendmail config or a formail pass at local delivery are the fallbacks. The Lambda parser additionally only trusts headers whose authserv-id matches the control domain exactly, as defense in depth.

The auth_results envelope field

envelope_dict() gains:

"auth_results": {"spf": "pass", "dkim": "pass", "dmarc": "fail"}

The IMAP fetch constant becomes:

ENVELOPE_HEADER_FIELDS_KEY = 'BODY[HEADER.FIELDS (X-PRIORITY REFERENCES AUTHENTICATION-RESULTS)]'

fetch_message needs no change: the reading view receives the envelope, and the raw source view already exposes the full header text.

Client display

Three states everywhere: verified-ok (dmarc pass), warning (dmarc fail/permerror, or dmarc absent with spf and dkim both fail), and not verified (no data — the quiet default for pre-feature and internal mail). Copy for the warning state says the message could not be authenticated as coming from its claimed sender — not “dangerous” — since forwarding legitimately breaks these checks.

Phase 1 — Verification milters on smtp-in

Verify: send from a well-configured external sender (Gmail) and from a deliberately misaligned one; confirm stamped verdicts in the delivered message source and in maillog on CloudWatch. Send a message pre-loaded with a forged Authentication-Results header claiming the control domain; confirm it is stripped. Confirm delivery latency is unchanged to within milter noise and no message is rejected.

Phase 1 stage finding (2026-07-05) — NLB client IP preservation

First stage test (known-good prod sender): dkim=pass, dmarc=pass (via DKIM alignment), but spf=fail on every message. The Received: line smtp-in wrote named the peer as an ip-10-64-x address — the stage NLB’s own ENI. NLB client IP preservation is disabled by default for ip-type TCP target groups, so sendmail’s peer was the load balancer, and opendmarc evaluated the sender’s SPF record against the NLB’s private address. The sending side was verified correct (prod’s SPF publishes exactly its NAT EIPs).

Fix: preserve_client_ip = "true" on the relay target group only (terraform/infra/modules/ecs/locals.tf). No SG change rides along — smtp-in already allows 25 from 0.0.0.0/0. The imap TG must keep the default: the NLB terminates TLS (993→143) and Dovecot’s login_trusted_networks treats the NLB forwarding path as the secured one, so preserving real client IPs there would break forwarded plain-auth logins. Side benefit on relay: confCONNECTION_RATE_THROTTLE, greet_pause, and access_db now key on real client IPs instead of treating the entire internet as one NLB-shaped client.

Phase 2 — Lambda envelope surface

Verify: envelopes for newly-delivered external mail carry auth_results; pre-feature messages carry none; a message with only a forged (untrusted-authserv-id) header parses as absent.

Phase 3 — React display

Phase 4 — Apple display

Rollback

Each phase is independently revertible. Phase 1: remove the two INPUT_MAIL_FILTER lines (and supervisord programs) and redeploy the tier — mail flows exactly as today; already-stamped headers are inert. Phases 2–4 are additive field/UI changes; clients already treat the field as optional, so reverting any one layer strands nothing.

Acceptance

Open questions