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>
166 lines
5.2 KiB
Go
166 lines
5.2 KiB
Go
package backend
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type TargetDedupReport struct {
|
|
Account string
|
|
Apply bool
|
|
Folders int
|
|
Groups int
|
|
ExtraCopies int
|
|
Candidates int
|
|
WithoutID int
|
|
VariantGroups int
|
|
Deleted int
|
|
FolderReports []TargetDedupFolderReport
|
|
}
|
|
|
|
type TargetDedupFolderReport struct {
|
|
Folder string
|
|
Groups int
|
|
ExtraCopies int
|
|
WithoutID int
|
|
VariantGroups int
|
|
Deletes []TargetDedupDelete
|
|
}
|
|
|
|
type TargetDedupDelete struct {
|
|
UID uint32
|
|
MessageID string
|
|
BodySHA256 string
|
|
}
|
|
|
|
func DedupTarget(name string, folders []string, apply bool) (TargetDedupReport, error) {
|
|
a, err := GetAccount(name)
|
|
if err != nil {
|
|
return TargetDedupReport{}, err
|
|
}
|
|
dst, err := OpenIMAPTarget(a)
|
|
if err != nil {
|
|
return TargetDedupReport{}, err
|
|
}
|
|
defer dst.Close()
|
|
allFolders, err := dst.Folders()
|
|
if err != nil {
|
|
return TargetDedupReport{}, err
|
|
}
|
|
selected := selectedFolderSet(folders)
|
|
report := TargetDedupReport{Account: name, Apply: apply}
|
|
for _, folder := range allFolders {
|
|
if len(selected) > 0 && !selected[folder.Name] {
|
|
continue
|
|
}
|
|
folderReport, err := dedupTargetFolder(dst, folder.Name, apply)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
report.Folders++
|
|
report.Groups += folderReport.Groups
|
|
report.ExtraCopies += folderReport.ExtraCopies
|
|
report.Candidates += len(folderReport.Deletes)
|
|
report.WithoutID += folderReport.WithoutID
|
|
report.VariantGroups += folderReport.VariantGroups
|
|
if apply {
|
|
report.Deleted += len(folderReport.Deletes)
|
|
}
|
|
report.FolderReports = append(report.FolderReports, folderReport)
|
|
}
|
|
log.Printf("target dedup %s apply=%v folders=%d groups=%d extra=%d candidates=%d deleted=%d without_id=%d protected_variant_groups=%d",
|
|
report.Account, report.Apply, report.Folders, report.Groups, report.ExtraCopies, report.Candidates, report.Deleted, report.WithoutID, report.VariantGroups)
|
|
return report, nil
|
|
}
|
|
|
|
func dedupTargetFolder(dst TargetMailbox, folder string, apply bool) (TargetDedupFolderReport, error) {
|
|
report := TargetDedupFolderReport{Folder: folder}
|
|
byID := map[string][]uint32{}
|
|
headers, err := dst.AllHeaders(folder)
|
|
if err != nil {
|
|
return report, fmt.Errorf("%s headers: %w", folder, err)
|
|
}
|
|
for _, header := range headers {
|
|
id := normalizeMessageID(header.MessageID)
|
|
if id == "" {
|
|
report.WithoutID++
|
|
continue
|
|
}
|
|
byID[id] = append(byID[id], header.UID)
|
|
}
|
|
var deleteUIDs []uint32
|
|
for id, uids := range byID {
|
|
if len(uids) < 2 {
|
|
continue
|
|
}
|
|
byBody := map[string][]uint32{}
|
|
for _, uid := range uids {
|
|
msg, err := dst.FetchOne(folder, uid)
|
|
if err != nil {
|
|
return report, fmt.Errorf("%s fetch uid %d for safe dedup: %w", folder, uid, err)
|
|
}
|
|
hash := bodySHA256(msg.Body)
|
|
byBody[hash] = append(byBody[hash], uid)
|
|
}
|
|
if len(byBody) > 1 {
|
|
report.VariantGroups++
|
|
}
|
|
hashes := make([]string, 0, len(byBody))
|
|
for hash := range byBody {
|
|
hashes = append(hashes, hash)
|
|
}
|
|
sort.Strings(hashes)
|
|
for _, hash := range hashes {
|
|
bodyUIDs := byBody[hash]
|
|
if len(bodyUIDs) < 2 {
|
|
continue
|
|
}
|
|
sort.Slice(bodyUIDs, func(i, j int) bool { return bodyUIDs[i] < bodyUIDs[j] })
|
|
report.Groups++
|
|
report.ExtraCopies += len(bodyUIDs) - 1
|
|
for _, uid := range bodyUIDs[1:] {
|
|
report.Deletes = append(report.Deletes, TargetDedupDelete{UID: uid, MessageID: id, BodySHA256: hash})
|
|
deleteUIDs = append(deleteUIDs, uid)
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(report.Deletes, func(i, j int) bool {
|
|
if report.Deletes[i].MessageID == report.Deletes[j].MessageID {
|
|
return report.Deletes[i].UID < report.Deletes[j].UID
|
|
}
|
|
return strings.Compare(report.Deletes[i].MessageID, report.Deletes[j].MessageID) < 0
|
|
})
|
|
if apply && len(deleteUIDs) > 0 {
|
|
sort.Slice(deleteUIDs, func(i, j int) bool { return deleteUIDs[i] < deleteUIDs[j] })
|
|
for _, del := range report.Deletes {
|
|
log.Printf("target dedup delete folder=%s uid=%d message_id=%s body_sha256=%s", folder, del.UID, del.MessageID, del.BodySHA256)
|
|
}
|
|
if err := dst.DeleteUIDs(folder, deleteUIDs); err != nil {
|
|
return report, fmt.Errorf("%s delete uids: %w", folder, err)
|
|
}
|
|
}
|
|
if report.Groups > 0 || report.WithoutID > 0 || report.VariantGroups > 0 {
|
|
log.Printf("target dedup folder=%s groups=%d extra=%d without_id=%d protected_variant_groups=%d apply=%v", folder, report.Groups, report.ExtraCopies, report.WithoutID, report.VariantGroups, apply)
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func LogTargetDedupReport(report TargetDedupReport) {
|
|
mode := "DRY-RUN"
|
|
if report.Apply {
|
|
mode = "APPLY"
|
|
}
|
|
log.Printf("target dedup report %s account=%s folders=%d groups=%d extra=%d candidates=%d deleted=%d without_id=%d protected_variant_groups=%d",
|
|
mode, report.Account, report.Folders, report.Groups, report.ExtraCopies, report.Candidates, report.Deleted, report.WithoutID, report.VariantGroups)
|
|
for _, folder := range report.FolderReports {
|
|
if folder.Groups == 0 && folder.WithoutID == 0 && folder.VariantGroups == 0 {
|
|
continue
|
|
}
|
|
log.Printf("target dedup report folder=%s groups=%d extra=%d without_id=%d protected_variant_groups=%d", folder.Folder, folder.Groups, folder.ExtraCopies, folder.WithoutID, folder.VariantGroups)
|
|
for _, del := range folder.Deletes {
|
|
log.Printf("target dedup report candidate folder=%s uid=%d message_id=%s", folder.Folder, del.UID, del.MessageID)
|
|
}
|
|
}
|
|
}
|