1// Package dmarcrpt parses DMARC aggregate feedback reports.
2package dmarcrpt
3
4import (
5 "archive/zip"
6 "bytes"
7 "compress/gzip"
8 "encoding/xml"
9 "errors"
10 "fmt"
11 "io"
12 "log/slog"
13 "net/http"
14 "strings"
15
16 "github.com/mjl-/mox/message"
17 "github.com/mjl-/mox/mlog"
18 "github.com/mjl-/mox/moxio"
19)
20
21var ErrNoReport = errors.New("no dmarc aggregate report found in message")
22
23// ParseReport parses an XML aggregate feedback report.
24// The maximum report size is 20MB.
25func ParseReport(r io.Reader) (*Feedback, error) {
26 r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024}
27 var feedback Feedback
28 d := xml.NewDecoder(r)
29 if err := d.Decode(&feedback); err != nil {
30 return nil, err
31 }
32 return &feedback, nil
33}
34
35// ParseMessageReport parses an aggregate feedback report from a mail message. The
36// maximum message size is 15MB, the maximum report size after decompression is
37// 20MB.
38func ParseMessageReport(elog *slog.Logger, r io.ReaderAt) (*Feedback, error) {
39 log := mlog.New("dmarcrpt", elog)
40 // ../rfc/7489:1801
41 p, err := message.Parse(log.Logger, true, &moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
42 if err != nil {
43 return nil, fmt.Errorf("parsing mail message: %s", err)
44 }
45
46 return parseMessageReport(log, p)
47}
48
49func parseMessageReport(log mlog.Log, p message.Part) (*Feedback, error) {
50 // Pretty much any mime structure is allowed. ../rfc/7489:1861
51 // In practice, some parties will send the report as the only (non-multipart)
52 // content of the message.
53
54 if p.MediaType != "MULTIPART" {
55 return parseReport(p)
56 }
57
58 for {
59 sp, err := p.ParseNextPart(log.Logger)
60 if err == io.EOF {
61 return nil, ErrNoReport
62 }
63 if err != nil {
64 return nil, err
65 }
66 report, err := parseMessageReport(log, *sp)
67 if err == ErrNoReport {
68 continue
69 } else if err != nil || report != nil {
70 return report, err
71 }
72 }
73}
74
75func parseReport(p message.Part) (*Feedback, error) {
76 ct := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
77 r := p.Reader()
78
79 // If no (useful) content-type is set, try to detect it.
80 if ct == "" || ct == "application/octet-stream" {
81 data := make([]byte, 512)
82 n, err := io.ReadFull(r, data)
83 if err == io.EOF {
84 return nil, ErrNoReport
85 } else if err != nil && err != io.ErrUnexpectedEOF {
86 return nil, fmt.Errorf("reading application/octet-stream for content-type detection: %v", err)
87 }
88 data = data[:n]
89 ct = http.DetectContentType(data)
90 r = io.MultiReader(bytes.NewReader(data), r)
91 }
92
93 switch ct {
94 case "application/zip":
95 // Google sends messages with direct application/zip content-type.
96 return parseZip(r)
97 case "application/gzip", "application/x-gzip":
98 gzr, err := gzip.NewReader(r)
99 if err != nil {
100 return nil, fmt.Errorf("decoding gzip xml report: %s", err)
101 }
102 return ParseReport(gzr)
103 case "text/xml", "application/xml":
104 return ParseReport(r)
105 }
106 return nil, ErrNoReport
107}
108
109func parseZip(r io.Reader) (*Feedback, error) {
110 buf, err := io.ReadAll(r)
111 if err != nil {
112 return nil, fmt.Errorf("reading feedback: %s", err)
113 }
114 zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
115 if err != nil {
116 return nil, fmt.Errorf("parsing zip file: %s", err)
117 }
118 if len(zr.File) != 1 {
119 return nil, fmt.Errorf("zip contains %d files, expected 1", len(zr.File))
120 }
121 f, err := zr.File[0].Open()
122 if err != nil {
123 return nil, fmt.Errorf("opening file in zip: %s", err)
124 }
125 defer f.Close()
126 return ParseReport(f)
127}
128