IMP-02: anhangsverarbeitung-bei-import
Anhänge aus importierten Nachrichten extrahieren, validieren und für Weiterverarbeitung bereitstellen. - mimeparse/tolerant.go: additive Erweiterung von ING-04 (Parse/ parseMultipart unverändert) — ParseTolerant bricht bei einem einzelnen fehlerhaften Teil nicht die gesamte Nachricht ab, verzeichnet ihn stattdessen in []PartError. Setzt zusätzlich ein Gesamtgrößenbudget über alle Teile durch (ErrMessageTooLarge), ergänzend zum bereits vorhandenen Je-Anhang-Limit. - attachments/attachments.go: Extract liefert Filename/Size/ DeclaredContentType/VerifiedContentType je Anhang. VerifiedContentType kommt aus http.DetectContentType (echtes Sniffing der Bytes), nicht aus der ungeprüften Absenderbehauptung. Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-02-PRUEFPROTOKOLL.md): 1. TestExtract_OversizedAttachmentIsCorrectlyLimited: überdimensionierter Anhang real übersprungen, Text bleibt real unangetastet. 2. TestExtract_MultipleAttachmentDifferentTypesAllImported: PDF+PNG real beide extrahiert, PNG-Sniffing liefert real image/png. 3. TestExtract_BrokenAttachmentLeavesTextAndOthersUntouched: defektes Base64 in einem Anhang, Text und zweiter Anhang real unangetastet. Kein Umbau: mimeparse Parse/parseMultipart (ING-04) unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
03c47d98f4
commit
7238568918
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user