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

View file

@ -3,6 +3,7 @@ package backend
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
@ -16,8 +17,10 @@ import (
// Posteingang) landen im vorhandenen Rollen-Ordner des Ziels statt als
// Dublette; Pfad-Trenner wird umgehaengt. Dann EnsureFolder + Mails streamen.
// 3. Pro Mail: Message-ID gegen copied-Cache pruefen -> schon da? ueberspringen.
// Sonst DOPPELT schreiben: TargetMailbox.Append (IMAP) + MboxWriter.Append
// (lokal), dann MarkCopied.
// Sonst zuerst ins lokale mbox-Sicherheitsarchiv schreiben und diesen
// Zustand festhalten; erst danach folgt TargetMailbox.Append. Beide Stufen
// sind getrennt wiederaufnehmbar, damit ein Zielausfall nie das Archiv
// verhindert und ein Nachlauf keine mbox-Dublette erzeugt.
// 4. Fortschritt in jobs-Tabelle schreiben (Browser-Anzeige).
//
// name = Konto-Name oder "all". watch = true -> Delta-Schleife bis Abbruch
@ -89,11 +92,6 @@ func migrateAccount(a Account, selectedFolders map[string]bool) error {
return fmt.Errorf("open source %s: %w", a.Name, err)
}
defer src.Close()
dst, err := OpenIMAPTarget(a)
if err != nil {
return fmt.Errorf("open target %s: %w", a.Name, err)
}
defer dst.Close()
archiveName := accountMboxDir(a)
if archiveName == "" {
return fmt.Errorf("keine Archiv-Mailbox fuer %s ausgewaehlt", a.Name)
@ -109,86 +107,187 @@ func migrateAccount(a Account, selectedFolders map[string]bool) error {
_ = finishJob(jobID, 0, 0, 1, "error")
return err
}
targets, err := dst.Folders()
if err != nil {
_ = finishJob(jobID, 0, 0, 1, "error")
return err
}
folderMap, err := FolderMap(a.ID)
if err != nil {
_ = finishJob(jobID, 0, 0, 1, "error")
return err
}
var dst TargetMailbox
var targets []TargetFolder
if opened, openErr := OpenIMAPTarget(a); openErr != nil {
log.Printf("%s target unavailable; continuing archive-only: %v", a.Name, openErr)
} else {
dst = opened
targets, err = dst.Folders()
if err != nil {
log.Printf("%s target folders unavailable; continuing archive-only: %v", a.Name, err)
_ = dst.Close()
dst = nil
targets = nil
}
}
defer func() {
if dst != nil {
_ = dst.Close()
}
}()
total, done, errs := 0, 0, 0
archived, targeted, targetPending := 0, 0, 0
targetUnavailableLogged := false
for _, folder := range folders {
if len(selectedFolders) > 0 && !selectedFolders[folder.Name] {
continue
}
dstFolder := MapSourceToTarget(folder.Name, folder.Attrs, folder.Delim, dst.Delim(), targets, folderMap[folder.Name])
dstDelim := "/"
if dst != nil {
dstDelim = dst.Delim()
}
dstFolder := MapSourceToTarget(folder.Name, folder.Attrs, folder.Delim, dstDelim, targets, folderMap[folder.Name])
if dstFolder == "" {
dstFolder = folder.Name
}
if err := dst.EnsureFolder(dstFolder); err != nil {
errs++
log.Printf("%s %s ensure target %s error: %v", a.Name, folder.Name, dstFolder, err)
continue
targetReady := false
if dst != nil {
if err := dst.EnsureFolder(dstFolder); err != nil {
log.Printf("%s %s ensure target %s error; retrying connection: %v", a.Name, folder.Name, dstFolder, err)
_ = dst.Close()
dst = nil
if reopened, reopenErr := OpenIMAPTarget(a); reopenErr != nil {
log.Printf("%s target reconnect failed; continuing archive-only: %v", a.Name, reopenErr)
} else if ensureErr := reopened.EnsureFolder(dstFolder); ensureErr != nil {
log.Printf("%s %s ensure target %s after reconnect failed; continuing archive-only: %v", a.Name, folder.Name, dstFolder, ensureErr)
_ = reopened.Close()
} else {
dst = reopened
targetReady = true
}
} else {
targetReady = true
}
}
folderDone, folderErrs := 0, 0
folderArchived, folderTargeted, folderPending := 0, 0, 0
folderTotal := 0
identityFolder := safeMboxName(folder.Name)
// After an identity-schema upgrade the indexed byte count is invalidated.
// Rebuild from the actual archive before consulting copied, otherwise a
// legacy Message-ID row could hide a byte-different message.
archivePath := filepath.Join(mboxDir, safeMboxName(folder.Name)+mboxFileExt())
if _, statErr := os.Stat(archivePath); statErr == nil {
if indexErr := EnsureMboxIndexed(archivePath); indexErr != nil {
errs++
folderErrs++
log.Printf("%s %s archive identity index error: %v", a.Name, folder.Name, indexErr)
continue
}
} else if !os.IsNotExist(statErr) {
errs++
folderErrs++
log.Printf("%s %s archive stat error: %v", a.Name, folder.Name, statErr)
continue
}
if err := src.Fetch(folder.Name, func(m RawMessage) error {
total++
folderTotal++
already, err := AlreadyCopied(a.ID, folder.Name, m.MessageID)
identity := identityForRawMessage(m)
state, err := GetCopyIdentityStateWithFolderAlias(a.ID, identityFolder, folder.Name, identity)
if err != nil {
errs++
folderErrs++
log.Printf("%s %s dedup error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
}
if already {
if state.MboxDone && state.TargetDone {
return nil
}
if err := dst.Append(dstFolder, m); err != nil {
errs++
folderErrs++
log.Printf("%s %s append to %s error %s: %v", a.Name, folder.Name, dstFolder, m.MessageID, err)
return nil
if !state.MboxDone {
appendInfo, err := mbox.Append(folder.Name, m)
if err != nil {
errs++
folderErrs++
log.Printf("%s %s mbox error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
}
if err := SaveMboxIndex(MboxIndexEntry{
AccountID: a.ID,
Folder: identityFolder,
MessageID: identity.MessageID,
BodySHA256: identity.BodySHA256,
Subject: appendInfo.Subject,
From: appendInfo.From,
Date: appendInfo.Date,
FileOffset: appendInfo.FileOffset,
FrameLen: appendInfo.FrameLen,
InnerOffset: appendInfo.InnerOffset,
InnerLen: appendInfo.InnerLen,
}); err != nil {
errs++
folderErrs++
log.Printf("%s %s mbox index error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
}
if err := UpdateMboxIndexState(a.ID, identityFolder, appendInfo.FileOffset+appendInfo.FrameLen); err != nil {
errs++
folderErrs++
log.Printf("%s %s mbox index state error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
}
if err := MarkIdentityMboxCopied(a.ID, identityFolder, identity); err != nil {
errs++
folderErrs++
log.Printf("%s %s mbox stage mark error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
}
state.MboxDone = true
archived++
folderArchived++
}
appendInfo, err := mbox.Append(folder.Name, m)
if err != nil {
errs++
folderErrs++
log.Printf("%s %s mbox error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
}
if err := SaveMboxIndex(MboxIndexEntry{
AccountID: a.ID,
Folder: folder.Name,
MessageID: m.MessageID,
Subject: appendInfo.Subject,
From: appendInfo.From,
Date: appendInfo.Date,
FileOffset: appendInfo.FileOffset,
FrameLen: appendInfo.FrameLen,
InnerOffset: appendInfo.InnerOffset,
InnerLen: appendInfo.InnerLen,
}); err != nil {
errs++
folderErrs++
log.Printf("%s %s mbox index error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
}
if err := UpdateMboxIndexState(a.ID, folder.Name, appendInfo.FileOffset+appendInfo.FrameLen); err != nil {
errs++
folderErrs++
log.Printf("%s %s mbox index state error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
}
if err := MarkCopied(a.ID, folder.Name, m.MessageID); err != nil {
errs++
folderErrs++
log.Printf("%s %s mark error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
if !state.TargetDone {
if dst == nil || !targetReady {
errs++
folderErrs++
targetPending++
folderPending++
if !targetUnavailableLogged {
log.Printf("%s target unavailable; messages remain safely archived with target_done=0", a.Name)
targetUnavailableLogged = true
}
return nil
}
if err := dst.Append(dstFolder, m); err != nil {
log.Printf("%s %s append to %s error %s; retrying connection: %v", a.Name, folder.Name, dstFolder, m.MessageID, err)
_ = dst.Close()
dst = nil
reopened, reopenErr := OpenIMAPTarget(a)
if reopenErr == nil {
reopenErr = reopened.EnsureFolder(dstFolder)
}
if reopenErr == nil {
reopenErr = reopened.Append(dstFolder, m)
}
if reopenErr != nil {
if reopened != nil {
_ = reopened.Close()
}
errs++
folderErrs++
targetPending++
folderPending++
targetReady = false
log.Printf("%s target reconnect/append failed; continuing archive-only: %v", a.Name, reopenErr)
return nil
}
dst = reopened
targetReady = true
}
if err := MarkIdentityTargetCopied(a.ID, identityFolder, identity); err != nil {
errs++
folderErrs++
log.Printf("%s %s target stage mark error %s: %v", a.Name, folder.Name, m.MessageID, err)
return nil
}
targeted++
folderTargeted++
}
done++
folderDone++
@ -198,14 +297,16 @@ func migrateAccount(a Account, selectedFolders map[string]bool) error {
folderErrs++
log.Printf("%s %s fetch error: %v", a.Name, folder.Name, err)
}
log.Printf("migration %s %s -> %s: total=%d copied=%d errors=%d", a.Name, folder.Name, dstFolder, folderTotal, folderDone, folderErrs)
log.Printf("migration %s %s -> %s: total=%d complete=%d archived=%d target=%d pending=%d errors=%d",
a.Name, folder.Name, dstFolder, folderTotal, folderDone, folderArchived, folderTargeted, folderPending, folderErrs)
}
state := "success"
if errs > 0 {
state = "partial"
}
_ = finishJob(jobID, total, done, errs, state)
log.Printf("migration %s all folders: total=%d copied=%d errors=%d", a.Name, total, done, errs)
log.Printf("migration %s all folders: total=%d complete=%d archived=%d target=%d pending=%d errors=%d",
a.Name, total, done, archived, targeted, targetPending, errs)
if errs > 0 {
return fmt.Errorf("migration %s finished with %d errors", a.Name, errs)
}