From 27aeadc71d55c11a561e35ecd2a8b19bf8b70f29 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Tue, 18 Aug 2026 22:53:36 +0300 Subject: [PATCH] release: 1.7.0 Receive DMARC aggregate reports on port 25 and show parsed summaries in the panel. Close Unreleased; pin compose and docs to 1.7.0. Co-authored-by: Cursor --- CHANGELOG.md | 13 + README.md | 2 +- build/postfix-config.sh | 91 ++++- cmd/panel/dmarc_ingest.go | 44 +++ cmd/panel/envdoc_test.go | 13 + cmd/panel/httpserver.go | 17 +- cmd/panel/main.go | 19 + deploy/.env.example | 9 + deploy/docker-compose.yml | 5 +- docs/development.md | 2 +- docs/guide.md | 52 ++- docs/plans/dmarc-reports.md | 30 +- docs/product.md | 4 +- docs/roadmap.md | 41 +- internal/dmarc/addresses.go | 31 ++ internal/dmarc/ingest.go | 133 +++++++ internal/dmarc/parse.go | 176 +++++++++ internal/dmarc/parse_test.go | 116 ++++++ internal/dmarc/service.go | 131 +++++++ internal/postfix/dmarc.go | 71 ++++ internal/postfix/dmarc_test.go | 35 ++ internal/store/dmarc.go | 354 ++++++++++++++++++ internal/store/dmarc_test.go | 68 ++++ .../store/migrations/0008_dmarc_reports.sql | 38 ++ internal/web/handlers/handlers.go | 6 + internal/web/handlers/handlers_apps.go | 14 +- internal/web/handlers/handlers_dmarc.go | 13 + .../web/handlers/handlers_dmarc_reports.go | 240 ++++++++++++ internal/web/handlers/handlers_settings.go | 10 + internal/web/view/templates/dmarc.html | 63 ++++ internal/web/view/templates/dmarc_domain.html | 59 +++ internal/web/view/templates/dmarc_report.html | 52 +++ .../web/view/templates/domain_detail.html | 8 +- internal/web/view/templates/layout.html | 4 + internal/web/view/templates/settings.html | 9 +- internal/web/view/templates_test.go | 4 +- internal/web/view/view.go | 10 + internal/web/web.go | 15 +- 38 files changed, 1923 insertions(+), 79 deletions(-) create mode 100644 cmd/panel/dmarc_ingest.go create mode 100644 internal/dmarc/addresses.go create mode 100644 internal/dmarc/ingest.go create mode 100644 internal/dmarc/parse.go create mode 100644 internal/dmarc/parse_test.go create mode 100644 internal/dmarc/service.go create mode 100644 internal/postfix/dmarc.go create mode 100644 internal/postfix/dmarc_test.go create mode 100644 internal/store/dmarc.go create mode 100644 internal/store/dmarc_test.go create mode 100644 internal/store/migrations/0008_dmarc_reports.sql create mode 100644 internal/web/handlers/handlers_dmarc_reports.go create mode 100644 internal/web/view/templates/dmarc.html create mode 100644 internal/web/view/templates/dmarc_domain.html create mode 100644 internal/web/view/templates/dmarc_report.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d0671..3d1e84c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +## [1.7.0] - 2026-08-18 + +Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse +gzip/XML into SQLite, and show summaries in the panel. + +### Added + +- Optional DMARC aggregate report ingest (`DMARC_REPORTS_ENABLE`): port 25 + accepts only configured `rua=` addresses, gzip/XML is parsed into SQLite, and + the panel shows per-domain roll-ups and individual reports under *DMARC*. +- Per-domain **SelfPost hosted** `rua=` mode when ingest is enabled + (`dmarc-reports+@`). + ## [1.6.0] - 2026-08-18 30-day sending statistics per domain and application, plus optional auto diff --git a/README.md b/README.md index 8dfd761..96d72d1 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ docker run --rm -d --name selfpost-try \ -e SELFPOST_HOSTNAME=mail.local.test \ -e PANEL_COOKIE_SECURE=false \ -v selfpost-try-data:/data \ - ghcr.io/mixeme/selfpost:1.6.0 + ghcr.io/mixeme/selfpost:1.7.0 ``` **Get the setup URL** (pick one): diff --git a/build/postfix-config.sh b/build/postfix-config.sh index adf98e2..66f9800 100644 --- a/build/postfix-config.sh +++ b/build/postfix-config.sh @@ -80,6 +80,15 @@ TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}" RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}" TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}" +# Optional DMARC aggregate ingest (plans/dmarc-reports.md). Off by default: +# port 25 does not accept report mail and the panel ingest maps stay empty. +DMARC_REPORTS_ENABLE="${DMARC_REPORTS_ENABLE:-false}" +DMARC_RATE_LIMIT_MESSAGES_PER_IP="${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}" +DMARC_MESSAGE_SIZE_LIMIT="${DMARC_MESSAGE_SIZE_LIMIT:-5242880}" +DMARC_RECIPIENTS_MAP="${POSTFIX_DMARC_RECIPIENTS:-/data/postfix/dmarc_recipients}" +DMARC_TRANSPORT_MAP="${POSTFIX_DMARC_TRANSPORT:-/data/postfix/dmarc_transport}" +DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_relay_domains}" + # Delivery log, written by postlogd and read by the panel's log-tailer. It lives # under the persistent /data (not the ephemeral /var/log) so the delivery lines # for messages still marked "queued" survive a container recreate — without @@ -211,23 +220,57 @@ else postconf -MX "submission/inet" 2>/dev/null || true fi -# --- inbound smtpd on port 25 (optional backup-MX / forwarder) --------------- -# Debian's stock master.cf enables smtp/inet. When the flag is off, remove that -# listener so port 25 is not an inbound smtpd (outbound delivery uses smtp/unix). -# When on: no SASL, no OpenDKIM, accept only relay_domains + listed recipients. -if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then - for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do - [ -e "$f" ] || : > "$f" - done +# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) ----- +# Debian's stock master.cf enables smtp/inet. When both flags are off, remove +# that listener so port 25 is not an inbound smtpd (outbound delivery uses +# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only +# configured domains/recipients; DMARC accepts only allow-listed report addresses. +if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then + RELAY_DOMAINS_SETTING="" + TRANSPORT_SETTING="" + RECIPIENT_RESTRICTIONS="reject" + if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then + for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do + [ -e "$f" ] || : > "$f" + done + RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}" + TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}" + RECIPIENT_RESTRICTIONS="reject_unauth_destination, reject_unlisted_recipient" + fi + if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then + for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do + [ -e "$f" ] || : > "$f" + done + postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe" + postconf -P \ + "dmarc-ingest/unix-pipe/flags=Rq" \ + "dmarc-ingest/unix-pipe/user=panel" \ + "dmarc-ingest/unix-pipe/argv=/usr/local/bin/panel -dmarc-ingest" + if [ -n "$RELAY_DOMAINS_SETTING" ]; then + RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}" + TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}" + else + RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}" + TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}" + fi + RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP}, ${RECIPIENT_RESTRICTIONS}" + fi postconf -e \ - "relay_domains=texthash:${RELAY_DOMAINS_MAP}" \ - "transport_maps=texthash:${TRANSPORT_MAP}" \ - "relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \ - "smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" \ + "relay_domains=${RELAY_DOMAINS_SETTING}" \ + "transport_maps=${TRANSPORT_SETTING}" \ "smtpd_reject_unlisted_recipient=yes" + if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then + postconf -e \ + "relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \ + "smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" + else + postconf -e \ + "relay_recipient_maps=" \ + "smtp_tls_policy_maps=" + fi INBOUND_MILTERS="" - if [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then + if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then case "${INBOUND_ANTISPAM_MILTER}" in inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;; *) @@ -245,6 +288,21 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then INBOUND_MILTERS="{ ${INBOUND_ANTISPAM_MILTER}, default_action=${INBOUND_ANTISPAM_MILTER_ACTION} }" fi + PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}" + PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}" + if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then + PORT25_RATE="${DMARC_RATE_LIMIT_MESSAGES_PER_IP}" + PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}" + fi + if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then + PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}" + if [ "${INBOUND_MESSAGE_SIZE_LIMIT}" -gt "${DMARC_MESSAGE_SIZE_LIMIT}" ]; then + PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}" + else + PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}" + fi + fi + postconf -M "smtp/inet=smtp inet n - n - - smtpd" postconf -P \ "smtp/inet/smtpd_sasl_auth_enable=no" \ @@ -253,12 +311,13 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then "smtp/inet/smtpd_sender_restrictions=" \ "smtp/inet/smtpd_client_restrictions=" \ "smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \ - "smtp/inet/smtpd_recipient_restrictions=reject_unauth_destination, reject_unlisted_recipient" \ + "smtp/inet/smtpd_recipient_restrictions=${RECIPIENT_RESTRICTIONS}" \ "smtp/inet/smtpd_milters=${INBOUND_MILTERS}" \ - "smtp/inet/smtpd_client_message_rate_limit=${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}" \ - "smtp/inet/message_size_limit=${INBOUND_MESSAGE_SIZE_LIMIT}" + "smtp/inet/smtpd_client_message_rate_limit=${PORT25_RATE}" \ + "smtp/inet/message_size_limit=${PORT25_SIZE}" else postconf -MX "smtp/inet" 2>/dev/null || true + postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true postconf -e \ "relay_domains=" \ "transport_maps=" \ diff --git a/cmd/panel/dmarc_ingest.go b/cmd/panel/dmarc_ingest.go new file mode 100644 index 0000000..5977bc2 --- /dev/null +++ b/cmd/panel/dmarc_ingest.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mixeme/selfpost/internal/dmarc" + "github.com/mixeme/selfpost/internal/store" +) + +func runDMARCIngestMode() error { + cfg := loadConfig() + if !cfg.dmarcEnabled { + return fmt.Errorf("dmarc ingest invoked but DMARC_REPORTS_ENABLE is not true") + } + st, err := store.Open(cfg.dbPath) + if err != nil { + return err + } + defer st.Close() + + recipient := strings.ToLower(strings.TrimSpace(os.Getenv("RECIPIENT"))) + if recipient == "" { + recipient = strings.ToLower(strings.TrimSpace(os.Getenv("ORIGINAL_RECIPIENT"))) + } + if err := dmarc.IngestMessage(st, os.Stdin, recipient, time.Now().UTC()); err != nil { + if incrErr := st.IncrDMARCParseFailures(); incrErr != nil { + log.Printf("dmarc ingest: record failure: %v", incrErr) + } + return err + } + return nil +} + +func isDMARCIngestInvocation() bool { + if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" { + return true + } + return filepath.Base(os.Args[0]) == "dmarc-ingest" +} diff --git a/cmd/panel/envdoc_test.go b/cmd/panel/envdoc_test.go index 666a41c..3425f38 100644 --- a/cmd/panel/envdoc_test.go +++ b/cmd/panel/envdoc_test.go @@ -20,6 +20,9 @@ var documentedPublic = []string{ "INBOUND_ANTISPAM_MILTER_ACTION", "INBOUND_RATE_LIMIT_MESSAGES_PER_IP", "INBOUND_MESSAGE_SIZE_LIMIT", + "DMARC_REPORTS_ENABLE", + "DMARC_RATE_LIMIT_MESSAGES_PER_IP", + "DMARC_MESSAGE_SIZE_LIMIT", } // documentedInternal matches architecture.md § Configuration "Internal env vars". @@ -43,6 +46,9 @@ var documentedInternal = []string{ "POSTFIX_TRANSPORT_MAPS", "POSTFIX_RELAY_RECIPIENTS", "POSTFIX_TLS_POLICY_MAPS", + "POSTFIX_DMARC_RECIPIENTS", + "POSTFIX_DMARC_TRANSPORT", + "POSTFIX_DMARC_RELAY_DOMAINS", "SELFPOST_DEPLOY_ROOT", "MILTER_CONNECT_TIMEOUT", "MILTER_COMMAND_TIMEOUT", @@ -83,6 +89,7 @@ var loadConfigKeys = []string{ "POSTFIX_DIR", "SELFPOST_DEPLOY_ROOT", "INBOUND_RELAY_ENABLE", + "DMARC_REPORTS_ENABLE", } // buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh @@ -105,10 +112,16 @@ var buildScriptKeys = []string{ "INBOUND_ANTISPAM_MILTER_ACTION", "INBOUND_RATE_LIMIT_MESSAGES_PER_IP", "INBOUND_MESSAGE_SIZE_LIMIT", + "DMARC_REPORTS_ENABLE", + "DMARC_RATE_LIMIT_MESSAGES_PER_IP", + "DMARC_MESSAGE_SIZE_LIMIT", "POSTFIX_RELAY_DOMAINS", "POSTFIX_TRANSPORT_MAPS", "POSTFIX_RELAY_RECIPIENTS", "POSTFIX_TLS_POLICY_MAPS", + "POSTFIX_DMARC_RECIPIENTS", + "POSTFIX_DMARC_TRANSPORT", + "POSTFIX_DMARC_RELAY_DOMAINS", "MILTER_CONNECT_TIMEOUT", "MILTER_COMMAND_TIMEOUT", "MILTER_CONTENT_TIMEOUT", diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 2d57bbf..f7ac478 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -10,6 +10,7 @@ import ( "github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/buildinfo" + "github.com/mixeme/selfpost/internal/dmarc" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/postfix" @@ -23,9 +24,11 @@ type mailStack struct { Domains *domain.Service Apps *app.Service Inbound *inbound.Service + DMARC *dmarc.Service pf *postfix.Postfix odk *domain.OpenDKIM inboundEnabled bool + dmarcEnabled bool } func newMailStack(cfg config, st *store.Store) *mailStack { @@ -34,7 +37,11 @@ func newMailStack(cfg config, st *store.Store) *mailStack { apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef) inb := inbound.NewService(st, pf) - return &mailStack{Domains: domains, Apps: apps, Inbound: inb, pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled} + dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled) + return &mailStack{ + Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc, + pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled, + } } // Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and @@ -51,6 +58,11 @@ func (m *mailStack) Resync() error { return fmt.Errorf("inbound maps resync: %w", err) } } + if m.dmarcEnabled { + if err := m.DMARC.Resync(); err != nil { + return fmt.Errorf("dmarc maps resync: %w", err) + } + } return nil } @@ -79,7 +91,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) { // effective config, including a manual override; the panel keeps this // snapshot for the process lifetime (architecture.md). retryPolicy := postfix.LoadRetryPolicy() - return web.New(st, ms.Domains, ms.Apps, ms.Inbound, web.Config{ + return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{ Hostname: cfg.hostname, CookieSecure: cfg.cookieSecure, SubmissionEnabled: cfg.submissionEnabled, @@ -98,6 +110,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) { RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RetryPolicy: retryPolicy, InboundEnabled: cfg.inboundEnabled, + DMARCEnabled: cfg.dmarcEnabled, SendLogRetentionEnvDefault: cfg.retentionDays, }, cfg.setupTokenPath) } diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 835caaf..e50c7c9 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -28,6 +28,16 @@ import ( ) func main() { + if isDMARCIngestInvocation() { + log.SetFlags(log.LstdFlags | log.LUTC) + log.SetPrefix("dmarc-ingest: ") + if err := runDMARCIngestMode(); err != nil { + log.Printf("ingest failed: %v", err) + os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer + } + return + } + showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() @@ -81,6 +91,7 @@ type config struct { deployRoot string inboundEnabled bool + dmarcEnabled bool } func loadConfig() config { @@ -144,6 +155,8 @@ func loadConfig() config { deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"), // Optional inbound relay (backup-MX / forwarder). Off unless exactly "true". inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true", + // Optional DMARC aggregate ingest on port 25. Off unless exactly "true". + dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true", } } @@ -247,6 +260,12 @@ func run() error { if err := resyncAfterRestore(cfg, st, false); err != nil { return err } + } else if cfg.dmarcEnabled { + ms := newMailStack(cfg, st) + ms.pf.SetReloadHook(func() error { return nil }) + if err := ms.DMARC.Resync(); err != nil { + log.Printf("dmarc maps bootstrap: %v", err) + } } if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil { diff --git a/deploy/.env.example b/deploy/.env.example index 2121190..c9c34a3 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -16,6 +16,15 @@ SUBMISSION_ENABLE=false # to the upstream you set. Off by default — the outbound path is unchanged. INBOUND_RELAY_ENABLE=false +# Optional DMARC aggregate report ingest. When true, Postfix accepts mail on port +# 25 only for report addresses configured in the panel (global default and/or +# per-domain SelfPost-hosted addresses) and pipes them to the ingest worker. +DMARC_REPORTS_ENABLE=false + +# Coarse per-client-IP limit and message size for DMARC ingest on port 25. +# DMARC_RATE_LIMIT_MESSAGES_PER_IP=20 +# DMARC_MESSAGE_SIZE_LIMIT=5242880 + # Optional milter on the inbound listener only (not 465/587). Empty = off. # Example with deploy/antispam/docker-compose.antispam.yml: # INBOUND_ANTISPAM_MILTER=inet:antispam:11332 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0075c74..e5d100a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -22,7 +22,7 @@ services: selfpost: - image: ghcr.io/mixeme/selfpost:1.6.0 + image: ghcr.io/mixeme/selfpost:1.7.0 restart: unless-stopped environment: SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" @@ -34,6 +34,9 @@ services: # alongside the primary 465/smtps listener (spec 5). SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}" INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}" + DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}" + DMARC_RATE_LIMIT_MESSAGES_PER_IP: "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}" + DMARC_MESSAGE_SIZE_LIMIT: "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}" INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}" INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}" INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" diff --git a/docs/development.md b/docs/development.md index 565a49b..20abe97 100644 --- a/docs/development.md +++ b/docs/development.md @@ -102,7 +102,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com 2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see [§ Release image](#release-image)). -**Planned version cuts** (from pin `1.6.0`; adjust if semver changes mid-track): +**Planned version cuts** (from pin `1.7.0`; adjust if semver changes mid-track): | Stage | ID | Cut | |---|---|---| diff --git a/docs/guide.md b/docs/guide.md index e926583..d534add 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -33,6 +33,7 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications). - [Full backup and restore](#full-backup-and-restore) - [Encrypting a backup or export](#encrypting-a-backup-or-export) - [Inbound relay](#inbound-relay) + - [DMARC reports](#dmarc-reports) - [Domain administration](#domain-administration) - [Domains page](#domains-page) - [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc) @@ -169,7 +170,7 @@ cat ./data/setup-token #### Fixed image tag `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), -deliberately never `:latest`. The current pin is `1.6.0`. Intermediate +deliberately never `:latest`. The current pin is `1.7.0`. Intermediate CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that image was published. Pinning matters because of the backup version check (see [Full backup and restore](#full-backup-and-restore)): the panel binary's @@ -190,6 +191,9 @@ expected to set; defaults match the code exactly. | `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` | | `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` | | `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` | +| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *DMARC*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` | +| `DMARC_RATE_LIMIT_MESSAGES_PER_IP` | Per-client-IP cap on port 25 when DMARC ingest is on (shared listener with inbound relay if both are enabled). | `20` | `.env` | +| `DMARC_MESSAGE_SIZE_LIMIT` | Maximum report message size in bytes when DMARC ingest is on. | `5242880` (5 MiB) | `.env` | | `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound listener only (not 465/587). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. | *(empty)* | `.env` | | `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.env` | | `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` | @@ -640,6 +644,44 @@ Inbound configuration lives in SQLite and `/data/postfix/` map files, so it is included in a [full backup](#full-backup-and-restore). Single-domain export/import is sending domains only. +### DMARC reports + +Optional ingest of DMARC **aggregate** reports (`rua=`): Postfix accepts mail on +port **25** only for addresses you configure, pipes each message to the panel +ingest worker, and stores parsed summaries in SQLite. Forensic reports (`ruf=`) +are not stored. This is separate from [Inbound relay](#inbound-relay) — no +backup-MX, no forwarding upstream. + +**Off by default.** Set `DMARC_REPORTS_ENABLE=true` in `.env` and recreate the +container. Until then there is no report ingest, no *DMARC* item in the nav, +and `/dmarc` is 404. Outbound 465/587 is unchanged. + +**Addresses.** In *Settings* (global administrator), set the default +`rua=` mailbox to an address on `SELFPOST_HOSTNAME`, e.g. +`dmarc-reports@mail.example.com`. Per domain you can choose **SelfPost hosted** +(`dmarc-reports+@`) under *Domain settings → DMARC reports*. +Only those allow-listed addresses are accepted on port 25. + +**DNS.** Publish the usual `_dmarc` TXT on each sending domain with +`rua=mailto:…` pointing at your hosted address. Receivers deliver to the +address domain — publish **MX** for `SELFPOST_HOSTNAME` (or the report +address domain if different) so reports reach this server. If a hub domain +authorises external destinations, publish `_report._dmarc` there too; the panel +checks it on the domain page. + +**Panel.** *DMARC* (global administrator) lists recent reports and ingest +health. Open a domain's roll-up from the list or from *View DMARC reports* on +the domain page. Domain administrators see reports only for domains assigned to +them. Summaries are pruned (500 kept, 90 days max). + +**Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest +alone, `check_recipient_access` permits only configured report addresses; +everything else is rejected. With inbound relay enabled too, both allow-lists +apply. + +Parsed report data lives in SQLite and is included in a +[full backup](#full-backup-and-restore). + ## Domain administration ### Domains page @@ -665,10 +707,10 @@ records; see [Inbound relay](#inbound-relay). - **DKIM** — a TXT record with the exact value the panel shows on that domain's page (`domain page → DKIM TXT record`), one selector per domain. - **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring - only, safe to publish immediately). On a send-only relay the sending domain - often has no inbox, so `rua=` is optional — configure a default report address - in *Settings* (see [Settings](#settings)) or per domain when you have a - mailbox that receives inbound mail elsewhere. If `rua=` points at another + only, safe to publish immediately). Set `rua=` to receive aggregate reports: + with [DMARC reports](#dmarc-reports) enabled, use a SelfPost-hosted address + from *Settings* or per-domain *Domain settings*; otherwise point `rua=` at a + mailbox elsewhere that receives inbound mail. If `rua=` points at another domain, publish `_report._dmarc` on that hub domain too; the panel checks it. Public mail hosts (Gmail, Outlook, …) cannot be used as external report destinations. diff --git a/docs/plans/dmarc-reports.md b/docs/plans/dmarc-reports.md index c2d0028..82c8dad 100644 --- a/docs/plans/dmarc-reports.md +++ b/docs/plans/dmarc-reports.md @@ -1,6 +1,7 @@ # Plan: dmarc-reports -**Status:** candidate +**Status:** done — shipped in `[1.7.0]` (2026-08-18); security review (Fable) +of the ingest path pending. --- @@ -51,15 +52,28 @@ remain a separate, opt-in feature that does not forward mail upstream. ## Implementation checklist +**Ingest path.** `DMARC_REPORTS_ENABLE=true` enables `smtp/inet` on 25 (shared +with inbound relay when both are on). Postfix `relay_domains` + +`transport_maps` route allow-listed recipients to a `dmarc-ingest` pipe +(`panel -dmarc-ingest`). `check_recipient_access` on `dmarc_recipients` is the +allow-list; no SASL, no local mailboxes. + +**Schema.** Migration `0008_dmarc_reports.sql`: `dmarc_reports` (summary per +aggregate) + `dmarc_report_records` (per-source rows). Dedup on +`(reporter, report_id, domain)`. + +**Retention.** Max 500 reports; drop older than 90 days; prune after each +ingest. + Target version cut: **`1.7.0`** (MINOR). One commit per step; code only after roadmap status is **agreed**. Expand the sketch sections above before step 1 if still thin. See [development.md](../development.md) § Plan checklists. -- [ ] Expand plan: ingest path, `dmarc_reports` schema, retention caps — **Sonnet** -- [ ] Opt-in inbound SMTP for report addresses only (allow-list) — **Opus** -- [ ] Worker: gzip/XML parse → SQLite — **Opus** -- [ ] Panel: domain roll-up + parsed report (panel-ui mockups) — **Sonnet** -- [ ] Tie-in `dmarc_report_email` / `domains.dmarc_rua` — **Sonnet** -- [ ] Tests and [guide.md](../guide.md) — **Sonnet** +- [x] Expand plan: ingest path, `dmarc_reports` schema, retention caps — **Sonnet** +- [x] Opt-in inbound SMTP for report addresses only (allow-list) — **Opus** +- [x] Worker: gzip/XML parse → SQLite — **Opus** +- [x] Panel: domain roll-up + parsed report (panel-ui mockups) — **Sonnet** +- [x] Tie-in `dmarc_report_email` / `domains.dmarc_rua` — **Sonnet** +- [x] Tests and [guide.md](../guide.md) — **Sonnet** - [ ] Security review ingest path — **Fable** -- [ ] `go vet`, `go test` on touched packages — **Haiku** +- [x] `go vet`, `go test` on touched packages — **Haiku** diff --git a/docs/product.md b/docs/product.md index 5da11ba..7de5ccc 100644 --- a/docs/product.md +++ b/docs/product.md @@ -60,7 +60,9 @@ domain administrators with assigned domains). The optional **inbound relay** (backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind `INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. **Send-log retention in Settings** ships in `[1.5.0]`. **30-day send statistics** and -**auto level-2 rate limits** ship in `[1.6.0]`. Items marked *candidate* in the +**auto level-2 rate limits** ship in `[1.6.0]`. **DMARC aggregate report +ingest** ships in `[1.7.0]`, off by default behind `DMARC_REPORTS_ENABLE`. +Items marked *candidate* in the [roadmap](roadmap.md) require explicit approval before coding. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 6d0c872..6f58a0f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,16 +29,16 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | ID | Topic | Status | Progress | Plan | |---|---|---|---|---| | contributing | `CONTRIBUTING.md` | candidate | — | — | -| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | 0/8 | [plans/dmarc-reports.md](plans/dmarc-reports.md) | | panel-docs | In-panel operator documentation | candidate | 0/6 | [plans/panel-docs.md](plans/panel-docs.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — | -**Recommended order** (not binding): the next feature is **dmarc-reports** once -agreed. domain-stats-auto-ratelimit shipped in -[CHANGELOG.md](../CHANGELOG.md) `[1.6.0]`; send-log-retention in `[1.5.0]`; -inbound-relay in `[1.4.0]`; queue-retries in `[1.3.1]`; the 2026-08-13 -full-tree review follow-ups are in `[1.3.0]`. Candidates need explicit -agreement before they join the queue. +**Recommended order** (not binding): the next feature is **panel-docs** once +agreed. dmarc-reports shipped in +[CHANGELOG.md](../CHANGELOG.md) `[1.7.0]` (security review of the ingest path +pending); domain-stats-auto-ratelimit in `[1.6.0]`; send-log-retention in +`[1.5.0]`; inbound-relay in `[1.4.0]`; queue-retries in `[1.3.1]`; the +2026-08-13 full-tree review follow-ups are in `[1.3.0]`. Candidates need +explicit agreement before they join the queue. After a context reset, pick an item marked `agreed` or `in progress`, then work the **Implementation checklist** in its linked plan. The `Progress` column above @@ -65,33 +65,6 @@ priority. --- -## dmarc-reports - -**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP, -parses the gzip/XML payloads, and shows pass/fail summaries in the panel — so -the operator does not need an external DMARC service or a separate mailbox -workflow. - -**Boundary:** an extension of v1.0 — not IMAP/webmail and not a general -inbound relay. A dedicated inbound path for report messages only; forensic -reports (`ruf=`) out of scope for v1. - -**Done when:** see [plans/dmarc-reports.md](plans/dmarc-reports.md). - -**Dependencies / risks:** inbound SMTP in the image (may share infrastructure -with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX); -storage and retention of parsed summaries; the `dmarc_report_email` setting -(migration `0005` moved it off the old `admin` table into `settings`) and -`domains.dmarc_rua` added in the DMARC template work must stay the source of -truth for `rua=` in DNS guidance. - -**Order:** after the DMARC `rua=` settings ship; may follow or overlap with -inbound-relay depending on how port 25 acceptance is structured. - -**Version:** `1.x` MINOR. - ---- - ## panel-docs **Goal:** built-in operator documentation in the panel — short pages (or a diff --git a/internal/dmarc/addresses.go b/internal/dmarc/addresses.go new file mode 100644 index 0000000..24a0d08 --- /dev/null +++ b/internal/dmarc/addresses.go @@ -0,0 +1,31 @@ +package dmarc + +import ( + "strings" +) + +// DefaultLocalPart is the mailbox local-part for the panel-wide hosted address. +const DefaultLocalPart = "dmarc-reports" + +// HostedReportAddress is the per-domain SelfPost-hosted rua= destination. +func HostedReportAddress(hostname, domain string) string { + hostname = strings.ToLower(strings.TrimSpace(hostname)) + domain = strings.ToLower(strings.TrimSpace(domain)) + return DefaultLocalPart + "+" + domain + "@" + hostname +} + +// DefaultHostedReportAddress is the settings-level hosted rua= when ingest is on. +func DefaultHostedReportAddress(hostname string) string { + return DefaultLocalPart + "@" + strings.ToLower(strings.TrimSpace(hostname)) +} + +// IsHostedOnHostname reports whether addr is delivered locally on hostname. +func IsHostedOnHostname(addr, hostname string) bool { + addr = strings.ToLower(strings.TrimSpace(addr)) + hostname = strings.ToLower(strings.TrimSpace(hostname)) + if hostname == "" || !strings.Contains(addr, "@") { + return false + } + at := strings.LastIndex(addr, "@") + return addr[at+1:] == hostname +} diff --git a/internal/dmarc/ingest.go b/internal/dmarc/ingest.go new file mode 100644 index 0000000..4296d0a --- /dev/null +++ b/internal/dmarc/ingest.go @@ -0,0 +1,133 @@ +package dmarc + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "net/textproto" + "strings" + "time" + + "github.com/mixeme/selfpost/internal/store" +) + +// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC +// aggregate attachment it finds. +func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt time.Time) error { + raw, err := io.ReadAll(io.LimitReader(r, 12<<20)) + if err != nil { + return fmt.Errorf("read message: %w", err) + } + payload, err := extractAggregatePayload(raw) + if err != nil { + return err + } + parsed, err := ParseAggregate(payload) + if err != nil { + return err + } + rep := store.DMARCReport{ + Domain: parsed.Domain, + Reporter: parsed.Reporter, + ReportID: parsed.ReportID, + PeriodBegin: parsed.PeriodBegin, + PeriodEnd: parsed.PeriodEnd, + ReceivedAt: receivedAt.UTC(), + ContactEmail: parsed.ContactEmail, + PolicyP: parsed.PolicyP, + PolicySP: parsed.PolicySP, + PolicyPct: parsed.PolicyPct, + PolicyADKIM: parsed.PolicyADKIM, + PolicyASPF: parsed.PolicyASPF, + PassCount: parsed.PassCount, + FailCount: parsed.FailCount, + Recipient: strings.ToLower(strings.TrimSpace(recipient)), + } + for _, rec := range parsed.Records { + rep.Records = append(rep.Records, store.DMARCReportRecord{ + SourceIP: rec.SourceIP, + Count: rec.Count, + Disposition: rec.Disposition, + SPFResult: rec.SPFResult, + DKIMResult: rec.DKIMResult, + HeaderFrom: rec.HeaderFrom, + }) + } + if _, err := st.InsertDMARCReport(rep); err != nil { + return err + } + return st.PruneDMARCReports() +} + +func extractAggregatePayload(raw []byte) ([]byte, error) { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("parse message: %w", err) + } + ct := msg.Header.Get("Content-Type") + if ct == "" { + return io.ReadAll(io.LimitReader(msg.Body, 8<<20)) + } + mediaType, params, err := mime.ParseMediaType(ct) + if err != nil { + return nil, fmt.Errorf("content-type: %w", err) + } + if strings.HasPrefix(mediaType, "multipart/") { + boundary := params["boundary"] + if boundary == "" { + return nil, fmt.Errorf("multipart without boundary") + } + mr := multipart.NewReader(msg.Body, boundary) + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("multipart: %w", err) + } + name := strings.ToLower(part.FileName()) + if name == "" { + name = strings.ToLower(part.Header.Get("Content-Type")) + } + if !looksLikeAggregate(name) { + continue + } + data, err := readEncodedBody(textproto.MIMEHeader(part.Header), part) + if err != nil { + return nil, err + } + if len(data) > 0 { + return data, nil + } + } + return nil, fmt.Errorf("no aggregate attachment found") + } + return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body) +} + +func looksLikeAggregate(name string) bool { + name = strings.ToLower(name) + return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip") +} + +func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) { + encoding := strings.ToLower(hdr.Get("Content-Transfer-Encoding")) + var body io.Reader = r + switch encoding { + case "base64": + body = base64.NewDecoder(base64.StdEncoding, r) + case "quoted-printable": + body = quotedprintable.NewReader(r) + } + data, err := io.ReadAll(io.LimitReader(body, 8<<20)) + if err != nil { + return nil, fmt.Errorf("read part body: %w", err) + } + return data, nil +} diff --git a/internal/dmarc/parse.go b/internal/dmarc/parse.go new file mode 100644 index 0000000..1cfdc97 --- /dev/null +++ b/internal/dmarc/parse.go @@ -0,0 +1,176 @@ +package dmarc + +import ( + "bytes" + "compress/gzip" + "encoding/xml" + "fmt" + "io" + "strings" + "time" +) + +// ParsedReport is the structured form of a DMARC aggregate XML payload. +type ParsedReport struct { + Reporter string + ReportID string + ContactEmail string + PeriodBegin time.Time + PeriodEnd time.Time + Domain string + PolicyP string + PolicySP string + PolicyPct int + PolicyADKIM string + PolicyASPF string + Records []ParsedRecord + PassCount int + FailCount int +} + +// ParsedRecord is one row from the aggregate XML. +type ParsedRecord struct { + SourceIP string + Count int + Disposition string + SPFResult string + DKIMResult string + HeaderFrom string +} + +type feedbackXML struct { + XMLName xml.Name `xml:"feedback"` + Metadata struct { + OrgName string `xml:"org_name"` + Email string `xml:"email"` + ReportID string `xml:"report_id"` + DateRange struct { + Begin int64 `xml:"begin"` + End int64 `xml:"end"` + } `xml:"date_range"` + } `xml:"report_metadata"` + Policy struct { + Domain string `xml:"domain"` + P string `xml:"p"` + SP string `xml:"sp"` + Pct int `xml:"pct"` + ADKIM string `xml:"adkim"` + ASPF string `xml:"aspf"` + } `xml:"policy_published"` + Records []struct { + Row struct { + SourceIP string `xml:"source_ip"` + Count int `xml:"count"` + Policy struct { + Disposition string `xml:"disposition"` + DKIM string `xml:"dkim"` + SPF string `xml:"spf"` + } `xml:"policy_evaluated"` + } `xml:"row"` + Identifiers struct { + HeaderFrom string `xml:"header_from"` + } `xml:"identifiers"` + } `xml:"record"` +} + +// ParseAggregate decodes gzip-compressed or raw DMARC aggregate XML. +func ParseAggregate(raw []byte) (ParsedReport, error) { + data, err := maybeGunzip(raw) + if err != nil { + return ParsedReport{}, err + } + var doc feedbackXML + if err := xml.Unmarshal(data, &doc); err != nil { + return ParsedReport{}, fmt.Errorf("dmarc xml: %w", err) + } + if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" { + return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain") + } + out := ParsedReport{ + Reporter: strings.TrimSpace(doc.Metadata.OrgName), + ReportID: strings.TrimSpace(doc.Metadata.ReportID), + ContactEmail: strings.TrimSpace(doc.Metadata.Email), + PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(), + PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(), + Domain: strings.ToLower(strings.TrimSpace(doc.Policy.Domain)), + PolicyP: strings.TrimSpace(doc.Policy.P), + PolicySP: strings.TrimSpace(doc.Policy.SP), + PolicyPct: doc.Policy.Pct, + PolicyADKIM: strings.TrimSpace(doc.Policy.ADKIM), + PolicyASPF: strings.TrimSpace(doc.Policy.ASPF), + } + if out.PolicyPct == 0 { + out.PolicyPct = 100 + } + for _, rec := range doc.Records { + row := ParsedRecord{ + SourceIP: strings.TrimSpace(rec.Row.SourceIP), + Count: rec.Row.Count, + Disposition: strings.TrimSpace(rec.Row.Policy.Disposition), + SPFResult: strings.TrimSpace(rec.Row.Policy.SPF), + DKIMResult: strings.TrimSpace(rec.Row.Policy.DKIM), + HeaderFrom: strings.TrimSpace(rec.Identifiers.HeaderFrom), + } + if row.Count <= 0 { + row.Count = 1 + } + if dmarcRecordPasses(row) { + out.PassCount += row.Count + } else { + out.FailCount += row.Count + } + out.Records = append(out.Records, row) + } + return out, nil +} + +func dmarcRecordPasses(r ParsedRecord) bool { + return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass") +} + +func maybeGunzip(raw []byte) ([]byte, error) { + if len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b { + zr, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("dmarc gzip: %w", err) + } + defer zr.Close() + data, err := io.ReadAll(io.LimitReader(zr, 8<<20)) + if err != nil { + return nil, fmt.Errorf("dmarc gzip read: %w", err) + } + return data, nil + } + return raw, nil +} + +// TightenPolicyHint summarises whether raising p= looks reasonable. +func TightenPolicyHint(pass, fail int, sources []SourceHint) string { + total := pass + fail + if total == 0 { + return "No messages in the reporting window yet." + } + failPct := float64(fail) * 100 / float64(total) + if fail > 0 { + for _, s := range sources { + if s.FailCount > 0 && !s.ThisRelay { + return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed." + } + } + } + if failPct <= 2 && pass > 0 { + return "Alignment looks strong. Tightening p= may be reasonable." + } + if fail > 0 { + return "Some failures remain. Review sources before tightening p=." + } + return "Alignment looks clean for this window." +} + +// SourceHint is a panel-facing rollup row with relay detection. +type SourceHint struct { + SourceIP string + PassCount int + FailCount int + ThisRelay bool +} diff --git a/internal/dmarc/parse_test.go b/internal/dmarc/parse_test.go new file mode 100644 index 0000000..9579d8b --- /dev/null +++ b/internal/dmarc/parse_test.go @@ -0,0 +1,116 @@ +package dmarc + +import ( + "bytes" + "compress/gzip" + "testing" + "time" + + "github.com/mixeme/selfpost/internal/store" +) + +const sampleXML = ` + + + google.com + noreply@google.com + 12345 + 17235936001723680000 + + + example.com +

