Add end-to-end tests for TLS RPT report ingestion and WebSocket event handling
- Implemented tests for ingesting TLS RPT reports from emails and ensuring they are stored correctly in the database. - Added tests to verify that the same report can be ingested multiple times without duplication. - Created tests for backfilling reports that were previously delivered to the inbox. - Developed WebSocket event handling tests to ensure events are correctly routed to subscribers based on mailbox ownership. - Included tests for the mail API to validate session handling, mailbox isolation, and error mapping. - Ensured that the API rejects unauthorized requests and handles malformed JSON gracefully.
This commit is contained in:
1 parent
880dced9e4
commit
f330c4324e
28 files changed
+2438
-6
No files matched your search
@@ -3,6 +3,8 @@ package port
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
@@ -11,3 +13,21 @@ type ReportRepository interface {
|
||||
SaveDmarcReport(ctx context.Context, report *entity.DmarcReport) error
|
||||
SaveTlsRptReport(ctx context.Context, report *entity.TlsRptReport) error
|
||||
}
|
||||
|
||||
// StoredReportMessage is one already-delivered message that looks like a
|
||||
// report: it was addressed to a report alias and is still filed in a mailbox.
|
||||
type StoredReportMessage struct {
|
||||
MessageID uuid.UUID
|
||||
BlobID uuid.UUID
|
||||
RecipientAddress string
|
||||
}
|
||||
|
||||
// DeliveredReportStore reaches the reports that were delivered before
|
||||
// automatic ingestion existed, so they can be parsed after the fact.
|
||||
type DeliveredReportStore interface {
|
||||
// ListUningestedReports returns stored messages addressed to a report
|
||||
// alias. limit caps the batch; 0 means a full pass.
|
||||
ListUningestedReports(ctx context.Context, limit int) ([]StoredReportMessage, error)
|
||||
// MoveToReportsFolder files a backfilled message out of the inbox.
|
||||
MoveToReportsFolder(ctx context.Context, messageID uuid.UUID) error
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// BackfillReportsUseCase parses reports that were delivered before automatic
|
||||
// ingestion existed.
|
||||
//
|
||||
// Every report received until now was filed in the admin's inbox as an
|
||||
// attachment and never read, so the tables the admin console shows start
|
||||
// empty however long the server has been running. The messages are still
|
||||
// stored, so the backlog can be parsed after the fact and filed out of the
|
||||
// inbox at the same time.
|
||||
type BackfillReportsUseCase struct {
|
||||
store port.DeliveredReportStore
|
||||
blobs port.BlobReader
|
||||
ingestor *IngestDeliveredReportsUseCase
|
||||
}
|
||||
|
||||
func NewBackfillReportsUseCase(
|
||||
store port.DeliveredReportStore,
|
||||
blobs port.BlobReader,
|
||||
ingestor *IngestDeliveredReportsUseCase,
|
||||
) *BackfillReportsUseCase {
|
||||
return &BackfillReportsUseCase{store: store, blobs: blobs, ingestor: ingestor}
|
||||
}
|
||||
|
||||
// BackfillSummary is what one run did, for the operator running it by hand.
|
||||
type BackfillSummary struct {
|
||||
// Scanned is how many stored report messages were examined.
|
||||
Scanned int
|
||||
// Ingested is how many produced a report row.
|
||||
Ingested int
|
||||
// Failed is how many could not be read or parsed. They are left filed in
|
||||
// Reports with their bytes intact, so a later run can retry them.
|
||||
Failed int
|
||||
}
|
||||
|
||||
// Run parses the backlog. limit caps how many messages are examined; 0 means
|
||||
// whatever the store considers a full pass.
|
||||
//
|
||||
// Only a failure to read the backlog itself stops the run. An individual
|
||||
// message that cannot be read or parsed is counted and skipped: one corrupt
|
||||
// attachment from years ago must not block every report behind it.
|
||||
func (uc *BackfillReportsUseCase) Run(ctx context.Context, limit int) (BackfillSummary, error) {
|
||||
summary := BackfillSummary{}
|
||||
|
||||
pending, err := uc.store.ListUningestedReports(ctx, limit)
|
||||
if err != nil {
|
||||
return summary, fmt.Errorf("list the report backlog: %w", err)
|
||||
}
|
||||
|
||||
for _, msg := range pending {
|
||||
summary.Scanned++
|
||||
|
||||
raw, err := uc.blobs.ReadByID(ctx, msg.BlobID)
|
||||
if err != nil {
|
||||
summary.Failed++
|
||||
log.Printf("backfill: could not read the message for %s: %v", msg.RecipientAddress, err)
|
||||
continue
|
||||
}
|
||||
|
||||
outcome, err := uc.ingestor.Ingest(ctx, []string{msg.RecipientAddress}, raw)
|
||||
switch {
|
||||
case err != nil:
|
||||
summary.Failed++
|
||||
log.Printf("backfill: could not ingest the message for %s: %v", msg.RecipientAddress, err)
|
||||
case outcome.Stored:
|
||||
summary.Ingested++
|
||||
default:
|
||||
// Recognised as a report but nothing parseable inside it.
|
||||
summary.Failed++
|
||||
}
|
||||
|
||||
// Filed either way. The inbox is being cleared, and a report this
|
||||
// server cannot parse today is still a report - its bytes stay on
|
||||
// disk, so a later run can try again once the parser is fixed.
|
||||
if err := uc.store.MoveToReportsFolder(ctx, msg.MessageID); err != nil {
|
||||
log.Printf("backfill: could not file %s into %s: %v", msg.MessageID, ReportsFolderName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// Reports that arrived before ingestion existed are still sitting in the
|
||||
// admin's inbox. They are ordinary stored messages, so they can be read back
|
||||
// out and parsed after the fact.
|
||||
|
||||
type stubDeliveredReportStore struct {
|
||||
pending []port.StoredReportMessage
|
||||
filed []uuid.UUID
|
||||
blobs map[uuid.UUID][]byte
|
||||
listErr error
|
||||
fileErr error
|
||||
blobErr error
|
||||
}
|
||||
|
||||
func (s *stubDeliveredReportStore) ListUningestedReports(
|
||||
_ context.Context, limit int,
|
||||
) ([]port.StoredReportMessage, error) {
|
||||
if s.listErr != nil {
|
||||
return nil, s.listErr
|
||||
}
|
||||
if limit > 0 && len(s.pending) > limit {
|
||||
return s.pending[:limit], nil
|
||||
}
|
||||
return s.pending, nil
|
||||
}
|
||||
|
||||
func (s *stubDeliveredReportStore) MoveToReportsFolder(_ context.Context, messageID uuid.UUID) error {
|
||||
if s.fileErr != nil {
|
||||
return s.fileErr
|
||||
}
|
||||
s.filed = append(s.filed, messageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubDeliveredReportStore) ReadByID(_ context.Context, blobID uuid.UUID) ([]byte, error) {
|
||||
if s.blobErr != nil {
|
||||
return nil, s.blobErr
|
||||
}
|
||||
body, ok := s.blobs[blobID]
|
||||
if !ok {
|
||||
return nil, errors.New("blob not found")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func newBackfill(store *stubDeliveredReportStore, repo *recordingReportRepo) *BackfillReportsUseCase {
|
||||
return NewBackfillReportsUseCase(
|
||||
store, store, NewIngestDeliveredReportsUseCase(NewIngestReportsUseCase(repo)))
|
||||
}
|
||||
|
||||
func TestBackfillIngestsAReportAlreadyInTheMailbox(t *testing.T) {
|
||||
blobID := uuid.New()
|
||||
messageID := uuid.New()
|
||||
payload := messageWithAttachment(googleTlsRptFilename, "application/gzip", gzipped(t, tlsRptJSON))
|
||||
|
||||
store := &stubDeliveredReportStore{
|
||||
pending: []port.StoredReportMessage{
|
||||
{MessageID: messageID, BlobID: blobID, RecipientAddress: "tlsrpt@example.test"},
|
||||
},
|
||||
blobs: map[uuid.UUID][]byte{blobID: payload},
|
||||
}
|
||||
repo := &recordingReportRepo{}
|
||||
|
||||
summary, err := newBackfill(store, repo).Run(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
if len(repo.tlsRpt) != 1 {
|
||||
t.Fatalf("the stored report was not ingested: %d", len(repo.tlsRpt))
|
||||
}
|
||||
if repo.tlsRpt[0].Domain != "example.test" {
|
||||
t.Errorf("ingested domain is %q", repo.tlsRpt[0].Domain)
|
||||
}
|
||||
if summary.Ingested != 1 {
|
||||
t.Errorf("summary reports %d ingested, want 1", summary.Ingested)
|
||||
}
|
||||
// Filed out of the inbox, which is the point of running this at all.
|
||||
if len(store.filed) != 1 || store.filed[0] != messageID {
|
||||
t.Errorf("the message was not moved to Reports: %v", store.filed)
|
||||
}
|
||||
}
|
||||
|
||||
// Running it twice must be safe: the second pass has nothing left to do, and
|
||||
// storage is idempotent anyway.
|
||||
func TestBackfillOverAnEmptyBacklogDoesNothing(t *testing.T) {
|
||||
store := &stubDeliveredReportStore{}
|
||||
repo := &recordingReportRepo{}
|
||||
|
||||
summary, err := newBackfill(store, repo).Run(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if summary.Ingested != 0 || summary.Failed != 0 {
|
||||
t.Errorf("an empty backlog produced %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// One unreadable report must not stop the rest of the backlog: a single
|
||||
// corrupted attachment from years ago would otherwise block every report
|
||||
// behind it forever.
|
||||
func TestBackfillContinuesPastAnUnreadableReport(t *testing.T) {
|
||||
goodBlob, badBlob := uuid.New(), uuid.New()
|
||||
goodMsg, badMsg := uuid.New(), uuid.New()
|
||||
|
||||
store := &stubDeliveredReportStore{
|
||||
pending: []port.StoredReportMessage{
|
||||
{MessageID: badMsg, BlobID: badBlob, RecipientAddress: "tlsrpt@example.test"},
|
||||
{MessageID: goodMsg, BlobID: goodBlob, RecipientAddress: "tlsrpt@example.test"},
|
||||
},
|
||||
blobs: map[uuid.UUID][]byte{
|
||||
badBlob: messageWithAttachment("broken.json.gz", "application/gzip", []byte("rubbish")),
|
||||
goodBlob: messageWithAttachment(googleTlsRptFilename, "application/gzip", gzipped(t, tlsRptJSON)),
|
||||
},
|
||||
}
|
||||
repo := &recordingReportRepo{}
|
||||
|
||||
summary, err := newBackfill(store, repo).Run(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if summary.Ingested != 1 {
|
||||
t.Errorf("the readable report was not ingested: %+v", summary)
|
||||
}
|
||||
if summary.Failed != 1 {
|
||||
t.Errorf("the unreadable one was not reported: %+v", summary)
|
||||
}
|
||||
// Both are filed either way - the inbox is being cleared, and a report
|
||||
// this server cannot parse is still a report.
|
||||
if len(store.filed) != 2 {
|
||||
t.Errorf("filed %d messages, want both", len(store.filed))
|
||||
}
|
||||
}
|
||||
|
||||
// A blob that cannot be read at all is counted and skipped, never fatal.
|
||||
func TestBackfillSurvivesAMissingBlob(t *testing.T) {
|
||||
store := &stubDeliveredReportStore{
|
||||
pending: []port.StoredReportMessage{
|
||||
{MessageID: uuid.New(), BlobID: uuid.New(), RecipientAddress: "dmarc@example.test"},
|
||||
},
|
||||
blobs: map[uuid.UUID][]byte{},
|
||||
}
|
||||
repo := &recordingReportRepo{}
|
||||
|
||||
summary, err := newBackfill(store, repo).Run(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("a missing blob must not fail the run: %v", err)
|
||||
}
|
||||
if summary.Failed != 1 {
|
||||
t.Errorf("the missing blob was not counted: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// A failure listing the backlog is the one thing worth stopping for: it means
|
||||
// the database is unreachable, and every message would be counted as failed.
|
||||
func TestBackfillStopsIfItCannotReadTheBacklog(t *testing.T) {
|
||||
store := &stubDeliveredReportStore{listErr: errors.New("database is down")}
|
||||
if _, err := newBackfill(store, &recordingReportRepo{}).Run(context.Background(), 0); err == nil {
|
||||
t.Error("a backlog that cannot be listed should be reported as an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-message"
|
||||
)
|
||||
|
||||
// ReportKind says what a delivered message is, decided by the address it was
|
||||
// sent to.
|
||||
type ReportKind string
|
||||
|
||||
const (
|
||||
// ReportKindNone is ordinary mail, which this use case leaves alone.
|
||||
ReportKindNone ReportKind = ""
|
||||
ReportKindDmarc ReportKind = "dmarc"
|
||||
ReportKindTlsRpt ReportKind = "tlsrpt"
|
||||
)
|
||||
|
||||
// reportLocalParts maps the mailbox a report is addressed to onto what it
|
||||
// contains.
|
||||
//
|
||||
// The address is the only trustworthy signal. The subject and the attachment
|
||||
// filename are both chosen by the sender, so keying on either would let anyone
|
||||
// who can send this server a message write rows into the report tables. These
|
||||
// local parts exist because EnsureSystemAliases creates them for every domain.
|
||||
var reportLocalParts = map[string]ReportKind{
|
||||
"dmarc": ReportKindDmarc,
|
||||
"tlsrpt": ReportKindTlsRpt,
|
||||
}
|
||||
|
||||
// ReportsFolderName is where an ingested report is filed.
|
||||
//
|
||||
// Not the inbox: these are machine-to-machine documents that arrive daily from
|
||||
// every large receiver, and nobody reads the attachment by hand. Not discarded
|
||||
// either - keeping the original bytes is what makes a parser fix re-appliable
|
||||
// to reports already received.
|
||||
const ReportsFolderName = "Reports"
|
||||
|
||||
// IngestDeliveredReportsUseCase reads DMARC and TLS-RPT reports out of the
|
||||
// mail that carries them.
|
||||
//
|
||||
// The parsers, the tables and the HTTP ingest endpoints all existed already,
|
||||
// but nothing connected inbound mail to them: reports were filed in the
|
||||
// admin's inbox as attachments nobody could read, and the report tables stayed
|
||||
// empty however many arrived.
|
||||
type IngestDeliveredReportsUseCase struct {
|
||||
reports *IngestReportsUseCase
|
||||
}
|
||||
|
||||
func NewIngestDeliveredReportsUseCase(reports *IngestReportsUseCase) *IngestDeliveredReportsUseCase {
|
||||
return &IngestDeliveredReportsUseCase{reports: reports}
|
||||
}
|
||||
|
||||
// Ingest parses any report the message carries and reports what kind of
|
||||
// message it was, so the caller can file it somewhere other than the inbox.
|
||||
//
|
||||
// It never returns an error for a bad report. A malformed payload is the
|
||||
// reporter's mistake and a storage failure is this server's, and neither is a
|
||||
// reason to answer the SMTP transaction with a failure: the reporter would
|
||||
// retry the same message on a schedule, forever, and no retry would ever help.
|
||||
// Failures are logged and the message is still delivered, which also leaves
|
||||
// the original bytes on disk to re-ingest once the cause is fixed.
|
||||
func (uc *IngestDeliveredReportsUseCase) Ingest(
|
||||
ctx context.Context, recipients []string, payload []byte,
|
||||
) (ReportOutcome, error) {
|
||||
kind := ReportKindNone
|
||||
for _, address := range recipients {
|
||||
if found := reportKindForAddress(address); found != ReportKindNone {
|
||||
kind = found
|
||||
break
|
||||
}
|
||||
}
|
||||
if kind == ReportKindNone || uc.reports == nil {
|
||||
return ReportOutcome{}, nil
|
||||
}
|
||||
|
||||
// Every part is tried in turn, so the covering note a reporter puts in
|
||||
// front of the attachment is simply one that does not parse. Only the
|
||||
// last failure is kept, and only reported if no part worked at all:
|
||||
// logging each one would file an error for the human-readable part of
|
||||
// every report that ingests perfectly well.
|
||||
var lastErr error
|
||||
for _, candidate := range reportPayloads(payload) {
|
||||
var err error
|
||||
switch kind {
|
||||
case ReportKindDmarc:
|
||||
_, err = uc.reports.IngestDmarc(ctx, candidate)
|
||||
case ReportKindTlsRpt:
|
||||
_, err = uc.reports.IngestTlsRpt(ctx, candidate)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
// One report per message, so there is nothing to gain from reading
|
||||
// the remaining parts once one has been stored.
|
||||
return ReportOutcome{Kind: kind, Stored: true}, nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
// Logged rather than returned: see the note above on why a bad report
|
||||
// must not fail the delivery.
|
||||
log.Printf("reports: no readable %s report in a message to %v: %v", kind, recipients, lastErr)
|
||||
}
|
||||
// Recognised by its address but nothing could be parsed out of it. Still a
|
||||
// report, so the caller files it with the others rather than leaving it in
|
||||
// the inbox.
|
||||
return ReportOutcome{Kind: kind}, nil
|
||||
}
|
||||
|
||||
// ReportOutcome says what a message turned out to be and whether anything was
|
||||
// actually stored from it.
|
||||
//
|
||||
// The two are separate because they drive different decisions: Kind decides
|
||||
// where the message is filed, and Stored is how a caller tells a report it
|
||||
// parsed from one it merely recognised - which Ingest deliberately does not
|
||||
// report as an error.
|
||||
type ReportOutcome struct {
|
||||
Kind ReportKind
|
||||
Stored bool
|
||||
}
|
||||
|
||||
// reportKindForAddress classifies one recipient address by its local part.
|
||||
func reportKindForAddress(address string) ReportKind {
|
||||
local, _, found := strings.Cut(address, "@")
|
||||
if !found {
|
||||
return ReportKindNone
|
||||
}
|
||||
return reportLocalParts[strings.ToLower(strings.TrimSpace(local))]
|
||||
}
|
||||
|
||||
// reportPayloads returns every part of the message that could be a report,
|
||||
// decoded.
|
||||
//
|
||||
// Every attachment is offered rather than the one whose name looks right:
|
||||
// filenames vary between reporters and the parsers already reject anything
|
||||
// that is not a report, so matching on the name would only add a way to miss
|
||||
// one. The whole body is offered last, for the reporters that send the
|
||||
// document as the message itself rather than as an attachment.
|
||||
func reportPayloads(payload []byte) [][]byte {
|
||||
entity, err := message.Read(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return [][]byte{payload}
|
||||
}
|
||||
|
||||
candidates := collectReportParts(entity, 0)
|
||||
return append(candidates, payload)
|
||||
}
|
||||
|
||||
// maxReportParts bounds the walk: a report has one attachment, and a message
|
||||
// with hundreds of nested parts is not one this server needs to read.
|
||||
const maxReportParts = 16
|
||||
|
||||
// maxReportPartBytes bounds each part. Aggregate reports are XML or JSON in
|
||||
// the tens of kilobytes even before compression.
|
||||
const maxReportPartBytes = 32 << 20
|
||||
|
||||
func collectReportParts(entity *message.Entity, depth int) [][]byte {
|
||||
// Bounded so a deliberately deep MIME tree cannot drive this into a
|
||||
// pathological walk.
|
||||
if depth > 8 {
|
||||
return nil
|
||||
}
|
||||
if multipart := entity.MultipartReader(); multipart != nil {
|
||||
out := [][]byte{}
|
||||
for len(out) < maxReportParts {
|
||||
part, err := multipart.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
out = append(out, collectReportParts(part, depth+1)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// go-message decodes the transfer encoding as the body is read, so what
|
||||
// comes back here is the attachment's real bytes rather than its base64.
|
||||
body, err := io.ReadAll(io.LimitReader(entity.Body, maxReportPartBytes))
|
||||
if err != nil || len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
return [][]byte{body}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
// Reports arrive as ordinary mail to the addresses the system aliases
|
||||
// guarantee exist. Nothing read them: they were filed in the admin's inbox as
|
||||
// attachments and the parsed tables stayed empty.
|
||||
|
||||
type recordingReportRepo struct {
|
||||
dmarc []*entity.DmarcReport
|
||||
tlsRpt []*entity.TlsRptReport
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recordingReportRepo) SaveDmarcReport(_ context.Context, report *entity.DmarcReport) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
r.dmarc = append(r.dmarc, report)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingReportRepo) SaveTlsRptReport(_ context.Context, report *entity.TlsRptReport) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
r.tlsRpt = append(r.tlsRpt, report)
|
||||
return nil
|
||||
}
|
||||
|
||||
// gzipped compresses a payload the way a reporter does before attaching it.
|
||||
func gzipped(t *testing.T, payload string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
if _, err := gz.Write([]byte(payload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// messageWithAttachment builds the shape a reporter actually sends: a short
|
||||
// human-readable part plus the report as a base64 attachment.
|
||||
func messageWithAttachment(filename, contentType string, content []byte) []byte {
|
||||
encoded := base64.StdEncoding.EncodeToString(content)
|
||||
var b bytes.Buffer
|
||||
b.WriteString("From: noreply-dmarc-support@google.com\r\n")
|
||||
b.WriteString("Subject: Report Domain: example.test\r\n")
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: multipart/mixed; boundary=\"sep\"\r\n\r\n")
|
||||
b.WriteString("--sep\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
b.WriteString("This is an aggregate report.\r\n")
|
||||
b.WriteString("--sep\r\n")
|
||||
b.WriteString("Content-Type: " + contentType + "; name=\"" + filename + "\"\r\n")
|
||||
b.WriteString("Content-Disposition: attachment; filename=\"" + filename + "\"\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
|
||||
for len(encoded) > 76 {
|
||||
b.WriteString(encoded[:76] + "\r\n")
|
||||
encoded = encoded[76:]
|
||||
}
|
||||
b.WriteString(encoded + "\r\n")
|
||||
b.WriteString("--sep--\r\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
const tlsRptJSON = `{"organization-name":"Google Inc.",` +
|
||||
`"date-range":{"start-datetime":"2026-08-19T00:00:00Z","end-datetime":"2026-08-19T23:59:59Z"},` +
|
||||
`"report-id":"2026-08-19T00:00:00Z_example.test",` +
|
||||
`"policies":[{"policy":{"policy-type":"no-policy-found","policy-domain":"example.test"},` +
|
||||
`"summary":{"total-successful-session-count":2,"total-failure-session-count":0}}]}`
|
||||
|
||||
// The exact filename Google uses, which is what arrives in the inbox today.
|
||||
const googleTlsRptFilename = "google.com!example.test!1787097600!1787183999!001.json.gz"
|
||||
|
||||
func newReportIngestor(repo *recordingReportRepo) *IngestDeliveredReportsUseCase {
|
||||
return NewIngestDeliveredReportsUseCase(NewIngestReportsUseCase(repo))
|
||||
}
|
||||
|
||||
func TestTlsRptReportAddressedToTlsRptIsIngested(t *testing.T) {
|
||||
repo := &recordingReportRepo{}
|
||||
uc := newReportIngestor(repo)
|
||||
|
||||
payload := messageWithAttachment(googleTlsRptFilename, "application/gzip", gzipped(t, tlsRptJSON))
|
||||
|
||||
outcome, err := uc.Ingest(context.Background(), []string{"tlsrpt@example.test"}, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if outcome.Kind != ReportKindTlsRpt {
|
||||
t.Errorf("kind is %q, want tlsrpt", outcome.Kind)
|
||||
}
|
||||
if len(repo.tlsRpt) != 1 {
|
||||
t.Fatalf("want 1 stored report, got %d", len(repo.tlsRpt))
|
||||
}
|
||||
if repo.tlsRpt[0].Domain != "example.test" {
|
||||
t.Errorf("stored domain is %q", repo.tlsRpt[0].Domain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDmarcReportAddressedToDmarcIsIngested(t *testing.T) {
|
||||
repo := &recordingReportRepo{}
|
||||
uc := newReportIngestor(repo)
|
||||
|
||||
payload := messageWithAttachment(
|
||||
"google.com!example.test!1787097600!1787183999.xml.gz",
|
||||
"application/gzip", gzipped(t, dmarcAggregateXMLFixture))
|
||||
|
||||
outcome, err := uc.Ingest(context.Background(), []string{"dmarc@example.test"}, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if outcome.Kind != ReportKindDmarc {
|
||||
t.Errorf("kind is %q, want dmarc", outcome.Kind)
|
||||
}
|
||||
if len(repo.dmarc) != 1 {
|
||||
t.Fatalf("want 1 stored report, got %d", len(repo.dmarc))
|
||||
}
|
||||
}
|
||||
|
||||
// The address decides, not the subject or the filename - both of which the
|
||||
// sender chooses freely. Ordinary mail must pass through untouched.
|
||||
func TestOrdinaryMailIsNotTreatedAsAReport(t *testing.T) {
|
||||
repo := &recordingReportRepo{}
|
||||
uc := newReportIngestor(repo)
|
||||
|
||||
payload := messageWithAttachment(googleTlsRptFilename, "application/gzip", gzipped(t, tlsRptJSON))
|
||||
|
||||
outcome, err := uc.Ingest(context.Background(), []string{"lucas@example.test"}, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if outcome.Kind != ReportKindNone {
|
||||
t.Errorf("ordinary mail was classified as %q", outcome.Kind)
|
||||
}
|
||||
if len(repo.tlsRpt) != 0 || len(repo.dmarc) != 0 {
|
||||
t.Error("a report was stored from mail addressed to a person")
|
||||
}
|
||||
}
|
||||
|
||||
// A report this server cannot parse is still a report: it must be recognised,
|
||||
// so it files with the others, and it must never fail the delivery. Returning
|
||||
// an error here would have the reporter retry the same broken payload forever.
|
||||
func TestUnparseableReportIsRecognisedButNeverFailsDelivery(t *testing.T) {
|
||||
repo := &recordingReportRepo{}
|
||||
uc := newReportIngestor(repo)
|
||||
|
||||
payload := messageWithAttachment("broken.json.gz", "application/gzip", []byte("not a report at all"))
|
||||
|
||||
outcome, err := uc.Ingest(context.Background(), []string{"tlsrpt@example.test"}, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("a malformed report must not fail the delivery: %v", err)
|
||||
}
|
||||
if outcome.Kind != ReportKindTlsRpt {
|
||||
t.Errorf("kind is %q; the address still says what this message is", outcome.Kind)
|
||||
}
|
||||
if len(repo.tlsRpt) != 0 {
|
||||
t.Error("an unparseable payload was stored")
|
||||
}
|
||||
}
|
||||
|
||||
// A storage failure is this server's problem, not the reporter's, and must not
|
||||
// turn into an SMTP error either.
|
||||
func TestStorageFailureDoesNotFailDelivery(t *testing.T) {
|
||||
repo := &recordingReportRepo{err: errors.New("database is down")}
|
||||
uc := newReportIngestor(repo)
|
||||
|
||||
payload := messageWithAttachment(googleTlsRptFilename, "application/gzip", gzipped(t, tlsRptJSON))
|
||||
|
||||
if _, err := uc.Ingest(context.Background(), []string{"tlsrpt@example.test"}, payload); err != nil {
|
||||
t.Errorf("a storage failure must not fail the delivery: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reporters vary the local part: dmarc-reports@, tlsrpt@, and the aliases this
|
||||
// server creates. Matching is on the local part, case-insensitively.
|
||||
func TestReportAddressMatchingIsCaseInsensitive(t *testing.T) {
|
||||
for _, address := range []string{"TLSRPT@example.test", "TlsRpt@Example.Test"} {
|
||||
if got := reportKindForAddress(address); got != ReportKindTlsRpt {
|
||||
t.Errorf("%s classified as %q, want tlsrpt", address, got)
|
||||
}
|
||||
}
|
||||
if got := reportKindForAddress("DMARC@example.test"); got != ReportKindDmarc {
|
||||
t.Errorf("DMARC@ classified as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A report with no attachment at all - some reporters inline small ones - must
|
||||
// still not fail, and must still be recognised by its address.
|
||||
func TestReportWithNoAttachmentDoesNotFail(t *testing.T) {
|
||||
repo := &recordingReportRepo{}
|
||||
uc := newReportIngestor(repo)
|
||||
|
||||
payload := []byte("From: a@b.test\r\nSubject: hi\r\n\r\nno attachment here\r\n")
|
||||
outcome, err := uc.Ingest(context.Background(), []string{"tlsrpt@example.test"}, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if outcome.Kind != ReportKindTlsRpt {
|
||||
t.Errorf("kind is %q", outcome.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// A DMARC aggregate report, trimmed to the fields the parser reads.
|
||||
const dmarcAggregateXMLFixture = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<report_id>987654321</report_id>
|
||||
<date_range><begin>1787097600</begin><end>1787183999</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published><domain>example.test</domain><p>quarantine</p></policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.10</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>example.test</header_from></identifiers>
|
||||
</record>
|
||||
</feedback>`
|
||||
|
||||
// --- the delivery path ---------------------------------------------------
|
||||
|
||||
// A report must be ingested and filed out of the way in one delivery, without
|
||||
// the address ever having to be a real mailbox the user reads.
|
||||
func TestDeliveredReportIsIngestedAndFiledInReports(t *testing.T) {
|
||||
repo := &recordingReportRepo{}
|
||||
messages := &recordingMessageRepository{}
|
||||
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
||||
uc.SetReportIngestor(newReportIngestor(repo))
|
||||
|
||||
payload := messageWithAttachment(googleTlsRptFilename, "application/gzip", gzipped(t, tlsRptJSON))
|
||||
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
||||
Sender: "smtp-tls-reporting@google.com",
|
||||
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
||||
RecipientAddresses: []string{"tlsrpt@example.test"},
|
||||
Body: bytes.NewReader(payload),
|
||||
}); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
|
||||
if len(repo.tlsRpt) != 1 {
|
||||
t.Fatalf("the report was not ingested: %d stored", len(repo.tlsRpt))
|
||||
}
|
||||
if len(messages.persisted) != 1 {
|
||||
t.Fatalf("want the message filed once, got %d", len(messages.persisted))
|
||||
}
|
||||
filed := messages.persisted[0]
|
||||
if filed.TargetFolderName != ReportsFolderName {
|
||||
t.Errorf("the report was filed in %q, want %q", filed.TargetFolderName, ReportsFolderName)
|
||||
}
|
||||
// It is machine-read mail; raising an unread badge for it would recreate
|
||||
// the inbox noise this exists to remove.
|
||||
if !filed.AlreadySeen {
|
||||
t.Error("the filed report was counted as unread mail")
|
||||
}
|
||||
}
|
||||
|
||||
// Ordinary mail must reach the inbox exactly as before.
|
||||
func TestOrdinaryMailStillReachesTheInbox(t *testing.T) {
|
||||
repo := &recordingReportRepo{}
|
||||
messages := &recordingMessageRepository{}
|
||||
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
||||
uc.SetReportIngestor(newReportIngestor(repo))
|
||||
|
||||
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
||||
Sender: "friend@example.test",
|
||||
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
||||
RecipientAddresses: []string{"lucas@example.test"},
|
||||
Body: bytes.NewBufferString("Subject: hello\r\n\r\nhi"),
|
||||
}); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
|
||||
filed := messages.persisted[0]
|
||||
if filed.TargetFolderName == ReportsFolderName {
|
||||
t.Error("ordinary mail was filed as a report")
|
||||
}
|
||||
if filed.AlreadySeen {
|
||||
t.Error("ordinary mail was filed as already read")
|
||||
}
|
||||
}
|
||||
|
||||
// With no ingestor configured, delivery must behave exactly as it did before
|
||||
// this existed - reports included.
|
||||
func TestDeliveryIsUnchangedWithoutAnIngestor(t *testing.T) {
|
||||
messages := &recordingMessageRepository{}
|
||||
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
|
||||
|
||||
payload := messageWithAttachment(googleTlsRptFilename, "application/gzip", gzipped(t, tlsRptJSON))
|
||||
if err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
||||
Sender: "smtp-tls-reporting@google.com",
|
||||
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
||||
RecipientAddresses: []string{"tlsrpt@example.test"},
|
||||
Body: bytes.NewReader(payload),
|
||||
}); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if messages.persisted[0].TargetFolderName == ReportsFolderName {
|
||||
t.Error("a report was routed with no ingestor configured")
|
||||
}
|
||||
}
|
||||
@@ -65,12 +65,20 @@ type ProcessInboundEmailUseCase struct {
|
||||
messages port.InboundMessageRepository
|
||||
folders port.ImapFolderRepository
|
||||
trackerManager *MailboxTrackerManager
|
||||
reportIngestor *IngestDeliveredReportsUseCase
|
||||
}
|
||||
|
||||
func NewProcessInboundEmailUseCase(mailboxes port.MailboxRepository, blobs port.BlobStorage, messages port.InboundMessageRepository) *ProcessInboundEmailUseCase {
|
||||
return &ProcessInboundEmailUseCase{mailboxes: mailboxes, blobs: blobs, messages: messages}
|
||||
}
|
||||
|
||||
// SetReportIngestor enables reading DMARC and TLS-RPT reports out of the mail
|
||||
// that carries them. Optional: with no ingestor a report is delivered like any
|
||||
// other message, which is what happened before this existed.
|
||||
func (uc *ProcessInboundEmailUseCase) SetReportIngestor(ingestor *IngestDeliveredReportsUseCase) {
|
||||
uc.reportIngestor = ingestor
|
||||
}
|
||||
|
||||
func (uc *ProcessInboundEmailUseCase) SetScanner(scanner port.ContentScanner) {
|
||||
uc.scanner = scanner
|
||||
}
|
||||
@@ -234,6 +242,26 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
||||
}
|
||||
}
|
||||
|
||||
// Reports are read here, after the scan has had its say and before the
|
||||
// message is filed, because this is where the recipient list and the
|
||||
// finished payload are both in hand.
|
||||
//
|
||||
// A report that fails to parse is still routed to Reports: the address it
|
||||
// was sent to is what makes it one, and leaving a broken report in the
|
||||
// inbox instead would put back exactly the noise this removes. The bytes
|
||||
// are kept either way, so it can be re-ingested once the cause is fixed.
|
||||
alreadySeen := false
|
||||
if uc.reportIngestor != nil {
|
||||
outcome, err := uc.reportIngestor.Ingest(ctx, input.RecipientAddresses, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outcome.Kind != ReportKindNone {
|
||||
targetFolder = ReportsFolderName
|
||||
alreadySeen = true
|
||||
}
|
||||
}
|
||||
|
||||
blob, err := uc.blobs.Store(ctx, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -261,6 +289,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
||||
FromDisplayName: headers.FromDisplayName,
|
||||
MessageIDHeader: headers.MessageID,
|
||||
HasAttachments: headers.HasAttachments,
|
||||
AlreadySeen: alreadySeen,
|
||||
SPFResult: authResult.SPF,
|
||||
DKIMResult: authResult.DKIM,
|
||||
DMARCResult: authResult.DMARC,
|
||||
|
||||
Reference in new issue
Block a user