7.2 KiB
Codex-Brief — Migrationskern auf emersion/go-imap/v2
Ziel: den handgerollten IMAP-Parser (simpleIMAP in 04-imap-source.go, plus
05-imap-target.go) durch github.com/emersion/go-imap/v2 ersetzen. Das
behebt in einem Zug die vier Integritäts-Bugs aus dem Review (siehe
REVIEW-FIXES.md), die alle Symptome desselben Problems sind: eigener Parser
statt Bibliothek.
Warum: Bei einem Beweis-/Compliance-Archiv ist Vollständigkeit der ganze Zweck. go-imap ist eine gepflegte pure-Go-Lib (keine Dependency-Lawine) und erledigt genau das, was der handgerollte Parser falsch macht.
Bleibt UNVERÄNDERT (nicht anfassen)
- Die Interfaces und Typen:
SourceMailbox,TargetMailbox,RawMessage,Folder{Name,Attrs,Delim},TargetFolder{Name,Attrs}. Nur die Implementierung dahinter wird getauscht. 06-mbox.go(mbox-Writer),11-folders.go(Rollen-Mapping),02-database.go, die ganze Web-/UI-Schicht.- Die Idempotenz-Reihenfolge in
07-migrate.go: prüfen →dst.Append→mbox.Append→ erst dannMarkCopied. Nicht verändern. - Die drei Security-Modi
tls/starttls/none+insecure(Semantik bleibt, nur die API dahinter wird go-imap).
Neu bauen: 04-imap-source.go + 05-imap-target.go
Alles Handgerollte raus: simpleIMAP, dialIMAP, parseListMailbox,
fetchAll, parseFlags, parseInternalDate, appendMessage. Und die
irreführenden Kommentare („go-imap dekodiert…") stimmen danach endlich.
go-imap/v2ist Beta — die exakten Signaturen gegen die gepinnte Version prüfen (go get github.com/emersion/go-imap/v2@latest, dann die aufgelöste Version ingo.modfestnageln). Unten steht die Form, nicht das Evangelium.
1. Verbinden nach Security-Modus (imapclient)
opts := &imapclient.Options{ TLSConfig: &tls.Config{
ServerName: host, MinVersion: tls.VersionTLS12,
InsecureSkipVerify: a.SrcInsecure } }
switch a.SrcSecurity {
case "tls": c, err = imapclient.DialTLS(addr, opts) // implizit, 993
case "starttls": c, err = imapclient.DialStartTLS(addr, opts) // 143 + Upgrade
case "none": c, err = imapclient.DialInsecure(addr, opts) // reiner Klartext
}
c.Login(a.SrcUser, a.SrcPass).Wait()
none (DialInsecure) ist der Alt-Provider-Fall — muss ohne TLS funktionieren.
2. Folders() — LIST mit SPECIAL-USE + Delimiter
cmd := c.List("", "*", &imap.ListOptions{ReturnSpecialUse: true})
for { d := cmd.Next(); if d == nil { break }
attrs := make([]string, len(d.Attrs))
for i, at := range d.Attrs { attrs[i] = string(at) } // z.B. "\\Sent"
folders = append(folders, Folder{
Name: d.Mailbox, // **bereits UTF-8-dekodiert** -> Bug 1 weg
Attrs: attrs, // -> 11-folders RoleFromAttrs funktioniert
Delim: string(d.Delim) }) // -> Trenner-Übersetzung stimmt
}
go-imap gibt d.Mailbox als dekodierten Klartext zurück → Gelöschte Objekte
statt Gel&APY-schte…. Damit greift RoleFromName/RoleFromAttrs und die
Standardordner werden nicht mehr gedoppelt.
3. Fetch() — FLAGS + INTERNALDATE + Body, ein Message pro Iteration
c.Select(folder, nil).Wait()
seq := imap.SeqSetRange(1, 0) // 1:* (alle)
fo := &imap.FetchOptions{
Flags: true, InternalDate: true, Envelope: true,
BodySection: []*imap.FetchItemBodySection{{Peek: true}} } // BODY.PEEK[] -> setzt kein \Seen
fcmd := c.Fetch(seq, fo)
for {
msg := fcmd.Next(); if msg == nil { break }
buf, err := msg.Collect() // puffert GENAU EINE Mail -> kein OOM mehr
raw := RawMessage{
MessageID: messageID(buf), // s.u.
Body: buf.FindBodySection(&imap.FetchItemBodySection{Peek:true}),
Flags: sanitizeFlags(buf.Flags), // s.u. — \Recent raus
InternalDate: buf.InternalDate } // go-imap liefert echte time.Time -> Bug 3 weg
// ... an Callback/Slice geben
}
buf.Flags/buf.InternalDate/buf.Envelope sind strukturiert und
reihenfolge-unabhängig geparst → Bug 4 weg. InternalDate als time.Time
direkt aus der Lib → Bug 3 (Leerzeichen-Tag) weg.
4. sanitizeFlags — \Recent/\* beim APPEND filtern (Bug 2)
func sanitizeFlags(in []imap.Flag) []imap.Flag {
var out []imap.Flag
for _, f := range in {
if f == imap.FlagRecent || f == imap.FlagWildcard { continue }
out = append(out, f)
}
return out
}
\Recent per APPEND ist RFC-verboten → Server antwortet NO → Mail ginge sonst
verloren. Muss raus.
5. Message-ID (Dedup-Schlüssel) — aus Envelope, mit Hash-Fallback
func messageID(buf *imapclient.FetchMessageBuffer) string {
if buf.Envelope != nil && buf.Envelope.MessageID != "" { return buf.Envelope.MessageID }
return "sha256:" + hex(sha256(body)) // Mails ohne Message-ID trotzdem deduplizieren
}
Verbessert das aktuelle Verhalten (Mails ohne Message-ID wurden bisher bei jedem Lauf erneut kopiert → Dubletten).
6. Ziel (05-imap-target.go)
Folders()/Delim(): gleiche LIST-Logik wie oben, in[]TargetFolder.EnsureFolder(name):c.Create(name, nil).Wait(); „already exists" schlucken; danachc.Subscribe(name).Wait(). Verschachtelte Ordner: wennnameTrenner enthält, Eltern-Pfade zuerst anlegen (manche Server legen sie nicht automatisch an — Review-Bug 6).Append(folder, m):
opts := &imap.AppendOptions{ Flags: sanitizeFlags(m.Flags), Time: m.InternalDate }
ac := c.Append(folder, int64(len(m.Body)), opts)
ac.Write(m.Body); ac.Close()
_, err := ac.Wait()
Flags + Originaldatum bleiben erhalten (Regel 1). Nie SMTP.
Streaming (Schritt 2 — nach dem Kern-Swap, separat verifizieren)
Damit ein GB-Postfach nicht in den RAM läuft, Fetch von „Slice zurückgeben"
auf Callback pro Mail umstellen:
// Interface:
Fetch(folder string, fn func(RawMessage) error) error
In 07-migrate.go wandert die Pro-Mail-Schleife (dedup → Append → mbox →
MarkCopied) in den Callback — die Reihenfolge bleibt exakt gleich, nur der
Rahmen ändert sich. So ist immer nur eine Mail im Speicher.
go.mod
github.com/emersion/go-imap/v2 hinzufügen, go mod tidy. (Zieht go-sasl/
go-message transitiv; kein cgo — bleibt single-binary.)
Abnahme (mit unserem echten Testkorpus)
Nutze die 9 Test-Mails in vdevop-02@golddata.eu (Text, Anhänge einzeln/
mehrfach, ZIP, HTML-Body, Umlaut-Betreff) als Quelle:
go test ./...grün (inkl.sanitizeFlags- undmessageID-Unit-Tests).- Migration zweimal laufen → zweiter Lauf kopiert 0 (Idempotenz).
- Ziel-
INTERNALDATE= Originaldatum der Quelle (nicht „heute") — inkl. einer Mail mit einstelligem Tag. - Quell-Ordner
Gesendete Objekte/Papierkorblanden im vorhandenen Rollen-Ordner des Ziels, keine Dublette. - Eine Mail mit
\Recentin der Quelle wird migriert (nicht mitNOabgelehnt). - Anhänge (PDF/PNG/ZIP) und Umlaut-Betreff kommen byte-identisch an (Quelle-Body == Ziel-Body == mbox-Body).
NICHT in diesem Schritt
- POP3-Fallback verdrahten (
src_proto=pop3→OpenPOP3SourceinmigrateAccount) — eigener kleiner Folgeschritt. - Nicht-ASCII-Betreff-Encoding im Forward (RFC 2047) — gehört zum späteren kontrollierten Forward-Endpunkt.