none

none100 + rr +
+ + + 203.0.113.10 + 10 + + none + passpass + + + example.com + + + + 198.51.100.1 + 2 + + none + failfail + + + example.com + +
` + +func TestParseAggregateGzip(t *testing.T) { + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + if _, err := zw.Write([]byte(sampleXML)); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + got, err := ParseAggregate(buf.Bytes()) + if err != nil { + t.Fatalf("ParseAggregate: %v", err) + } + if got.Domain != "example.com" || got.Reporter != "google.com" { + t.Fatalf("metadata = %+v", got) + } + if got.PassCount != 10 || got.FailCount != 2 { + t.Fatalf("counts = %d pass %d fail", got.PassCount, got.FailCount) + } + if !got.PeriodBegin.Equal(time.Unix(1723593600, 0).UTC()) { + t.Fatalf("period begin = %v", got.PeriodBegin) + } +} + +func TestIngestMessageStoresReport(t *testing.T) { + st, err := store.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + + var gz bytes.Buffer + zw := gzip.NewWriter(&gz) + _, _ = zw.Write([]byte(sampleXML)) + _ = zw.Close() + + msg := bytes.NewBufferString( + "From: noreply@google.com\r\n" + + "To: dmarc-reports@mail.example.com\r\n" + + "Subject: Report\r\n" + + "Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" + + "\r\n", + ) + // append raw gzip for simple single-part test + msg.Write(gz.Bytes()) + + if err := IngestMessage(st, msg, "dmarc-reports@mail.example.com", time.Now().UTC()); err != nil { + t.Fatalf("IngestMessage: %v", err) + } + list, err := st.ListDMARCReports(nil, 10) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 { + t.Fatalf("reports = %d", len(list)) + } + if list[0].PassCount != 10 { + t.Fatalf("pass = %d", list[0].PassCount) + } +} + +func TestHostedReportAddress(t *testing.T) { + got := HostedReportAddress("mail.example.com", "Example.COM") + want := "dmarc-reports+example.com@mail.example.com" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} diff --git a/internal/dmarc/service.go b/internal/dmarc/service.go new file mode 100644 index 0000000..4af04ca --- /dev/null +++ b/internal/dmarc/service.go @@ -0,0 +1,131 @@ +// Package dmarc receives DMARC aggregate reports: Postfix pipes messages here, +// gzip/XML is parsed, and summaries land in SQLite for the panel. +package dmarc + +import ( + "fmt" + "sort" + "strings" + + "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/postfix" + "github.com/mixeme/selfpost/internal/store" +) + +// Maps is the Postfix manager surface the DMARC service needs. +type Maps interface { + RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error +} + +// Service coordinates DMARC ingest allow-lists across SQLite and Postfix. +type Service struct { + store *store.Store + maps Maps + hostname string + enabled bool +} + +// NewService builds the DMARC ingest service. +func NewService(st *store.Store, maps Maps, hostname string, enabled bool) *Service { + return &Service{store: st, maps: maps, hostname: strings.ToLower(strings.TrimSpace(hostname)), enabled: enabled} +} + +// Enabled reports whether DMARC ingest is active in this deployment. +func (s *Service) Enabled() bool { return s.enabled } + +// Resync rebuilds the Postfix allow-list and transport maps from SQLite. +func (s *Service) Resync() error { + if !s.enabled { + return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{}) + } + addrs, err := s.AllowedRecipients() + if err != nil { + return err + } + domains := recipientDomains(addrs) + return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{ + Recipients: addrs, + Domains: domains, + }) +} + +// AllowedRecipients returns every address Postfix may accept for DMARC ingest. +func (s *Service) AllowedRecipients() ([]string, error) { + if !s.enabled || s.hostname == "" { + return nil, nil + } + profile, err := s.store.GlobalDMARCReportEmail() + if err != nil { + return nil, err + } + seen := make(map[string]bool) + var out []string + add := func(addr string) { + addr = strings.ToLower(strings.TrimSpace(addr)) + if addr == "" || !IsHostedOnHostname(addr, s.hostname) || seen[addr] { + return + } + seen[addr] = true + out = append(out, addr) + } + add(profile) + if profile == "" { + add(DefaultHostedReportAddress(s.hostname)) + } + domains, err := s.store.ListDomains() + if err != nil { + return nil, err + } + for _, d := range domains { + rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile) + if rua == "" { + continue + } + if IsHostedOnHostname(rua, s.hostname) { + add(rua) + continue + } + if d.DMARCRua.Valid && d.DMARCRua.String == "" { + continue + } + hosted := HostedReportAddress(s.hostname, d.Name) + if strings.EqualFold(rua, hosted) { + add(rua) + } + } + sort.Strings(out) + return out, nil +} + +func recipientDomains(addrs []string) []string { + seen := make(map[string]bool) + var out []string + for _, addr := range addrs { + d := dnscheck.EmailDomain(addr) + if d == "" || seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + sort.Strings(out) + return out +} + +// HostedSuggestion returns the address the panel should suggest for a domain. +func (s *Service) HostedSuggestion(domain string) string { + return HostedReportAddress(s.hostname, domain) +} + +// DefaultHostedSuggestion is the settings-level hosted address. +func (s *Service) DefaultHostedSuggestion() string { + return DefaultHostedReportAddress(s.hostname) +} + +// ValidateHostedAddress ensures addr is on this hostname before saving. +func (s *Service) ValidateHostedAddress(addr string) error { + if !IsHostedOnHostname(addr, s.hostname) { + return fmt.Errorf("hosted report address must be on %s", s.hostname) + } + return nil +} diff --git a/internal/postfix/dmarc.go b/internal/postfix/dmarc.go new file mode 100644 index 0000000..568313c --- /dev/null +++ b/internal/postfix/dmarc.go @@ -0,0 +1,71 @@ +package postfix + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +// DMARCMapsConfig is the allow-list material for DMARC aggregate ingest. +type DMARCMapsConfig struct { + Recipients []string + Domains []string +} + +func (p *Postfix) dmarcMapPaths() (recipients, transport, relayDomains string) { + dir := filepath.Dir(p.senderLoginMapsPath) + return filepath.Join(dir, "dmarc_recipients"), + filepath.Join(dir, "dmarc_transport"), + filepath.Join(dir, "dmarc_relay_domains") +} + +// RebuildDMARCMaps regenerates DMARC ingest lookup tables. Empty config clears +// the files so port-25 ingest is off even if the listener stays up for inbound +// relay. +func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error { + recip, transport, relay, err := renderDMARCMaps(cfg) + if err != nil { + return err + } + rc, tr, rd := p.dmarcMapPaths() + if err := writeFileAtomic(rc, recip, 0o640); err != nil { + return err + } + if err := writeFileAtomic(tr, transport, 0o640); err != nil { + return err + } + if err := writeFileAtomic(rd, relay, 0o640); err != nil { + return err + } + return p.reload() +} + +func renderDMARCMaps(cfg DMARCMapsConfig) (recipients, transport, relayDomains []byte, err error) { + recipients = []byte{} + transport = []byte{} + relayDomains = []byte{} + if len(cfg.Recipients) == 0 { + return recipients, transport, relayDomains, nil + } + addrs := append([]string(nil), cfg.Recipients...) + sort.Strings(addrs) + var recipB, transportB strings.Builder + for _, addr := range addrs { + if err := assertMapToken(addr, "recipient"); err != nil { + return nil, nil, nil, err + } + fmt.Fprintf(&recipB, "%s OK\n", addr) + fmt.Fprintf(&transportB, "%s dmarc-ingest:\n", addr) + } + domains := append([]string(nil), cfg.Domains...) + sort.Strings(domains) + var relayB strings.Builder + for _, d := range domains { + if err := assertMapToken(d, "domain"); err != nil { + return nil, nil, nil, err + } + fmt.Fprintf(&relayB, "%s OK\n", d) + } + return []byte(recipB.String()), []byte(transportB.String()), []byte(relayB.String()), nil +} diff --git a/internal/postfix/dmarc_test.go b/internal/postfix/dmarc_test.go new file mode 100644 index 0000000..404592c --- /dev/null +++ b/internal/postfix/dmarc_test.go @@ -0,0 +1,35 @@ +package postfix + +import ( + "strings" + "testing" +) + +func TestRenderDMARCMaps(t *testing.T) { + recip, transport, relay, err := renderDMARCMaps(DMARCMapsConfig{ + Recipients: []string{"dmarc-reports@mail.example.com"}, + Domains: []string{"mail.example.com"}, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(recip), "dmarc-reports@mail.example.com OK") { + t.Fatalf("recipients:\n%s", recip) + } + if !strings.Contains(string(transport), "dmarc-ingest:") { + t.Fatalf("transport:\n%s", transport) + } + if !strings.Contains(string(relay), "mail.example.com OK") { + t.Fatalf("relay:\n%s", relay) + } +} + +func TestRenderDMARCMapsEmpty(t *testing.T) { + recip, transport, relay, err := renderDMARCMaps(DMARCMapsConfig{}) + if err != nil { + t.Fatal(err) + } + if len(recip)+len(transport)+len(relay) != 0 { + t.Fatalf("expected empty maps") + } +} diff --git a/internal/store/dmarc.go b/internal/store/dmarc.go new file mode 100644 index 0000000..310f640 --- /dev/null +++ b/internal/store/dmarc.go @@ -0,0 +1,354 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +const ( + // DMARCReportsMaxKeep is how many parsed reports are kept before pruning. + DMARCReportsMaxKeep = 500 + // DMARCReportsMaxAgeDays drops reports older than this window. + DMARCReportsMaxAgeDays = 90 + // DMARCParseFailuresKey counts ingest failures (settings). + DMARCParseFailuresKey = "dmarc_parse_failures_total" +) + +// ErrDMARCReportNotFound is returned when a report id does not exist. +var ErrDMARCReportNotFound = errors.New("dmarc report not found") + +// DMARCReport is one parsed aggregate summary. +type DMARCReport struct { + ID int64 + Domain string + Reporter string + ReportID string + PeriodBegin time.Time + PeriodEnd time.Time + ReceivedAt time.Time + ContactEmail string + PolicyP string + PolicySP string + PolicyPct int + PolicyADKIM string + PolicyASPF string + PassCount int + FailCount int + Recipient string + Records []DMARCReportRecord +} + +// DMARCReportRecord is one source row inside a report. +type DMARCReportRecord struct { + SourceIP string + Count int + Disposition string + SPFResult string + DKIMResult string + HeaderFrom string +} + +// DMARCReportSummary is a list-row without per-record detail. +type DMARCReportSummary struct { + ID int64 + Domain string + Reporter string + PeriodBegin time.Time + PeriodEnd time.Time + ReceivedAt time.Time + PassCount int + FailCount int +} + +// DMARCSourceRollup aggregates pass/fail per source over a window. +type DMARCSourceRollup struct { + SourceIP string + PassCount int + FailCount int + Disposition string +} + +// DMARCIngestStats is panel-facing ingest health. +type DMARCIngestStats struct { + LastReceivedAt *time.Time + KeptThisWeek int + ParseFailures int + IngestOK bool +} + +// InsertDMARCReport stores a parsed report and its records, replacing any prior +// row with the same reporter/report_id/domain triple. +func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) { + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("begin insert dmarc report: %w", err) + } + defer tx.Rollback() + + if _, err := tx.Exec( + `DELETE FROM dmarc_reports WHERE reporter = ? AND report_id = ? AND domain = ?`, + rep.Reporter, rep.ReportID, rep.Domain, + ); err != nil { + return 0, fmt.Errorf("delete prior dmarc report: %w", err) + } + + res, err := tx.Exec(` + INSERT INTO dmarc_reports ( + domain, reporter, report_id, period_begin, period_end, received_at, + contact_email, policy_p, policy_sp, policy_pct, policy_adkim, policy_aspf, + pass_count, fail_count, recipient + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + rep.Domain, rep.Reporter, rep.ReportID, + rep.PeriodBegin.UTC().Format(time.RFC3339), + rep.PeriodEnd.UTC().Format(time.RFC3339), + rep.ReceivedAt.UTC().Format(time.RFC3339), + rep.ContactEmail, rep.PolicyP, rep.PolicySP, rep.PolicyPct, + rep.PolicyADKIM, rep.PolicyASPF, rep.PassCount, rep.FailCount, rep.Recipient, + ) + if err != nil { + return 0, fmt.Errorf("insert dmarc report: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("dmarc report id: %w", err) + } + for _, rec := range rep.Records { + if _, err := tx.Exec(` + INSERT INTO dmarc_report_records ( + report_row_id, source_ip, count, disposition, spf_result, dkim_result, header_from + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, rec.SourceIP, rec.Count, rec.Disposition, rec.SPFResult, rec.DKIMResult, rec.HeaderFrom, + ); err != nil { + return 0, fmt.Errorf("insert dmarc record: %w", err) + } + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit dmarc report: %w", err) + } + return id, nil +} + +// PruneDMARCReports enforces count and age caps. +func (s *Store) PruneDMARCReports() error { + cutoff := time.Now().UTC().AddDate(0, 0, -DMARCReportsMaxAgeDays).Format(time.RFC3339) + if _, err := s.db.Exec(`DELETE FROM dmarc_reports WHERE received_at < ?`, cutoff); err != nil { + return fmt.Errorf("prune dmarc by age: %w", err) + } + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports`).Scan(&count); err != nil { + return fmt.Errorf("count dmarc reports: %w", err) + } + if count <= DMARCReportsMaxKeep { + return nil + } + excess := count - DMARCReportsMaxKeep + _, err := s.db.Exec(` + DELETE FROM dmarc_reports WHERE id IN ( + SELECT id FROM dmarc_reports ORDER BY received_at ASC LIMIT ? + )`, excess) + if err != nil { + return fmt.Errorf("prune dmarc by count: %w", err) + } + return nil +} + +// IncrDMARCParseFailures bumps the failure counter in settings. +func (s *Store) IncrDMARCParseFailures() error { + raw, err := s.GetSetting(DMARCParseFailuresKey) + if err != nil { + return err + } + n := 0 + if raw != "" { + fmt.Sscanf(raw, "%d", &n) + } + return s.SetSetting(DMARCParseFailuresKey, fmt.Sprintf("%d", n+1)) +} + +// ListDMARCReports returns recent summaries, optionally limited to domains. +func (s *Store) ListDMARCReports(domains []string, limit int) ([]DMARCReportSummary, error) { + if limit <= 0 { + limit = 50 + } + var ( + rows *sql.Rows + err error + ) + if len(domains) == 0 { + rows, err = s.db.Query(` + SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count + FROM dmarc_reports ORDER BY received_at DESC LIMIT ?`, limit) + } else { + placeholders := make([]any, 0, len(domains)+1) + q := `SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count + FROM dmarc_reports WHERE domain IN (` + for i, d := range domains { + if i > 0 { + q += "," + } + q += "?" + placeholders = append(placeholders, d) + } + q += `) ORDER BY received_at DESC LIMIT ?` + placeholders = append(placeholders, limit) + rows, err = s.db.Query(q, placeholders...) + } + if err != nil { + return nil, fmt.Errorf("list dmarc reports: %w", err) + } + defer rows.Close() + var out []DMARCReportSummary + for rows.Next() { + var ( + summary DMARCReportSummary + begin string + end string + recv string + ) + if err := rows.Scan(&summary.ID, &summary.Domain, &summary.Reporter, &begin, &end, &recv, &summary.PassCount, &summary.FailCount); err != nil { + return nil, err + } + summary.PeriodBegin, _ = time.Parse(time.RFC3339, begin) + summary.PeriodEnd, _ = time.Parse(time.RFC3339, end) + summary.ReceivedAt, _ = time.Parse(time.RFC3339, recv) + out = append(out, summary) + } + return out, rows.Err() +} + +// GetDMARCReport loads one report with records. +func (s *Store) GetDMARCReport(id int64) (DMARCReport, error) { + row := s.db.QueryRow(` + SELECT id, domain, reporter, report_id, period_begin, period_end, received_at, + contact_email, policy_p, policy_sp, policy_pct, policy_adkim, policy_aspf, + pass_count, fail_count, recipient + FROM dmarc_reports WHERE id = ?`, id) + rep, err := scanDMARCReport(row) + if errors.Is(err, sql.ErrNoRows) { + return DMARCReport{}, ErrDMARCReportNotFound + } + if err != nil { + return DMARCReport{}, err + } + recs, err := s.listDMARCReportRecords(id) + if err != nil { + return DMARCReport{}, err + } + rep.Records = recs + return rep, nil +} + +// ListDMARCReportsForDomain returns summaries for one sending domain. +func (s *Store) ListDMARCReportsForDomain(domain string, limit int) ([]DMARCReportSummary, error) { + return s.ListDMARCReports([]string{domain}, limit) +} + +// DMARCDomainRollup summarises pass/fail for a domain over the last windowDays. +func (s *Store) DMARCDomainRollup(domain string, windowDays int) (pass, fail int, err error) { + cutoff := time.Now().UTC().AddDate(0, 0, -windowDays).Format(time.RFC3339) + err = s.db.QueryRow(` + SELECT COALESCE(SUM(pass_count), 0), COALESCE(SUM(fail_count), 0) + FROM dmarc_reports WHERE domain = ? AND received_at >= ?`, + domain, cutoff, + ).Scan(&pass, &fail) + if err != nil { + return 0, 0, fmt.Errorf("dmarc domain rollup: %w", err) + } + return pass, fail, nil +} + +// DMARCSourceRollups aggregates per-source rows for a domain over windowDays. +func (s *Store) DMARCSourceRollups(domain string, windowDays int) ([]DMARCSourceRollup, error) { + cutoff := time.Now().UTC().AddDate(0, 0, -windowDays).Format(time.RFC3339) + rows, err := s.db.Query(` + SELECT r.source_ip, + SUM(CASE WHEN r.spf_result = 'pass' OR r.dkim_result = 'pass' THEN r.count ELSE 0 END), + SUM(CASE WHEN r.spf_result != 'pass' AND r.dkim_result != 'pass' THEN r.count ELSE 0 END), + MAX(r.disposition) + FROM dmarc_report_records r + INNER JOIN dmarc_reports d ON d.id = r.report_row_id + WHERE d.domain = ? AND d.received_at >= ? + GROUP BY r.source_ip + ORDER BY 2 DESC, 3 DESC`, domain, cutoff) + if err != nil { + return nil, fmt.Errorf("dmarc source rollups: %w", err) + } + defer rows.Close() + var out []DMARCSourceRollup + for rows.Next() { + var rollup DMARCSourceRollup + if err := rows.Scan(&rollup.SourceIP, &rollup.PassCount, &rollup.FailCount, &rollup.Disposition); err != nil { + return nil, err + } + out = append(out, rollup) + } + return out, rows.Err() +} + +// DMARCIngestStats returns ingest health for the panel. +func (s *Store) DMARCIngestStats() (DMARCIngestStats, error) { + var stats DMARCIngestStats + var last sql.NullString + err := s.db.QueryRow(`SELECT MAX(received_at) FROM dmarc_reports`).Scan(&last) + if err != nil { + return stats, fmt.Errorf("dmarc last received: %w", err) + } + if last.Valid && last.String != "" { + t, _ := time.Parse(time.RFC3339, last.String) + stats.LastReceivedAt = &t + stats.IngestOK = time.Since(t) < 8*24*time.Hour + } + weekCutoff := time.Now().UTC().AddDate(0, 0, -7).Format(time.RFC3339) + if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports WHERE received_at >= ?`, weekCutoff).Scan(&stats.KeptThisWeek); err != nil { + return stats, fmt.Errorf("dmarc week count: %w", err) + } + raw, err := s.GetSetting(DMARCParseFailuresKey) + if err != nil { + return stats, err + } + if raw != "" { + fmt.Sscanf(raw, "%d", &stats.ParseFailures) + } + return stats, nil +} + +func (s *Store) listDMARCReportRecords(reportID int64) ([]DMARCReportRecord, error) { + rows, err := s.db.Query(` + SELECT source_ip, count, disposition, spf_result, dkim_result, header_from + FROM dmarc_report_records WHERE report_row_id = ? ORDER BY count DESC`, reportID) + if err != nil { + return nil, fmt.Errorf("list dmarc records: %w", err) + } + defer rows.Close() + var out []DMARCReportRecord + for rows.Next() { + var rec DMARCReportRecord + if err := rows.Scan(&rec.SourceIP, &rec.Count, &rec.Disposition, &rec.SPFResult, &rec.DKIMResult, &rec.HeaderFrom); err != nil { + return nil, err + } + out = append(out, rec) + } + return out, rows.Err() +} + +func scanDMARCReport(r scanRow) (DMARCReport, error) { + var ( + rep DMARCReport + begin string + end string + recv string + ) + if err := r.Scan( + &rep.ID, &rep.Domain, &rep.Reporter, &rep.ReportID, &begin, &end, &recv, + &rep.ContactEmail, &rep.PolicyP, &rep.PolicySP, &rep.PolicyPct, + &rep.PolicyADKIM, &rep.PolicyASPF, &rep.PassCount, &rep.FailCount, &rep.Recipient, + ); err != nil { + return DMARCReport{}, err + } + rep.PeriodBegin, _ = time.Parse(time.RFC3339, begin) + rep.PeriodEnd, _ = time.Parse(time.RFC3339, end) + rep.ReceivedAt, _ = time.Parse(time.RFC3339, recv) + return rep, nil +} diff --git a/internal/store/dmarc_test.go b/internal/store/dmarc_test.go new file mode 100644 index 0000000..b69a9cd --- /dev/null +++ b/internal/store/dmarc_test.go @@ -0,0 +1,68 @@ +package store + +import ( + "testing" + "time" +) + +func TestDMARCReportRoundTrip(t *testing.T) { + st, err := Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + + now := time.Now().UTC().Truncate(time.Second) + _, err = st.InsertDMARCReport(DMARCReport{ + Domain: "example.com", + Reporter: "google.com", + ReportID: "abc", + PeriodBegin: now.Add(-24 * time.Hour), + PeriodEnd: now, + ReceivedAt: now, + PassCount: 5, + FailCount: 1, + Records: []DMARCReportRecord{{ + SourceIP: "203.0.113.1", Count: 5, SPFResult: "pass", DKIMResult: "pass", + }}, + }) + if err != nil { + t.Fatal(err) + } + list, err := st.ListDMARCReports([]string{"example.com"}, 10) + if err != nil || len(list) != 1 { + t.Fatalf("list: %v err=%v", list, err) + } + got, err := st.GetDMARCReport(list[0].ID) + if err != nil { + t.Fatal(err) + } + if got.PassCount != 5 || len(got.Records) != 1 { + t.Fatalf("got %+v", got) + } +} + +func TestPruneDMARCReports(t *testing.T) { + st, err := Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + + old := time.Now().UTC().AddDate(0, 0, -120) + for i := 0; i < 3; i++ { + if _, err := st.InsertDMARCReport(DMARCReport{ + Domain: "example.com", Reporter: "r", ReportID: string(rune('a' + i)), + PeriodBegin: old, PeriodEnd: old, ReceivedAt: old, + }); err != nil { + t.Fatal(err) + } + } + if err := st.PruneDMARCReports(); err != nil { + t.Fatal(err) + } + list, _ := st.ListDMARCReports(nil, 100) + if len(list) != 0 { + t.Fatalf("expected prune by age, got %d", len(list)) + } +} diff --git a/internal/store/migrations/0008_dmarc_reports.sql b/internal/store/migrations/0008_dmarc_reports.sql new file mode 100644 index 0000000..5fa4da7 --- /dev/null +++ b/internal/store/migrations/0008_dmarc_reports.sql @@ -0,0 +1,38 @@ +-- DMARC aggregate report summaries (plans/dmarc-reports.md). One row per parsed +-- report; per-source rows hang off it. Forensic (ruf=) payloads are not stored. + +CREATE TABLE dmarc_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL, + reporter TEXT NOT NULL, + report_id TEXT NOT NULL, + period_begin TEXT NOT NULL, + period_end TEXT NOT NULL, + received_at TEXT NOT NULL, + contact_email TEXT NOT NULL DEFAULT '', + policy_p TEXT NOT NULL DEFAULT '', + policy_sp TEXT NOT NULL DEFAULT '', + policy_pct INTEGER NOT NULL DEFAULT 100, + policy_adkim TEXT NOT NULL DEFAULT '', + policy_aspf TEXT NOT NULL DEFAULT '', + pass_count INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + recipient TEXT NOT NULL DEFAULT '', + UNIQUE (reporter, report_id, domain) +); + +CREATE INDEX idx_dmarc_reports_domain ON dmarc_reports (domain); +CREATE INDEX idx_dmarc_reports_received ON dmarc_reports (received_at); + +CREATE TABLE dmarc_report_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_row_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE, + source_ip TEXT NOT NULL DEFAULT '', + count INTEGER NOT NULL, + disposition TEXT NOT NULL DEFAULT '', + spf_result TEXT NOT NULL DEFAULT '', + dkim_result TEXT NOT NULL DEFAULT '', + header_from TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_dmarc_report_records_report ON dmarc_report_records (report_row_id); diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go index f44c1f3..c8742f0 100644 --- a/internal/web/handlers/handlers.go +++ b/internal/web/handlers/handlers.go @@ -5,6 +5,7 @@ import ( "log" "github.com/mixeme/selfpost/internal/app" + "github.com/mixeme/selfpost/internal/dmarc" "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/health" @@ -38,6 +39,8 @@ type Config struct { // InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and // routes exist only when this is true. InboundEnabled bool + // DMARCEnabled mirrors DMARC_REPORTS_ENABLE. + DMARCEnabled bool // SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used // as bootstrap and fallback when the settings row is missing or invalid. SendLogRetentionEnvDefault int @@ -49,6 +52,7 @@ type Handlers struct { domains *domain.Service apps *app.Service inbound *inbound.Service + dmarc *dmarc.Service cfg Config view *view.Engine dns *dnscheck.Checker @@ -62,6 +66,7 @@ func New( domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, + dmarcSvc *dmarc.Service, cfg Config, v *view.Engine, dns *dnscheck.Checker, @@ -73,6 +78,7 @@ func New( domains: domains, apps: apps, inbound: inboundSvc, + dmarc: dmarcSvc, cfg: cfg, view: v, dns: dns, diff --git a/internal/web/handlers/handlers_apps.go b/internal/web/handlers/handlers_apps.go index 0bc135c..9606066 100644 --- a/internal/web/handlers/handlers_apps.go +++ b/internal/web/handlers/handlers_apps.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/mixeme/selfpost/internal/dmarc" "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/store" @@ -163,9 +164,15 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail) dmarcMode := "inherit" dmarcCustom := "" + hostedAddr := "" + if h.dmarc != nil && h.cfg.DMARCEnabled { + hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name) + } if d.DMARCRua.Valid { if d.DMARCRua.String == "" { dmarcMode = "none" + } else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) { + dmarcMode = "hosted" } else { dmarcMode = "custom" dmarcCustom = d.DMARCRua.String @@ -173,6 +180,8 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st } dmarcSource := "policy" switch { + case dmarcMode == "hosted": + dmarcSource = "hosted" case dmarcMode == "custom": dmarcSource = "custom" case dmarcMode == "none": @@ -195,10 +204,13 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st data["ResolvedDMARCEmail"] = reportEmail data["DMARCRuaMode"] = dmarcMode data["DMARCRuaCustom"] = dmarcCustom + data["HostedDMARCEmail"] = hostedAddr + data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled data["ReportAuthName"] = reportAuthName data["ReportAuthValue"] = reportAuthValue data["NeedsReportAuth"] = needsReportAuth - data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) + data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) && + !(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname)) data["Hostname"] = h.cfg.Hostname data["SubmissionEnabled"] = h.cfg.SubmissionEnabled data["Apps"] = appViews diff --git a/internal/web/handlers/handlers_dmarc.go b/internal/web/handlers/handlers_dmarc.go index 12c1e74..6632d7f 100644 --- a/internal/web/handlers/handlers_dmarc.go +++ b/internal/web/handlers/handlers_dmarc.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" + "github.com/mixeme/selfpost/internal/dmarc" "github.com/mixeme/selfpost/internal/web/validate" ) @@ -42,6 +43,13 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) { return } rua = sql.NullString{Valid: true, String: email} + case "hosted": + if h.dmarc == nil || !h.dmarc.Enabled() { + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."}) + return + } + addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name) + rua = sql.NullString{Valid: true, String: addr} default: h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."}) return @@ -52,6 +60,11 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) { h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."}) return } + if h.dmarc != nil && h.dmarc.Enabled() { + if err := h.dmarc.Resync(); err != nil { + logf("panel: domain %d: dmarc resync: %v", d.ID, err) + } + } h.dns.Forget(d.Name) http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther) } diff --git a/internal/web/handlers/handlers_dmarc_reports.go b/internal/web/handlers/handlers_dmarc_reports.go new file mode 100644 index 0000000..41f564a --- /dev/null +++ b/internal/web/handlers/handlers_dmarc_reports.go @@ -0,0 +1,240 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/mixeme/selfpost/internal/dmarc" + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" +) + +type dmarcListRow struct { + store.DMARCReportSummary + DomainID int64 + ReceivedLabel string + PeriodLabel string +} + +func (h *Handlers) requireDMARC(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) { + if !h.cfg.DMARCEnabled || h.dmarc == nil { + http.NotFound(w, r) + return auth.Principal{}, false + } + p, ok := h.principal(r) + if !ok { + http.NotFound(w, r) + return auth.Principal{}, false + } + return p, true +} + +func (h *Handlers) canViewDMARCDomain(p auth.Principal, d store.Domain) bool { + if p.IsGlobal() { + return true + } + for _, id := range p.Domains { + if id == d.ID { + return true + } + } + return false +} + +// HandleDMARCList is the global DMARC reports index. +func (h *Handlers) HandleDMARCList(w http.ResponseWriter, r *http.Request) { + p, ok := h.requireDMARC(w, r) + if !ok || !p.IsGlobal() { + if ok && !p.IsGlobal() { + http.NotFound(w, r) + } + return + } + assigned, err := h.assignedDomains(p) + if err != nil { + logf("panel: dmarc list domains: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + names := make([]string, 0, len(assigned)) + domainIDs := make(map[string]int64, len(assigned)) + for _, d := range assigned { + names = append(names, d.Name) + domainIDs[d.Name] = d.ID + } + reports, err := h.store.ListDMARCReports(names, 100) + if err != nil { + logf("panel: dmarc list: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + stats, err := h.store.DMARCIngestStats() + if err != nil { + logf("panel: dmarc ingest stats: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + rows := make([]dmarcListRow, len(reports)) + for i, rep := range reports { + rows[i] = dmarcListRow{ + DMARCReportSummary: rep, + DomainID: domainIDs[rep.Domain], + ReceivedLabel: rep.ReceivedAt.UTC().Format("2006-01-02 15:04"), + PeriodLabel: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd), + } + } + data := h.pageBase(r) + data["Title"] = "SelfPost — DMARC reports" + data["Active"] = "dmarc" + data["Reports"] = rows + data["IngestStats"] = stats + data["HostedAddress"] = h.dmarc.DefaultHostedSuggestion() + data["RetentionMax"] = store.DMARCReportsMaxKeep + data["RetentionDays"] = store.DMARCReportsMaxAgeDays + if stats.LastReceivedAt != nil { + data["LastReceivedLabel"] = stats.LastReceivedAt.UTC().Format("2006-01-02 15:04") + } + h.view.Render(w, http.StatusOK, "dmarc", data) +} + +// HandleDMARCDomain shows roll-ups for one sending domain. +func (h *Handlers) HandleDMARCDomain(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireDMARC(w, r); !ok { + return + } + d, ok := h.lookupDomain(w, r) + if !ok { + return + } + p, _ := h.principal(r) + if !h.canViewDMARCDomain(p, d) { + http.NotFound(w, r) + return + } + const windowDays = 7 + pass, fail, err := h.store.DMARCDomainRollup(d.Name, windowDays) + if err != nil { + logf("panel: dmarc domain rollup %s: %v", d.Name, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + sources, err := h.store.DMARCSourceRollups(d.Name, windowDays) + if err != nil { + logf("panel: dmarc source rollups %s: %v", d.Name, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + reports, err := h.store.ListDMARCReportsForDomain(d.Name, 50) + if err != nil { + logf("panel: dmarc domain reports %s: %v", d.Name, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + reportRows := make([]dmarcListRow, len(reports)) + for i, rep := range reports { + reportRows[i] = dmarcListRow{ + DMARCReportSummary: rep, + ReceivedLabel: rep.ReceivedAt.UTC().Format("2006-01-02 15:04"), + PeriodLabel: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd), + } + } + hints := make([]dmarc.SourceHint, len(sources)) + for i, s := range sources { + hints[i] = dmarc.SourceHint{ + SourceIP: s.SourceIP, + PassCount: s.PassCount, + FailCount: s.FailCount, + ThisRelay: h.sourceIsThisRelay(s.SourceIP), + } + } + data := h.pageBase(r) + data["Title"] = "SelfPost — " + d.Name + " DMARC" + data["Active"] = "dmarc" + data["Domain"] = d + data["Reports"] = reportRows + data["Pass7d"] = pass + data["Fail7d"] = fail + data["Sources"] = hints + data["PolicyHint"] = dmarc.TightenPolicyHint(pass, fail, hints) + data["WindowDays"] = windowDays + h.view.Render(w, http.StatusOK, "dmarc_domain", data) +} + +// HandleDMARCReport shows one parsed aggregate report. +func (h *Handlers) HandleDMARCReport(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireDMARC(w, r); !ok { + return + } + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + http.NotFound(w, r) + return + } + rep, err := h.store.GetDMARCReport(id) + if errors.Is(err, store.ErrDMARCReportNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + logf("panel: dmarc report %d: %v", id, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + p, _ := h.principal(r) + domains, err := h.assignedDomains(p) + if err != nil { + logf("panel: dmarc report authz: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + var d store.Domain + found := false + for _, cand := range domains { + if cand.Name == rep.Domain { + d = cand + found = true + break + } + } + if !found { + http.NotFound(w, r) + return + } + data := h.pageBase(r) + data["Title"] = fmt.Sprintf("SelfPost — %s report", rep.Reporter) + data["Active"] = "dmarc" + data["Report"] = rep + data["Domain"] = d + data["Hostname"] = h.cfg.Hostname + data["WindowLabel"] = formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd) + data["ReceivedLabel"] = rep.ReceivedAt.UTC().Format("2006-01-02 15:04") + data["PeriodBeginLabel"] = rep.PeriodBegin.UTC().Format("2006-01-02 15:04") + data["PeriodEndLabel"] = rep.PeriodEnd.UTC().Format("2006-01-02 15:04") + h.view.Render(w, http.StatusOK, "dmarc_report", data) +} + +func (h *Handlers) sourceIsThisRelay(ip string) bool { + if ip == "" || h.dns == nil || h.cfg.Hostname == "" { + return false + } + srv := h.dns.Server(h.cfg.Hostname, false) + for _, s := range srv.IPs { + if ip == s { + return true + } + } + return false +} + +func formatDMARCWindow(begin, end time.Time) string { + if begin.IsZero() { + return "" + } + if begin.Year() == end.Year() && begin.YearDay() == end.YearDay() { + return begin.UTC().Format("2 Jan") + } + return begin.UTC().Format("2 Jan") + " – " + end.UTC().Format("2 Jan") +} diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go index 194112f..2619b47 100644 --- a/internal/web/handlers/handlers_settings.go +++ b/internal/web/handlers/handlers_settings.go @@ -63,6 +63,10 @@ func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status data["Flash"] = settingsFlash(r) data["L1Messages"] = h.l1Messages() data["L1Window"] = h.l1Window() + data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled + if h.dmarc != nil && h.cfg.DMARCEnabled { + data["HostedReportAddress"] = h.dmarc.DefaultHostedSuggestion() + } h.view.Render(w, status, "settings", data) } @@ -220,6 +224,12 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) { } } + if emailChanging && h.dmarc != nil && h.dmarc.Enabled() { + if err := h.dmarc.Resync(); err != nil { + logf("panel: settings: dmarc resync: %v", err) + } + } + if token, ok := h.auth.SessionToken(r); ok { if renaming { h.auth.RenameSession(token, username) diff --git a/internal/web/view/templates/dmarc.html b/internal/web/view/templates/dmarc.html new file mode 100644 index 0000000..86802e7 --- /dev/null +++ b/internal/web/view/templates/dmarc.html @@ -0,0 +1,63 @@ +{{define "wide"}}wide{{end}} + +{{define "content"}} +

