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