From 1dfd8c18acd14fc546f3bd0e740e0e39672ec34e Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 6 Aug 2026 19:58:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(PROJ-84):=20fix-subjects=20CLI-Backfill=20?= =?UTF-8?q?f=C3=BCr=20kaputte=20MIME-Header-Betreffs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI-Subkommando "archivmail fix-subjects" korrigiert Bestandsmails mit undekodiertem RFC-2047-Encoded-Word im Betreff (Folge des Fixes in 31d7113). Dry-Run per Default, echte Änderung nur mit --apply. Original-EML im Storage bleibt unangetastet - nur Postgres emails.subject wird korrigiert, danach Manticore-Reindex inkl. Erhalt von OCR-Text. Jeder Lauf erzeugt einen Audit-Log-Eintrag (metadata_backfill). Verifiziert auf 192.168.1.132: 543 Kandidaten, 541 reparierbar, 2 zu Recht übersprungen (RFC-2047-Verletzung im Original). --apply noch nicht ausgeführt. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WapWkrQusDuBMhaN8WyuXB --- cmd/archivmail/cmd_fix_subjects.go | 319 ++++++++++++++++++ cmd/archivmail/cmd_import.go | 1 + cmd/archivmail/main.go | 3 + features/INDEX.md | 3 +- ...ROJ-84-fix-mime-header-charset-backfill.md | 73 ++++ internal/audit/audit.go | 5 + internal/storage/subject_backfill.go | 74 ++++ pkg/mailparser/header_decode.go | 22 ++ src/data/features.ts | 1 + 9 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 cmd/archivmail/cmd_fix_subjects.go create mode 100644 features/PROJ-84-fix-mime-header-charset-backfill.md create mode 100644 internal/storage/subject_backfill.go create mode 100644 pkg/mailparser/header_decode.go diff --git a/cmd/archivmail/cmd_fix_subjects.go b/cmd/archivmail/cmd_fix_subjects.go new file mode 100644 index 0000000..18904d3 --- /dev/null +++ b/cmd/archivmail/cmd_fix_subjects.go @@ -0,0 +1,319 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "strings" + + "archivmail/config" + "archivmail/internal/audit" + "archivmail/internal/index" + "archivmail/internal/storage" + "archivmail/pkg/mailparser" +) + +// runFixSubjects repairs `emails.subject` rows that still contain a raw, +// undecoded RFC 2047 encoded-word (e.g. `=?Windows-1252?Q?...?=`). Those were +// written before decodeMIMEHeader() got a CharsetReader for charsets outside +// UTF-8/US-ASCII/ISO-8859-1. +// +// Scope of the repair: +// - ONLY the PostgreSQL metadata column `subject` is rewritten. The archived +// original (encrypted EML in the store) is never touched — this is a +// display/search metadata repair, not a change to the immutable archive +// copy (same principle as PROJ-34 / PROJ-48). +// - After a successful DB update the mail is re-indexed in Manticore +// (emails_global / emails_tenant_N), otherwise search would keep showing +// the old subject. +// - The run itself is written to the audit log (system action). +// +// Dry-run is the DEFAULT: without --apply nothing is written. +// +// Usage: +// +// archivmail fix-subjects # dry-run over all tenants +// archivmail fix-subjects --tenant 2 --limit 50 +// archivmail fix-subjects --apply +func runFixSubjects(args []string) { + fs := flag.NewFlagSet("fix-subjects", flag.ExitOnError) + configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file") + tenantIDFlag := fs.Int64("tenant", 0, "tenant ID (0 = all tenants)") + limitFlag := fs.Int("limit", 0, "max number of mails to inspect (0 = no limit)") + applyFlag := fs.Bool("apply", false, "actually write changes (default: dry-run only)") + verboseFlag := fs.Bool("verbose", false, "log every affected subject (old -> new)") + fs.Parse(args) + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + cfg, err := config.Load(*configPath) + if err != nil { + logger.Error("failed to load config", "err", err) + os.Exit(1) + } + + storeCfg := storage.Config{ + Dir: cfg.Storage.StorePath, + Keyfile: cfg.Storage.Keyfile, + DSN: cfg.Database.DSN(), + CompressEnabled: cfg.Storage.Compress, + } + mailStore, err := storage.New(storeCfg) + if err != nil { + logger.Error("storage init failed", "err", err) + os.Exit(1) + } + defer mailStore.Close() + + ctx := context.Background() + + var tenantPtr *int64 + if *tenantIDFlag > 0 { + t := *tenantIDFlag + tenantPtr = &t + } + + // Index manager is only needed when we actually write. + var idxMgr index.TenantIndexer + if *applyFlag { + idxMgr, err = openIndexManager(cfg) + if err != nil { + logger.Error("index init failed", "err", err) + os.Exit(1) + } + defer func() { idxMgr.Close() }() + } + + rows, err := mailStore.ListRawEncodedSubjects(ctx, tenantPtr, *limitFlag) + if err != nil { + logger.Error("failed to list candidate subjects", "err", err) + os.Exit(1) + } + + mode := "DRY-RUN" + if *applyFlag { + mode = "APPLY" + } + logger.Info("fix-subjects: starting", + "mode", mode, "tenant", *tenantIDFlag, "candidates", len(rows)) + + var ( + affected int // subject really contains an encoded-word and decodes differently + updated int + undecodable int // still encoded after decoding attempt + reindexed int + indexSkipped int + errCount int + ) + + for _, row := range rows { + if !mailparser.HasEncodedWord(row.Subject) { + continue + } + + // Preferred source of truth: re-parse the archived original (read-only). + // Falls back to decoding the stored string if the mail cannot be read. + newSubject := "" + var pm *mailparser.ParsedMail + var raw []byte + if data, lerr := mailStore.Load(row.ID); lerr == nil { + if parsed, perr := mailparser.Parse(data); perr == nil { + pm = parsed + raw = data + newSubject = parsed.Subject + } else { + logger.Warn("fix-subjects: parse failed, falling back to header decode", + "id", row.ID, "err", perr) + } + } else { + logger.Warn("fix-subjects: load failed, falling back to header decode", + "id", row.ID, "err", lerr) + } + if newSubject == "" { + newSubject = mailparser.DecodeMIMEHeader(row.Subject) + } + + if newSubject == row.Subject { + undecodable++ + logger.Warn("fix-subjects: subject still not decodable", "id", row.ID, "subject", row.Subject) + continue + } + affected++ + + if *verboseFlag || !*applyFlag { + logger.Info("fix-subjects: would fix", + "id", row.ID, "tenant", tenantLabel(row.TenantID), + "old", row.Subject, "new", newSubject) + } + if !*applyFlag { + continue + } + + if err := mailStore.UpdateSubjectMetadata(ctx, row.ID, newSubject); err != nil { + logger.Warn("fix-subjects: update failed", "id", row.ID, "err", err) + errCount++ + continue + } + updated++ + + // Pull the corrected subject into the full-text index. + if pm == nil { + indexSkipped++ + logger.Warn("fix-subjects: mail not parseable — index not updated", "id", row.ID) + continue + } + if err := reindexOne(ctx, mailStore, idxMgr, row.ID, row.TenantID, pm, raw); err != nil { + logger.Warn("fix-subjects: reindex failed", "id", row.ID, "err", err) + errCount++ + continue + } + reindexed++ + } + + logger.Info("fix-subjects: complete", + "mode", mode, + "candidates", len(rows), + "affected", affected, + "updated", updated, + "reindexed", reindexed, + "index_skipped", indexSkipped, + "undecodable", undecodable, + "errors", errCount) + + writeFixSubjectsAudit(cfg, logger, fixSubjectsAudit{ + Apply: *applyFlag, + TenantID: tenantPtr, + Candidates: len(rows), + Affected: affected, + Updated: updated, + Reindexed: reindexed, + IndexSkipped: indexSkipped, + Undecodable: undecodable, + Errors: errCount, + }) + + if errCount > 0 { + os.Exit(1) + } +} + +// openIndexManager builds the configured index backend (manticore by default). +func openIndexManager(cfg *config.Config) (index.TenantIndexer, error) { + backend := cfg.Index.Backend + if backend == "" { + backend = "manticore" + } + if backend == "manticore" { + dsn := cfg.Index.ManticoreDSN + if dsn == "" { + dsn = "manticore@tcp(127.0.0.1:9306)/?charset=utf8mb4" + } + m, err := index.NewManticoreTenantManager(dsn) + if err != nil { + return nil, fmt.Errorf("manticore init: %w", err) + } + return m, nil + } + batchSize := cfg.Index.BatchSize + if batchSize <= 0 { + batchSize = 100 + } + m, err := index.NewTenantIndexManager(cfg.Index.Path, batchSize, backend) + if err != nil { + return nil, fmt.Errorf("index manager init: %w", err) + } + return m, nil +} + +// reindexOne re-indexes a single mail from an already parsed message. Existing +// OCR text is preserved because IndexSync uses REPLACE (see runReindex). +func reindexOne(ctx context.Context, store *storage.Store, mgr index.TenantIndexer, + id string, tenantID *int64, pm *mailparser.ParsedMail, raw []byte) error { + + var attachNames []string + for _, a := range pm.Attachments { + if a.Filename != "" { + attachNames = append(attachNames, a.Filename) + } + } + + doc := index.MailDocument{ + ID: id, + From: pm.From, + To: strings.Join(pm.To, ", "), + CC: strings.Join(pm.CC, ", "), + Subject: pm.Subject, + Body: pm.TextBody, + AttachNames: strings.Join(attachNames, " "), + HasAttachment: len(pm.Attachments) > 0, + Date: pm.Date, + Size: int64(len(raw)), + TenantID: tenantID, + } + + idx := mgr.ForTenant(tenantID) + + if _, ocrChars, err := store.GetOCRMeta(ctx, id); err == nil && ocrChars > 0 { + if reader, ok := idx.(index.AttachmentTextReader); ok { + if existing, rerr := reader.GetAttachmentText(id); rerr == nil && existing != "" { + doc.AttachmentText = existing + } + } + } + + if err := idx.IndexSync(doc); err != nil { + return fmt.Errorf("index sync: %w", err) + } + return nil +} + +// fixSubjectsAudit carries the counters of one backfill run into the audit log. +type fixSubjectsAudit struct { + Apply bool + TenantID *int64 + Candidates int + Affected int + Updated int + Reindexed int + IndexSkipped int + Undecodable int + Errors int +} + +// writeFixSubjectsAudit records the run (dry-run included) in the audit log. +// Audit failures must never abort the maintenance run itself. +func writeFixSubjectsAudit(cfg *config.Config, logger *slog.Logger, a fixSubjectsAudit) { + audlog, err := audit.New(cfg.Database.DSN(), cfg.Audit.ResolvedLogPath(), logger) + if err != nil { + logger.Warn("fix-subjects: audit log unavailable", "err", err) + return + } + defer audlog.Close() + + mode := "dry-run" + if a.Apply { + mode = "apply" + } + audlog.Log(audit.Entry{ + EventType: audit.EventMetadataBackfill, + Username: "system", + IPAddress: "cli", + Query: "fix-subjects", + Success: a.Errors == 0, + TenantID: a.TenantID, + Detail: fmt.Sprintf( + "fix-subjects (%s): candidates=%d affected=%d updated=%d reindexed=%d index_skipped=%d undecodable=%d errors=%d; "+ + "nur emails.subject korrigiert, Archivoriginal unverändert", + mode, a.Candidates, a.Affected, a.Updated, a.Reindexed, a.IndexSkipped, a.Undecodable, a.Errors), + }) +} + +// tenantLabel renders a nullable tenant ID for log output. +func tenantLabel(t *int64) string { + if t == nil { + return "global" + } + return fmt.Sprintf("%d", *t) +} diff --git a/cmd/archivmail/cmd_import.go b/cmd/archivmail/cmd_import.go index e387ef5..4c8c684 100644 --- a/cmd/archivmail/cmd_import.go +++ b/cmd/archivmail/cmd_import.go @@ -308,6 +308,7 @@ Commands: purge Mails mit abgelaufener Aufbewahrungsfrist löschen (cron-fähig) recompress Bestehende Mails nachträglich gzip-komprimieren rethread Thread-IDs rückwirkend aus In-Reply-To/References befüllen + fix-subjects Kaputte RFC-2047-Betreffs in den Metadaten reparieren (Dry-Run per Default) ocr-reprocess OCR für Anhänge nachholen (alle oder pro Mandant/Status) index-pending Ungeindexte Mails nachindexieren (cron-fähig, PROJ-58 batch_mode) backup Store, Keyfile, PostgreSQL und Config konsistent sichern (PROJ-66) diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index e119d1d..351e4e9 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -78,6 +78,9 @@ func main() { case "rethread": runRethread(os.Args[2:]) return + case "fix-subjects": + runFixSubjects(os.Args[2:]) + return case "ocr-reprocess": runOCRReprocess(os.Args[2:]) return diff --git a/features/INDEX.md b/features/INDEX.md index b1c054c..0df2258 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -99,7 +99,8 @@ | PROJ-81 | Anhang-Online-Vorschau (PDF, Bilder) | In Review | [PROJ-81](PROJ-81-anhang-online-vorschau.md) | 2026-08-06 | | 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 | -## Next Available ID: PROJ-84 +## Next Available ID: PROJ-85 diff --git a/features/PROJ-84-fix-mime-header-charset-backfill.md b/features/PROJ-84-fix-mime-header-charset-backfill.md new file mode 100644 index 0000000..0115969 --- /dev/null +++ b/features/PROJ-84-fix-mime-header-charset-backfill.md @@ -0,0 +1,73 @@ +# PROJ-84: Fix MIME-Header-Charset-Dekodierung + Backfill für Bestandsmails + +## Status: In Review +**Created:** 2026-08-06 +**Last Updated:** 2026-08-06 + +## Kontext +Betreffzeilen mit MIME-Encoded-Words (RFC 2047) in Charsets außerhalb UTF-8/US-ASCII/ISO-8859-1 (z.B. Windows-1252, ISO-8859-15) wurden nicht dekodiert und roh angezeigt (`=?Windows-1252?Q?ARAG_4_...?=`). Ursache: `decodeMIMEHeader()` in `pkg/mailparser/parser.go` nutzte `mime.WordDecoder` ohne `CharsetReader` — Go unterstützt dort nativ nur die drei genannten Charsets. Der Body-Decoder (`decodeCharset`, genutzt seit PROJ-57) konnte das bereits über `htmlindex`, der Header-Decoder nicht. + +Fix (Commit 31d7113) behebt das für künftige Importe. Bestandsmails behalten den kaputten Betreff, bis sie per Backfill korrigiert werden — dafür dieses Ticket. + +## Dependencies +- Baut auf PROJ-57 (UTF-8-Encoding-Fix) auf — nutzt dieselbe `htmlindex`-Charset-Logik +- Betrifft PROJ-11/PROJ-48 (Audit-Log) — Backfill-Lauf wird auditiert + +## User Stories +- Als User will ich, dass Mail-Betreffs unabhängig vom ursprünglichen Absender-Charset korrekt lesbar sind, sowohl bei neuen als auch bei bereits archivierten Mails. +- Als Admin will ich einen nachvollziehbaren, wiederholbaren Weg haben, bestehende Mails mit kaputtem Betreff zu korrigieren, ohne das archivierte Original anzufassen. + +## Acceptance Criteria +- [x] `decodeMIMEHeader()` dekodiert Encoded-Words in Windows-1252, ISO-8859-15 und weiteren von `htmlindex` unterstützten Charsets korrekt +- [x] CLI-Kommando `archivmail fix-subjects` identifiziert Bestandsmails mit undekodiertem Encoded-Word im Betreff +- [x] Dry-Run ist Default, echte Änderung nur mit `--apply` +- [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 +- [ ] `--apply`-Lauf auf 131 (Produktiv) durchgeführt, nach Freigabe + +## Edge Cases +- Encoded-Word verletzt RFC 2047 selbst (z.B. Leerzeichen im codierten Teil, an Vortext geklebt) → wird übersprungen, mit WARN geloggt, bleibt unverändert (2 von 543 Fällen auf 132) +- Mail-Original nicht mehr ladbar/parsebar (z.B. DSGVO-gelöscht) → Index wird nicht angefasst, als `index_skipped` gezählt, DB-Subject bleibt beim Fallback (`DecodeMIMEHeader` auf vorhandenem String) +- Mail bereits mit OCR-Text indexiert → Reindex darf `attachment_text` nicht löschen, wird aus bestehendem Index-Dokument übernommen + +## Technical Requirements (optional) +- Kein neues Datenbankschema, keine neue Tabelle +- CLI-Subkommando statt Wegwerf-Skript, für künftige ähnliche Encoding-Bugs wiederverwendbar +- Exit-Code 1 bei Fehlern (cron-/scripttauglich) + +--- + + +## Tech Design (Solution Architect) +Direkt umgesetzt ohne vorgelagerte Architektur-Phase — kleiner, klar umrissener Bugfix + Backfill-Tool, kein neues UI, kein neues Datenmodell. + +Quelle der Wahrheit ist das archivierte Original: pro Mail wird die verschlüsselte EML lesend geladen und mit `mailparser.Parse()` neu geparst, der neue Betreff kommt aus `pm.Subject`. Fallback auf direktes Redekodieren des gespeicherten Strings, falls Original nicht ladbar. Geschrieben wird ausschließlich `UPDATE emails SET subject=...`. + +## Implementation Notes (Backend, 2026-08-06) + +Neue/geänderte Dateien: +- `pkg/mailparser/header_decode.go` (neu) — `DecodeMIMEHeader()` exportierter Wrapper, `HasEncodedWord()` Erkennung +- `internal/storage/subject_backfill.go` (neu) — `ListRawEncodedSubjects()`, `UpdateSubjectMetadata()` +- `cmd/archivmail/cmd_fix_subjects.go` (neu) — CLI-Kommando inkl. Reindex + Audit +- `internal/audit/audit.go` — neue Konstante `EventMetadataBackfill = "metadata_backfill"` +- `cmd/archivmail/main.go`, `cmd/archivmail/cmd_import.go` — Dispatch + Hilfetext + +Kommando: +```bash +archivmail fix-subjects # Dry-Run (Default), alle Mandanten +archivmail fix-subjects --tenant 3 --limit 50 +archivmail fix-subjects --apply +archivmail fix-subjects --apply --verbose # + jede Änderung alt->neu loggen +``` + +Messung auf 192.168.1.132 (Dry-Run, read-only): 543 Kandidaten, 541 reparierbar, 2 nicht dekodierbar (RFC-2047-Verletzung im Original, korrekt übersprungen). Charset-Verteilung: ISO-8859-15 (219), windows-1252 (185), Cp1252 (69), Windows-1252 (38), iso-8859-15 (11), utf8-Varianten (14), windows-1258 (5), ASCII (1). + +Build auf 132 verifiziert (`CGO_ENABLED=0 go build -buildvcs=false ./cmd/archivmail/` → OK), Dry-Run zweimal gegen Live-DB gelaufen, Stichproben korrekt (u.a. mehrteilige Windows-1252-Encoded-Words richtig zusammengesetzt). `--apply` noch nicht ausgeführt. + +## QA Test Results +_To be added by /qa_ + +## Deployment +_To be added by /deploy_ diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 784dfc7..4c3550f 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -32,6 +32,11 @@ const ( // day dropped significantly below its trailing 7-day average, or the IMAP // soll/ist comparison revealed a shortfall. EventReconciliationAnomaly = "reconciliation_anomaly" + // EventMetadataBackfill: a CLI/system maintenance run repaired derived + // metadata columns (e.g. `emails.subject` after the RFC 2047 charset fix). + // The archived original is never modified by such a run — the entry + // documents scope, mode (dry-run/apply) and counters for traceability. + EventMetadataBackfill = "metadata_backfill" ) // Entry is a single audit log record. diff --git a/internal/storage/subject_backfill.go b/internal/storage/subject_backfill.go new file mode 100644 index 0000000..f836382 --- /dev/null +++ b/internal/storage/subject_backfill.go @@ -0,0 +1,74 @@ +package storage + +import ( + "context" + "fmt" +) + +// SubjectRow is a minimal projection of an email used by metadata repair runs +// (e.g. the fix-subjects backfill). It never carries mail content — the +// encrypted original in the store is not touched by such runs. +type SubjectRow struct { + ID string + TenantID *int64 + Subject string +} + +// ListRawEncodedSubjects returns emails whose subject still contains an +// RFC 2047 encoded-word pattern (`=?charset?B|Q?...?=`). The SQL LIKE is only +// a cheap prefilter; the caller must verify with mailparser.HasEncodedWord and +// decide whether decoding actually yields a different value. +// +// tenantID nil = all tenants. limit <= 0 = no limit. +func (s *Store) ListRawEncodedSubjects(ctx context.Context, tenantID *int64, limit int) ([]SubjectRow, error) { + if s.db == nil { + return nil, fmt.Errorf("storage: list raw encoded subjects: no database configured") + } + + query := `SELECT id, tenant_id, COALESCE(subject, '') + FROM emails + WHERE subject LIKE '%=?%?=%'` + 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 raw encoded 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 raw encoded subjects: scan: %w", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("storage: list raw encoded subjects: rows: %w", err) + } + return out, nil +} + +// UpdateSubjectMetadata rewrites only the `subject` metadata column of an +// email. The archived original (encrypted EML in the store) is deliberately +// left untouched — this is a display/search metadata repair, not a change to +// the immutable archive copy. +func (s *Store) UpdateSubjectMetadata(ctx context.Context, id, subject string) error { + if s.db == nil { + return fmt.Errorf("storage: update subject metadata: no database configured") + } + if _, err := s.db.Exec(ctx, `UPDATE emails SET subject = $1 WHERE id = $2`, subject, id); err != nil { + return fmt.Errorf("storage: update subject metadata %s: %w", id, err) + } + return nil +} diff --git a/pkg/mailparser/header_decode.go b/pkg/mailparser/header_decode.go new file mode 100644 index 0000000..9fa1e4f --- /dev/null +++ b/pkg/mailparser/header_decode.go @@ -0,0 +1,22 @@ +package mailparser + +import "regexp" + +// encodedWordRe matches an RFC 2047 encoded-word (=?charset?B|Q?text?=). +// Used to detect header values that were stored raw because decoding failed +// (e.g. Windows-1252 before the CharsetReader fix in decodeMIMEHeader). +var encodedWordRe = regexp.MustCompile(`=\?[^?\s]+\?[BbQq]\?[^?]*\?=`) + +// HasEncodedWord reports whether s still contains an undecoded RFC 2047 +// encoded-word. A correctly decoded subject never contains one. +func HasEncodedWord(s string) bool { + return encodedWordRe.MatchString(s) +} + +// DecodeMIMEHeader is the exported form of decodeMIMEHeader. It decodes +// RFC 2047 encoded-word headers including charsets outside UTF-8/US-ASCII/ +// ISO-8859-1 (e.g. Windows-1252). Callers outside the parser need it for +// metadata repair runs (subject backfill). +func DecodeMIMEHeader(s string) string { + return decodeMIMEHeader(s) +} diff --git a/src/data/features.ts b/src/data/features.ts index 336a769..53277d6 100644 --- a/src/data/features.ts +++ b/src/data/features.ts @@ -87,4 +87,5 @@ export const features: Feature[] = [ { id: "PROJ-81", name: "Anhang-Online-Vorschau (PDF, Bilder)", status: "In Review", frontend: true, backend: false, lastUpdated: "2026-08-06", version: "1.0" }, { 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" }, ];