Mail-Graveyard/go-imap-migration.md
2026-07-14 05:37:49 +02:00

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.Appendmbox.Appenderst dann MarkCopied. 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/v2 ist Beta — die exakten Signaturen gegen die gepinnte Version prüfen (go get github.com/emersion/go-imap/v2@latest, dann die aufgelöste Version in go.mod festnageln). 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; danach c.Subscribe(name).Wait(). Verschachtelte Ordner: wenn name Trenner 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:

  1. go test ./... grün (inkl. sanitizeFlags- und messageID-Unit-Tests).
  2. Migration zweimal laufen → zweiter Lauf kopiert 0 (Idempotenz).
  3. Ziel-INTERNALDATE = Originaldatum der Quelle (nicht „heute") — inkl. einer Mail mit einstelligem Tag.
  4. Quell-Ordner Gesendete Objekte/Papierkorb landen im vorhandenen Rollen-Ordner des Ziels, keine Dublette.
  5. Eine Mail mit \Recent in der Quelle wird migriert (nicht mit NO abgelehnt).
  6. 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=pop3OpenPOP3Source in migrateAccount) — eigener kleiner Folgeschritt.
  • Nicht-ASCII-Betreff-Encoding im Forward (RFC 2047) — gehört zum späteren kontrollierten Forward-Endpunkt.