DMARC reports

+ +

Aggregate reports SelfPost accepted for rua=. +Forensic (ruf=) is out of scope. Open a report for the parsed +XML — not a dashboard.

+ +
+
+

Ingest {{if .IngestStats.IngestOK}}ok{{else}}quiet{{end}}

+ {{if .LastReceivedLabel}} +

Last report {{.LastReceivedLabel}} UTC. Port 25 accepts only configured report addresses.

+ {{else}} +

No reports received yet. Point rua= at + {{.HostedAddress}} (or a per-domain hosted address) and publish + MX for {{.HostedAddress}} on this server.

+ {{end}} +
+
+

This week

+

{{.IngestStats.KeptThisWeek}} kept{{if .IngestStats.ParseFailures}}, {{.IngestStats.ParseFailures}} parse failures{{end}}. + Older summaries are pruned ({{.RetentionMax}} max, {{.RetentionDays}} days).

+
+
+ +
+

Recent reports

+ {{if .Reports}} + + + + {{range .Reports}} + + + + + + + + + + {{end}} + +
ReceivedDomainReporterWindowPassFail
{{.ReceivedLabel}}{{if .DomainID}}{{.Domain}}{{else}}{{.Domain}}{{end}}{{.Reporter}}{{.PeriodLabel}}{{.PassCount}}{{.FailCount}}View
+ + {{else}} +

