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

160 lines
7.2 KiB
Markdown

# 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.