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
+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
}