release: 1.7.0
test / test (push) Waiting to run

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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 22:53:36 +03:00
parent c1ec4fbd79
commit 27aeadc71d
38 changed files with 1923 additions and 79 deletions
+13
View File
@@ -5,6 +5,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [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+<domain>@<hostname>`).
## [1.6.0] - 2026-08-18 ## [1.6.0] - 2026-08-18
30-day sending statistics per domain and application, plus optional auto 30-day sending statistics per domain and application, plus optional auto
+1 -1
View File
@@ -108,7 +108,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -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): **Get the setup URL** (pick one):
+75 -16
View File
@@ -80,6 +80,15 @@ TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}" RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}" 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 # 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 # under the persistent /data (not the ephemeral /var/log) so the delivery lines
# for messages still marked "queued" survive a container recreate — without # for messages still marked "queued" survive a container recreate — without
@@ -211,23 +220,57 @@ else
postconf -MX "submission/inet" 2>/dev/null || true postconf -MX "submission/inet" 2>/dev/null || true
fi fi
# --- inbound smtpd on port 25 (optional backup-MX / forwarder) --------------- # --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
# Debian's stock master.cf enables smtp/inet. When the flag is off, remove that # Debian's stock master.cf enables smtp/inet. When both flags are off, remove
# listener so port 25 is not an inbound smtpd (outbound delivery uses smtp/unix). # that listener so port 25 is not an inbound smtpd (outbound delivery uses
# When on: no SASL, no OpenDKIM, accept only relay_domains + listed recipients. # smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then # configured domains/recipients; DMARC accepts only allow-listed report addresses.
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
[ -e "$f" ] || : > "$f" RELAY_DOMAINS_SETTING=""
done 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 \ postconf -e \
"relay_domains=texthash:${RELAY_DOMAINS_MAP}" \ "relay_domains=${RELAY_DOMAINS_SETTING}" \
"transport_maps=texthash:${TRANSPORT_MAP}" \ "transport_maps=${TRANSPORT_SETTING}" \
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" \
"smtpd_reject_unlisted_recipient=yes" "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="" INBOUND_MILTERS=""
if [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
case "${INBOUND_ANTISPAM_MILTER}" in case "${INBOUND_ANTISPAM_MILTER}" in
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;; 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} }" INBOUND_MILTERS="{ ${INBOUND_ANTISPAM_MILTER}, default_action=${INBOUND_ANTISPAM_MILTER_ACTION} }"
fi 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 -M "smtp/inet=smtp inet n - n - - smtpd"
postconf -P \ postconf -P \
"smtp/inet/smtpd_sasl_auth_enable=no" \ "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_sender_restrictions=" \
"smtp/inet/smtpd_client_restrictions=" \ "smtp/inet/smtpd_client_restrictions=" \
"smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \ "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_milters=${INBOUND_MILTERS}" \
"smtp/inet/smtpd_client_message_rate_limit=${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}" \ "smtp/inet/smtpd_client_message_rate_limit=${PORT25_RATE}" \
"smtp/inet/message_size_limit=${INBOUND_MESSAGE_SIZE_LIMIT}" "smtp/inet/message_size_limit=${PORT25_SIZE}"
else else
postconf -MX "smtp/inet" 2>/dev/null || true postconf -MX "smtp/inet" 2>/dev/null || true
postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true
postconf -e \ postconf -e \
"relay_domains=" \ "relay_domains=" \
"transport_maps=" \ "transport_maps=" \
+44
View File
@@ -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"
}
+13
View File
@@ -20,6 +20,9 @@ var documentedPublic = []string{
"INBOUND_ANTISPAM_MILTER_ACTION", "INBOUND_ANTISPAM_MILTER_ACTION",
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP", "INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
"INBOUND_MESSAGE_SIZE_LIMIT", "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". // documentedInternal matches architecture.md § Configuration "Internal env vars".
@@ -43,6 +46,9 @@ var documentedInternal = []string{
"POSTFIX_TRANSPORT_MAPS", "POSTFIX_TRANSPORT_MAPS",
"POSTFIX_RELAY_RECIPIENTS", "POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS", "POSTFIX_TLS_POLICY_MAPS",
"POSTFIX_DMARC_RECIPIENTS",
"POSTFIX_DMARC_TRANSPORT",
"POSTFIX_DMARC_RELAY_DOMAINS",
"SELFPOST_DEPLOY_ROOT", "SELFPOST_DEPLOY_ROOT",
"MILTER_CONNECT_TIMEOUT", "MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT", "MILTER_COMMAND_TIMEOUT",
@@ -83,6 +89,7 @@ var loadConfigKeys = []string{
"POSTFIX_DIR", "POSTFIX_DIR",
"SELFPOST_DEPLOY_ROOT", "SELFPOST_DEPLOY_ROOT",
"INBOUND_RELAY_ENABLE", "INBOUND_RELAY_ENABLE",
"DMARC_REPORTS_ENABLE",
} }
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh // 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_ANTISPAM_MILTER_ACTION",
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP", "INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
"INBOUND_MESSAGE_SIZE_LIMIT", "INBOUND_MESSAGE_SIZE_LIMIT",
"DMARC_REPORTS_ENABLE",
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
"DMARC_MESSAGE_SIZE_LIMIT",
"POSTFIX_RELAY_DOMAINS", "POSTFIX_RELAY_DOMAINS",
"POSTFIX_TRANSPORT_MAPS", "POSTFIX_TRANSPORT_MAPS",
"POSTFIX_RELAY_RECIPIENTS", "POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS", "POSTFIX_TLS_POLICY_MAPS",
"POSTFIX_DMARC_RECIPIENTS",
"POSTFIX_DMARC_TRANSPORT",
"POSTFIX_DMARC_RELAY_DOMAINS",
"MILTER_CONNECT_TIMEOUT", "MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT", "MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT", "MILTER_CONTENT_TIMEOUT",
+15 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/buildinfo" "github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
@@ -23,9 +24,11 @@ type mailStack struct {
Domains *domain.Service Domains *domain.Service
Apps *app.Service Apps *app.Service
Inbound *inbound.Service Inbound *inbound.Service
DMARC *dmarc.Service
pf *postfix.Postfix pf *postfix.Postfix
odk *domain.OpenDKIM odk *domain.OpenDKIM
inboundEnabled bool inboundEnabled bool
dmarcEnabled bool
} }
func newMailStack(cfg config, st *store.Store) *mailStack { 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) apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef) domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
inb := inbound.NewService(st, pf) 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 // 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) 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 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 // effective config, including a manual override; the panel keeps this
// snapshot for the process lifetime (architecture.md). // snapshot for the process lifetime (architecture.md).
retryPolicy := postfix.LoadRetryPolicy() 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, Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure, CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled, SubmissionEnabled: cfg.submissionEnabled,
@@ -98,6 +110,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy, RetryPolicy: retryPolicy,
InboundEnabled: cfg.inboundEnabled, InboundEnabled: cfg.inboundEnabled,
DMARCEnabled: cfg.dmarcEnabled,
SendLogRetentionEnvDefault: cfg.retentionDays, SendLogRetentionEnvDefault: cfg.retentionDays,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
} }
+19
View File
@@ -28,6 +28,16 @@ import (
) )
func main() { 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") showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse() flag.Parse()
@@ -81,6 +91,7 @@ type config struct {
deployRoot string deployRoot string
inboundEnabled bool inboundEnabled bool
dmarcEnabled bool
} }
func loadConfig() config { func loadConfig() config {
@@ -144,6 +155,8 @@ func loadConfig() config {
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"), deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true". // Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "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 { if err := resyncAfterRestore(cfg, st, false); err != nil {
return err 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 { if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
+9
View File
@@ -16,6 +16,15 @@ SUBMISSION_ENABLE=false
# to the upstream you set. Off by default — the outbound path is unchanged. # to the upstream you set. Off by default — the outbound path is unchanged.
INBOUND_RELAY_ENABLE=false 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. # Optional milter on the inbound listener only (not 465/587). Empty = off.
# Example with deploy/antispam/docker-compose.antispam.yml: # Example with deploy/antispam/docker-compose.antispam.yml:
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332 # INBOUND_ANTISPAM_MILTER=inet:antispam:11332
+4 -1
View File
@@ -22,7 +22,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.6.0 image: ghcr.io/mixeme/selfpost:1.7.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" 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). # alongside the primary 465/smtps listener (spec 5).
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}" SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_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: "${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}" INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
+1 -1
View File
@@ -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 2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)). [§ 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 | | Stage | ID | Cut |
|---|---|---| |---|---|---|
+47 -5
View File
@@ -33,6 +33,7 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
- [Full backup and restore](#full-backup-and-restore) - [Full backup and restore](#full-backup-and-restore)
- [Encrypting a backup or export](#encrypting-a-backup-or-export) - [Encrypting a backup or export](#encrypting-a-backup-or-export)
- [Inbound relay](#inbound-relay) - [Inbound relay](#inbound-relay)
- [DMARC reports](#dmarc-reports)
- [Domain administration](#domain-administration) - [Domain administration](#domain-administration)
- [Domains page](#domains-page) - [Domains page](#domains-page)
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc) - [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
@@ -169,7 +170,7 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `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 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 image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's [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` | | `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` | | `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` | | `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` | 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_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` | | `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 is included in a [full backup](#full-backup-and-restore). Single-domain
export/import is sending domains only. 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+<domain>@<hostname>`) 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 ## Domain administration
### Domains page ### 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 - **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. domain's page (`domain page → DKIM TXT record`), one selector per domain.
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring - **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
only, safe to publish immediately). On a send-only relay the sending domain only, safe to publish immediately). Set `rua=` to receive aggregate reports:
often has no inbox, so `rua=` is optional — configure a default report address with [DMARC reports](#dmarc-reports) enabled, use a SelfPost-hosted address
in *Settings* (see [Settings](#settings)) or per domain when you have a from *Settings* or per-domain *Domain settings*; otherwise point `rua=` at a
mailbox that receives inbound mail elsewhere. If `rua=` points at another mailbox elsewhere that receives inbound mail. If `rua=` points at another
domain, publish `_report._dmarc` on that hub domain too; the panel checks domain, publish `_report._dmarc` on that hub domain too; the panel checks
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
report destinations. report destinations.
+22 -8
View File
@@ -1,6 +1,7 @@
# Plan: dmarc-reports # 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 ## 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 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 roadmap status is **agreed**. Expand the sketch sections above before step 1
if still thin. See [development.md](../development.md) § Plan checklists. if still thin. See [development.md](../development.md) § Plan checklists.
- [ ] Expand plan: ingest path, `dmarc_reports` schema, retention caps — **Sonnet** - [x] Expand plan: ingest path, `dmarc_reports` schema, retention caps — **Sonnet**
- [ ] Opt-in inbound SMTP for report addresses only (allow-list) — **Opus** - [x] Opt-in inbound SMTP for report addresses only (allow-list) — **Opus**
- [ ] Worker: gzip/XML parse → SQLite — **Opus** - [x] Worker: gzip/XML parse → SQLite — **Opus**
- [ ] Panel: domain roll-up + parsed report (panel-ui mockups) — **Sonnet** - [x] Panel: domain roll-up + parsed report (panel-ui mockups) — **Sonnet**
- [ ] Tie-in `dmarc_report_email` / `domains.dmarc_rua`**Sonnet** - [x] Tie-in `dmarc_report_email` / `domains.dmarc_rua`**Sonnet**
- [ ] Tests and [guide.md](../guide.md) — **Sonnet** - [x] Tests and [guide.md](../guide.md) — **Sonnet**
- [ ] Security review ingest path — **Fable** - [ ] Security review ingest path — **Fable**
- [ ] `go vet`, `go test` on touched packages — **Haiku** - [x] `go vet`, `go test` on touched packages — **Haiku**
+3 -1
View File
@@ -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 (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 `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 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. [roadmap](roadmap.md) require explicit approval before coding.
--- ---
+7 -34
View File
@@ -29,16 +29,16 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Progress | Plan | | ID | Topic | Status | Progress | Plan |
|---|---|---|---|---| |---|---|---|---|---|
| contributing | `CONTRIBUTING.md` | candidate | — | — | | 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) | | 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** | — | — | | schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding): the next feature is **dmarc-reports** once **Recommended order** (not binding): the next feature is **panel-docs** once
agreed. domain-stats-auto-ratelimit shipped in agreed. dmarc-reports shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.6.0]`; send-log-retention in `[1.5.0]`; [CHANGELOG.md](../CHANGELOG.md) `[1.7.0]` (security review of the ingest path
inbound-relay in `[1.4.0]`; queue-retries in `[1.3.1]`; the 2026-08-13 pending); domain-stats-auto-ratelimit in `[1.6.0]`; send-log-retention in
full-tree review follow-ups are in `[1.3.0]`. Candidates need explicit `[1.5.0]`; inbound-relay in `[1.4.0]`; queue-retries in `[1.3.1]`; the
agreement before they join the queue. 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 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 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 ## panel-docs
**Goal:** built-in operator documentation in the panel — short pages (or a **Goal:** built-in operator documentation in the panel — short pages (or a
+31
View File
@@ -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
}
+133
View File
@@ -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
}
+176
View File
@@ -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 <record> 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
}
+116
View File
@@ -0,0 +1,116 @@
package dmarc
import (
"bytes"
"compress/gzip"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
const sampleXML = `<?xml version="1.0"?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply@google.com</email>
<report_id>12345</report_id>
<date_range><begin>1723593600</begin><end>1723680000</end></date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<p>none</p><sp>none</sp><pct>100</pct>
<adkim>r</adkim><aspf>r</aspf>
</policy_published>
<record>
<row>
<source_ip>203.0.113.10</source_ip>
<count>10</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim><spf>pass</spf>
</policy_evaluated>
</row>
<identifiers><header_from>example.com</header_from></identifiers>
</record>
<record>
<row>
<source_ip>198.51.100.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>fail</dkim><spf>fail</spf>
</policy_evaluated>
</row>
<identifiers><header_from>example.com</header_from></identifiers>
</record>
</feedback>`
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)
}
}
+131
View File
@@ -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
}
+71
View File
@@ -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
}
+35
View File
@@ -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")
}
}
+354
View File
@@ -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
}
+68
View File
@@ -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))
}
}
@@ -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);
+6
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/health"
@@ -38,6 +39,8 @@ type Config struct {
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and // InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
// routes exist only when this is true. // routes exist only when this is true.
InboundEnabled bool InboundEnabled bool
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
DMARCEnabled bool
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used // SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
// as bootstrap and fallback when the settings row is missing or invalid. // as bootstrap and fallback when the settings row is missing or invalid.
SendLogRetentionEnvDefault int SendLogRetentionEnvDefault int
@@ -49,6 +52,7 @@ type Handlers struct {
domains *domain.Service domains *domain.Service
apps *app.Service apps *app.Service
inbound *inbound.Service inbound *inbound.Service
dmarc *dmarc.Service
cfg Config cfg Config
view *view.Engine view *view.Engine
dns *dnscheck.Checker dns *dnscheck.Checker
@@ -62,6 +66,7 @@ func New(
domains *domain.Service, domains *domain.Service,
apps *app.Service, apps *app.Service,
inboundSvc *inbound.Service, inboundSvc *inbound.Service,
dmarcSvc *dmarc.Service,
cfg Config, cfg Config,
v *view.Engine, v *view.Engine,
dns *dnscheck.Checker, dns *dnscheck.Checker,
@@ -73,6 +78,7 @@ func New(
domains: domains, domains: domains,
apps: apps, apps: apps,
inbound: inboundSvc, inbound: inboundSvc,
dmarc: dmarcSvc,
cfg: cfg, cfg: cfg,
view: v, view: v,
dns: dns, dns: dns,
+13 -1
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store" "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) reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
dmarcMode := "inherit" dmarcMode := "inherit"
dmarcCustom := "" dmarcCustom := ""
hostedAddr := ""
if h.dmarc != nil && h.cfg.DMARCEnabled {
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
}
if d.DMARCRua.Valid { if d.DMARCRua.Valid {
if d.DMARCRua.String == "" { if d.DMARCRua.String == "" {
dmarcMode = "none" dmarcMode = "none"
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) {
dmarcMode = "hosted"
} else { } else {
dmarcMode = "custom" dmarcMode = "custom"
dmarcCustom = d.DMARCRua.String dmarcCustom = d.DMARCRua.String
@@ -173,6 +180,8 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
} }
dmarcSource := "policy" dmarcSource := "policy"
switch { switch {
case dmarcMode == "hosted":
dmarcSource = "hosted"
case dmarcMode == "custom": case dmarcMode == "custom":
dmarcSource = "custom" dmarcSource = "custom"
case dmarcMode == "none": case dmarcMode == "none":
@@ -195,10 +204,13 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
data["ResolvedDMARCEmail"] = reportEmail data["ResolvedDMARCEmail"] = reportEmail
data["DMARCRuaMode"] = dmarcMode data["DMARCRuaMode"] = dmarcMode
data["DMARCRuaCustom"] = dmarcCustom data["DMARCRuaCustom"] = dmarcCustom
data["HostedDMARCEmail"] = hostedAddr
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
data["ReportAuthName"] = reportAuthName data["ReportAuthName"] = reportAuthName
data["ReportAuthValue"] = reportAuthValue data["ReportAuthValue"] = reportAuthValue
data["NeedsReportAuth"] = needsReportAuth 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["Hostname"] = h.cfg.Hostname
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
data["Apps"] = appViews data["Apps"] = appViews
+13
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/web/validate" "github.com/mixeme/selfpost/internal/web/validate"
) )
@@ -42,6 +43,13 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
return return
} }
rua = sql.NullString{Valid: true, String: email} 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: default:
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."}) h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
return 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."}) h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
return 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) h.dns.Forget(d.Name)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
} }
@@ -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")
}
@@ -63,6 +63,10 @@ func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status
data["Flash"] = settingsFlash(r) data["Flash"] = settingsFlash(r)
data["L1Messages"] = h.l1Messages() data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window() 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) 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 token, ok := h.auth.SessionToken(r); ok {
if renaming { if renaming {
h.auth.RenameSession(token, username) h.auth.RenameSession(token, username)
+63
View File
@@ -0,0 +1,63 @@
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>DMARC reports</h1>
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>.
Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed
XML — not a dashboard.</p>
<div class="pair">
<div class="card">
<h2>Ingest {{if .IngestStats.IngestOK}}<span class="st st-ok">ok</span>{{else}}<span class="st st-warn">quiet</span>{{end}}</h2>
{{if .LastReceivedLabel}}
<p>Last report {{.LastReceivedLabel}} UTC. Port 25 accepts only configured report addresses.</p>
{{else}}
<p>No reports received yet. Point <code>rua=</code> at
<code>{{.HostedAddress}}</code> (or a per-domain hosted address) and publish
MX for {{.HostedAddress}} on this server.</p>
{{end}}
</div>
<div class="card">
<h2>This week</h2>
<p>{{.IngestStats.KeptThisWeek}} kept{{if .IngestStats.ParseFailures}}, {{.IngestStats.ParseFailures}} parse failures{{end}}.
Older summaries are pruned ({{.RetentionMax}} max, {{.RetentionDays}} days).</p>
</div>
</div>
<div class="card">
<h2>Recent reports</h2>
{{if .Reports}}
<table class="desk-only">
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
{{range .Reports}}
<tr>
<td class="time muted">{{.ReceivedLabel}}</td>
<td>{{if .DomainID}}<a href="/dmarc/domains/{{.DomainID}}">{{.Domain}}</a>{{else}}{{.Domain}}{{end}}</td>
<td class="muted">{{.Reporter}}</td>
<td>{{.PeriodLabel}}</td>
<td>{{.PassCount}}</td>
<td>{{.FailCount}}</td>
<td class="actions"><a href="/dmarc/reports/{{.ID}}">View</a></td>
</tr>
{{end}}
</tbody>
</table>
<ul class="phone-list phone-only">
{{range .Reports}}
<li>
<a href="/dmarc/reports/{{.ID}}">
<span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.PassCount}} pass</span>
<span class="when">{{.ReceivedLabel}}</span>
<span class="meta">{{.Domain}} · {{.Reporter}}</span>
<span class="subj">{{.FailCount}} fail</span>
</a>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No reports yet.</p>
{{end}}
</div>
{{end}}
@@ -0,0 +1,59 @@
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
{{template "back_link" (back "/dmarc" "All DMARC reports")}}
{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
<div class="pair">
<div class="card">
<h2>Last {{.WindowDays}} days {{if and (gt .Pass7d 0) (eq .Fail7d 0)}}<span class="st st-ok">pass</span>{{else if gt .Fail7d 0}}<span class="st st-warn">review</span>{{end}}</h2>
<p>{{.PolicyHint}}</p>
<p class="muted"><a href="/domains/{{.Domain.ID}}#dmarc">Domain DNS and rua=</a></p>
</div>
<div class="card">
<h2>Totals</h2>
<p>{{.Pass7d}} pass · {{.Fail7d}} fail in aggregate summaries.</p>
</div>
</div>
<div class="card">
<h2>Reports</h2>
{{if .Reports}}
<table class="desk-only">
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
{{range .Reports}}
<tr>
<td class="time muted">{{.ReceivedLabel}}</td>
<td class="muted">{{.Reporter}}</td>
<td>{{.PeriodLabel}}</td>
<td>{{.PassCount}}</td>
<td>{{.FailCount}}</td>
<td class="actions"><a href="/dmarc/reports/{{.ID}}">View</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No reports for this domain yet.</p>
{{end}}
</div>
{{if .Sources}}
<div class="card">
<h2>Sources · last {{.WindowDays}} days</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody>
{{range .Sources}}
<tr>
<td class="muted">{{.SourceIP}}{{if .ThisRelay}} (this relay){{end}}</td>
<td>{{.PassCount}}</td>
<td>{{.FailCount}}</td>
<td><span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.Disposition}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{end}}
@@ -0,0 +1,52 @@
{{define "content"}}
<h1 class="subject">{{.Report.Reporter}} · {{.WindowLabel}}</h1>
<p class="route">
<span class="addr">{{.Report.Domain}}</span>
<span class="st st-ok">{{.Report.PassCount}} pass</span>
{{if gt .Report.FailCount 0}}<span class="st st-warn">{{.Report.FailCount}} fail</span>{{end}}
</p>
{{template "back_link" (back (printf "/dmarc/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
<div class="pair">
<div class="card">
<h2>Report</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">{{.Report.Reporter}}</span></div>
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">{{.Report.ReportID}}</span></div>
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">{{.PeriodBeginLabel}} {{.PeriodEndLabel}} UTC</span></div>
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">{{.ReceivedLabel}} UTC</span></div>
{{if .Report.ContactEmail}}<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">{{.Report.ContactEmail}}</span></div>{{end}}
</div>
</div>
<div class="card{{if gt .Report.FailCount 0}} attn{{end}}">
<h2>Published policy</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">{{.Report.Domain}}</span></div>
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">{{.Report.PolicyP}} / {{.Report.PolicySP}} / {{.Report.PolicyPct}}</span></div>
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">{{.Report.PolicyADKIM}} / {{.Report.PolicyASPF}}</span></div>
{{if .Report.Recipient}}<div class="fact"><span class="fact-label">rua</span><span class="fact-value">{{.Report.Recipient}}</span></div>{{end}}
</div>
<p class="muted"><code>p={{.Report.PolicyP}}</code> is what receivers enforced in this window.</p>
</div>
</div>
<div class="card">
<h2>Records</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
<tbody>
{{range .Report.Records}}
<tr>
<td class="muted">{{.SourceIP}}</td>
<td>{{.Count}}</td>
<td><span class="st {{if or (eq .SPFResult "pass") (eq .DKIMResult "pass")}}st-ok{{else}}st-warn{{end}}">{{.Disposition}}</span></td>
<td><span class="st {{if eq .SPFResult "pass"}}st-ok{{else}}st-error{{end}}">{{.SPFResult}}</span></td>
<td><span class="st {{if eq .DKIMResult "pass"}}st-ok{{else}}st-error{{end}}">{{.DKIMResult}}</span></td>
<td>{{.HeaderFrom}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
</div>
{{end}}
@@ -170,8 +170,11 @@
</div> </div>
{{if .SameDomainRUA}} {{if .SameDomainRUA}}
<p class="error">The report address is on this sending domain. SelfPost does <p class="error">The report address is on this sending domain.{{if not .DMARCIngestEnabled}}
not receive inbound mail — use a mailbox on another domain.</p> SelfPost does not receive inbound mail — use a mailbox on another domain.{{end}}</p>
{{end}}
{{if and .DMARCIngestEnabled .ResolvedDMARCEmail}}
<p class="muted"><a href="/dmarc/domains/{{.Domain.ID}}">View DMARC reports</a> for this domain.</p>
{{end}} {{end}}
{{if .NeedsReportAuth}} {{if .NeedsReportAuth}}
@@ -385,6 +388,7 @@
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label> <label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom"> <select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option> <option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
{{if .DMARCIngestEnabled}}<option value="hosted"{{if eq .DMARCRuaMode "hosted"}} selected{{end}}>SelfPost hosted ({{.HostedDMARCEmail}})</option>{{end}}
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option> <option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option> <option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
</select> </select>
+4
View File
@@ -73,6 +73,9 @@
{{if and .IsGlobal .InboundEnabled}} {{if and .IsGlobal .InboundEnabled}}
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}} {{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
{{end}} {{end}}
{{if .DMARCEnabled}}
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
{{end}}
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}} {{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{if .IsGlobal}} {{if .IsGlobal}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}} {{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
@@ -116,6 +119,7 @@
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}} {{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}} {{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}} {{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
{{define "icon-dmarc"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11v7H2.5z"/><path d="M5 7.5h6M5 9.5h4"/></svg>{{end}}
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}} {{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}} {{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}} {{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
+5 -4
View File
@@ -41,14 +41,15 @@
<div class="card"> <div class="card">
<h2>DMARC aggregate reports</h2> <h2>DMARC aggregate reports</h2>
<p class="muted">Optional default <code>rua=</code> address for every sending <p class="muted">Optional default <code>rua=</code> address for every sending
domain (can be overridden per domain). Use a mailbox on a domain that domain (can be overridden per domain).{{if .DMARCIngestEnabled}} When set to
receives inbound mail. SelfPost does not receive inbound mail — point an address on <code>{{.HostedReportAddress}}</code>, SelfPost receives and
<code>rua=</code> at a mailbox elsewhere.</p> parses aggregate reports on port&nbsp;25.{{else}} Use a mailbox on a domain
that receives inbound mail elsewhere.{{end}}</p>
<label for="dmarc_report_email">Default report address</label> <label for="dmarc_report_email">Default report address</label>
<input id="dmarc_report_email" name="dmarc_report_email" type="email" <input id="dmarc_report_email" name="dmarc_report_email" type="email"
autocomplete="email" autocapitalize="none" spellcheck="false" autocomplete="email" autocapitalize="none" spellcheck="false"
value="{{.FormDMARCEmail}}" placeholder="reports@your-mail-domain.com"> value="{{.FormDMARCEmail}}" placeholder="{{if .DMARCIngestEnabled}}{{.HostedReportAddress}}{{else}}reports@your-mail-domain.com{{end}}">
{{if .FormDMARCEmail}} {{if .FormDMARCEmail}}
<p class="muted">When <code>rua=</code> points at another domain, that hub <p class="muted">When <code>rua=</code> points at another domain, that hub
+3 -1
View File
@@ -222,7 +222,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
wide := map[string]bool{ wide := map[string]bool{
"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "settings": true, "deliveries": true, "delivery": true, "mail_queue": true,
"status": true, "system_log": true, "domain_detail": 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() { for name, page := range engine.Pages() {
var buf bytes.Buffer var buf bytes.Buffer
@@ -324,6 +324,8 @@ func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
"domain_delete.html": true, "domain_delete.html": true,
"inbound_domain.html": true, "inbound_domain.html": true,
"inbound_delete.html": true, "inbound_delete.html": true,
"dmarc_domain.html": true,
"dmarc_report.html": true,
"delivery.html": true, "delivery.html": true,
} }
forEachTemplate(t, func(name, body string) { forEachTemplate(t, func(name, body string) {
+10
View File
@@ -22,6 +22,7 @@ type Engine struct {
fragments map[string]*template.Template fragments map[string]*template.Template
version string version string
inboundEnabled bool inboundEnabled bool
dmarcEnabled bool
} }
// pageFiles maps a logical page name to its template files. Every page // 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": {"templates/inbound.html"},
"inbound_domain": {"templates/inbound_domain.html"}, "inbound_domain": {"templates/inbound_domain.html"},
"inbound_delete": {"templates/inbound_delete.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"}, "deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"delivery": {"templates/delivery.html"}, "delivery": {"templates/delivery.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.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 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. // templateFuncs supplies helpers shared across page templates.
func templateFuncs() template.FuncMap { func templateFuncs() template.FuncMap {
return 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["Copyright"] = legal.CopyrightLine
m["SourceURL"] = legal.SourceURL m["SourceURL"] = legal.SourceURL
m["InboundEnabled"] = e.inboundEnabled m["InboundEnabled"] = e.inboundEnabled
m["DMARCEnabled"] = e.dmarcEnabled
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
+13 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/legal" "github.com/mixeme/selfpost/internal/legal"
@@ -85,6 +86,8 @@ type Config struct {
RetryPolicy postfix.RetryPolicy RetryPolicy postfix.RetryPolicy
// InboundEnabled mirrors INBOUND_RELAY_ENABLE. // InboundEnabled mirrors INBOUND_RELAY_ENABLE.
InboundEnabled bool InboundEnabled bool
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
DMARCEnabled bool
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start. // SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
SendLogRetentionEnvDefault int SendLogRetentionEnvDefault int
} }
@@ -101,19 +104,20 @@ type Server struct {
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM); // that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
// apps owns application SASL accounts and the Postfix sender map // apps owns application SASL accounts and the Postfix sender map
// (architecture.md § Mail path). // (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) v, err := view.New(cfg.Version)
if err != nil { if err != nil {
return nil, err return nil, err
} }
v.SetInboundEnabled(cfg.InboundEnabled) v.SetInboundEnabled(cfg.InboundEnabled)
v.SetDMARCEnabled(cfg.DMARCEnabled)
a := auth.New(st, auth.Config{ a := auth.New(st, auth.Config{
CookieSecure: cfg.CookieSecure, CookieSecure: cfg.CookieSecure,
Hostname: cfg.Hostname, Hostname: cfg.Hostname,
SessionIdleDays: cfg.SessionIdleDays, SessionIdleDays: cfg.SessionIdleDays,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
}, v, setupTokenPath) }, v, setupTokenPath)
h := handlers.New(st, domains, apps, inboundSvc, handlers.Config{ h := handlers.New(st, domains, apps, inboundSvc, dmarcSvc, handlers.Config{
Hostname: cfg.Hostname, Hostname: cfg.Hostname,
SubmissionEnabled: cfg.SubmissionEnabled, SubmissionEnabled: cfg.SubmissionEnabled,
MailLogPath: cfg.MailLogPath, MailLogPath: cfg.MailLogPath,
@@ -128,6 +132,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds, RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
RetryPolicy: cfg.RetryPolicy, RetryPolicy: cfg.RetryPolicy,
InboundEnabled: cfg.InboundEnabled, InboundEnabled: cfg.InboundEnabled,
DMARCEnabled: cfg.DMARCEnabled,
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault, SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, nil 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) 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("/settings", h.HandleSettings)
authed.HandleFunc("/account", redirectSettings) authed.HandleFunc("/account", redirectSettings)