mirror of
https://github.com/mjl-/mox.git
synced 2024-12-27 08:53:48 +03:00
125 lines
3.1 KiB
Go
125 lines
3.1 KiB
Go
|
// Package dmarcrpt parses DMARC aggregate feedback reports.
|
||
|
package dmarcrpt
|
||
|
|
||
|
import (
|
||
|
"archive/zip"
|
||
|
"bytes"
|
||
|
"compress/gzip"
|
||
|
"encoding/xml"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"net/http"
|
||
|
"strings"
|
||
|
|
||
|
"github.com/mjl-/mox/message"
|
||
|
"github.com/mjl-/mox/moxio"
|
||
|
)
|
||
|
|
||
|
var ErrNoReport = errors.New("no dmarc report found in message")
|
||
|
|
||
|
// ParseReport parses an XML aggregate feedback report.
|
||
|
// The maximum report size is 20MB.
|
||
|
func ParseReport(r io.Reader) (*Feedback, error) {
|
||
|
r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024}
|
||
|
var feedback Feedback
|
||
|
d := xml.NewDecoder(r)
|
||
|
if err := d.Decode(&feedback); err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
return &feedback, nil
|
||
|
}
|
||
|
|
||
|
// ParseMessageReport parses an aggregate feedback report from a mail message. The
|
||
|
// maximum message size is 15MB, the maximum report size after decompression is
|
||
|
// 20MB.
|
||
|
func ParseMessageReport(r io.ReaderAt) (*Feedback, error) {
|
||
|
// ../rfc/7489:1801
|
||
|
p, err := message.Parse(&moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("parsing mail message: %s", err)
|
||
|
}
|
||
|
|
||
|
return parseMessageReport(p)
|
||
|
}
|
||
|
|
||
|
func parseMessageReport(p message.Part) (*Feedback, error) {
|
||
|
// Pretty much any mime structure is allowed. ../rfc/7489:1861
|
||
|
// In practice, some parties will send the report as the only (non-multipart)
|
||
|
// content of the message.
|
||
|
|
||
|
if p.MediaType != "MULTIPART" {
|
||
|
return parseReport(p)
|
||
|
}
|
||
|
|
||
|
for {
|
||
|
sp, err := p.ParseNextPart()
|
||
|
if err == io.EOF {
|
||
|
return nil, ErrNoReport
|
||
|
}
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
report, err := parseMessageReport(*sp)
|
||
|
if err == ErrNoReport {
|
||
|
continue
|
||
|
} else if err != nil || report != nil {
|
||
|
return report, err
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func parseReport(p message.Part) (*Feedback, error) {
|
||
|
ct := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
|
||
|
r := p.Reader()
|
||
|
|
||
|
// If no (useful) content-type is set, try to detect it.
|
||
|
if ct == "" || ct == "application/octect-stream" {
|
||
|
data := make([]byte, 512)
|
||
|
n, err := io.ReadFull(r, data)
|
||
|
if err == io.EOF {
|
||
|
return nil, ErrNoReport
|
||
|
} else if err != nil && err != io.ErrUnexpectedEOF {
|
||
|
return nil, fmt.Errorf("reading application/octet-stream for content-type detection: %v", err)
|
||
|
}
|
||
|
data = data[:n]
|
||
|
ct = http.DetectContentType(data)
|
||
|
r = io.MultiReader(bytes.NewReader(data), r)
|
||
|
}
|
||
|
|
||
|
switch ct {
|
||
|
case "application/zip":
|
||
|
// Google sends messages with direct application/zip content-type.
|
||
|
return parseZip(r)
|
||
|
case "application/gzip":
|
||
|
gzr, err := gzip.NewReader(r)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("decoding gzip xml report: %s", err)
|
||
|
}
|
||
|
return ParseReport(gzr)
|
||
|
case "text/xml", "application/xml":
|
||
|
return ParseReport(r)
|
||
|
}
|
||
|
return nil, ErrNoReport
|
||
|
}
|
||
|
|
||
|
func parseZip(r io.Reader) (*Feedback, error) {
|
||
|
buf, err := io.ReadAll(r)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("reading feedback: %s", err)
|
||
|
}
|
||
|
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("parsing zip file: %s", err)
|
||
|
}
|
||
|
if len(zr.File) != 1 {
|
||
|
return nil, fmt.Errorf("zip contains %d files, expected 1", len(zr.File))
|
||
|
}
|
||
|
f, err := zr.File[0].Open()
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("opening file in zip: %s", err)
|
||
|
}
|
||
|
defer f.Close()
|
||
|
return ParseReport(f)
|
||
|
}
|