27aeadc71d
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>
134 lines
3.5 KiB
Go
134 lines
3.5 KiB
Go
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
|
|
}
|