Mail-Graveyard/bug-htmltotext-panic.md
DonVoo 9f5cc59af7 Stabilize mail identity: canonical hash, index drift, HTML panic
Behebt drei Fehler derselben Klasse: an jeder Stelle wurde derselbe Wert
zweimal berechnet, statt einmal berechnet und weitergereicht - und die
beiden Berechnungen liefen auseinander.

1. Instabiler Ersatzschluessel (bug-hashkey-instabil.md)
   Mails ohne Message-ID bekamen sha256 ueber die IMAP-Rohbytes, der
   Reindex hashte dieselbe Mail ueber die mbox-gespeicherten Bytes
   (>From-Quoting, andere Zeilenenden) -> zwei Schluessel, 4.024
   Doppel-Eintraege in copied.
   Fix: canonicalMessageBytes() bringt beide Seiten auf eine Form
   (>From zurueckdrehen, CRLF->LF, Trailing-Newlines weg). Alle Pfade
   (Index, Migration, Viewer, Dedup) nutzen dieselbe Funktion.
   body_sha256 in copied + mbox_index, UNIQUE erweitert.

2. Index-Drift (bug-index-drift.md)
   Der plain-mbox-Reader las nach Datei-Position statt nach dem
   gespeicherten file_offset. Weil SQLITE_BUSY (busy_timeout=0) je
   Ordner einen Index-Eintrag verschluckt hatte, war ab dieser Luecke
   alles um 1 verschoben: Klick auf Mail X zeigte Mail X+1.
   Betroffen genau die 6 Ordner mit Busy-Fehler beim Rettungslauf.
   Fix: busy_timeout=10000, Lesen ueber file_offset, --reindex --rebuild.

3. HTML-Vorschau-Panic (bug-htmltotext-panic.md)
   replaceCaseInsensitive/stripHTMLBlock indizierten mit Offsets aus
   strings.ToLower(s) in s - ToLower ist nicht byte-laengen-erhaltend
   (z.B. U+0130). ~0,1% der Mails brachten die Vorschau zum Absturz.
   Fix: asciiFoldIndex() sucht direkt auf den Original-Bytes; zusaetzlich
   Rohtext-Fallback, damit keine archivierte Mail unsichtbar wird.

Weiter: Archiv-zuerst-Reihenfolge (mbox_done/target_done) - ein
sterbendes Ziel kostet keine Archiv-Kopie mehr; Dedup vergleicht
zusaetzlich den Inhalt und schuetzt byte-verschiedene Varianten.

WICHTIG: Die 62.073 alten Alias-Zeilen in copied bleiben bewusst
erhalten. Sie sehen wie Muell aus, sind aber die Zeilen, auf die der
alte Schluessel matcht - ein Loeschen wuerde Mails erneut in fremde
Postfaecher kopieren.

Verifiziert: go test ./... gruen; 58.051 Archivmails, 0 ohne
copied-Zeile; Drift 0 ueber alle 69 Ordner; 2.000 HTTP-Stichproben
ueber 8 Ordner: 0 falsche Zuordnung, 0 Panics; Watcher 220 + 60 Mails
archived=0 target=0 errors=0. Live als Image fix-identity2-20260716.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:22:27 +02:00

