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

4.1 KiB
Raw Blame History

🔴 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:

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) → (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:

// 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!):
    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.