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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user