106 lines
4.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🔴 Vorschau stürzt bei ~0,1 % der Mails ab (htmlToText, Unicode)
Gefunden beim Verifizieren des Index-Drift-Fixes. **Der Drift-Fix selbst ist
sauber** (2000 Stichproben über 8 Ordner, 0 falsche Zuordnungen, Drift 0 über
alle 69 Ordner) — das hier ist ein **separater, neuer Befund**.
## Symptom
```
2026/07/16 09:45:44 http: panic serving: runtime error:
slice bounds out of range [:207] with length 175
backend.replaceCaseInsensitive 06-mbox.go:770
backend.markHTMLBreaks 06-mbox.go:755
backend.htmlToText 06-mbox.go:690
backend.extractTextBody / messageBody / messageHandler
```
Die betroffene Mail ist **nicht anzeigbar** — die Verbindung stirbt. Gemessen:
**1 von 2000 Stichproben (~0,1 %)**, hochgerechnet ~58 der 57.771 Mails.
Beispiel: `kolmer/INBOX seq=2378 "[www.mtp24.pl] Weryfikacja adresu"` — eine
polnische Mail. Auslöser ist Nicht-ASCII.
## Ursache — dieselbe Klasse wie der Buffer-Bug: zwei Koordinatensysteme
[`06-mbox.go:760`](backend/06-mbox.go):
```go
func replaceCaseInsensitive(s, old, new string) string {
lower := strings.ToLower(s) // <-- kann ANDERE Byte-Laenge haben als s!
needle := strings.ToLower(old)
for {
i := strings.Index(lower, needle) // Index in LOWER
...
b.WriteString(s[:i]) // auf S angewendet <-- FALSCH
cut := i + len(old)
s = s[cut:]
lower = lower[cut:] // beide mit demselben cut -> driften auseinander
}
}
```
`strings.ToLower` ist **nicht byte-laengen-erhaltend**:
- `İ` (U+0130, 2 Bytes) → `i̇` (3 Bytes) = **+1**
- `K` (U+212A Kelvin, 3 Bytes) → `k` (1 Byte) = **2**
Sobald ein solches Zeichen vorkommt, passen die Indizes aus `lower` nicht mehr
auf `s`. Mit jedem `cut` driftet es weiter, bis `i > len(s)` → Panic. Das erklaert
`[:207] with length 175` exakt.
**`stripHTMLBlock` direkt darunter hat denselben Fehler** (`lower`-Index auf `s`
angewendet) — bitte mitfixen, auch wenn er noch nicht gecrasht ist.
## Fix
Die Needles sind **alle ASCII** (`<br>`, `</p>`, `</div>`, …). Also gar keine
lowercase-Kopie bauen, sondern direkt auf dem Original suchen — mit
ASCII-Case-Folding:
```go
// asciiFoldIndex sucht needle (ASCII, bereits lowercase) case-insensitiv in s
// und liefert einen Index, der IMMER fuer s gueltig ist.
func asciiFoldIndex(s, needle string) int {
n := len(needle)
if n == 0 || len(s) < n { return -1 }
for i := 0; i+n <= len(s); i++ {
ok := true
for j := 0; j < n; j++ {
c := s[i+j]
if 'A' <= c && c <= 'Z' { c += 'a' - 'A' }
if c != needle[j] { ok = false; break }
}
if ok { return i }
}
return -1
}
```
Damit entfaellt das zweite Koordinatensystem komplett — es gibt nur noch `s`.
(Alternative: vorkompilierte `regexp.MustCompile("(?i)"+regexp.QuoteMeta(old))`
— ebenfalls korrekt, aber langsamer und schwerer als noetig.)
## Zusaetzlich: `recover()` im Vorschau-Pfad — Pflicht für ein Beweis-Archiv
`htmlToText` verarbeitet **beliebiges, fremdes HTML** aus 20 Jahren Mail
(Spam, kaputte Mailer, alle Sprachen). Ein Parser-Bug darf **nie** dazu fuehren,
dass eine archivierte Mail unsichtbar wird.
In `messageHandler` (bzw. um `messageBody`) ein `defer recover()`:
- bei Panic → **nicht** die Verbindung sterben lassen,
- stattdessen den **Rohtext** der Mail anzeigen (bzw. Header + Hinweis
„Darstellung fehlgeschlagen, Rohfassung unten"),
- und den Vorfall loggen (Konto/Ordner/seq), damit wir solche Mails finden.
**Grundsatz:** Man muss jede archivierte Mail *irgendwie* ansehen koennen —
notfalls roh. Lieber haesslich als unsichtbar.
## Abnahme
1. Unit-Test, der den Bug faengt (mit ASCII-only Test wuerde er durchrutschen!):
```go
in := "<p>Turkish İ test</p><BR>x" // U+0130 aendert die Byte-Laenge
out := htmlToText(in) // darf NICHT panicken
```
Zusaetzlich ein Fall mit `K` (U+212A) und ein gemischter (mehrere Vorkommen,
damit die Drift ueber mehrere Schleifendurchlaeufe sichtbar wuerde).
2. `go test ./...` gruen.
3. Live: `kolmer/INBOX seq=2378` (`[www.mtp24.pl] Weryfikacja adresu`) muss sich
oeffnen lassen — heute stirbt die Verbindung.
4. Ich (Claude) fahre danach den Sweep erneut: **0 Abstuerze** in 2000+ Proben.