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>
This commit is contained in:
DonVoo 2026-07-16 13:22:27 +02:00
parent 6c4073a01c
commit 9f5cc59af7
15 changed files with 1650 additions and 181 deletions

84
bug-hashkey-instabil.md Normal file
View file

@ -0,0 +1,84 @@
# 🟠 Instabiler Ersatzschlüssel: 4.024 Doppel-Einträge in `copied`
**Kein Datenverlust, keine Dubletten in Postfächern.** Aber die Buchhaltung
stimmt nicht — und beim Aufräumen lauert eine Falle (siehe „⚠️ NICHT TUN").
## Befund
```
Datei-Records: 58.051 ═ mbox_index: 58.051 ✓ (Drift 0)
copied: 62.073 ← 4.022 mehr
```
`copied`-Zeilen ohne passenden `mbox_index`-Eintrag: **4.024**, davon
**4.021 mit `sha256:`-Schlüssel**. Verteilung exakt auf die Konten mit
`--reindex --rebuild`: gb +2533, rb +719, byrne +564, crystal +112, roby +92.
(gb hatte gemessen 2.537 Mails ohne Message-ID — passt auf ~4 genau.)
## Ursache
Mails **ohne `Message-ID`-Header** bekommen `sha256:<hash>` als Ersatzschlüssel.
Der Hash wird aber über **unterschiedliche Bytes** gebildet:
| Pfad | hasht |
|---|---|
| Migration (`07-migrate.go`) | die **IMAP-Rohbytes** der Mail |
| Reindex/Rebuild (`06-mbox.go`) | den **mbox-Record aus der Datei** |
Die mbox-Fassung ist nicht byte-gleich mit der IMAP-Fassung: Der Writer setzt
`>From `-Quoting und normalisiert Zeilenenden. → **anderer Hash → anderer
Schlüssel → dieselbe Mail steht zweimal in `copied`.**
Betroffen sind alle **6.623** Mails ohne Message-ID (so viele hat `mbox_index`
mit `sha256:`-Schlüssel).
## ⚠️ NICHT TUN: die „Waisen" einfach löschen
Die 4.024 Waisen sind **nicht** der Müll — sie sind die Zeilen, auf die der
**Migrationspfad tatsächlich matcht** (er rechnet den IMAP-Hash aus). Die vom
Rebuild ergänzten Zeilen (mbox-Hash) sind die redundanten.
**Werden die Waisen gelöscht, hält die Migration diese 4.021 Mails für
unkopiert und schreibt sie erneut ins Ziel-Postfach → echte Dubletten in
fremden Postfächern.** Genau der Schaden, den wir vermeiden wollen.
Aus demselben Grund darf die **Schlüsselberechnung nicht einfach umgestellt**
werden: Für Konten **ohne** Rebuild (kolmer, palamari, colak, …) existiert nur
der IMAP-Hash. Ein Wechsel auf den mbox-Hash lässt dort **alle** Message-ID-losen
Mails als „neu" erscheinen → Massen-Neukopie.
## Fix (in dieser Reihenfolge, sonst knallt es)
1. **Schlüssel vereinheitlichen:** Der Ersatzschlüssel muss in **beiden** Pfaden
über **dieselben** Bytes gehen. Sauberste Wahl: über die **mbox-normalisierte
Fassung** (also das, was tatsächlich im Archiv steht) — dann liefern
Migration und Reindex/Rebuild dauerhaft denselben Wert, auch nach jedem
künftigen Rebuild.
→ In `07-migrate.go` den Hash **nach** der mbox-Normalisierung bilden, nicht
über die IMAP-Rohbytes.
2. **Einmalige `copied`-Migration** (zwingend zusammen mit 1., nicht danach):
Für jedes Konto/Ordner `copied` aus `mbox_index` neu ableiten
(`message_id` aus dem Index, `mbox_done=1`), `target_done` aus dem
bestehenden Stand übernehmen (die Mails **sind** im Ziel — nicht auf 0
zurücksetzen!). Anschließend die alten IMAP-Hash-Zeilen entfernen.
**In einer Transaktion**, mit Zählung vorher/nachher.
3. **Abnahme:** `copied` == `mbox_index` (58.051 == 58.051), und der erste
Watch-/Migrationslauf danach meldet **`archived=0 target=0 pending=0`** —
also keine einzige Neukopie. Meldet er mehr, sofort stoppen: dann matcht der
Schlüssel nicht und es entstehen gerade Dubletten.
## Alternativ (wenn 1.+2. zu heikel erscheinen)
Zustand **so lassen**. Er ist funktional korrekt: Der Migrationspfad matcht auf
die IMAP-Hash-Zeilen, es wird nichts doppelt kopiert (live bestätigt:
`complete=0 archived=0 target=0 pending=0 errors=0`). Kosten: `copied` ist um
~7 % aufgebläht und die Zahl taugt nicht als Beleg für „so viel ist archiviert".
Dann aber **in `INSTALL.md` dokumentieren**, warum die Zahlen auseinandergehen —
sonst rätselt in zwei Jahren jemand daran.
## Hängt zusammen mit
Der noch offenen `body_sha256`-Bereinigung (byte-verschiedene Mails mit gleicher
Message-ID, ~109 Nachzügler): Beide drehen sich um „was ist die Identität einer
Mail". Am besten **in einem Zug** lösen und **eine** Schlüsselregel festlegen:
- Message-ID vorhanden → `message_id` **+ `body_sha256`** (fängt die
byte-verschiedenen Fälle),
- keine Message-ID → nur `body_sha256`,
- **`body_sha256` immer über die mbox-normalisierte Fassung.**