# 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 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`) ```go 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 ```go 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 ```go 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) ```go 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 ```go 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)`: ```go 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: ```go // 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=pop3` → `OpenPOP3Source` in `migrateAccount`) — eigener kleiner Folgeschritt. - Nicht-ASCII-Betreff-Encoding im Forward (RFC 2047) — gehört zum späteren kontrollierten Forward-Endpunkt.