From 92d108be70f8aab756ff30b8d4b2df369755d1c9 Mon Sep 17 00:00:00 2001
From: ale <ale@incal.net>
Date: Fri, 23 Oct 2020 16:31:12 +0100
Subject: [PATCH] Implement support for email ingestion

Run our own little SMTP server, attempt to parse all incoming
messages. Currently only DMARC email support is implemented (both ZIP
and gzip formats).
---
 README.md                     |  11 +++-
 browser.go                    |   6 ++
 cmd/reports-collector/main.go | 114 +++++++++++++++++++++++++---------
 collector.go                  |  48 ++++++++++++++
 collector_test.go             |  36 +++++++++++
 csp.go                        |   6 ++
 dmarc.go                      |  84 ++++++++++++++++++++++---
 go.mod                        |   5 +-
 go.sum                        |  26 ++++++++
 tlsrpt.go                     |   6 ++
 10 files changed, 302 insertions(+), 40 deletions(-)

diff --git a/README.md b/README.md
index 38df0ae..43272f7 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,8 @@ reports-collector
 
 Proof of concept of an ingestion service for anomaly reports.
 
-Supports receiving reports over HTTPS for the following mechanisms:
+Supports receiving reports over HTTPS and SMTP for the following
+mechanisms:
 
 * [Browser Reporting API (NEL, CSP, etc)](https://www.w3.org/TR/reporting/)
 * [TLS-RPT](https://tools.ietf.org/html/rfc8460)
@@ -17,12 +18,18 @@ The source IP address is not included in the output, but for user
 reports we add instead the ASN, to allow for some meaningful
 non-deanonymizing aggregation of the reports.
 
+The SMTP receiver works by running a very simple SMTP server (on the
+port specified by *--smtp-addr*), meant to be downstream to your
+MTA. This SMTP server does not check the recipient address and just
+attempts to parse all received email.
+
 Things that are still to do:
 
+* Implement email ingestion support for TLS-RPT reports
 * More testing with real-world reports
-* Maybe add support for email ingestion
 
 Right now the server offers a single intake endpoint at */ingest/v1*,
 and then looks at the Content-Type of the request to figure out what
 kind of report it is. It might be just simpler to switch to separate
 endpoints per report type.
+
diff --git a/browser.go b/browser.go
index 36da13e..09e506b 100644
--- a/browser.go
+++ b/browser.go
@@ -6,6 +6,8 @@ import (
 	"net/http"
 	"net/url"
 	"time"
+
+	"github.com/jhillyerd/enmime"
 )
 
 // Generic browser report as per https://www.w3.org/TR/reporting/.
@@ -36,6 +38,10 @@ func (h *ReportHandler) Parse(contentType string, req *http.Request) ([]Event, e
 	return events, nil
 }
 
+func (h *ReportHandler) ParseMIME(*enmime.Part) ([]Event, error) {
+	return nil, ErrNoMatch
+}
+
 func (h *ReportHandler) eventFromReport(req *http.Request, report *report) Event {
 	ts := time.Now().Add(time.Duration(-report.Age) * time.Second)
 
diff --git a/cmd/reports-collector/main.go b/cmd/reports-collector/main.go
index da1bd0c..5278fc6 100644
--- a/cmd/reports-collector/main.go
+++ b/cmd/reports-collector/main.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"flag"
 	"log"
+	"net"
 	"net/http"
 	"os"
 	"os/signal"
@@ -11,13 +12,17 @@ import (
 	"time"
 
 	rc "git.autistici.org/ai3/tools/reports-collector"
+	"github.com/chrj/smtpd"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
+	"golang.org/x/sync/errgroup"
 )
 
 var (
-	addr = flag.String("addr", fromEnv("ADDR", ":4890"), "address to listen on")
+	addr     = flag.String("addr", fromEnv("ADDR", ":4890"), "address to listen on (HTTP)")
+	smtpAddr = flag.String("smtp-addr", fromEnv("SMTP_ADDR", ""), "address to listen on (SMTP), disable incoming SMTP if empty")
 
 	gracefulShutdownTimeout = 5 * time.Second
+	maxMessageSize          = 20 * 1024 * 1024
 )
 
 func fromEnv(name, deflt string) string {
@@ -39,42 +44,93 @@ func main() {
 		new(rc.DMARCHandler),
 	)
 
+	// Crete an errgroup.Group with a controlling Context that
+	// will be canceled if any of our protocol servers fail to
+	// start.
+	outerCtx, cancel := context.WithCancel(context.Background())
+	g, ctx := errgroup.WithContext(outerCtx)
+
 	// Create the http.Server.
-	mux := http.NewServeMux()
-	mux.Handle("/ingest/v1", collector)
-	mux.Handle("/metrics", promhttp.Handler())
-	server := &http.Server{
-		Addr:         *addr,
-		Handler:      mux,
-		ReadTimeout:  10 * time.Second,
-		IdleTimeout:  30 * time.Second,
-		WriteTimeout: 10 * time.Second,
+	g.Go(func() error {
+		mux := http.NewServeMux()
+		mux.Handle("/ingest/v1", collector)
+		mux.Handle("/metrics", promhttp.Handler())
+		server := &http.Server{
+			Addr:         *addr,
+			Handler:      mux,
+			ReadTimeout:  10 * time.Second,
+			IdleTimeout:  30 * time.Second,
+			WriteTimeout: 10 * time.Second,
+		}
+
+		go func() {
+			<-ctx.Done()
+			if ctx.Err() != context.Canceled {
+				return
+			}
+			// Gracefully terminate, then shut
+			// down remaining clients.
+			sctx, scancel := context.WithTimeout(
+				context.Background(),
+				gracefulShutdownTimeout)
+			defer scancel()
+			if err := server.Shutdown(sctx); err == context.Canceled {
+				if err := server.Close(); err != nil {
+					log.Printf("error terminating server: %v", err)
+				}
+			}
+		}()
+
+		log.Printf("starting HTTP server on %s", *addr)
+		err := server.ListenAndServe()
+		if err != nil && err != http.ErrServerClosed {
+			return err
+		}
+		return nil
+	})
+
+	// Create the SMTP server.
+	if *smtpAddr != "" {
+		g.Go(func() error {
+			hostname, _ := os.Hostname()
+			server := &smtpd.Server{
+				Hostname:       hostname,
+				Handler:        collector.ServeSMTP,
+				ReadTimeout:    60 * time.Second,
+				WriteTimeout:   60 * time.Second,
+				DataTimeout:    60 * time.Second,
+				MaxConnections: 100,
+				MaxMessageSize: maxMessageSize,
+				MaxRecipients:  1,
+			}
+
+			// Create our own listener so we can shut down cleanly.
+			log.Printf("starting SMTP server on %s", *smtpAddr)
+			l, err := net.Listen("tcp", *smtpAddr)
+			if err != nil {
+				log.Fatal(err)
+			}
+			go func() {
+				<-ctx.Done()
+				l.Close()
+			}()
+
+			return server.Serve(l)
+		})
 	}
 
-	done := make(chan struct{})
+	// Cancel the outer context if we receive a termination
+	// signal. This will cause the servers to be stopped.
 	sigCh := make(chan os.Signal, 1)
 	go func() {
 		<-sigCh
-		log.Printf("terminating")
-		// Gracefully terminate, then shut down remaining
-		// clients.
-		ctx, cancel := context.WithTimeout(
-			context.Background(), gracefulShutdownTimeout)
-		defer cancel()
-		if err := server.Shutdown(ctx); err == context.Canceled {
-			if err := server.Close(); err != nil {
-				log.Printf("error terminating server: %v", err)
-			}
-		}
-		close(done)
+		log.Printf("signal received, terminating")
+		cancel()
 	}()
-
 	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
 
-	err := server.ListenAndServe()
-	if err != nil && err != http.ErrServerClosed {
-		log.Fatalf("server error: %v", err)
+	err := g.Wait()
+	if err != nil && err != context.Canceled {
+		log.Printf("error: %v", err)
 	}
-
-	<-done
 }
diff --git a/collector.go b/collector.go
index b3d1a92..24e3a9a 100644
--- a/collector.go
+++ b/collector.go
@@ -1,12 +1,15 @@
 package reportscollector
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
 	"log"
 	"mime"
 	"net/http"
 
+	"github.com/chrj/smtpd"
+	"github.com/jhillyerd/enmime"
 	"github.com/prometheus/client_golang/prometheus"
 )
 
@@ -14,6 +17,7 @@ var ErrNoMatch = errors.New("no match")
 
 type Handler interface {
 	Parse(string, *http.Request) ([]Event, error)
+	ParseMIME(*enmime.Part) ([]Event, error)
 }
 
 type Sink interface {
@@ -91,6 +95,50 @@ hloop:
 	}
 }
 
+func (c *Collector) ServeSMTP(peer smtpd.Peer, env smtpd.Envelope) error {
+	msgParts, err := enmime.ReadParts(bytes.NewReader(env.Data))
+	if err != nil {
+		log.Printf("smtp: error parsing input: %v", err)
+		return smtpd.Error{550, "Error parsing input"}
+	}
+
+	// Find a handler that can successfully parse the request, and
+	// get a list of Events.
+	var events []Event
+	matched := false
+hloop:
+	for _, h := range c.handlers {
+		var err error
+		events, err = h.ParseMIME(msgParts)
+		switch err {
+		case ErrNoMatch:
+			continue
+		case nil:
+			matched = true
+			break hloop
+		default:
+			log.Printf("smtp: error handling report: %v", err)
+			// Discard the message.
+			return nil
+		}
+	}
+
+	if !matched {
+		log.Printf("smtp: no matching handlers")
+		// Discard the message.
+		return nil
+	}
+
+	// Augment the Events with additional information obtained
+	// from the HTTP request, and send them to the forwarder.
+	for _, e := range events {
+		c.sink.Send(e)
+		reportsByType.WithLabelValues(
+			e.GetString("type"), e.GetString("domain")).Inc()
+	}
+	return nil
+}
+
 var (
 	reportsByType = prometheus.NewCounterVec(
 		prometheus.CounterOpts{
diff --git a/collector_test.go b/collector_test.go
index 752fc23..8103020 100644
--- a/collector_test.go
+++ b/collector_test.go
@@ -1,10 +1,14 @@
 package reportscollector
 
 import (
+	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
+	"path/filepath"
 	"strings"
 	"testing"
+
+	"github.com/chrj/smtpd"
 )
 
 type countingSink struct {
@@ -15,6 +19,8 @@ func (c *countingSink) Send(e Event) {
 	c.counter++
 }
 
+func (c *countingSink) reset() { c.counter = 0 }
+
 func createTestCollector() (*countingSink, string, func()) {
 	sink := new(countingSink)
 	c := NewCollector(
@@ -290,6 +296,36 @@ func TestDMARC_IgnoreSuccesses(t *testing.T) {
 	}
 }
 
+func TestDMARC_Emails(t *testing.T) {
+	files, err := filepath.Glob("dmarc/email-samples/*.txt")
+	if err != nil {
+		t.Skip()
+	}
+
+	sink := new(countingSink)
+	c := NewCollector(
+		sink,
+		new(DMARCHandler),
+	)
+
+	for _, testf := range files {
+		data, _ := ioutil.ReadFile(testf)
+
+		sink.reset()
+		err := c.ServeSMTP(
+			smtpd.Peer{},
+			smtpd.Envelope{Data: data},
+		)
+		if err != nil {
+			t.Errorf("error parsing %s: %v", testf, err)
+			continue
+		}
+		if sink.counter == 0 {
+			t.Errorf("nothing parsed from %s", testf)
+		}
+	}
+}
+
 var cspTestData = `{
   "csp-report": {
     "document-uri": "http://localhost:3000/content",
diff --git a/csp.go b/csp.go
index 8fe37ec..698c38e 100644
--- a/csp.go
+++ b/csp.go
@@ -4,6 +4,8 @@ import (
 	"encoding/json"
 	"net/http"
 	"time"
+
+	"github.com/jhillyerd/enmime"
 )
 
 type legacyCSPReport struct {
@@ -41,6 +43,10 @@ func (h *LegacyCSPHandler) Parse(contentType string, req *http.Request) ([]Event
 	return []Event{h.eventFromReport(req, cnt.Report)}, nil
 }
 
+func (h *LegacyCSPHandler) ParseMIME(*enmime.Part) ([]Event, error) {
+	return nil, ErrNoMatch
+}
+
 func (h *LegacyCSPHandler) eventFromReport(req *http.Request, report *legacyCSPReport) Event {
 	e := make(Event)
 	if asn, ok := lookupASN(getRemoteIP(req)); ok {
diff --git a/dmarc.go b/dmarc.go
index e50d754..064036c 100644
--- a/dmarc.go
+++ b/dmarc.go
@@ -1,11 +1,17 @@
 package reportscollector
 
 import (
+	"archive/zip"
+	"bytes"
 	"compress/gzip"
 	"encoding/xml"
 	"io"
+	"io/ioutil"
 	"net/http"
+	"strings"
 	"time"
+
+	"github.com/jhillyerd/enmime"
 )
 
 type dmarcRecord struct {
@@ -69,6 +75,21 @@ type dmarcReport struct {
 
 type DMARCHandler struct{}
 
+func (h *DMARCHandler) parseDMARC(r io.Reader) ([]Event, error) {
+	var report dmarcReport
+	if err := xml.NewDecoder(r).Decode(&report); err != nil {
+		return nil, err
+	}
+
+	var events []Event
+	for _, rec := range report.Records {
+		if rec.isFailure() {
+			events = append(events, h.eventFromRecord(&report, rec))
+		}
+	}
+	return events, nil
+}
+
 func (h *DMARCHandler) Parse(contentType string, req *http.Request) ([]Event, error) {
 	var r io.Reader
 	switch contentType {
@@ -84,18 +105,42 @@ func (h *DMARCHandler) Parse(contentType string, req *http.Request) ([]Event, er
 		return nil, ErrNoMatch
 	}
 
-	var report dmarcReport
-	if err := xml.NewDecoder(r).Decode(&report); err != nil {
-		return nil, err
+	return h.parseDMARC(r)
+}
+
+func (h *DMARCHandler) ParseMIME(msg *enmime.Part) ([]Event, error) {
+	// Try plain text/xml first.
+	if part := msg.DepthMatchFirst(func(p *enmime.Part) bool {
+		return p.ContentType == "text/xml"
+	}); part != nil {
+		return h.parseDMARC(bytes.NewReader(part.Content))
 	}
 
-	var events []Event
-	for _, rec := range report.Records {
-		if rec.isFailure() {
-			events = append(events, h.eventFromRecord(&report, rec))
+	// Try to decode a gzipped attachment (also detected based on
+	// the filename).
+	if part := msg.DepthMatchFirst(func(p *enmime.Part) bool {
+		return (p.ContentType == "application/gzip" ||
+			strings.HasSuffix(p.FileName, ".xml.gz"))
+	}); part != nil {
+		gz, err := gzip.NewReader(bytes.NewReader(part.Content))
+		if err != nil {
+			return nil, err
 		}
+		return h.parseDMARC(gz)
 	}
-	return events, nil
+
+	// Try to decode a ZIP attachment.
+	if part := msg.DepthMatchFirst(func(p *enmime.Part) bool {
+		return (p.ContentType == "application/zip" ||
+			strings.HasSuffix(p.FileName, ".zip"))
+	}); part != nil {
+		data, ok := extractXMLFromZip(part.Content)
+		if ok {
+			return h.parseDMARC(bytes.NewReader(data))
+		}
+	}
+
+	return nil, ErrNoMatch
 }
 
 func (h *DMARCHandler) eventFromRecord(report *dmarcReport, rec *dmarcRecord) Event {
@@ -114,3 +159,26 @@ func (h *DMARCHandler) eventFromRecord(report *dmarcReport, rec *dmarcRecord) Ev
 
 	return e
 }
+
+// Extract the first file with a .xml extension from a ZIP archive.
+func extractXMLFromZip(zipData []byte) ([]byte, bool) {
+	zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
+	if err != nil {
+		return nil, false
+	}
+	for _, file := range zr.File {
+		if strings.HasSuffix(file.Name, ".xml") {
+			f, err := file.Open()
+			if err != nil {
+				return nil, false
+			}
+			defer f.Close()
+			data, err := ioutil.ReadAll(f)
+			if err != nil {
+				return nil, false
+			}
+			return data, true
+		}
+	}
+	return nil, false
+}
diff --git a/go.mod b/go.mod
index d20925b..b477fe3 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,11 @@
 module git.autistici.org/ai3/tools/reports-collector
 
 require (
+	github.com/chrj/smtpd v0.1.2
+	github.com/jhillyerd/enmime v0.8.2
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
-	github.com/oschwald/geoip2-golang v1.4.0 // indirect
+	github.com/oschwald/geoip2-golang v1.4.0
 	github.com/prometheus/client_golang v1.8.0
+	golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 )
diff --git a/go.sum b/go.sum
index aef073a..01e2350 100644
--- a/go.sum
+++ b/go.sum
@@ -28,8 +28,14 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
 github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
 github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
+github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
 github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chrj/smtpd v0.1.2 h1:yWaMOCmnPlcNgJzkak1TBhhkObAfomd+NmZG5epdO88=
+github.com/chrj/smtpd v0.1.2/go.mod h1:jt4ydELuZmqhn9hn3YpEPV1dY00aOB+Q1nWXnBDFKeY=
+github.com/chrj/smtpd v0.2.0 h1:QGbE4UQz7sKjvXpRgNLuiBOjcWTzBKu/dj0hyDLpD14=
+github.com/chrj/smtpd v0.2.0/go.mod h1:1hmG9KbrE10JG1SmvG79Krh4F6713oUrw2+gRp1oSYk=
 github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
@@ -43,6 +49,7 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/eaigner/dkim v0.0.0-20150301120808-6fe4a7ee9cfb/go.mod h1:FSCIHbrqk7D01Mj8y/jW+NS1uoCerr+ad+IckTHTFf4=
 github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
 github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
 github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
@@ -63,10 +70,13 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
 github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
 github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
+github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
+github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -124,6 +134,10 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO
 github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
+github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE=
+github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
+github.com/jhillyerd/enmime v0.8.2 h1:F3VbaJL1GfIcyKl7Utxcg4lR+LsM/OObxh+N9sWmp7g=
+github.com/jhillyerd/enmime v0.8.2/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
@@ -149,6 +163,8 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO
 github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
+github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
@@ -175,6 +191,8 @@ github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OS
 github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
 github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
 github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
+github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
+github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -199,6 +217,7 @@ github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0
 github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -235,6 +254,7 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
 github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
@@ -247,6 +267,8 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
 github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
+github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
 github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
 github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
@@ -301,7 +323,9 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -310,6 +334,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -334,6 +359,7 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211 h1:9UQO31fZ+0aKQOFldThf7BKPMJTiBfWycGh/u3UoO88=
 golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
diff --git a/tlsrpt.go b/tlsrpt.go
index 525fa7c..3f169e7 100644
--- a/tlsrpt.go
+++ b/tlsrpt.go
@@ -6,6 +6,8 @@ import (
 	"io"
 	"net/http"
 	"time"
+
+	"github.com/jhillyerd/enmime"
 )
 
 type tlsrptFailure struct {
@@ -78,6 +80,10 @@ func (h *TLSRPTHandler) Parse(contentType string, req *http.Request) ([]Event, e
 	return events, nil
 }
 
+func (h *TLSRPTHandler) ParseMIME(*enmime.Part) ([]Event, error) {
+	return nil, ErrNoMatch
+}
+
 func (h *TLSRPTHandler) eventFromFailure(report *tlsrpt, policy *tlsrptPolicy, failure *tlsrptFailure) Event {
 	e := make(Event)
 	e.Set("type", "tlsrpt")
-- 
GitLab