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

Receive DMARC aggregate reports on port 25 and show parsed summaries in the panel. Close Unreleased; pin compose and docs to 1.7.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 22:53:36 +03:00
parent c1ec4fbd79
commit 27aeadc71d
38 changed files with 1923 additions and 79 deletions
@@ -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")
}