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:
@@ -5,6 +5,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.7.0] - 2026-08-18
|
||||
|
||||
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse
|
||||
gzip/XML into SQLite, and show summaries in the panel.
|
||||
|
||||
### Added
|
||||
|
||||
- Optional DMARC aggregate report ingest (`DMARC_REPORTS_ENABLE`): port 25
|
||||
accepts only configured `rua=` addresses, gzip/XML is parsed into SQLite, and
|
||||
the panel shows per-domain roll-ups and individual reports under *DMARC*.
|
||||
- Per-domain **SelfPost hosted** `rua=` mode when ingest is enabled
|
||||
(`dmarc-reports+<domain>@<hostname>`).
|
||||
|
||||
## [1.6.0] - 2026-08-18
|
||||
|
||||
30-day sending statistics per domain and application, plus optional auto
|
||||
|
||||
@@ -108,7 +108,7 @@ docker run --rm -d --name selfpost-try \
|
||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||
-e PANEL_COOKIE_SECURE=false \
|
||||
-v selfpost-try-data:/data \
|
||||
ghcr.io/mixeme/selfpost:1.6.0
|
||||
ghcr.io/mixeme/selfpost:1.7.0
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
|
||||
+75
-16
@@ -80,6 +80,15 @@ TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
|
||||
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
|
||||
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}"
|
||||
|
||||
# Optional DMARC aggregate ingest (plans/dmarc-reports.md). Off by default:
|
||||
# port 25 does not accept report mail and the panel ingest maps stay empty.
|
||||
DMARC_REPORTS_ENABLE="${DMARC_REPORTS_ENABLE:-false}"
|
||||
DMARC_RATE_LIMIT_MESSAGES_PER_IP="${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
DMARC_MESSAGE_SIZE_LIMIT="${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||
DMARC_RECIPIENTS_MAP="${POSTFIX_DMARC_RECIPIENTS:-/data/postfix/dmarc_recipients}"
|
||||
DMARC_TRANSPORT_MAP="${POSTFIX_DMARC_TRANSPORT:-/data/postfix/dmarc_transport}"
|
||||
DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_relay_domains}"
|
||||
|
||||
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
|
||||
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
|
||||
# for messages still marked "queued" survive a container recreate — without
|
||||
@@ -211,23 +220,57 @@ else
|
||||
postconf -MX "submission/inet" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# --- inbound smtpd on port 25 (optional backup-MX / forwarder) ---------------
|
||||
# Debian's stock master.cf enables smtp/inet. When the flag is off, remove that
|
||||
# listener so port 25 is not an inbound smtpd (outbound delivery uses smtp/unix).
|
||||
# When on: no SASL, no OpenDKIM, accept only relay_domains + listed recipients.
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
|
||||
[ -e "$f" ] || : > "$f"
|
||||
done
|
||||
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
|
||||
# Debian's stock master.cf enables smtp/inet. When both flags are off, remove
|
||||
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
|
||||
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
|
||||
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
RELAY_DOMAINS_SETTING=""
|
||||
TRANSPORT_SETTING=""
|
||||
RECIPIENT_RESTRICTIONS="reject"
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
|
||||
[ -e "$f" ] || : > "$f"
|
||||
done
|
||||
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
|
||||
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
|
||||
RECIPIENT_RESTRICTIONS="reject_unauth_destination, reject_unlisted_recipient"
|
||||
fi
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
|
||||
[ -e "$f" ] || : > "$f"
|
||||
done
|
||||
postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe"
|
||||
postconf -P \
|
||||
"dmarc-ingest/unix-pipe/flags=Rq" \
|
||||
"dmarc-ingest/unix-pipe/user=panel" \
|
||||
"dmarc-ingest/unix-pipe/argv=/usr/local/bin/panel -dmarc-ingest"
|
||||
if [ -n "$RELAY_DOMAINS_SETTING" ]; then
|
||||
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
|
||||
else
|
||||
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
||||
fi
|
||||
RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP}, ${RECIPIENT_RESTRICTIONS}"
|
||||
fi
|
||||
postconf -e \
|
||||
"relay_domains=texthash:${RELAY_DOMAINS_MAP}" \
|
||||
"transport_maps=texthash:${TRANSPORT_MAP}" \
|
||||
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
|
||||
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" \
|
||||
"relay_domains=${RELAY_DOMAINS_SETTING}" \
|
||||
"transport_maps=${TRANSPORT_SETTING}" \
|
||||
"smtpd_reject_unlisted_recipient=yes"
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
postconf -e \
|
||||
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
|
||||
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
||||
else
|
||||
postconf -e \
|
||||
"relay_recipient_maps=" \
|
||||
"smtp_tls_policy_maps="
|
||||
fi
|
||||
|
||||
INBOUND_MILTERS=""
|
||||
if [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
||||
case "${INBOUND_ANTISPAM_MILTER}" in
|
||||
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
|
||||
*)
|
||||
@@ -245,6 +288,21 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
INBOUND_MILTERS="{ ${INBOUND_ANTISPAM_MILTER}, default_action=${INBOUND_ANTISPAM_MILTER_ACTION} }"
|
||||
fi
|
||||
|
||||
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
PORT25_RATE="${DMARC_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||
fi
|
||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||
if [ "${INBOUND_MESSAGE_SIZE_LIMIT}" -gt "${DMARC_MESSAGE_SIZE_LIMIT}" ]; then
|
||||
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||
else
|
||||
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||
fi
|
||||
fi
|
||||
|
||||
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
|
||||
postconf -P \
|
||||
"smtp/inet/smtpd_sasl_auth_enable=no" \
|
||||
@@ -253,12 +311,13 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||
"smtp/inet/smtpd_sender_restrictions=" \
|
||||
"smtp/inet/smtpd_client_restrictions=" \
|
||||
"smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \
|
||||
"smtp/inet/smtpd_recipient_restrictions=reject_unauth_destination, reject_unlisted_recipient" \
|
||||
"smtp/inet/smtpd_recipient_restrictions=${RECIPIENT_RESTRICTIONS}" \
|
||||
"smtp/inet/smtpd_milters=${INBOUND_MILTERS}" \
|
||||
"smtp/inet/smtpd_client_message_rate_limit=${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}" \
|
||||
"smtp/inet/message_size_limit=${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||
"smtp/inet/smtpd_client_message_rate_limit=${PORT25_RATE}" \
|
||||
"smtp/inet/message_size_limit=${PORT25_SIZE}"
|
||||
else
|
||||
postconf -MX "smtp/inet" 2>/dev/null || true
|
||||
postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true
|
||||
postconf -e \
|
||||
"relay_domains=" \
|
||||
"transport_maps=" \
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -20,6 +20,9 @@ var documentedPublic = []string{
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||
}
|
||||
|
||||
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||
@@ -43,6 +46,9 @@ var documentedInternal = []string{
|
||||
"POSTFIX_TRANSPORT_MAPS",
|
||||
"POSTFIX_RELAY_RECIPIENTS",
|
||||
"POSTFIX_TLS_POLICY_MAPS",
|
||||
"POSTFIX_DMARC_RECIPIENTS",
|
||||
"POSTFIX_DMARC_TRANSPORT",
|
||||
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||
"SELFPOST_DEPLOY_ROOT",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
@@ -83,6 +89,7 @@ var loadConfigKeys = []string{
|
||||
"POSTFIX_DIR",
|
||||
"SELFPOST_DEPLOY_ROOT",
|
||||
"INBOUND_RELAY_ENABLE",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
}
|
||||
|
||||
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||
@@ -105,10 +112,16 @@ var buildScriptKeys = []string{
|
||||
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||
"DMARC_REPORTS_ENABLE",
|
||||
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||
"POSTFIX_RELAY_DOMAINS",
|
||||
"POSTFIX_TRANSPORT_MAPS",
|
||||
"POSTFIX_RELAY_RECIPIENTS",
|
||||
"POSTFIX_TLS_POLICY_MAPS",
|
||||
"POSTFIX_DMARC_RECIPIENTS",
|
||||
"POSTFIX_DMARC_TRANSPORT",
|
||||
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
|
||||
+15
-2
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
@@ -23,9 +24,11 @@ type mailStack struct {
|
||||
Domains *domain.Service
|
||||
Apps *app.Service
|
||||
Inbound *inbound.Service
|
||||
DMARC *dmarc.Service
|
||||
pf *postfix.Postfix
|
||||
odk *domain.OpenDKIM
|
||||
inboundEnabled bool
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||
@@ -34,7 +37,11 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
||||
inb := inbound.NewService(st, pf)
|
||||
return &mailStack{Domains: domains, Apps: apps, Inbound: inb, pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled}
|
||||
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
||||
return &mailStack{
|
||||
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
||||
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
||||
@@ -51,6 +58,11 @@ func (m *mailStack) Resync() error {
|
||||
return fmt.Errorf("inbound maps resync: %w", err)
|
||||
}
|
||||
}
|
||||
if m.dmarcEnabled {
|
||||
if err := m.DMARC.Resync(); err != nil {
|
||||
return fmt.Errorf("dmarc maps resync: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -79,7 +91,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
// effective config, including a manual override; the panel keeps this
|
||||
// snapshot for the process lifetime (architecture.md).
|
||||
retryPolicy := postfix.LoadRetryPolicy()
|
||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, web.Config{
|
||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
SubmissionEnabled: cfg.submissionEnabled,
|
||||
@@ -98,6 +110,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||
RetryPolicy: retryPolicy,
|
||||
InboundEnabled: cfg.inboundEnabled,
|
||||
DMARCEnabled: cfg.dmarcEnabled,
|
||||
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||
}, cfg.setupTokenPath)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if isDMARCIngestInvocation() {
|
||||
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||
log.SetPrefix("dmarc-ingest: ")
|
||||
if err := runDMARCIngestMode(); err != nil {
|
||||
log.Printf("ingest failed: %v", err)
|
||||
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
@@ -81,6 +91,7 @@ type config struct {
|
||||
deployRoot string
|
||||
|
||||
inboundEnabled bool
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -144,6 +155,8 @@ func loadConfig() config {
|
||||
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
|
||||
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
|
||||
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
|
||||
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +260,12 @@ func run() error {
|
||||
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if cfg.dmarcEnabled {
|
||||
ms := newMailStack(cfg, st)
|
||||
ms.pf.SetReloadHook(func() error { return nil })
|
||||
if err := ms.DMARC.Resync(); err != nil {
|
||||
log.Printf("dmarc maps bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
|
||||
|
||||
@@ -16,6 +16,15 @@ SUBMISSION_ENABLE=false
|
||||
# to the upstream you set. Off by default — the outbound path is unchanged.
|
||||
INBOUND_RELAY_ENABLE=false
|
||||
|
||||
# Optional DMARC aggregate report ingest. When true, Postfix accepts mail on port
|
||||
# 25 only for report addresses configured in the panel (global default and/or
|
||||
# per-domain SelfPost-hosted addresses) and pipes them to the ingest worker.
|
||||
DMARC_REPORTS_ENABLE=false
|
||||
|
||||
# Coarse per-client-IP limit and message size for DMARC ingest on port 25.
|
||||
# DMARC_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||
# DMARC_MESSAGE_SIZE_LIMIT=5242880
|
||||
|
||||
# Optional milter on the inbound listener only (not 465/587). Empty = off.
|
||||
# Example with deploy/antispam/docker-compose.antispam.yml:
|
||||
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:1.6.0
|
||||
image: ghcr.io/mixeme/selfpost:1.7.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||
@@ -34,6 +34,9 @@ services:
|
||||
# alongside the primary 465/smtps listener (spec 5).
|
||||
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
||||
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
|
||||
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
|
||||
DMARC_RATE_LIMIT_MESSAGES_PER_IP: "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
DMARC_MESSAGE_SIZE_LIMIT: "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}"
|
||||
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
|
||||
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
||||
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
|
||||
[§ Release image](#release-image)).
|
||||
|
||||
**Planned version cuts** (from pin `1.6.0`; adjust if semver changes mid-track):
|
||||
**Planned version cuts** (from pin `1.7.0`; adjust if semver changes mid-track):
|
||||
|
||||
| Stage | ID | Cut |
|
||||
|---|---|---|
|
||||
|
||||
+47
-5
@@ -33,6 +33,7 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
|
||||
- [Full backup and restore](#full-backup-and-restore)
|
||||
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
|
||||
- [Inbound relay](#inbound-relay)
|
||||
- [DMARC reports](#dmarc-reports)
|
||||
- [Domain administration](#domain-administration)
|
||||
- [Domains page](#domains-page)
|
||||
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
|
||||
@@ -169,7 +170,7 @@ cat ./data/setup-token
|
||||
#### Fixed image tag
|
||||
|
||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||
deliberately never `:latest`. The current pin is `1.6.0`. Intermediate
|
||||
deliberately never `:latest`. The current pin is `1.7.0`. Intermediate
|
||||
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
||||
image was published. Pinning matters because of the backup version check (see
|
||||
[Full backup and restore](#full-backup-and-restore)): the panel binary's
|
||||
@@ -190,6 +191,9 @@ expected to set; defaults match the code exactly.
|
||||
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
|
||||
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
|
||||
| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` |
|
||||
| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *DMARC*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` |
|
||||
| `DMARC_RATE_LIMIT_MESSAGES_PER_IP` | Per-client-IP cap on port 25 when DMARC ingest is on (shared listener with inbound relay if both are enabled). | `20` | `.env` |
|
||||
| `DMARC_MESSAGE_SIZE_LIMIT` | Maximum report message size in bytes when DMARC ingest is on. | `5242880` (5 MiB) | `.env` |
|
||||
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound listener only (not 465/587). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. | *(empty)* | `.env` |
|
||||
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.env` |
|
||||
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
|
||||
@@ -640,6 +644,44 @@ Inbound configuration lives in SQLite and `/data/postfix/` map files, so it
|
||||
is included in a [full backup](#full-backup-and-restore). Single-domain
|
||||
export/import is sending domains only.
|
||||
|
||||
### DMARC reports
|
||||
|
||||
Optional ingest of DMARC **aggregate** reports (`rua=`): Postfix accepts mail on
|
||||
port **25** only for addresses you configure, pipes each message to the panel
|
||||
ingest worker, and stores parsed summaries in SQLite. Forensic reports (`ruf=`)
|
||||
are not stored. This is separate from [Inbound relay](#inbound-relay) — no
|
||||
backup-MX, no forwarding upstream.
|
||||
|
||||
**Off by default.** Set `DMARC_REPORTS_ENABLE=true` in `.env` and recreate the
|
||||
container. Until then there is no report ingest, no *DMARC* item in the nav,
|
||||
and `/dmarc` is 404. Outbound 465/587 is unchanged.
|
||||
|
||||
**Addresses.** In *Settings* (global administrator), set the default
|
||||
`rua=` mailbox to an address on `SELFPOST_HOSTNAME`, e.g.
|
||||
`dmarc-reports@mail.example.com`. Per domain you can choose **SelfPost hosted**
|
||||
(`dmarc-reports+<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
|
||||
|
||||
### Domains page
|
||||
@@ -665,10 +707,10 @@ records; see [Inbound relay](#inbound-relay).
|
||||
- **DKIM** — a TXT record with the exact value the panel shows on that
|
||||
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
||||
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
||||
only, safe to publish immediately). On a send-only relay the sending domain
|
||||
often has no inbox, so `rua=` is optional — configure a default report address
|
||||
in *Settings* (see [Settings](#settings)) or per domain when you have a
|
||||
mailbox that receives inbound mail elsewhere. If `rua=` points at another
|
||||
only, safe to publish immediately). Set `rua=` to receive aggregate reports:
|
||||
with [DMARC reports](#dmarc-reports) enabled, use a SelfPost-hosted address
|
||||
from *Settings* or per-domain *Domain settings*; otherwise point `rua=` at a
|
||||
mailbox elsewhere that receives inbound mail. If `rua=` points at another
|
||||
domain, publish `_report._dmarc` on that hub domain too; the panel checks
|
||||
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
|
||||
report destinations.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Plan: dmarc-reports
|
||||
|
||||
**Status:** candidate
|
||||
**Status:** done — shipped in `[1.7.0]` (2026-08-18); security review (Fable)
|
||||
of the ingest path pending.
|
||||
|
||||
---
|
||||
|
||||
@@ -51,15 +52,28 @@ remain a separate, opt-in feature that does not forward mail upstream.
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
**Ingest path.** `DMARC_REPORTS_ENABLE=true` enables `smtp/inet` on 25 (shared
|
||||
with inbound relay when both are on). Postfix `relay_domains` +
|
||||
`transport_maps` route allow-listed recipients to a `dmarc-ingest` pipe
|
||||
(`panel -dmarc-ingest`). `check_recipient_access` on `dmarc_recipients` is the
|
||||
allow-list; no SASL, no local mailboxes.
|
||||
|
||||
**Schema.** Migration `0008_dmarc_reports.sql`: `dmarc_reports` (summary per
|
||||
aggregate) + `dmarc_report_records` (per-source rows). Dedup on
|
||||
`(reporter, report_id, domain)`.
|
||||
|
||||
**Retention.** Max 500 reports; drop older than 90 days; prune after each
|
||||
ingest.
|
||||
|
||||
Target version cut: **`1.7.0`** (MINOR). One commit per step; code only after
|
||||
roadmap status is **agreed**. Expand the sketch sections above before step 1
|
||||
if still thin. See [development.md](../development.md) § Plan checklists.
|
||||
|
||||
- [ ] Expand plan: ingest path, `dmarc_reports` schema, retention caps — **Sonnet**
|
||||
- [ ] Opt-in inbound SMTP for report addresses only (allow-list) — **Opus**
|
||||
- [ ] Worker: gzip/XML parse → SQLite — **Opus**
|
||||
- [ ] Panel: domain roll-up + parsed report (panel-ui mockups) — **Sonnet**
|
||||
- [ ] Tie-in `dmarc_report_email` / `domains.dmarc_rua` — **Sonnet**
|
||||
- [ ] Tests and [guide.md](../guide.md) — **Sonnet**
|
||||
- [x] Expand plan: ingest path, `dmarc_reports` schema, retention caps — **Sonnet**
|
||||
- [x] Opt-in inbound SMTP for report addresses only (allow-list) — **Opus**
|
||||
- [x] Worker: gzip/XML parse → SQLite — **Opus**
|
||||
- [x] Panel: domain roll-up + parsed report (panel-ui mockups) — **Sonnet**
|
||||
- [x] Tie-in `dmarc_report_email` / `domains.dmarc_rua` — **Sonnet**
|
||||
- [x] Tests and [guide.md](../guide.md) — **Sonnet**
|
||||
- [ ] Security review ingest path — **Fable**
|
||||
- [ ] `go vet`, `go test` on touched packages — **Haiku**
|
||||
- [x] `go vet`, `go test` on touched packages — **Haiku**
|
||||
|
||||
+3
-1
@@ -60,7 +60,9 @@ domain administrators with assigned domains). The optional **inbound relay**
|
||||
(backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind
|
||||
`INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. **Send-log
|
||||
retention in Settings** ships in `[1.5.0]`. **30-day send statistics** and
|
||||
**auto level-2 rate limits** ship in `[1.6.0]`. Items marked *candidate* in the
|
||||
**auto level-2 rate limits** ship in `[1.6.0]`. **DMARC aggregate report
|
||||
ingest** ships in `[1.7.0]`, off by default behind `DMARC_REPORTS_ENABLE`.
|
||||
Items marked *candidate* in the
|
||||
[roadmap](roadmap.md) require explicit approval before coding.
|
||||
|
||||
---
|
||||
|
||||
+7
-34
@@ -29,16 +29,16 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| ID | Topic | Status | Progress | Plan |
|
||||
|---|---|---|---|---|
|
||||
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
||||
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | 0/8 | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
||||
| panel-docs | In-panel operator documentation | candidate | 0/6 | [plans/panel-docs.md](plans/panel-docs.md) |
|
||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
|
||||
|
||||
**Recommended order** (not binding): the next feature is **dmarc-reports** once
|
||||
agreed. domain-stats-auto-ratelimit shipped in
|
||||
[CHANGELOG.md](../CHANGELOG.md) `[1.6.0]`; send-log-retention in `[1.5.0]`;
|
||||
inbound-relay in `[1.4.0]`; queue-retries in `[1.3.1]`; the 2026-08-13
|
||||
full-tree review follow-ups are in `[1.3.0]`. Candidates need explicit
|
||||
agreement before they join the queue.
|
||||
**Recommended order** (not binding): the next feature is **panel-docs** once
|
||||
agreed. dmarc-reports shipped in
|
||||
[CHANGELOG.md](../CHANGELOG.md) `[1.7.0]` (security review of the ingest path
|
||||
pending); domain-stats-auto-ratelimit in `[1.6.0]`; send-log-retention in
|
||||
`[1.5.0]`; inbound-relay in `[1.4.0]`; queue-retries in `[1.3.1]`; the
|
||||
2026-08-13 full-tree review follow-ups are in `[1.3.0]`. Candidates need
|
||||
explicit agreement before they join the queue.
|
||||
|
||||
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
||||
the **Implementation checklist** in its linked plan. The `Progress` column above
|
||||
@@ -65,33 +65,6 @@ priority.
|
||||
|
||||
---
|
||||
|
||||
## dmarc-reports
|
||||
|
||||
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
|
||||
parses the gzip/XML payloads, and shows pass/fail summaries in the panel — so
|
||||
the operator does not need an external DMARC service or a separate mailbox
|
||||
workflow.
|
||||
|
||||
**Boundary:** an extension of v1.0 — not IMAP/webmail and not a general
|
||||
inbound relay. A dedicated inbound path for report messages only; forensic
|
||||
reports (`ruf=`) out of scope for v1.
|
||||
|
||||
**Done when:** see [plans/dmarc-reports.md](plans/dmarc-reports.md).
|
||||
|
||||
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
|
||||
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
|
||||
storage and retention of parsed summaries; the `dmarc_report_email` setting
|
||||
(migration `0005` moved it off the old `admin` table into `settings`) and
|
||||
`domains.dmarc_rua` added in the DMARC template work must stay the source of
|
||||
truth for `rua=` in DNS guidance.
|
||||
|
||||
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with
|
||||
inbound-relay depending on how port 25 acceptance is structured.
|
||||
|
||||
**Version:** `1.x` MINOR.
|
||||
|
||||
---
|
||||
|
||||
## panel-docs
|
||||
|
||||
**Goal:** built-in operator documentation in the panel — short pages (or a
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
@@ -38,6 +39,8 @@ type Config struct {
|
||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
|
||||
// routes exist only when this is true.
|
||||
InboundEnabled bool
|
||||
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
|
||||
DMARCEnabled bool
|
||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
|
||||
// as bootstrap and fallback when the settings row is missing or invalid.
|
||||
SendLogRetentionEnvDefault int
|
||||
@@ -49,6 +52,7 @@ type Handlers struct {
|
||||
domains *domain.Service
|
||||
apps *app.Service
|
||||
inbound *inbound.Service
|
||||
dmarc *dmarc.Service
|
||||
cfg Config
|
||||
view *view.Engine
|
||||
dns *dnscheck.Checker
|
||||
@@ -62,6 +66,7 @@ func New(
|
||||
domains *domain.Service,
|
||||
apps *app.Service,
|
||||
inboundSvc *inbound.Service,
|
||||
dmarcSvc *dmarc.Service,
|
||||
cfg Config,
|
||||
v *view.Engine,
|
||||
dns *dnscheck.Checker,
|
||||
@@ -73,6 +78,7 @@ func New(
|
||||
domains: domains,
|
||||
apps: apps,
|
||||
inbound: inboundSvc,
|
||||
dmarc: dmarcSvc,
|
||||
cfg: cfg,
|
||||
view: v,
|
||||
dns: dns,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
@@ -163,9 +164,15 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||
dmarcMode := "inherit"
|
||||
dmarcCustom := ""
|
||||
hostedAddr := ""
|
||||
if h.dmarc != nil && h.cfg.DMARCEnabled {
|
||||
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
}
|
||||
if d.DMARCRua.Valid {
|
||||
if d.DMARCRua.String == "" {
|
||||
dmarcMode = "none"
|
||||
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) {
|
||||
dmarcMode = "hosted"
|
||||
} else {
|
||||
dmarcMode = "custom"
|
||||
dmarcCustom = d.DMARCRua.String
|
||||
@@ -173,6 +180,8 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
}
|
||||
dmarcSource := "policy"
|
||||
switch {
|
||||
case dmarcMode == "hosted":
|
||||
dmarcSource = "hosted"
|
||||
case dmarcMode == "custom":
|
||||
dmarcSource = "custom"
|
||||
case dmarcMode == "none":
|
||||
@@ -195,10 +204,13 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
data["ResolvedDMARCEmail"] = reportEmail
|
||||
data["DMARCRuaMode"] = dmarcMode
|
||||
data["DMARCRuaCustom"] = dmarcCustom
|
||||
data["HostedDMARCEmail"] = hostedAddr
|
||||
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
|
||||
data["ReportAuthName"] = reportAuthName
|
||||
data["ReportAuthValue"] = reportAuthValue
|
||||
data["NeedsReportAuth"] = needsReportAuth
|
||||
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name)
|
||||
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
|
||||
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname))
|
||||
data["Hostname"] = h.cfg.Hostname
|
||||
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
|
||||
data["Apps"] = appViews
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,13 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
rua = sql.NullString{Valid: true, String: email}
|
||||
case "hosted":
|
||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
|
||||
return
|
||||
}
|
||||
addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
rua = sql.NullString{Valid: true, String: addr}
|
||||
default:
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||
return
|
||||
@@ -52,6 +60,11 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||
return
|
||||
}
|
||||
if h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: domain %d: dmarc resync: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
h.dns.Forget(d.Name)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -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["L1Messages"] = h.l1Messages()
|
||||
data["L1Window"] = h.l1Window()
|
||||
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
|
||||
if h.dmarc != nil && h.cfg.DMARCEnabled {
|
||||
data["HostedReportAddress"] = h.dmarc.DefaultHostedSuggestion()
|
||||
}
|
||||
h.view.Render(w, status, "settings", data)
|
||||
}
|
||||
|
||||
@@ -220,6 +224,12 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if emailChanging && h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: settings: dmarc resync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if token, ok := h.auth.SessionToken(r); ok {
|
||||
if renaming {
|
||||
h.auth.RenameSession(token, username)
|
||||
|
||||
@@ -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>
|
||||
|
||||
{{if .SameDomainRUA}}
|
||||
<p class="error">The report address is on this sending domain. SelfPost does
|
||||
not receive inbound mail — use a mailbox on another domain.</p>
|
||||
<p class="error">The report address is on this sending domain.{{if not .DMARCIngestEnabled}}
|
||||
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}}
|
||||
|
||||
{{if .NeedsReportAuth}}
|
||||
@@ -385,6 +388,7 @@
|
||||
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
||||
<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>
|
||||
{{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="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
||||
</select>
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
{{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}}
|
||||
{{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 .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}}
|
||||
@@ -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-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-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-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}}
|
||||
|
||||
@@ -41,14 +41,15 @@
|
||||
<div class="card">
|
||||
<h2>DMARC aggregate reports</h2>
|
||||
<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
|
||||
receives inbound mail. SelfPost does not receive inbound mail — point
|
||||
<code>rua=</code> at a mailbox elsewhere.</p>
|
||||
domain (can be overridden per domain).{{if .DMARCIngestEnabled}} When set to
|
||||
an address on <code>{{.HostedReportAddress}}</code>, SelfPost receives and
|
||||
parses aggregate reports on port 25.{{else}} Use a mailbox on a domain
|
||||
that receives inbound mail elsewhere.{{end}}</p>
|
||||
|
||||
<label for="dmarc_report_email">Default report address</label>
|
||||
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
|
||||
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}}
|
||||
<p class="muted">When <code>rua=</code> points at another domain, that hub
|
||||
|
||||
@@ -222,7 +222,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
wide := map[string]bool{
|
||||
"settings": true, "deliveries": true, "delivery": true, "mail_queue": true,
|
||||
"status": true, "system_log": true, "domain_detail": true,
|
||||
"inbound": true, "inbound_domain": true,
|
||||
"inbound": true, "inbound_domain": true, "dmarc": true,
|
||||
}
|
||||
for name, page := range engine.Pages() {
|
||||
var buf bytes.Buffer
|
||||
@@ -324,6 +324,8 @@ func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
"domain_delete.html": true,
|
||||
"inbound_domain.html": true,
|
||||
"inbound_delete.html": true,
|
||||
"dmarc_domain.html": true,
|
||||
"dmarc_report.html": true,
|
||||
"delivery.html": true,
|
||||
}
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
|
||||
@@ -22,6 +22,7 @@ type Engine struct {
|
||||
fragments map[string]*template.Template
|
||||
version string
|
||||
inboundEnabled bool
|
||||
dmarcEnabled bool
|
||||
}
|
||||
|
||||
// pageFiles maps a logical page name to its template files. Every page
|
||||
@@ -45,6 +46,9 @@ var pageFiles = map[string][]string{
|
||||
"inbound": {"templates/inbound.html"},
|
||||
"inbound_domain": {"templates/inbound_domain.html"},
|
||||
"inbound_delete": {"templates/inbound_delete.html"},
|
||||
"dmarc": {"templates/dmarc.html"},
|
||||
"dmarc_domain": {"templates/dmarc_domain.html"},
|
||||
"dmarc_report": {"templates/dmarc_report.html"},
|
||||
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
|
||||
"delivery": {"templates/delivery.html"},
|
||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||
@@ -92,6 +96,11 @@ func (e *Engine) SetInboundEnabled(v bool) {
|
||||
e.inboundEnabled = v
|
||||
}
|
||||
|
||||
// SetDMARCEnabled controls whether the DMARC nav item is shown.
|
||||
func (e *Engine) SetDMARCEnabled(v bool) {
|
||||
e.dmarcEnabled = v
|
||||
}
|
||||
|
||||
// templateFuncs supplies helpers shared across page templates.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
@@ -135,6 +144,7 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any
|
||||
m["Copyright"] = legal.CopyrightLine
|
||||
m["SourceURL"] = legal.SourceURL
|
||||
m["InboundEnabled"] = e.inboundEnabled
|
||||
m["DMARCEnabled"] = e.dmarcEnabled
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
|
||||
+13
-2
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/legal"
|
||||
@@ -85,6 +86,8 @@ type Config struct {
|
||||
RetryPolicy postfix.RetryPolicy
|
||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE.
|
||||
InboundEnabled bool
|
||||
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
|
||||
DMARCEnabled bool
|
||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
|
||||
SendLogRetentionEnvDefault int
|
||||
}
|
||||
@@ -101,19 +104,20 @@ type Server struct {
|
||||
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
|
||||
// apps owns application SASL accounts and the Postfix sender map
|
||||
// (architecture.md § Mail path).
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
v, err := view.New(cfg.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.SetInboundEnabled(cfg.InboundEnabled)
|
||||
v.SetDMARCEnabled(cfg.DMARCEnabled)
|
||||
a := auth.New(st, auth.Config{
|
||||
CookieSecure: cfg.CookieSecure,
|
||||
Hostname: cfg.Hostname,
|
||||
SessionIdleDays: cfg.SessionIdleDays,
|
||||
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
||||
}, v, setupTokenPath)
|
||||
h := handlers.New(st, domains, apps, inboundSvc, handlers.Config{
|
||||
h := handlers.New(st, domains, apps, inboundSvc, dmarcSvc, handlers.Config{
|
||||
Hostname: cfg.Hostname,
|
||||
SubmissionEnabled: cfg.SubmissionEnabled,
|
||||
MailLogPath: cfg.MailLogPath,
|
||||
@@ -128,6 +132,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
||||
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||
RetryPolicy: cfg.RetryPolicy,
|
||||
InboundEnabled: cfg.InboundEnabled,
|
||||
DMARCEnabled: cfg.DMARCEnabled,
|
||||
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
|
||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
||||
@@ -188,6 +193,12 @@ func (s *Server) Handler() http.Handler {
|
||||
authed.HandleFunc("POST /inbound/{id}/delete", h.HandleInboundDelete)
|
||||
}
|
||||
|
||||
if s.cfg.DMARCEnabled {
|
||||
authed.HandleFunc("GET /dmarc", h.HandleDMARCList)
|
||||
authed.HandleFunc("GET /dmarc/reports/{id}", h.HandleDMARCReport)
|
||||
authed.HandleFunc("GET /dmarc/domains/{id}", h.HandleDMARCDomain)
|
||||
}
|
||||
|
||||
authed.HandleFunc("/settings", h.HandleSettings)
|
||||
authed.HandleFunc("/account", redirectSettings)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user