Host your own email and enhance your privacy
Status: Tentative design, not on the roadmap. Written 2026-07-05.
Depends on
docs/0.10.x/inbound-auth-verification-plan.md
shipping first — without inbound verification there is nothing to report.
We participate in the DMARC reporting ecosystem in one direction only. As a
sender, we publish strict policies and ingest the aggregate reports other
receivers send us (lambda/api/process_dmarc → cabal-dmarc-reports →
admin Dmarc view). As a receiver, we send nothing: domains whose mail we
accept never learn how their messages authenticated at our MX.
Once the inbound verification plan lands, smtp-in evaluates SPF/DKIM/DMARC
on every inbound message. This plan closes the loop: accumulate those
per-message verdicts per sending domain, and once a day emit an RFC 7489
aggregate report to each domain’s published rua= address, exactly as
Google and Microsoft do for us.
Honest value assessment: we are a small MX. Our reports will be tiny and go mostly to large senders who will barely notice them. The reasons to do it anyway: it is correct ecosystem citizenship; the reports become genuinely useful to any smaller correspondent domain debugging its DMARC rollout; and the marginal cost on top of the verification plan is one DynamoDB table, one shipper daemon, and one Lambda. If that trade ever stops looking worthwhile, this doc stays tentative.
rua= destination receives one aggregate report
for that day, spec-compliant enough that the big receivers’ pipelines
accept it (gzip XML, correct filename, correct subject, external
destination verification honored).docs/operations/runbooks/heartbeat-dmarc-ingest.md).ruf=). Privacy-sensitive, rarely consumed,
and a per-message real-time pipeline is a different shape of system.ri= intervals other than daily. RFC 7489 explicitly blesses
a fixed daily cadence.OpenDMARC (running in monitor mode per the verification plan) can write a per-message history file: an append-only record of exactly the fields an aggregate report row needs (source IP, header-from, envelope-from, SPF/DKIM/DMARC results and alignment, the sender’s published policy, disposition). This is the purpose-built capture point — richer than anything reconstructable from maillog — but it is a local file, and smtp-in is a multi-task ECS service with stateless containers.
Resolution: a small shipper daemon in the smtp-in container
(supervisord program, same idiom as docker/shared/reconfigure.sh) that
every ~10 minutes rotates the history file, parses the completed slice,
and folds it into DynamoDB with UpdateItem/ADD counter increments.
Properties:
ADD merges
concurrent writers without coordination.Rejected alternatives: parsing CloudWatch maillog (opendmarc’s syslog lines don’t carry full row fidelity — alignment detail and published policy are missing) and mounting the history file on EFS (shared-write append from multiple tasks to one file is exactly the failure mode the stateless design avoids).
IAM note: the smtp-in task role today only reads config state
(cabal-addresses via generate-config.sh). Writing to the new stats
table is a new permission — a real IAM delta; like everything else it
routes through stage, and its stage validation deserves care.
cabal-dmarc-outbound-statsSibling of cabal-dmarc-reports in
terraform/infra/modules/table/main.tf:
pk (S): <policy-domain>#<YYYY-MM-DD> (UTC day of receipt)sk (S): <source-ip>#<disposition>#<dkim-aligned>#<spf-aligned> — the
RFC 7489 <record> grouping keycount (N): incremented via ADDp, sp,
adkim, aspf, pct) and representative per-method auth detail
(dkim domain/selector/result, spf domain/scope/result) — set once per
row, if_not_existsreport_sent (S): report-id, written by the generator for idempotencyexpires_at (N): TTL, ~14 days (the cabal-rate-limits pattern)Volume is bounded by inbound mail volume × distinct (ip, verdict) tuples per domain — for this MX, trivially within on-demand pricing noise.
lambda/api/generate_dmarc_reportsEventBridge-scheduled daily (shortly after 00:00 UTC, generating for the
previous UTC day) — the process_dmarc orchestration pattern. Per policy
domain with rows for the day:
_dmarc.<policy-domain>; parse rua= mailto URIs (cap at 2
destinations, per spec allowance to limit). No record or no rua= →
skip silently; that’s the common case and not an error.rua address’s
domain differs from the policy domain, require the
<policy-domain>._report._dmarc.<destination-domain> TXT
authorization record (RFC 7489 §7.1). Skipping this check produces
reports that compliant receivers discard — it is not optional.
Honor a !10m-style size suffix if present (our reports are far
smaller in practice).report_metadata (org = control
domain, contact = dmarc-reports@mail-admin.<first-mail-domain>,
report-id, epoch date range), policy_published from the stored
attributes, one <record> per sk row. Gzip. Filename
<control-domain>!<policy-domain>!<begin>!<end>.xml.gz; subject
Report Domain: <policy-domain> Submitter: <control-domain>
Report-ID: <report-id>.dmarc
system account, from dmarc-reports@mail-admin.<first-mail-domain>
(the address already provisioned by
terraform/infra/modules/app/dmarc_user.tf
with full MX/SPF/DKIM/DMARC — our reports authenticate cleanly at the
receiving end). How the Lambda authenticates to Dovecot submission as
a system account is the main implementation detail to pin down:
master-user credentials from SSM (the IMAP-side pattern) if Dovecot’s
submission service honors them, else a dedicated SSM-stored password
for the dmarc account.report_sent = <report-id> on the day’s rows
(conditional on absence) before SMTP; a retried run skips marked
domains. Crash-between-mark-and-send loses one domain-day rather than
double-sending — the right side to err on.Healthchecks heartbeat ping per run (the process_dmarc pattern), a
heartbeat-dmarc-outbound.md runbook, and structured per-domain
sent/skipped/failed counts in the Lambda log
(/cabal/lambda/generate_dmarc_reports).
cabal-dmarc-outbound-stats table, task-role write grant. Verifiable
on stage by inspecting table rows after sending test mail — no reports
leave the system yet.