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

@ -0,0 +1,70 @@
package backend
import (
"fmt"
"reflect"
"testing"
)
type dedupTargetStub struct {
headers []MessageHeader
bodies map[uint32][]byte
deleted []uint32
}
func (s *dedupTargetStub) Folders() ([]TargetFolder, error) { return nil, nil }
func (s *dedupTargetStub) Delim() string { return "/" }
func (s *dedupTargetStub) EnsureFolder(string) error { return nil }
func (s *dedupTargetStub) Append(string, RawMessage) error { return nil }
func (s *dedupTargetStub) Headers(string, int, int) ([]MessageHeader, error) {
return s.headers, nil
}
func (s *dedupTargetStub) AllHeaders(string) ([]MessageHeader, error) { return s.headers, nil }
func (s *dedupTargetStub) FetchOne(_ string, uid uint32) (RawMessage, error) {
body, ok := s.bodies[uid]
if !ok {
return RawMessage{}, fmt.Errorf("missing uid %d", uid)
}
return RawMessage{Body: body}, nil
}
func (s *dedupTargetStub) DeleteUIDs(_ string, uids []uint32) error {
s.deleted = append(s.deleted, uids...)
return nil
}
func (s *dedupTargetStub) Close() error { return nil }
func TestDedupTargetFolderProtectsDifferentBodiesWithSameMessageID(t *testing.T) {
sameA := []byte("Message-ID: <same@example.com>\r\n\r\nA")
sameB := []byte("Message-ID: <same@example.com>\r\n\r\nB")
dst := &dedupTargetStub{
headers: []MessageHeader{
{UID: 1, MessageID: "same@example.com"},
{UID: 2, MessageID: "same@example.com"},
{UID: 3, MessageID: "same@example.com"},
},
bodies: map[uint32][]byte{1: sameA, 2: sameA, 3: sameB},
}
report, err := dedupTargetFolder(dst, "INBOX", true)
if err != nil {
t.Fatal(err)
}
if report.Groups != 1 || report.ExtraCopies != 1 || report.VariantGroups != 1 {
t.Fatalf("unexpected report: %#v", report)
}
if !reflect.DeepEqual(dst.deleted, []uint32{2}) {
t.Fatalf("deleted=%v, want only exact duplicate UID 2", dst.deleted)
}
}
func TestDedupTargetFolderAbortsBeforeDeleteWhenBodyCannotBeVerified(t *testing.T) {
dst := &dedupTargetStub{
headers: []MessageHeader{{UID: 1, MessageID: "same@example.com"}, {UID: 2, MessageID: "same@example.com"}},
bodies: map[uint32][]byte{1: []byte("Message-ID: <same@example.com>\r\n\r\nA")},
}
if _, err := dedupTargetFolder(dst, "INBOX", true); err == nil {
t.Fatal("expected safe dedup to fail when a body cannot be fetched")
}
if len(dst.deleted) != 0 {
t.Fatalf("deleted despite incomplete verification: %v", dst.deleted)
}
}