// Package mimeparse implementiert ING-04: MIME-/Anhang-Parsing für // ein- und ausgehende Nachrichten (Multipart, verschachtelt, // Content-Transfer-Encoding, defensive Zeichensatz-Reparatur). // // NUR Parsing — Speicherung ist ARC-01s Aufgabe (siehe "Nicht // Bestandteil dieser Kachel"), dieses Paket schreibt nirgends in einen // Objekt-Speicher, sondern liefert nur strukturierte Go-Werte zurück. // // Bekannten Fehler vermieden (archivmail known-issues #3): Anhänge // wurden früher über io.ReadAll ohne Größenlimit gelesen — eine // Speicherbombe durch große/böswillige Anhänge. Hier läuft JEDER // Anhang-Lesevorgang über io.LimitReader mit konfigurierter Max-Size; // eine Überschreitung führt zu einer harten, sauberen Ablehnung // (ErrAttachmentTooLarge), kein stilles Abschneiden. package mimeparse import ( "bytes" "encoding/base64" "errors" "fmt" "io" "mime" "mime/multipart" "mime/quotedprintable" "net/mail" "strings" "golang.org/x/text/encoding/htmlindex" ) // ErrAttachmentTooLarge wird geliefert, wenn ein Anhang die // konfigurierte Maximalgröße überschreitet (Akzeptanzkriterium/ // Pflichtprüfung 1). var ErrAttachmentTooLarge = errors.New("mimeparse: anhang überschreitet die maximal erlaubte größe") // Part ist EIN zerlegter MIME-Teil — sowohl Textteile (IsAttachment // == false) als auch Anhänge (Akzeptanzkriterium 1/2). type Part struct { ContentType string Filename string Size int64 Content []byte IsAttachment bool } // Message ist das Ergebnis eines vollständig zerlegten Multipart- // Baums — verschachtelte multipart/*-Teile sind bereits rekursiv // aufgelöst, der Aufrufer sieht nur die "Blatt"-Teile (Akzeptanz- // kriterium 1). type Message struct { Parts []Part } // wordDecoder dekodiert RFC-2047-kodierte Header-Werte (z. B. // Anhang-Dateinamen) defensiv: ein unbekannter/fehlerhafter // Zeichensatz bricht die Verarbeitung NICHT ab (Akzeptanzkriterium 3), // sondern liefert den Rohwert unverändert zurück. var wordDecoder = &mime.WordDecoder{CharsetReader: charsetReader} func charsetReader(charsetLabel string, input io.Reader) (io.Reader, error) { enc, err := htmlindex.Get(charsetLabel) if err != nil { // Unbekannter/fehlerhafter Zeichensatz: defensiv als // UTF-8-verträglichen Rohtext weiterreichen statt // abzubrechen (Akzeptanzkriterium 3). return input, nil } return enc.NewDecoder().Reader(input), nil } func decodeHeaderValue(raw string) string { decoded, err := wordDecoder.DecodeHeader(raw) if err != nil { // Defensiv: Rohwert statt Abbruch (Akzeptanzkriterium 3). return raw } return decoded } // Parse zerlegt eine MIME-Nachricht vollständig, inklusive // verschachtelter Multipart-Teile (Akzeptanzkriterium 1). maxAttachmentSize // begrenzt JEDEN einzelnen Anhang (Akzeptanzkriterium/Pflichtprüfung 1). func Parse(r io.Reader, maxAttachmentSize int64) (Message, error) { msg, err := mail.ReadMessage(r) if err != nil { return Message{}, fmt.Errorf("mimeparse: nachricht lesen: %w", err) } mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) if err != nil { // Kein/kaputtes Content-Type: als einzelnen Textteil behandeln // statt abzubrechen (Akzeptanzkriterium 3: defensiv reparieren). body, readErr := readLimited(msg.Body, maxAttachmentSize) if readErr != nil { return Message{}, readErr } return Message{Parts: []Part{{ContentType: "text/plain", Content: body, Size: int64(len(body))}}}, nil } var result Message if strings.HasPrefix(mediaType, "multipart/") { if err := parseMultipart(msg.Body, params["boundary"], maxAttachmentSize, &result); err != nil { return Message{}, err } return result, nil } // Einzelner Teil (keine Multipart-Hülle). part, err := readSinglePart(msg.Header.Get("Content-Transfer-Encoding"), mediaType, "", msg.Body, maxAttachmentSize) if err != nil { return Message{}, err } result.Parts = append(result.Parts, part) return result, nil } // parseMultipart löst EINEN Multipart-Container rekursiv auf — // verschachtelte multipart/*-Teile (z. B. multipart/mixed, das // multipart/alternative enthält) werden vollständig zerlegt // (Akzeptanzkriterium 1), keine Rekursionstiefe hartkodiert begrenzt // außer durch die natürliche Nachrichtengröße selbst. func parseMultipart(r io.Reader, boundary string, maxAttachmentSize int64, result *Message) 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 { // Kaputte Multipart-Struktur: kontrolliert abbrechen // (Pflichtprüfung 3), nicht abstürzen. 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" } if strings.HasPrefix(mediaType, "multipart/") { if err := parseMultipart(p, subParams["boundary"], maxAttachmentSize, result); err != nil { return err } continue } part, err := readSinglePart(p.Header.Get("Content-Transfer-Encoding"), mediaType, decodeHeaderValue(p.FileName()), p, maxAttachmentSize) if err != nil { return err } result.Parts = append(result.Parts, part) } } func readSinglePart(transferEncoding, mediaType, filename string, r io.Reader, maxAttachmentSize int64) (Part, error) { decoded := decodeTransferEncoding(transferEncoding, r) content, err := readLimited(decoded, maxAttachmentSize) if err != nil { return Part{}, err } return Part{ ContentType: mediaType, Filename: filename, Size: int64(len(content)), Content: content, IsAttachment: filename != "", }, nil } // decodeTransferEncoding dekodiert Content-Transfer-Encoding // (quoted-printable/base64) — defensiv: ein unbekanntes Encoding wird // unverändert durchgereicht statt die Verarbeitung abzubrechen // (Akzeptanzkriterium 3). func decodeTransferEncoding(encoding string, r io.Reader) io.Reader { switch strings.ToLower(strings.TrimSpace(encoding)) { case "quoted-printable": return quotedprintable.NewReader(r) case "base64": return base64.NewDecoder(base64.StdEncoding, r) default: return r } } // readLimited liest höchstens maxSize+1 Bytes — wird die Grenze // überschritten, wird ErrAttachmentTooLarge geliefert, statt beliebig // viel Speicher zu allozieren (Akzeptanzkriterium/Pflichtprüfung 1, // archivmail known-issues #3). func readLimited(r io.Reader, maxSize int64) ([]byte, error) { limited := io.LimitReader(r, maxSize+1) var buf bytes.Buffer if _, err := io.Copy(&buf, limited); err != nil { return nil, fmt.Errorf("mimeparse: teil lesen: %w", err) } if int64(buf.Len()) > maxSize { return nil, ErrAttachmentTooLarge } return buf.Bytes(), nil }