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