diff --git a/mail/docs/IMP-02-PRUEFPROTOKOLL.md b/mail/docs/IMP-02-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..98f823e --- /dev/null +++ b/mail/docs/IMP-02-PRUEFPROTOKOLL.md @@ -0,0 +1,48 @@ +# IMP-02 – Prüfprotokoll: Anhangsverarbeitung bei Import + +Voraussetzung ING-04, IMP-01 (beide Fertig). + +## Umsetzung + +- `mail/internal/mimeparse/tolerant.go` — additive Erweiterung von ING-04 + (Parse/parseMultipart bleiben UNVERÄNDERT): `ParseTolerant` bricht bei + einem einzelnen fehlerhaften Teil NICHT die gesamte Nachricht ab + (Akzeptanzkriterium 3), sondern verzeichnet ihn in `[]PartError` und + verarbeitet die übrigen Teile weiter. Setzt zusätzlich ein + Gesamtgrößenbudget über alle Teile durch (`ErrMessageTooLarge`, + Akzeptanzkriterium 2 — ergänzt das bereits vorhandene + Je-Anhang-Limit aus ING-04 um ein Je-Nachricht-Limit). +- `mail/internal/attachments/attachments.go` — `Extract`: liefert + `Attachment{Filename, Size, DeclaredContentType, VerifiedContentType}` + je Anhang (Akzeptanzkriterium 1) — `VerifiedContentType` kommt aus + `net/http.DetectContentType` (echtes Sniffing der Bytes), nicht aus der + ungeprüft übernommenen Absenderbehauptung. `Options{MaxAttachmentSize, + MaxMessageSize}` mit sinnvollen Vorgabewerten (25 MiB je Anhang, + 100 MiB je Nachricht). +- Kein Umbau: `mail/internal/mimeparse` Parse/parseMultipart (ING-04) + unverändert — bestehende Tests laufen unangetastet weiter. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test mit Nachricht, die einen überdimensionierten Anhang enthält, wird korrekt begrenzt | **bestanden** – `TestExtract_OversizedAttachmentIsCorrectlyLimited`: Anhang über dem Limit wird real übersprungen (nicht extrahiert), Nachrichtentext bleibt real unangetastet | +| 2 | Test mit mehreren Anhängen unterschiedlichen Typs importiert alle korrekt | **bestanden** – `TestExtract_MultipleAttachmentDifferentTypesAllImported`: PDF + PNG in einer Nachricht, beide real extrahiert, PNG-Anhang liefert real den korrekten gesniffeten Content-Type `image/png` (echte Magic-Bytes) | +| 3 | Test: ein defekter Anhang lässt Text und übrige Anhänge unangetastet | **bestanden** – `TestExtract_BrokenAttachmentLeavesTextAndOthersUntouched`: ungültiges Base64 in einem Anhang, Nachrichtentext UND der zweite, gültige Anhang kommen real unverändert an | + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./internal/attachments/... -v -> 3/3 bestanden +TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1 + -> alle 16 Pakete bestanden, keine Regression (mimeparse: 6/6 weiterhin grün + nach additiver ParseTolerant-Erweiterung) +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +real erfüllt. Entsperrt IMP-06, trägt (gemeinsam mit IMP-03) zu IMP-09 bei. diff --git a/mail/internal/attachments/attachments.go b/mail/internal/attachments/attachments.go new file mode 100644 index 0000000..b5bc16a --- /dev/null +++ b/mail/internal/attachments/attachments.go @@ -0,0 +1,109 @@ +// Package attachments implementiert IMP-02: Anhänge aus importierten +// Nachrichten extrahieren, validieren und für die Weiterverarbeitung +// (Speicherung, Virenscan — beides spätere Kacheln, siehe "Nicht +// Bestandteil dieser Kachel") bereitstellen. Baut auf ING-04 +// (mail/internal/mimeparse) auf, unverändert wiederverwendet über die +// additive Erweiterung mimeparse.ParseTolerant — kein Umbau der +// bestehenden, fertigen ING-04-Logik. +package attachments + +import ( + "io" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/mimeparse" +) + +// Attachment ist EIN extrahierter, validierter Anhang +// (Akzeptanzkriterium 1: Originaldateiname, Größe, geprüfter +// Content-Type). +type Attachment struct { + Filename string + Size int64 + Content []byte + // DeclaredContentType kommt unverändert aus dem MIME-Header des + // Absenders — NICHT vertrauenswürdig, ein Absender kann hier + // beliebiges behaupten. + DeclaredContentType string + // VerifiedContentType wird aus den tatsächlichen Bytes gesniffed + // (net/http.DetectContentType, RFC-basierte Inhaltserkennung) — + // Akzeptanzkriterium 1: "geprüfter Content-Type", unabhängig von der + // Absenderbehauptung. + VerifiedContentType string +} + +// SkippedPart beschreibt einen Anhang/Teil, der NICHT extrahiert werden +// konnte — der Rest der Nachricht (Text und übrige Anhänge) bleibt davon +// unangetastet (Akzeptanzkriterium 3). +type SkippedPart struct { + Filename string + Reason error +} + +// Result ist das Ergebnis einer Anhangsextraktion. +type Result struct { + Attachments []Attachment + // TextParts sind die Nicht-Anhang-Teile (Nachrichtentext) — + // unverändert aus mimeparse übernommen, diese Kachel fasst sie nicht + // an. + TextParts []mimeparse.Part + Skipped []SkippedPart +} + +// DefaultMaxAttachmentSize/DefaultMaxMessageSize sind Vorgabewerte, +// überschreibbar über Options — großzügig für typische Geschäftspost +// (kleinste Lösung, keine Konfigurationsoberfläche in dieser Kachel). +const ( + DefaultMaxAttachmentSize = 25 * 1024 * 1024 // 25 MiB je Anhang + DefaultMaxMessageSize = 100 * 1024 * 1024 // 100 MiB je Nachricht gesamt +) + +// Options steuert die Größenlimits (Akzeptanzkriterium 2). +type Options struct { + MaxAttachmentSize int64 + MaxMessageSize int64 +} + +func (o Options) withDefaults() Options { + if o.MaxAttachmentSize <= 0 { + o.MaxAttachmentSize = DefaultMaxAttachmentSize + } + if o.MaxMessageSize <= 0 { + o.MaxMessageSize = DefaultMaxMessageSize + } + return o +} + +// Extract zerlegt eine E-Mail (RFC 5322 + MIME) in Anhänge und +// Textteile. Ein einzelner fehlerhafter oder überdimensionierter Anhang +// blockiert NICHT die Verarbeitung der übrigen Teile +// (Akzeptanzkriterium 3) — nur eine strukturell unlesbare Nachricht +// (kaputte Kopfzeilen) liefert einen echten Fehler. +func Extract(r io.Reader, opts Options) (Result, error) { + opts = opts.withDefaults() + + msg, partErrors, err := mimeparse.ParseTolerant(r, opts.MaxAttachmentSize, opts.MaxMessageSize) + if err != nil { + return Result{}, err + } + + var result Result + for _, pe := range partErrors { + result.Skipped = append(result.Skipped, SkippedPart{Filename: pe.Filename, Reason: pe.Err}) + } + + for _, part := range msg.Parts { + if !part.IsAttachment { + result.TextParts = append(result.TextParts, part) + continue + } + result.Attachments = append(result.Attachments, Attachment{ + Filename: part.Filename, + Size: part.Size, + Content: part.Content, + DeclaredContentType: part.ContentType, + VerifiedContentType: http.DetectContentType(part.Content), + }) + } + return result, nil +} diff --git a/mail/internal/attachments/attachments_test.go b/mail/internal/attachments/attachments_test.go new file mode 100644 index 0000000..ecdef37 --- /dev/null +++ b/mail/internal/attachments/attachments_test.go @@ -0,0 +1,139 @@ +package attachments + +import ( + "encoding/base64" + "strings" + "testing" +) + +// TestExtract_OversizedAttachmentIsCorrectlyLimited ist die geforderte +// Pflichtprüfung 1: Nachricht mit überdimensioniertem Anhang wird +// korrekt begrenzt. +func TestExtract_OversizedAttachmentIsCorrectlyLimited(t *testing.T) { + oversized := strings.Repeat("A", 200) + raw := "From: a@example.com\r\n" + + "To: b@example.com\r\n" + + "Subject: Test\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" + + "--b\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n\r\n" + + "Kurzer Nachrichtentext\r\n" + + "--b\r\n" + + "Content-Type: application/octet-stream\r\n" + + "Content-Disposition: attachment; filename=\"riesig.bin\"\r\n\r\n" + + oversized + "\r\n" + + "--b--\r\n" + + result, err := Extract(strings.NewReader(raw), Options{MaxAttachmentSize: 50, MaxMessageSize: DefaultMaxMessageSize}) + if err != nil { + t.Fatalf("extract: %v", err) + } + if len(result.Attachments) != 0 { + t.Fatalf("erwartete 0 extrahierte anhänge (überdimensioniert), habe %d", len(result.Attachments)) + } + if len(result.Skipped) != 1 || result.Skipped[0].Filename != "riesig.bin" { + t.Fatalf("erwartete genau 1 übersprungenen anhang 'riesig.bin', habe: %+v", result.Skipped) + } + if len(result.TextParts) != 1 || string(result.TextParts[0].Content) != "Kurzer Nachrichtentext" { + t.Fatalf("erwartete unangetasteten text trotz überdimensioniertem anhang, habe: %+v", result.TextParts) + } +} + +// TestExtract_MultipleAttachmentDifferentTypesAllImported ist die +// geforderte Pflichtprüfung 2: mehrere Anhänge unterschiedlichen Typs +// werden alle korrekt importiert. +func TestExtract_MultipleAttachmentDifferentTypesAllImported(t *testing.T) { + pdfContent := base64.StdEncoding.EncodeToString([]byte("%PDF-1.4 fake pdf bytes")) + pngContent := base64.StdEncoding.EncodeToString([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0}) + + raw := "From: a@example.com\r\n" + + "To: b@example.com\r\n" + + "Subject: Test\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" + + "--b\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n\r\n" + + "Anbei zwei Anhänge\r\n" + + "--b\r\n" + + "Content-Type: application/pdf\r\n" + + "Content-Disposition: attachment; filename=\"rechnung.pdf\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + pdfContent + "\r\n" + + "--b\r\n" + + "Content-Type: image/png\r\n" + + "Content-Disposition: attachment; filename=\"logo.png\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + pngContent + "\r\n" + + "--b--\r\n" + + result, err := Extract(strings.NewReader(raw), Options{}) + if err != nil { + t.Fatalf("extract: %v", err) + } + if len(result.Attachments) != 2 { + t.Fatalf("erwartete 2 extrahierte anhänge, habe %d: %+v", len(result.Attachments), result.Attachments) + } + byName := map[string]Attachment{} + for _, a := range result.Attachments { + byName[a.Filename] = a + } + pdf, ok := byName["rechnung.pdf"] + if !ok || pdf.DeclaredContentType != "application/pdf" { + t.Fatalf("pdf-anhang fehlt oder falscher deklarierter typ: %+v", byName) + } + if !strings.Contains(pdf.VerifiedContentType, "text/plain") && !strings.Contains(pdf.VerifiedContentType, "application/") { + // http.DetectContentType erkennt unser Fake-PDF (kein echter PDF- + // Header) plausibel als Text — hier zählt nur, dass überhaupt ein + // echter, aus dem Inhalt gesniffter Wert vorliegt (Akzeptanz- + // kriterium 1: geprüfter statt blind übernommener Content-Type). + t.Fatalf("erwartete real gesniffeden content-type, habe: %q", pdf.VerifiedContentType) + } + png, ok := byName["logo.png"] + if !ok || png.DeclaredContentType != "image/png" { + t.Fatalf("png-anhang fehlt oder falscher deklarierter typ: %+v", byName) + } + if png.VerifiedContentType != "image/png" { + t.Fatalf("erwartete real gesniffeten content-type image/png (echte PNG-Magic-Bytes), habe: %q", png.VerifiedContentType) + } +} + +// TestExtract_BrokenAttachmentLeavesTextAndOthersUntouched ist die +// geforderte Pflichtprüfung 3: ein defekter Anhang lässt Text und übrige +// Anhänge unangetastet. +func TestExtract_BrokenAttachmentLeavesTextAndOthersUntouched(t *testing.T) { + goodContent := base64.StdEncoding.EncodeToString([]byte("echter anhangsinhalt")) + raw := "From: a@example.com\r\n" + + "To: b@example.com\r\n" + + "Subject: Test\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" + + "--b\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n\r\n" + + "Wichtiger Nachrichtentext\r\n" + + "--b\r\n" + + "Content-Type: application/octet-stream\r\n" + + "Content-Disposition: attachment; filename=\"kaputt.bin\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + "DAS_IST_KEIN_GUELTIGES_BASE64!!!\r\n" + + "--b\r\n" + + "Content-Type: application/octet-stream\r\n" + + "Content-Disposition: attachment; filename=\"gut.bin\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\n" + + goodContent + "\r\n" + + "--b--\r\n" + + result, err := Extract(strings.NewReader(raw), Options{}) + if err != nil { + t.Fatalf("extract: %v", err) + } + if len(result.TextParts) != 1 || string(result.TextParts[0].Content) != "Wichtiger Nachrichtentext" { + t.Fatalf("erwartete unangetasteten text trotz defektem anhang, habe: %+v", result.TextParts) + } + if len(result.Attachments) != 1 || result.Attachments[0].Filename != "gut.bin" { + t.Fatalf("erwartete den guten anhang unangetastet, habe: %+v", result.Attachments) + } + if string(result.Attachments[0].Content) != "echter anhangsinhalt" { + t.Fatalf("guter anhang hat unerwarteten inhalt: %q", result.Attachments[0].Content) + } +} diff --git a/mail/internal/mimeparse/tolerant.go b/mail/internal/mimeparse/tolerant.go new file mode 100644 index 0000000..ecbdda8 --- /dev/null +++ b/mail/internal/mimeparse/tolerant.go @@ -0,0 +1,132 @@ +// IMP-02: fehlertolerantes Parsing für den Import-Pfad. Additive +// Erweiterung — Parse/parseMultipart (ING-04) bleiben UNVERÄNDERT, deren +// Verhalten und Tests sind nicht Gegenstand dieser Kachel. ParseTolerant +// nutzt dieselben internen Helfer (readSinglePart, decodeTransferEncoding +// usw.), bricht aber bei EINEM fehlerhaften Teil NICHT die gesamte +// Nachricht ab (Akzeptanzkriterium 3), sondern verzeichnet den Fehler und +// verarbeitet die übrigen Teile weiter. Zusätzlich wird ein +// Gesamtgrößenlimit über alle Teile hinweg durchgesetzt +// (Akzeptanzkriterium 2 — maxAttachmentSize aus Parse ist nur das Limit +// je EINZELNEM Anhang). +package mimeparse + +import ( + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/mail" + "strings" +) + +// PartError beschreibt EINEN Teil, der nicht verarbeitet werden konnte — +// die übrigen Teile der Nachricht sind davon unberührt. +type PartError struct { + Filename string + Err error +} + +func (e PartError) Error() string { + return fmt.Sprintf("mimeparse: teil %q: %v", e.Filename, e.Err) +} + +// ParseTolerant ist wie Parse, bricht aber bei einem fehlerhaften +// EINZELNEN Teil (z. B. überdimensionierter Anhang) nicht die gesamte +// Nachricht ab — der fehlerhafte Teil landet in den zurückgegebenen +// PartErrors, Text und übrige Anhänge werden unangetastet weiter +// verarbeitet (Akzeptanzkriterium 3). Nur eine strukturell unlesbare +// Nachricht (kaputte Kopfzeilen, fehlende Boundary) liefert weiterhin +// einen echten Fehler — davon kann sich kein Teil-für-Teil-Fallback +// erholen. +func ParseTolerant(r io.Reader, maxAttachmentSize, maxMessageSize int64) (Message, []PartError, error) { + msg, err := mail.ReadMessage(r) + if err != nil { + return Message{}, nil, fmt.Errorf("mimeparse: nachricht lesen: %w", err) + } + + mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) + if err != nil { + body, readErr := readLimited(msg.Body, maxAttachmentSize) + if readErr != nil { + return Message{}, []PartError{{Filename: "", Err: readErr}}, nil + } + return Message{Parts: []Part{{ContentType: "text/plain", Content: body, Size: int64(len(body))}}}, nil, nil + } + + var result Message + var partErrors []PartError + budget := maxMessageSize + + if strings.HasPrefix(mediaType, "multipart/") { + if err := parseMultipartTolerant(msg.Body, params["boundary"], maxAttachmentSize, &budget, &result, &partErrors); err != nil { + return Message{}, partErrors, err + } + return result, partErrors, nil + } + + part, err := readSinglePart(msg.Header.Get("Content-Transfer-Encoding"), mediaType, "", msg.Body, maxAttachmentSize) + if err != nil { + return Message{}, []PartError{{Filename: "", Err: err}}, nil + } + result.Parts = append(result.Parts, part) + return result, nil, nil +} + +func parseMultipartTolerant(r io.Reader, boundary string, maxAttachmentSize int64, budget *int64, result *Message, partErrors *[]PartError) error { + if boundary == "" { + return errors.New("mimeparse: multipart ohne boundary") + } + mr := multipart.NewReader(r, boundary) + for { + p, err := mr.NextPart() + if err == io.EOF { + return nil + } + if err != nil { + // Eine strukturell kaputte Multipart-Hülle (nicht ein + // einzelner Teil) kann von hier aus nicht sinnvoll fortgesetzt + // werden — kontrollierter Abbruch, wie in Parse. + return fmt.Errorf("mimeparse: multipart-teil lesen: %w", err) + } + + contentType := p.Header.Get("Content-Type") + mediaType, subParams, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = "text/plain" + } + filename := decodeHeaderValue(p.FileName()) + + if strings.HasPrefix(mediaType, "multipart/") { + if err := parseMultipartTolerant(p, subParams["boundary"], maxAttachmentSize, budget, result, partErrors); err != nil { + *partErrors = append(*partErrors, PartError{Filename: filename, Err: err}) + } + continue + } + + if *budget <= 0 { + *partErrors = append(*partErrors, PartError{Filename: filename, Err: ErrMessageTooLarge}) + continue + } + + part, err := readSinglePart(p.Header.Get("Content-Transfer-Encoding"), mediaType, filename, p, maxAttachmentSize) + if err != nil { + // Akzeptanzkriterium 3: NUR dieser eine Teil fällt weg, + // Verarbeitung läuft weiter. + *partErrors = append(*partErrors, PartError{Filename: filename, Err: err}) + continue + } + if part.Size > *budget { + *partErrors = append(*partErrors, PartError{Filename: filename, Err: ErrMessageTooLarge}) + continue + } + *budget -= part.Size + result.Parts = append(result.Parts, part) + } +} + +// ErrMessageTooLarge wird geliefert (als PartError), wenn die Summe aller +// Anhangsgrößen einer Nachricht das Gesamtlimit überschreitet +// (Akzeptanzkriterium 2 — je-Nachricht-Limit, zusätzlich zum +// je-Anhang-Limit ErrAttachmentTooLarge aus Parse/readLimited). +var ErrMessageTooLarge = errors.New("mimeparse: nachricht überschreitet die maximal erlaubte gesamtgröße")