No reports yet.

+ {{end}} +
+{{end}} diff --git a/internal/web/view/templates/dmarc_domain.html b/internal/web/view/templates/dmarc_domain.html new file mode 100644 index 0000000..92219bc --- /dev/null +++ b/internal/web/view/templates/dmarc_domain.html @@ -0,0 +1,59 @@ +{{define "content"}} +

{{.Domain.Name}}

+{{template "back_link" (back "/dmarc" "All DMARC reports")}} +{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}} + +
+
+

Last {{.WindowDays}} days {{if and (gt .Pass7d 0) (eq .Fail7d 0)}}pass{{else if gt .Fail7d 0}}review{{end}}

+

{{.PolicyHint}}

+

Domain DNS and rua=

+
+
+

Totals

+

{{.Pass7d}} pass · {{.Fail7d}} fail in aggregate summaries.

+
+
+ +
+

Reports

+ {{if .Reports}} + + + + {{range .Reports}} + + + + + + + + + {{end}} + +
ReceivedReporterWindowPassFail
{{.ReceivedLabel}}{{.Reporter}}{{.PeriodLabel}}{{.PassCount}}{{.FailCount}}View
+ {{else}} +

No reports for this domain yet.

+ {{end}} +
+ +{{if .Sources}} +
+

Sources · last {{.WindowDays}} days

