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