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

137
rettungslauf-fixes.md Normal file
View file

@ -0,0 +1,137 @@
# Codex-Brief — Fixes aus dem echten Rettungslauf (14.07., 15 GB / 58k Mails)
Der Lauf gegen die 18 echten `@dr-gold.de`-Postfaecher hat vier Schwaechen
freigelegt, die im Testbetrieb nie sichtbar waren. **Fix 1 und 2 sind Pflicht,
bevor wir den Nachschlag fahren** — sonst reisst es an derselben Stelle wieder.
Kontext: Der Quell-Hoster verliert derzeit Mails. Das lokale mbox-Archiv ist das
Sicherheitsnetz. **Alles, was das Archiv gefaehrdet, ist ein Notfall.**
---
## 1. 🔴 `busy_timeout = 0` — SQLite scheitert sofort statt zu warten
**Befund:** `PRAGMA busy_timeout` steht auf **0**. Bei Lock-Konflikten (Watcher +
Web-App + Migration auf derselben DB) bricht die Abfrage **sofort** ab:
```
mbox index error <id>: database is locked (5) (SQLITE_BUSY)
```
Sechsmal im Rettungslauf passiert.
**Fix:** In `ConnectDB` (02-database.go) neben `journal_mode=WAL` setzen:
```sql
PRAGMA busy_timeout = 10000; -- 10s warten statt sofort scheitern
```
Einzeiler, grosse Wirkung.
---
## 2. 🔴 Das Archiv haengt am Ziel — und das ist verkehrt herum
**Der schwerste Befund.** Heutige Reihenfolge in `migrateAccount`:
```
AlreadyCopied -> dst.Append (All-Inkl) -> mbox.Append (lokal) -> MarkCopied
^^^^^^^^^^ scheitert das hier, wird per `return nil` abgebrochen
-> die Mail landet AUCH NICHT im lokalen Archiv
```
**Real passiert bei `petarus`:** Die Ziel-Verbindung starb mitten im Lauf
(`use of closed network connection`). Danach schlug jedes `EnsureFolder`/`Append`
fehl → **~620 von 1135 Mails wurden weder ins Ziel noch ins Archiv geschrieben.**
Das Sicherheitsnetz hat genau dann gerissen, als es gebraucht wurde.
**Das Archiv muss zuerst kommen und vom Ziel unabhaengig sein.** Dafuer braucht
`copied` getrennte Zustaende — sonst erzeugt ein Retry mbox-Dubletten:
```sql
ALTER TABLE copied ADD COLUMN mbox_done INTEGER NOT NULL DEFAULT 1;
ALTER TABLE copied ADD COLUMN target_done INTEGER NOT NULL DEFAULT 1;
-- Default 1: Bestandszeilen sind beides-fertig, das stimmt.
```
**Neuer Ablauf:**
```
1. Zeile vorhanden und mbox_done=1 und target_done=1 -> ueberspringen
2. mbox.Append (ZUERST — das Archiv ist das Sicherheitsnetz)
scheitert -> Fehler zaehlen, Mail ueberspringen, nichts markieren
3. Zeile schreiben/aktualisieren: mbox_done=1, target_done=0
4. dst.Append (Ziel)
OK -> target_done=1
scheitert -> Fehler zaehlen, WEITERMACHEN. Die Mail IST archiviert.
```
**Was das loest:**
- Ein sterbendes Ziel kostet nie wieder eine Archiv-Kopie.
- Ein zweiter Lauf sieht `mbox_done=1, target_done=0` → **ueberspringt den
mbox-Append** (keine Dublette!) und **holt nur das Ziel nach**.
`MarkCopied` bleibt sinngemaess das Letzte je Stufe. Ein Absturz zwischen
mbox-Write und Zeilen-Update kopiert die Mail beim naechsten Lauf erneut ins
Archiv — dieselbe bewusste Entscheidung wie bisher, nur jetzt pro Stufe.
---
## 3. 🟠 Kein Reconnect zum Ziel
Bei einem 2-GB-Postfach haelt All-Inkl die IMAP-Sitzung nicht durch. Stirbt sie,
scheitert **jede** weitere Operation — der Rest des Kontos faellt weg.
**Fix:** Verbindungsfehler erkennen (`use of closed network connection`, EOF,
`broken pipe`) und **die Ziel-Verbindung neu aufbauen** (12 Versuche mit kurzer
Pause). Gelingt das nicht:
- **nicht abbrechen**, sondern fuer den Rest des Kontos im **Archiv-Only-Modus**
weiterlaufen (dank Fix 2 ist das gefahrlos — die Mails landen sicher im mbox,
`target_done=0`), und
- am Ende **laut melden**: `ZIEL NICHT ERREICHBAR — N Mails nur archiviert,
Nachlauf noetig`.
Lieber ein vollstaendiges Archiv mit offenem Ziel-Nachlauf als beides halb.
---
## 4. 🟠 Gleiche Message-ID im selben Ordner → Mail wird still verworfen
**Befund:** Bei mehreren Konten ist `copied < total` **ohne** Fehler
(neubauer 1, simsek 5, palamari 8, gb u. a. — zusammen ~25 Mails). Ursache: Die
gleiche Message-ID kommt im selben Ordner zweimal vor (Re-Import, kaputte Mailer),
und der Dedup wirft die zweite weg.
Fuer ein **Beweis-Archiv ist das nicht hinnehmbar**: Zwei Mails mit derselben
Message-ID koennen **unterschiedlichen Inhalt** haben. Wir duerfen nie still eine
byte-verschiedene Mail verwerfen.
**Fix:** `copied` um `body_sha256` erweitern, Unique-Key auf
`(account_id, folder, message_id, body_sha256)`.
- Gleiche ID **und** gleiche Bytes → echte Dublette, ueberspringen (wie bisher).
- Gleiche ID, **andere** Bytes → **andere Mail**, archivieren.
- Bestandszeilen: `body_sha256` leer lassen und beim Vergleich als Wildcard
behandeln (kein Reindex-Zwang, keine Re-Kopie des Bestands).
---
## 5. 🟢 Phantom-Ordner tolerieren
`LIST` meldet `INBOX.Posteingang`, `SELECT` sagt `NO Mailbox doesn't exist`.
Kaputte Quelle (passt zum Hoster-Problem). Heute: Fehler. Kuenftig: **Warnung**,
Ordner ueberspringen, Lauf sauber weiterfuehren — das ist kein Fehler unseres
Tools.
---
## Abnahme
1. `go test ./...` gruen.
2. `PRAGMA busy_timeout` → 10000.
3. **Der entscheidende Test — Ziel-Ausfall simulieren:** Migration eines
Testkontos starten, waehrend des Laufs das Ziel unerreichbar machen
(z. B. falscher `dst_host`/Port). Erwartung:
- **Alle** Quell-Mails landen trotzdem **vollstaendig im lokalen mbox**.
- `target_done=0` fuer diese Mails.
- Lauf meldet laut „Ziel nicht erreichbar, Nachlauf noetig".
- **Zweiter Lauf mit erreichbarem Ziel:** kopiert **nichts** ins mbox
(keine Dublette!) und holt **nur** das Ziel nach.
Das ist die Regression, die uns bei `petarus` ~620 Mails gekostet haette.
4. Nachschlag `petarus` (`--folders` ordnerweise) → alle 1135 Mails im Archiv.
Ich (Claude) fahre danach den Verifikationslauf: Quell-Anzahl vs. Archiv-Anzahl
je Konto, und ziehe alle Luecken nach.