160 lines
7.2 KiB
Markdown
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.
|