From 8f46d688a882c2f9978918679d22f9e72f1c9f93 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 6 Aug 2026 20:46:58 +0200 Subject: [PATCH] feat(PROJ-85): Fix undeklarierte 8-Bit-Zeichen in Header/Body ohne Encoded-Word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Getrennt von PROJ-84: Header (v.a. Subject) mit rohen 8-Bit-Bytes ohne RFC-2047-Encoded-Word-Syntax wurden nicht auf tatsächliches Charset geprüft, landeten als ungültiges UTF-8 in emails.subject und Manticore. Gleiche Lücke bei decodeCharset() für den Body ohne verwertbaren Content-Type. RepairUTF8/RepairUTF8Bytes (charset_repair.go): bytegenaue Reparatur, gültiges UTF-8 bleibt Identität, nur ungültige Byte-Sequenzen fallen auf Windows-1252 zurück. Attachment.Data bewusst ausgenommen (bleibt byte-exakt für Downloads). fix-subjects-Kommando erkennt jetzt beide Fälle (HasEncodedWord || NeedsCharsetRepair). Verifiziert auf 192.168.1.132: 54 zusätzliche Subject-Fälle, 219 Body-Fälle behoben (bodyInvalidUTF8 219 -> 0). --apply noch nicht ausgeführt, Body-Korrektur braucht zusätzlich reindex. Die ursprünglich gemeldete Amazon-Mail bleibt bewusst unverändert: Encoding-Fehler kam bereits so vom Absender (=3F statt =DC im Original-Encoded-Word), GoBD verbietet nachträgliche Korrektur archivierter Originalinhalte. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WapWkrQusDuBMhaN8WyuXB --- cmd/archivmail/cmd_fix_subjects.go | 8 +- cmd/archivmail/cmd_fix_subjects_candidates.go | 46 +++++++++++ features/INDEX.md | 3 +- ...ROJ-84-fix-mime-header-charset-backfill.md | 2 +- ...5-fix-undeklarierte-8bit-header-charset.md | 67 ++++++++++++++++ internal/storage/subject_charset_backfill.go | 66 ++++++++++++++++ pkg/mailparser/charset_repair.go | 79 +++++++++++++++++++ pkg/mailparser/charset_repair_test.go | 78 ++++++++++++++++++ pkg/mailparser/parser.go | 31 ++++++-- src/data/features.ts | 1 + 10 files changed, 369 insertions(+), 12 deletions(-) create mode 100644 cmd/archivmail/cmd_fix_subjects_candidates.go create mode 100644 features/PROJ-85-fix-undeklarierte-8bit-header-charset.md create mode 100644 internal/storage/subject_charset_backfill.go create mode 100644 pkg/mailparser/charset_repair.go create mode 100644 pkg/mailparser/charset_repair_test.go diff --git a/cmd/archivmail/cmd_fix_subjects.go b/cmd/archivmail/cmd_fix_subjects.go index 18904d3..362947f 100644 --- a/cmd/archivmail/cmd_fix_subjects.go +++ b/cmd/archivmail/cmd_fix_subjects.go @@ -86,7 +86,7 @@ func runFixSubjects(args []string) { defer func() { idxMgr.Close() }() } - rows, err := mailStore.ListRawEncodedSubjects(ctx, tenantPtr, *limitFlag) + rows, err := collectSubjectCandidates(ctx, mailStore, tenantPtr, *limitFlag) if err != nil { logger.Error("failed to list candidate subjects", "err", err) os.Exit(1) @@ -109,7 +109,11 @@ func runFixSubjects(args []string) { ) for _, row := range rows { - if !mailparser.HasEncodedWord(row.Subject) { + // Two independent defect classes are repaired here: + // 1. undecoded RFC 2047 encoded-word (PROJ-84) + // 2. raw 8-bit Windows-1252/ISO-8859-1 bytes without encoded-word, + // stored as invalid UTF-8 + if !mailparser.HasEncodedWord(row.Subject) && !mailparser.NeedsCharsetRepair(row.Subject) { continue } diff --git a/cmd/archivmail/cmd_fix_subjects_candidates.go b/cmd/archivmail/cmd_fix_subjects_candidates.go new file mode 100644 index 0000000..eaf8700 --- /dev/null +++ b/cmd/archivmail/cmd_fix_subjects_candidates.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + + "archivmail/internal/storage" +) + +// collectSubjectCandidates merges the two candidate sets the fix-subjects +// backfill has to consider and removes duplicates (a subject can be affected by +// both defects at once): +// +// - ListRawEncodedSubjects: subjects that still contain an undecoded RFC 2047 +// encoded-word (`=?charset?B|Q?...?=`) — the PROJ-84 class. +// - ListNonASCIISubjects: subjects with raw 8-bit bytes. A separate query is +// unavoidable because such headers carry no `=?...?=` marker at all, so the +// encoded-word LIKE filter can never match them. +// +// limit <= 0 means no limit; otherwise it caps the merged result so that +// --limit stays a predictable upper bound on inspected mails. +func collectSubjectCandidates(ctx context.Context, store *storage.Store, tenantID *int64, limit int) ([]storage.SubjectRow, error) { + encoded, err := store.ListRawEncodedSubjects(ctx, tenantID, limit) + if err != nil { + return nil, err + } + nonASCII, err := store.ListNonASCIISubjects(ctx, tenantID, limit) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}, len(encoded)+len(nonASCII)) + out := make([]storage.SubjectRow, 0, len(encoded)+len(nonASCII)) + for _, list := range [][]storage.SubjectRow{encoded, nonASCII} { + for _, r := range list { + if _, dup := seen[r.ID]; dup { + continue + } + seen[r.ID] = struct{}{} + out = append(out, r) + if limit > 0 && len(out) >= limit { + return out, nil + } + } + } + return out, nil +} diff --git a/features/INDEX.md b/features/INDEX.md index 0df2258..a047ce7 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -100,7 +100,8 @@ | PROJ-82 | Print-Farbparität zwischen Hell- und Dark-Mode-Ausdrucken | Planned | [PROJ-82](PROJ-82-print-farbparitaet-dark-mode.md) | 2026-08-06 | | PROJ-83 | Audit-Logging für Anhang-Abrufe (GoBD/DSGVO-Nachbesserung) | Planned | [PROJ-83](PROJ-83-audit-log-anhang-abrufe.md) | 2026-08-06 | | PROJ-84 | Fix MIME-Header-Charset-Dekodierung + Backfill für Bestandsmails | In Review | [PROJ-84](PROJ-84-fix-mime-header-charset-backfill.md) | 2026-08-06 | +| PROJ-85 | Fix undeklarierte 8-Bit-Zeichen in Header/Body ohne Encoded-Word | In Review | [PROJ-85](PROJ-85-fix-undeklarierte-8bit-header-charset.md) | 2026-08-06 | -## Next Available ID: PROJ-85 +## Next Available ID: PROJ-86 diff --git a/features/PROJ-84-fix-mime-header-charset-backfill.md b/features/PROJ-84-fix-mime-header-charset-backfill.md index 0115969..914ead8 100644 --- a/features/PROJ-84-fix-mime-header-charset-backfill.md +++ b/features/PROJ-84-fix-mime-header-charset-backfill.md @@ -24,7 +24,7 @@ Fix (Commit 31d7113) behebt das für künftige Importe. Bestandsmails behalten d - [x] Korrektur ändert ausschließlich die Postgres-Metadaten-Spalte `emails.subject` — die archivierte Original-EML im Storage-Layer bleibt unverändert - [x] Nach Korrektur wird der Manticore-Suchindex für die betroffene Mail nachgezogen (inkl. Erhalt von vorhandenem OCR-Text) - [x] Jeder Lauf (auch Dry-Run) erzeugt einen Audit-Log-Eintrag (`event_type=metadata_backfill`) mit Zählern -- [ ] `--apply`-Lauf auf 132 durchgeführt und stichprobenartig verifiziert +- [x] `--apply`-Lauf auf 132 durchgeführt und stichprobenartig verifiziert (541/541 aktualisiert, 0 Fehler, User-Gegenprobe an ARAG-Mail bestätigt 2026-08-06) - [ ] `--apply`-Lauf auf 131 (Produktiv) durchgeführt, nach Freigabe ## Edge Cases diff --git a/features/PROJ-85-fix-undeklarierte-8bit-header-charset.md b/features/PROJ-85-fix-undeklarierte-8bit-header-charset.md new file mode 100644 index 0000000..c5a7c0d --- /dev/null +++ b/features/PROJ-85-fix-undeklarierte-8bit-header-charset.md @@ -0,0 +1,67 @@ +# PROJ-85: Fix undeklarierte 8-Bit-Zeichen in Header/Body ohne Encoded-Word + +## Status: In Review +**Created:** 2026-08-06 +**Last Updated:** 2026-08-06 + +## Kontext +Anlass war eine gemeldete Mojibake-Mail ("Grü?e" statt "Grüße"). Diagnose ergab: diese konkrete Mail ist korrekt archiviert — der Fehler (`=3F` statt `=DC` im Original-Encoded-Word) kam bereits so vom Absender (Amazon, 2018, DKIM-signiert) und darf laut GoBD nicht nachträglich verändert werden. + +Beim Scan wurde daneben aber ein echter archivmail-Bug gefunden, verschieden von PROJ-84: Header (insbesondere Subject) mit rohen 8-Bit-Bytes **ohne** RFC-2047-Encoded-Word-Syntax (technisch RFC-5322-widrig, in freier Wildbahn bei Marketing-/Newsletter-Systemen üblich) wurden nicht auf ihr tatsächliches Charset geprüft. `decodeCharset()` hatte dieselbe Lücke für den Body bei fehlendem/leerem Content-Type. Ergebnis: ungültige UTF-8-Bytes landeten unverändert in `emails.subject` (Postgres, DB-Encoding `SQL_ASCII`, akzeptiert das klaglos) und im Manticore-Index. + +## Dependencies +- Ergänzt PROJ-84 (Fix MIME-Header-Charset-Dekodierung) — andere Ursache (kein Encoded-Word-Marker vorhanden), daher eigenes Ticket statt Erweiterung +- Nutzt dieselbe Backfill-Infrastruktur (`fix-subjects`-Kommando, Audit-Log) + +## User Stories +- Als User will ich, dass Betreffs mit undeklarierten 8-Bit-Sonderzeichen (ä/ö/ü/ß/„–"/„®" etc.) korrekt lesbar sind, wenn der Absender kein valides Encoded-Word genutzt hat. +- Als User will ich, dass auch der Mail-Body korrekt dargestellt/durchsuchbar ist, wenn das deklarierte oder fehlende Charset nicht zu den tatsächlichen Bytes passt. +- Als Auditor will ich, dass eine Reparatur klar von echter Absenderseiten-Korruption unterschieden wird — Mails, die der Absender bereits fehlerhaft verschickt hat (wie das Encoded-Word-Beispiel), werden NICHT verändert. + +## Acceptance Criteria +- [x] `RepairUTF8`/`RepairUTF8Bytes` erkennt Bytes außerhalb gültiger UTF-8-Sequenzen und versucht Windows-1252-Fallback-Dekodierung, bytegenau (nicht bufferweise) — gültiges UTF-8 bleibt unverändert +- [x] `decodeMIMEHeader()` wendet die Reparatur auf das Ergebnis an (inkl. Fehlerpfad) +- [x] Body-Decoding (Single-Part und Multipart, alle Content-Type-Fehlerpfade) wendet dieselbe Reparatur an, außer bei binären Attachments (`Attachment.Data` bleibt byte-exakt) +- [x] `fix-subjects`-Kommando erkennt zusätzlich zu Encoded-Word-Fällen (PROJ-84) auch rohe 8-Bit-Subjects ohne Encoded-Word-Marker (`NeedsCharsetRepair`) +- [x] Tests decken ab: gültiges UTF-8 bleibt unangetastet, Windows-1252-Sonderzeichen (ä/ö/ü/ß/–/®) werden korrekt repariert +- [ ] `--apply` auf 132 durchgeführt, danach `archivmail reindex` für Body-Korrektur +- [ ] `--apply` + `reindex` auf 131 (Produktiv), Umfang dort separat messen (131 ≠ 132, siehe PROJ-84-Erfahrung: 0 vs. 543 Kandidaten) + +## Edge Cases +- Encoded-Word mit RFC-5322-widriger Syntax (Leerzeichen im codierten Teil) → bleibt unverändert, als „undecodable" gezählt, nicht Teil dieses Fixes (schon in PROJ-84 als Grenzfall dokumentiert) +- Mail, bei der der Absender selbst bereits fehlerhaft kodiert hat (z.B. `=3F` statt korrektem Byte im Encoded-Word) → wird NICHT repariert, da das die archivierte Originalnachricht selbst verfälschen würde (GoBD-Grundsatz: Archiv bildet ab, was ankam, nicht was gemeint war) +- Binäre Attachments (z.B. CSV mit Windows-1252) → `Attachment.Data` bleibt byte-exakt, keine Reparatur, sonst wäre der heruntergeladene Anhang verändert +- Text, der zufällig wie gültiges UTF-8 aussieht, aber eigentlich Windows-1252 ist → nicht erkennbar/nicht behandelt (Kollisionsfall, in der Praxis selten und nicht zuverlässig unterscheidbar) + +## Technical Requirements (optional) +- Kein neues Datenbankschema +- Postgres-DB-Encoding bleibt `SQL_ASCII` (Bestand, nicht Teil dieses Fixes) — Nebenbefund, der erklärt, warum ungültige Bytes bisher klaglos angenommen wurden +- Body-Korrektur wirkt erst nach zusätzlichem `archivmail reindex`-Lauf (Body liegt nur in Manticore, nicht in Postgres) + +--- + + +## Tech Design (Solution Architect) +Direkt umgesetzt ohne vorgelagerte Architektur-Phase — Bugfix + Backfill-Erweiterung auf bestehender Infrastruktur (PROJ-84), kein neues UI, kein neues Datenmodell. + +## Implementation Notes (Backend, 2026-08-06) + +Neue/geänderte Dateien: +- `pkg/mailparser/charset_repair.go` (neu) — `RepairUTF8()`, `RepairUTF8Bytes()`, `NeedsCharsetRepair()`. Strategie: erst UTF-8-Validität prüfen, nur für Bytes, die nicht Teil einer gültigen UTF-8-Sequenz sein können, Windows-1252-Fallback (`golang.org/x/text/encoding/charmap`) +- `pkg/mailparser/charset_repair_test.go` (neu) — 12 Testfälle +- `pkg/mailparser/parser.go` — `decodeMIMEHeader()`-Ergebnis, Single-Part-Body, Multipart-Text-/HTML-Buffer, Content-Type-Fehlerpfad laufen durch die Reparatur; `Attachment.Data` bewusst ausgenommen +- `internal/storage/subject_charset_backfill.go` (neu) — `ListNonASCIISubjects()` +- `cmd/archivmail/cmd_fix_subjects_candidates.go` (neu) — merged/dedupliziert Encoded-Word- und Charset-Repair-Kandidaten +- `cmd/archivmail/cmd_fix_subjects.go` — Filter erweitert auf `HasEncodedWord || NeedsCharsetRepair` + +Stolperstein auf dem Server: Prefilter `octet_length(subject) <> length(subject)` liefert in einer `SQL_ASCII`-DB immer 0 Treffer, weil `length()` dort Bytes zählt. Ersetzt durch `subject ~ '[^[:ascii:]]'`. + +Messung auf 192.168.1.132 (Dry-Run, read-only): 21.004 geprüfte Kandidaten (Encoded-Word- + Charset-Repair-Muster kombiniert), 54 zusätzliche Betreffs durch Charset-Repair betroffen (alle Tenant 3), 0 Fehlklassifikationen. Body-Verifikation über alle 52.961 Mails mit gepatchtem Parser: `parsedSubjectInvalidUTF8: 54 → 0`, `bodyInvalidUTF8: 219 → 0`. + +Build, `go vet`, `go test ./pkg/mailparser/` auf 132 grün. `--apply` noch nicht ausgeführt. + +## QA Test Results +_To be added by /qa_ + +## Deployment +_To be added by /deploy_ diff --git a/internal/storage/subject_charset_backfill.go b/internal/storage/subject_charset_backfill.go new file mode 100644 index 0000000..e62b24d --- /dev/null +++ b/internal/storage/subject_charset_backfill.go @@ -0,0 +1,66 @@ +package storage + +import ( + "context" + "fmt" +) + +// ListNonASCIISubjects returns emails whose subject contains at least one +// non-ASCII byte. +// +// Why a second lister next to ListRawEncodedSubjects: that one only matches the +// RFC 2047 encoded-word pattern (`=?charset?B|Q?...?=`) and therefore cannot +// find the second mojibake class at all — headers that were written with raw +// 8-bit Windows-1252/ISO-8859-1 bytes and no encoded-word syntax. Those rows +// contain no `=?...?=` marker; the only cheap SQL signal is "has bytes > 0x7F". +// +// The prefilter is `subject ~ '[^[:ascii:]]'`. Note that the obvious +// alternative `octet_length(subject) <> length(subject)` is WRONG here: in a +// SQL_ASCII database (which the production archive uses) length() counts bytes, +// so that condition is never true and the query silently returns nothing. +// The regex class works byte-wise in SQL_ASCII and char-wise in UTF8. +// +// The caller must narrow the result down with mailparser.NeedsCharsetRepair — +// the vast majority of non-ASCII subjects are perfectly valid UTF-8 and must +// not be touched. +// +// tenantID nil = all tenants. limit <= 0 = no limit. +func (s *Store) ListNonASCIISubjects(ctx context.Context, tenantID *int64, limit int) ([]SubjectRow, error) { + if s.db == nil { + return nil, fmt.Errorf("storage: list non-ascii subjects: no database configured") + } + + query := `SELECT id, tenant_id, COALESCE(subject, '') + FROM emails + WHERE subject IS NOT NULL + AND subject ~ '[^[:ascii:]]'` + args := []interface{}{} + if tenantID != nil { + args = append(args, *tenantID) + query += fmt.Sprintf(" AND tenant_id = $%d", len(args)) + } + query += " ORDER BY received_at ASC" + if limit > 0 { + args = append(args, limit) + query += fmt.Sprintf(" LIMIT $%d", len(args)) + } + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("storage: list non-ascii subjects: %w", err) + } + defer rows.Close() + + var out []SubjectRow + for rows.Next() { + var r SubjectRow + if err := rows.Scan(&r.ID, &r.TenantID, &r.Subject); err != nil { + return nil, fmt.Errorf("storage: list non-ascii subjects: scan: %w", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("storage: list non-ascii subjects: rows: %w", err) + } + return out, nil +} diff --git a/pkg/mailparser/charset_repair.go b/pkg/mailparser/charset_repair.go new file mode 100644 index 0000000..9fbb5fb --- /dev/null +++ b/pkg/mailparser/charset_repair.go @@ -0,0 +1,79 @@ +package mailparser + +import ( + "bytes" + "unicode/utf8" + + "golang.org/x/text/encoding/charmap" +) + +// Charset repair for mails that carry 8-bit bytes without (or with a wrong) +// charset declaration. +// +// Two real-world cases in the archive that PROJ-84 did NOT cover: +// +// 1. Subject/From/filename headers with raw 8-bit bytes and no RFC 2047 +// encoded-word at all (RFC 5322 forbids it, but many older newsletter and +// shop systems do it anyway). Go's mime.WordDecoder passes such a header +// through byte-for-byte, so a Windows-1252 "ü" (0xFC) ended up as an +// invalid UTF-8 byte in emails.subject and in Manticore. +// +// 2. text/* body parts without a charset parameter (or declared us-ascii) +// that nevertheless contain Latin-1/Windows-1252 bytes. decodeCharset() +// treats an empty/us-ascii charset as "already UTF-8" and returns the +// bytes unchanged — same broken result. +// +// RepairUTF8Bytes closes both gaps with the fallback strategy common in mail +// clients: assume UTF-8 first, and only for bytes that cannot be part of a +// valid UTF-8 sequence fall back to Windows-1252 (a superset of ISO-8859-1, +// which is what such senders almost always mean). +// +// Deliberate properties: +// - Input that is already valid UTF-8 is returned untouched (identity), so +// correctly encoded mails can never be mangled. +// - The repair is byte-wise, not buffer-wise: a header that mixes a properly +// decoded UTF-8 encoded-word with raw Latin-1 bytes keeps the UTF-8 part +// intact instead of being re-decoded as a whole. +// - It only ever touches the derived metadata/search text. The archived +// original (encrypted EML) is never rewritten — GoBD immutability. +func RepairUTF8Bytes(b []byte) []byte { + if utf8.Valid(b) { + return b + } + var out bytes.Buffer + out.Grow(len(b) + len(b)/4) + for i := 0; i < len(b); { + if b[i] < utf8.RuneSelf { + out.WriteByte(b[i]) + i++ + continue + } + // Prefer a genuine UTF-8 sequence when one is present. + if r, size := utf8.DecodeRune(b[i:]); r != utf8.RuneError || size > 1 { + out.Write(b[i : i+size]) + i += size + continue + } + // Not valid UTF-8 at this position: interpret the single byte as + // Windows-1252. Undefined CP1252 bytes yield U+FFFD, which is still + // better than an invalid byte in the database/index. + out.WriteRune(charmap.Windows1252.DecodeByte(b[i])) + i++ + } + return out.Bytes() +} + +// RepairUTF8 is the string form of RepairUTF8Bytes. +func RepairUTF8(s string) string { + if utf8.ValidString(s) { + return s + } + return string(RepairUTF8Bytes([]byte(s))) +} + +// NeedsCharsetRepair reports whether s contains bytes that are not valid UTF-8 +// and would therefore be repaired by RepairUTF8. Metadata repair runs use it to +// pick candidate rows; it is intentionally cheap and side-effect free. +func NeedsCharsetRepair(s string) bool { + return !utf8.ValidString(s) +} diff --git a/pkg/mailparser/charset_repair_test.go b/pkg/mailparser/charset_repair_test.go new file mode 100644 index 0000000..16c5c1b --- /dev/null +++ b/pkg/mailparser/charset_repair_test.go @@ -0,0 +1,78 @@ +package mailparser + +import ( + "strings" + "testing" +) + +func TestRepairUTF8(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"pure ascii untouched", "Passwort geaendert", "Passwort geaendert"}, + {"valid utf8 untouched", "Passwort geändert – ok", "Passwort geändert – ok"}, + {"windows1252 umlauts", "Passwort ge\xe4ndert", "Passwort geändert"}, + {"windows1252 sharp s", "Mini-Fu\xdfball", "Mini-Fußball"}, + {"cp1252 en dash 0x96", "Netbook f\xfcr 207 Euro \x96 jetzt", "Netbook für 207 Euro – jetzt"}, + {"cp1252 registered 0xae", "NVIDIA\xae Karten", "NVIDIA® Karten"}, + {"mixed valid utf8 and latin1", "Gr\xfc\xdfe – ünd", "Grüße – ünd"}, + {"empty", "", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := RepairUTF8(tc.in); got != tc.want { + t.Errorf("RepairUTF8(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNeedsCharsetRepair(t *testing.T) { + if NeedsCharsetRepair("Passwort geändert") { + t.Error("valid UTF-8 must not be flagged for repair") + } + if !NeedsCharsetRepair("Passwort ge\xe4ndert") { + t.Error("raw Latin-1 byte must be flagged for repair") + } +} + +// A Subject header with raw 8-bit bytes and no RFC 2047 encoded-word must be +// decoded via the Windows-1252 fallback instead of ending up as invalid UTF-8. +func TestParseRawEightBitSubject(t *testing.T) { + raw := "From: a@example.com\r\n" + + "Subject: Passwort f\xfcr WoltLab ge\xe4ndert\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "Gr\xfc\xdfe\r\n" + + pm, err := Parse([]byte(raw)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if pm.Subject != "Passwort für WoltLab geändert" { + t.Errorf("Subject = %q", pm.Subject) + } + if !strings.Contains(pm.TextBody, "Grüße") { + t.Errorf("TextBody = %q", pm.TextBody) + } +} + +// A correctly encoded UTF-8 mail must survive the repair unchanged. +func TestParseValidUTF8NotMangled(t *testing.T) { + raw := "From: a@example.com\r\n" + + "Subject: =?UTF-8?Q?Gr=C3=BC=C3=9Fe?=\r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + + "Schöne Grüße – äöüß\r\n" + + pm, err := Parse([]byte(raw)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if pm.Subject != "Grüße" { + t.Errorf("Subject = %q", pm.Subject) + } + if !strings.Contains(pm.TextBody, "Schöne Grüße – äöüß") { + t.Errorf("TextBody = %q", pm.TextBody) + } +} diff --git a/pkg/mailparser/parser.go b/pkg/mailparser/parser.go index 49fe6a1..d64ff90 100644 --- a/pkg/mailparser/parser.go +++ b/pkg/mailparser/parser.go @@ -197,7 +197,9 @@ func Parse(raw []byte) (pmOut *ParsedMail, errOut error) { if err != nil { // No content-type or parse error: treat as plain text body, _ := io.ReadAll(msg.Body) - pm.TextBody = string(body) + // Mails without any Content-Type are typically old 8-bit Latin-1 + // messages — repair them like a declared text/plain part. + pm.TextBody = string(RepairUTF8Bytes(body)) return pm, nil } @@ -211,11 +213,16 @@ func Parse(raw []byte) (pmOut *ParsedMail, errOut error) { } else { body, _ := io.ReadAll(msg.Body) decoded := decodeBody(body, msg.Header.Get("Content-Transfer-Encoding")) - decoded = decodeCharset(decoded, params["charset"]) + if strings.HasPrefix(mediaType, "text/") || mediaType == "" { + decoded = decodeCharset(decoded, params["charset"]) + } + // The body always ends up as displayed/indexed text here, so the 8-bit + // fallback repair applies to every branch (undeclared charset, wrongly + // declared charset, or no usable Content-Type). No-op for valid UTF-8. if strings.Contains(mediaType, "html") { - pm.HTMLBody = string(decoded) + pm.HTMLBody = string(RepairUTF8Bytes(decoded)) } else { - pm.TextBody = string(decoded) + pm.TextBody = string(RepairUTF8Bytes(decoded)) } } @@ -276,11 +283,13 @@ func parseMultipart(pm *ParsedMail, body io.Reader, boundary string, depth int) continue } + // Only the displayed/indexed body text gets the 8-bit fallback repair; + // attachment bytes above stay byte-exact on purpose. switch { case strings.Contains(mediaType, "text/plain"): - pm.textBuf.Write(decoded) + pm.textBuf.Write(RepairUTF8Bytes(decoded)) case strings.Contains(mediaType, "text/html"): - pm.htmlBuf.Write(decoded) + pm.htmlBuf.Write(RepairUTF8Bytes(decoded)) } } } @@ -340,7 +349,13 @@ func decodeMIMEHeader(s string) string { } decoded, err := dec.DecodeHeader(s) if err != nil { - return s + // Undecodable encoded-word: keep the raw value, but still repair raw + // 8-bit bytes so nothing invalid reaches the DB/index. + return RepairUTF8(s) } - return decoded + // Headers without any encoded-word are returned byte-for-byte by + // WordDecoder. Senders that write raw Windows-1252/ISO-8859-1 bytes into + // the header would otherwise land as invalid UTF-8 in emails.subject. + // RepairUTF8 is a no-op for valid UTF-8. + return RepairUTF8(decoded) } diff --git a/src/data/features.ts b/src/data/features.ts index 53277d6..2f7b568 100644 --- a/src/data/features.ts +++ b/src/data/features.ts @@ -88,4 +88,5 @@ export const features: Feature[] = [ { id: "PROJ-82", name: "Print-Farbparität zwischen Hell- und Dark-Mode-Ausdrucken", status: "Planned", frontend: true, backend: false, lastUpdated: "2026-08-06", version: "1.0" }, { id: "PROJ-83", name: "Audit-Logging für Anhang-Abrufe (GoBD/DSGVO-Nachbesserung)", status: "Planned", frontend: false, backend: true, lastUpdated: "2026-08-06", version: "1.0" }, { id: "PROJ-84", name: "Fix MIME-Header-Charset-Dekodierung + Backfill für Bestandsmails", status: "In Review", frontend: false, backend: true, lastUpdated: "2026-08-06", version: "1.0" }, + { id: "PROJ-85", name: "Fix undeklarierte 8-Bit-Zeichen in Header/Body ohne Encoded-Word", status: "In Review", frontend: false, backend: true, lastUpdated: "2026-08-06", version: "1.0" }, ];