Files
LambdaMail/services/protocols/internal/application/usecase/ingest_delivered_reports.go
T
Lucas Rayan f330c4324e 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.
2026-08-21 09:55:37 -03:00

189 lines
6.5 KiB
Go

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}
}