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
+31
View File
@@ -0,0 +1,31 @@
package dmarc
import (
"strings"
)
// DefaultLocalPart is the mailbox local-part for the panel-wide hosted address.
const DefaultLocalPart = "dmarc-reports"
// HostedReportAddress is the per-domain SelfPost-hosted rua= destination.
func HostedReportAddress(hostname, domain string) string {
hostname = strings.ToLower(strings.TrimSpace(hostname))
domain = strings.ToLower(strings.TrimSpace(domain))
return DefaultLocalPart + "+" + domain + "@" + hostname
}
// DefaultHostedReportAddress is the settings-level hosted rua= when ingest is on.
func DefaultHostedReportAddress(hostname string) string {
return DefaultLocalPart + "@" + strings.ToLower(strings.TrimSpace(hostname))
}
// IsHostedOnHostname reports whether addr is delivered locally on hostname.
func IsHostedOnHostname(addr, hostname string) bool {
addr = strings.ToLower(strings.TrimSpace(addr))
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" || !strings.Contains(addr, "@") {
return false
}
at := strings.LastIndex(addr, "@")
return addr[at+1:] == hostname
}
+133
View File
@@ -0,0 +1,133 @@
package dmarc
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"strings"
"time"
"github.com/mixeme/selfpost/internal/store"
)
// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC
// aggregate attachment it finds.
func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt time.Time) error {
raw, err := io.ReadAll(io.LimitReader(r, 12<<20))
if err != nil {
return fmt.Errorf("read message: %w", err)
}
payload, err := extractAggregatePayload(raw)
if err != nil {
return err
}
parsed, err := ParseAggregate(payload)
if err != nil {
return err
}
rep := store.DMARCReport{
Domain: parsed.Domain,
Reporter: parsed.Reporter,
ReportID: parsed.ReportID,
PeriodBegin: parsed.PeriodBegin,
PeriodEnd: parsed.PeriodEnd,
ReceivedAt: receivedAt.UTC(),
ContactEmail: parsed.ContactEmail,
PolicyP: parsed.PolicyP,
PolicySP: parsed.PolicySP,
PolicyPct: parsed.PolicyPct,
PolicyADKIM: parsed.PolicyADKIM,
PolicyASPF: parsed.PolicyASPF,
PassCount: parsed.PassCount,
FailCount: parsed.FailCount,
Recipient: strings.ToLower(strings.TrimSpace(recipient)),
}
for _, rec := range parsed.Records {
rep.Records = append(rep.Records, store.DMARCReportRecord{
SourceIP: rec.SourceIP,
Count: rec.Count,
Disposition: rec.Disposition,
SPFResult: rec.SPFResult,
DKIMResult: rec.DKIMResult,
HeaderFrom: rec.HeaderFrom,
})
}
if _, err := st.InsertDMARCReport(rep); err != nil {
return err
}
return st.PruneDMARCReports()
}
func extractAggregatePayload(raw []byte) ([]byte, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("parse message: %w", err)
}
ct := msg.Header.Get("Content-Type")
if ct == "" {
return io.ReadAll(io.LimitReader(msg.Body, 8<<20))
}
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
return nil, fmt.Errorf("content-type: %w", err)
}
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil, fmt.Errorf("multipart without boundary")
}
mr := multipart.NewReader(msg.Body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("multipart: %w", err)
}
name := strings.ToLower(part.FileName())
if name == "" {
name = strings.ToLower(part.Header.Get("Content-Type"))
}
if !looksLikeAggregate(name) {
continue
}
data, err := readEncodedBody(textproto.MIMEHeader(part.Header), part)
if err != nil {
return nil, err
}
if len(data) > 0 {
return data, nil
}
}
return nil, fmt.Errorf("no aggregate attachment found")
}
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
}
func looksLikeAggregate(name string) bool {
name = strings.ToLower(name)
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
}
func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
encoding := strings.ToLower(hdr.Get("Content-Transfer-Encoding"))
var body io.Reader = r
switch encoding {
case "base64":
body = base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
body = quotedprintable.NewReader(r)
}
data, err := io.ReadAll(io.LimitReader(body, 8<<20))
if err != nil {
return nil, fmt.Errorf("read part body: %w", err)
}
return data, nil
}
+176
View File
@@ -0,0 +1,176 @@
package dmarc
import (
"bytes"
"compress/gzip"
"encoding/xml"
"fmt"
"io"
"strings"
"time"
)
// ParsedReport is the structured form of a DMARC aggregate XML payload.
type ParsedReport struct {
Reporter string
ReportID string
ContactEmail string
PeriodBegin time.Time
PeriodEnd time.Time
Domain string
PolicyP string
PolicySP string
PolicyPct int
PolicyADKIM string
PolicyASPF string
Records []ParsedRecord
PassCount int
FailCount int
}
// ParsedRecord is one <record> row from the aggregate XML.
type ParsedRecord struct {
SourceIP string
Count int
Disposition string
SPFResult string
DKIMResult string
HeaderFrom string
}
type feedbackXML struct {
XMLName xml.Name `xml:"feedback"`
Metadata struct {
OrgName string `xml:"org_name"`
Email string `xml:"email"`
ReportID string `xml:"report_id"`
DateRange struct {
Begin int64 `xml:"begin"`
End int64 `xml:"end"`
} `xml:"date_range"`
} `xml:"report_metadata"`
Policy struct {
Domain string `xml:"domain"`
P string `xml:"p"`
SP string `xml:"sp"`
Pct int `xml:"pct"`
ADKIM string `xml:"adkim"`
ASPF string `xml:"aspf"`
} `xml:"policy_published"`
Records []struct {
Row struct {
SourceIP string `xml:"source_ip"`
Count int `xml:"count"`
Policy struct {
Disposition string `xml:"disposition"`
DKIM string `xml:"dkim"`
SPF string `xml:"spf"`
} `xml:"policy_evaluated"`
} `xml:"row"`
Identifiers struct {
HeaderFrom string `xml:"header_from"`
} `xml:"identifiers"`
} `xml:"record"`
}
// ParseAggregate decodes gzip-compressed or raw DMARC aggregate XML.
func ParseAggregate(raw []byte) (ParsedReport, error) {
data, err := maybeGunzip(raw)
if err != nil {
return ParsedReport{}, err
}
var doc feedbackXML
if err := xml.Unmarshal(data, &doc); err != nil {
return ParsedReport{}, fmt.Errorf("dmarc xml: %w", err)
}
if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" {
return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain")
}
out := ParsedReport{
Reporter: strings.TrimSpace(doc.Metadata.OrgName),
ReportID: strings.TrimSpace(doc.Metadata.ReportID),
ContactEmail: strings.TrimSpace(doc.Metadata.Email),
PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(),
PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(),
Domain: strings.ToLower(strings.TrimSpace(doc.Policy.Domain)),
PolicyP: strings.TrimSpace(doc.Policy.P),
PolicySP: strings.TrimSpace(doc.Policy.SP),
PolicyPct: doc.Policy.Pct,
PolicyADKIM: strings.TrimSpace(doc.Policy.ADKIM),
PolicyASPF: strings.TrimSpace(doc.Policy.ASPF),
}
if out.PolicyPct == 0 {
out.PolicyPct = 100
}
for _, rec := range doc.Records {
row := ParsedRecord{
SourceIP: strings.TrimSpace(rec.Row.SourceIP),
Count: rec.Row.Count,
Disposition: strings.TrimSpace(rec.Row.Policy.Disposition),
SPFResult: strings.TrimSpace(rec.Row.Policy.SPF),
DKIMResult: strings.TrimSpace(rec.Row.Policy.DKIM),
HeaderFrom: strings.TrimSpace(rec.Identifiers.HeaderFrom),
}
if row.Count <= 0 {
row.Count = 1
}
if dmarcRecordPasses(row) {
out.PassCount += row.Count
} else {
out.FailCount += row.Count
}
out.Records = append(out.Records, row)
}
return out, nil
}
func dmarcRecordPasses(r ParsedRecord) bool {
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
}
func maybeGunzip(raw []byte) ([]byte, error) {
if len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("dmarc gzip: %w", err)
}
defer zr.Close()
data, err := io.ReadAll(io.LimitReader(zr, 8<<20))
if err != nil {
return nil, fmt.Errorf("dmarc gzip read: %w", err)
}
return data, nil
}
return raw, nil
}
// TightenPolicyHint summarises whether raising p= looks reasonable.
func TightenPolicyHint(pass, fail int, sources []SourceHint) string {
total := pass + fail
if total == 0 {
return "No messages in the reporting window yet."
}
failPct := float64(fail) * 100 / float64(total)
if fail > 0 {
for _, s := range sources {
if s.FailCount > 0 && !s.ThisRelay {
return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed."
}
}
}
if failPct <= 2 && pass > 0 {
return "Alignment looks strong. Tightening p= may be reasonable."
}
if fail > 0 {
return "Some failures remain. Review sources before tightening p=."
}
return "Alignment looks clean for this window."
}
// SourceHint is a panel-facing rollup row with relay detection.
type SourceHint struct {
SourceIP string
PassCount int
FailCount int
ThisRelay bool
}
+116
View File
@@ -0,0 +1,116 @@
package dmarc
import (
"bytes"
"compress/gzip"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
const sampleXML = `<?xml version="1.0"?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply@google.com</email>
<report_id>12345</report_id>
<date_range><begin>1723593600</begin><end>1723680000</end></date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<p>none</p><sp>none</sp><pct>100</pct>
<adkim>r</adkim><aspf>r</aspf>
</policy_published>
<record>
<row>
<source_ip>203.0.113.10</source_ip>
<count>10</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim><spf>pass</spf>
</policy_evaluated>
</row>
<identifiers><header_from>example.com</header_from></identifiers>
</record>
<record>
<row>
<source_ip>198.51.100.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>fail</dkim><spf>fail</spf>
</policy_evaluated>
</row>
<identifiers><header_from>example.com</header_from></identifiers>
</record>
</feedback>`
func TestParseAggregateGzip(t *testing.T) {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write([]byte(sampleXML)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
got, err := ParseAggregate(buf.Bytes())
if err != nil {
t.Fatalf("ParseAggregate: %v", err)
}
if got.Domain != "example.com" || got.Reporter != "google.com" {
t.Fatalf("metadata = %+v", got)
}
if got.PassCount != 10 || got.FailCount != 2 {
t.Fatalf("counts = %d pass %d fail", got.PassCount, got.FailCount)
}
if !got.PeriodBegin.Equal(time.Unix(1723593600, 0).UTC()) {
t.Fatalf("period begin = %v", got.PeriodBegin)
}
}
func TestIngestMessageStoresReport(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
var gz bytes.Buffer
zw := gzip.NewWriter(&gz)
_, _ = zw.Write([]byte(sampleXML))
_ = zw.Close()
msg := bytes.NewBufferString(
"From: noreply@google.com\r\n" +
"To: dmarc-reports@mail.example.com\r\n" +
"Subject: Report\r\n" +
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
"\r\n",
)
// append raw gzip for simple single-part test
msg.Write(gz.Bytes())
if err := IngestMessage(st, msg, "dmarc-reports@mail.example.com", time.Now().UTC()); err != nil {
t.Fatalf("IngestMessage: %v", err)
}
list, err := st.ListDMARCReports(nil, 10)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("reports = %d", len(list))
}
if list[0].PassCount != 10 {
t.Fatalf("pass = %d", list[0].PassCount)
}
}
func TestHostedReportAddress(t *testing.T) {
got := HostedReportAddress("mail.example.com", "Example.COM")
want := "dmarc-reports+example.com@mail.example.com"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
+131
View File
@@ -0,0 +1,131 @@
// Package dmarc receives DMARC aggregate reports: Postfix pipes messages here,
// gzip/XML is parsed, and summaries land in SQLite for the panel.
package dmarc
import (
"fmt"
"sort"
"strings"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// Maps is the Postfix manager surface the DMARC service needs.
type Maps interface {
RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error
}
// Service coordinates DMARC ingest allow-lists across SQLite and Postfix.
type Service struct {
store *store.Store
maps Maps
hostname string
enabled bool
}
// NewService builds the DMARC ingest service.
func NewService(st *store.Store, maps Maps, hostname string, enabled bool) *Service {
return &Service{store: st, maps: maps, hostname: strings.ToLower(strings.TrimSpace(hostname)), enabled: enabled}
}
// Enabled reports whether DMARC ingest is active in this deployment.
func (s *Service) Enabled() bool { return s.enabled }
// Resync rebuilds the Postfix allow-list and transport maps from SQLite.
func (s *Service) Resync() error {
if !s.enabled {
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{})
}
addrs, err := s.AllowedRecipients()
if err != nil {
return err
}
domains := recipientDomains(addrs)
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{
Recipients: addrs,
Domains: domains,
})
}
// AllowedRecipients returns every address Postfix may accept for DMARC ingest.
func (s *Service) AllowedRecipients() ([]string, error) {
if !s.enabled || s.hostname == "" {
return nil, nil
}
profile, err := s.store.GlobalDMARCReportEmail()
if err != nil {
return nil, err
}
seen := make(map[string]bool)
var out []string
add := func(addr string) {
addr = strings.ToLower(strings.TrimSpace(addr))
if addr == "" || !IsHostedOnHostname(addr, s.hostname) || seen[addr] {
return
}
seen[addr] = true
out = append(out, addr)
}
add(profile)
if profile == "" {
add(DefaultHostedReportAddress(s.hostname))
}
domains, err := s.store.ListDomains()
if err != nil {
return nil, err
}
for _, d := range domains {
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile)
if rua == "" {
continue
}
if IsHostedOnHostname(rua, s.hostname) {
add(rua)
continue
}
if d.DMARCRua.Valid && d.DMARCRua.String == "" {
continue
}
hosted := HostedReportAddress(s.hostname, d.Name)
if strings.EqualFold(rua, hosted) {
add(rua)
}
}
sort.Strings(out)
return out, nil
}
func recipientDomains(addrs []string) []string {
seen := make(map[string]bool)
var out []string
for _, addr := range addrs {
d := dnscheck.EmailDomain(addr)
if d == "" || seen[d] {
continue
}
seen[d] = true
out = append(out, d)
}
sort.Strings(out)
return out
}
// HostedSuggestion returns the address the panel should suggest for a domain.
func (s *Service) HostedSuggestion(domain string) string {
return HostedReportAddress(s.hostname, domain)
}
// DefaultHostedSuggestion is the settings-level hosted address.
func (s *Service) DefaultHostedSuggestion() string {
return DefaultHostedReportAddress(s.hostname)
}
// ValidateHostedAddress ensures addr is on this hostname before saving.
func (s *Service) ValidateHostedAddress(addr string) error {
if !IsHostedOnHostname(addr, s.hostname) {
return fmt.Errorf("hosted report address must be on %s", s.hostname)
}
return nil
}