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

@ -1,6 +1,7 @@
package backend
import (
"bytes"
"crypto/sha256"
"crypto/tls"
"errors"
@ -25,6 +26,17 @@ type RawMessage struct {
InternalDate time.Time // Original-Zeit, per APPEND erhalten
}
// MessageIdentity is stable across the IMAP and mbox representations. A real
// Message-ID is not sufficient on its own: broken mailers sometimes reuse it
// for byte-different messages. Messages without a Message-ID are identified by
// BodySHA256 alone. LegacyMessageID keeps the old raw-IMAP hash addressable
// while existing databases are migrated without re-copying mail.
type MessageIdentity struct {
MessageID string
BodySHA256 string
LegacyMessageID string
}
type MessageHeader struct {
UID uint32
MessageID string
@ -475,6 +487,43 @@ func messageID(body []byte) string {
return fmt.Sprintf("sha256:%x", sha256.Sum256(body))
}
func identityForRawMessage(m RawMessage) MessageIdentity {
id := normalizeMessageID(m.MessageID)
legacyID := id
if strings.HasPrefix(strings.ToLower(id), "sha256:") {
id = ""
}
if id == "" {
if msg, err := mail.ReadMessage(bytes.NewReader(m.Body)); err == nil {
id = normalizeMessageID(msg.Header.Get("Message-ID"))
}
}
return MessageIdentity{
MessageID: id,
BodySHA256: bodySHA256(m.Body),
LegacyMessageID: legacyID,
}
}
// canonicalMessageBytes mirrors the bytes which can be recovered from the
// current mbox writer/reader pair: line endings are LF, trailing record
// separators are removed and mbox's >From escaping is undone. Hashing this
// representation on both paths prevents the raw-IMAP/mbox hash split.
func canonicalMessageBytes(raw []byte) []byte {
raw = bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n"))
lines := bytes.Split(raw, []byte("\n"))
for i, line := range lines {
if bytes.HasPrefix(line, []byte(">From ")) {
lines[i] = line[1:]
}
}
return bytes.TrimRight(bytes.Join(lines, []byte("\n")), "\n")
}
func bodySHA256(raw []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(canonicalMessageBytes(raw)))
}
func normalizeMessageID(id string) string {
return strings.Trim(strings.TrimSpace(id), "<>")
}