+ + + + {{range .Sources}} + + + + + + + {{end}} + +
SourcePassFailDisposition
{{.SourceIP}}{{if .ThisRelay}} (this relay){{end}}{{.PassCount}}{{.FailCount}}{{.Disposition}}
+
+{{end}} +{{end}} diff --git a/internal/web/view/templates/dmarc_report.html b/internal/web/view/templates/dmarc_report.html new file mode 100644 index 0000000..302a66a --- /dev/null +++ b/internal/web/view/templates/dmarc_report.html @@ -0,0 +1,52 @@ +{{define "content"}} +

{{.Report.Reporter}} · {{.WindowLabel}}

+

+ {{.Report.Domain}} + {{.Report.PassCount}} pass + {{if gt .Report.FailCount 0}}{{.Report.FailCount}} fail{{end}} +

+{{template "back_link" (back (printf "/dmarc/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}} + +
+
+

Report

+
+
Reporter{{.Report.Reporter}}
+
Report id{{.Report.ReportID}}
+
Window{{.PeriodBeginLabel}} – {{.PeriodEndLabel}} UTC
+
Received{{.ReceivedLabel}} UTC
+ {{if .Report.ContactEmail}}
Contact{{.Report.ContactEmail}}
{{end}} +
+
+
+

Published policy

+
+
Domain{{.Report.Domain}}
+
p / sp / pct{{.Report.PolicyP}} / {{.Report.PolicySP}} / {{.Report.PolicyPct}}
+
adkim / aspf{{.Report.PolicyADKIM}} / {{.Report.PolicyASPF}}
+ {{if .Report.Recipient}}
rua{{.Report.Recipient}}
{{end}} +
+

p={{.Report.PolicyP}} is what receivers enforced in this window.

+
+
+ +
+

Records

+ + + + {{range .Report.Records}} + + + + + + + + + {{end}} + +
SourceCountDispositionSPFDKIMHeader from
{{.SourceIP}}{{.Count}}{{.Disposition}}{{.SPFResult}}{{.DKIMResult}}{{.HeaderFrom}}
+

Parsed from the aggregate XML. Forensic (ruf=) samples are not stored.

+
+{{end}} diff --git a/internal/web/view/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html index 5b17951..d911bb3 100644 --- a/internal/web/view/templates/domain_detail.html +++ b/internal/web/view/templates/domain_detail.html @@ -170,8 +170,11 @@ {{if .SameDomainRUA}} -

The report address is on this sending domain. SelfPost does - not receive inbound mail — use a mailbox on another domain.

+

The report address is on this sending domain.{{if not .DMARCIngestEnabled}} + SelfPost does not receive inbound mail — use a mailbox on another domain.{{end}}

+ {{end}} + {{if and .DMARCIngestEnabled .ResolvedDMARCEmail}} +

View DMARC reports for this domain.

{{end}} {{if .NeedsReportAuth}} @@ -385,6 +388,7 @@ diff --git a/internal/web/view/templates/layout.html b/internal/web/view/templates/layout.html index a331896..5ce8f93 100644 --- a/internal/web/view/templates/layout.html +++ b/internal/web/view/templates/layout.html @@ -73,6 +73,9 @@ {{if and .IsGlobal .InboundEnabled}} {{if eq .Active "inbound"}}{{template "icon-inbound"}}Inbound{{else}}{{template "icon-inbound"}}Inbound{{end}} {{end}} + {{if .DMARCEnabled}} + {{if eq .Active "dmarc"}}{{template "icon-dmarc"}}DMARC{{else}}{{template "icon-dmarc"}}DMARC{{end}} + {{end}} {{if eq .Active "deliveries"}}{{template "icon-deliveries"}}Deliveries{{else}}{{template "icon-deliveries"}}Deliveries{{end}} {{if .IsGlobal}} {{if eq .Active "mail_queue"}}{{template "icon-mail-queue"}}Mail queue{{else}}{{template "icon-mail-queue"}}Mail queue{{end}} @@ -116,6 +119,7 @@ {{define "icon-status"}}{{end}} {{define "icon-domains"}}{{end}} {{define "icon-inbound"}}{{end}} +{{define "icon-dmarc"}}{{end}} {{define "icon-deliveries"}}{{end}} {{define "icon-mail-queue"}}{{end}} {{define "icon-system-log"}}{{end}} diff --git a/internal/web/view/templates/settings.html b/internal/web/view/templates/settings.html index 9b3b15f..79ef823 100644 --- a/internal/web/view/templates/settings.html +++ b/internal/web/view/templates/settings.html @@ -41,14 +41,15 @@

DMARC aggregate reports

Optional default rua= address for every sending - domain (can be overridden per domain). Use a mailbox on a domain that - receives inbound mail. SelfPost does not receive inbound mail — point - rua= at a mailbox elsewhere.

+ domain (can be overridden per domain).{{if .DMARCIngestEnabled}} When set to + an address on {{.HostedReportAddress}}, SelfPost receives and + parses aggregate reports on port 25.{{else}} Use a mailbox on a domain + that receives inbound mail elsewhere.{{end}}

+ value="{{.FormDMARCEmail}}" placeholder="{{if .DMARCIngestEnabled}}{{.HostedReportAddress}}{{else}}reports@your-mail-domain.com{{end}}"> {{if .FormDMARCEmail}}

When rua= points at another domain, that hub diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go index 0b54af4..6d38041 100644 --- a/internal/web/view/templates_test.go +++ b/internal/web/view/templates_test.go @@ -222,7 +222,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) { wide := map[string]bool{ "settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true, - "inbound": true, "inbound_domain": true, + "inbound": true, "inbound_domain": true, "dmarc": true, } for name, page := range engine.Pages() { var buf bytes.Buffer @@ -324,6 +324,8 @@ func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) { "domain_delete.html": true, "inbound_domain.html": true, "inbound_delete.html": true, + "dmarc_domain.html": true, + "dmarc_report.html": true, "delivery.html": true, } forEachTemplate(t, func(name, body string) { diff --git a/internal/web/view/view.go b/internal/web/view/view.go index 13443c0..a14c62a 100644 --- a/internal/web/view/view.go +++ b/internal/web/view/view.go @@ -22,6 +22,7 @@ type Engine struct { fragments map[string]*template.Template version string inboundEnabled bool + dmarcEnabled bool } // pageFiles maps a logical page name to its template files. Every page @@ -45,6 +46,9 @@ var pageFiles = map[string][]string{ "inbound": {"templates/inbound.html"}, "inbound_domain": {"templates/inbound_domain.html"}, "inbound_delete": {"templates/inbound_delete.html"}, + "dmarc": {"templates/dmarc.html"}, + "dmarc_domain": {"templates/dmarc_domain.html"}, + "dmarc_report": {"templates/dmarc_report.html"}, "deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"}, "delivery": {"templates/delivery.html"}, "mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"}, @@ -92,6 +96,11 @@ func (e *Engine) SetInboundEnabled(v bool) { e.inboundEnabled = v } +// SetDMARCEnabled controls whether the DMARC nav item is shown. +func (e *Engine) SetDMARCEnabled(v bool) { + e.dmarcEnabled = v +} + // templateFuncs supplies helpers shared across page templates. func templateFuncs() template.FuncMap { return template.FuncMap{ @@ -135,6 +144,7 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any m["Copyright"] = legal.CopyrightLine m["SourceURL"] = legal.SourceURL m["InboundEnabled"] = e.inboundEnabled + m["DMARCEnabled"] = e.dmarcEnabled } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { diff --git a/internal/web/web.go b/internal/web/web.go index 201989a..3e70f2e 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -11,6 +11,7 @@ import ( "github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" + "github.com/mixeme/selfpost/internal/dmarc" "github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/legal" @@ -85,6 +86,8 @@ type Config struct { RetryPolicy postfix.RetryPolicy // InboundEnabled mirrors INBOUND_RELAY_ENABLE. InboundEnabled bool + // DMARCEnabled mirrors DMARC_REPORTS_ENABLE. + DMARCEnabled bool // SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start. SendLogRetentionEnvDefault int } @@ -101,19 +104,20 @@ type Server struct { // that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM); // apps owns application SASL accounts and the Postfix sender map // (architecture.md § Mail path). -func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, cfg Config, setupTokenPath string) (*Server, error) { +func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) { v, err := view.New(cfg.Version) if err != nil { return nil, err } v.SetInboundEnabled(cfg.InboundEnabled) + v.SetDMARCEnabled(cfg.DMARCEnabled) a := auth.New(st, auth.Config{ CookieSecure: cfg.CookieSecure, Hostname: cfg.Hostname, SessionIdleDays: cfg.SessionIdleDays, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, }, v, setupTokenPath) - h := handlers.New(st, domains, apps, inboundSvc, handlers.Config{ + h := handlers.New(st, domains, apps, inboundSvc, dmarcSvc, handlers.Config{ Hostname: cfg.Hostname, SubmissionEnabled: cfg.SubmissionEnabled, MailLogPath: cfg.MailLogPath, @@ -128,6 +132,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc RateLimitWindowSeconds: cfg.RateLimitWindowSeconds, RetryPolicy: cfg.RetryPolicy, InboundEnabled: cfg.InboundEnabled, + DMARCEnabled: cfg.DMARCEnabled, SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault, }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) return &Server{cfg: cfg, auth: a, handlers: h}, nil @@ -188,6 +193,12 @@ func (s *Server) Handler() http.Handler { authed.HandleFunc("POST /inbound/{id}/delete", h.HandleInboundDelete) } + if s.cfg.DMARCEnabled { + authed.HandleFunc("GET /dmarc", h.HandleDMARCList) + authed.HandleFunc("GET /dmarc/reports/{id}", h.HandleDMARCReport) + authed.HandleFunc("GET /dmarc/domains/{id}", h.HandleDMARCDomain) + } + authed.HandleFunc("/settings", h.HandleSettings) authed.HandleFunc("/account", redirectSettings)