Mail-Graveyard/streaming-brief.md
2026-07-14 17:56:33 +02:00

119 lines
5.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Codex-Brief — Streaming (Schritt 2 aus `go-imap-migration.md`)
Ziel: `Fetch` puffert nicht länger **alle Bodies eines Ordners** im RAM, sondern
verarbeitet **eine Mail nach der anderen**. Das ist der **letzte Blocker vor den
echten `@dr-gold.de`-Postfächern** — deren Backup-Ordner sind noch leer, und da
reden wir über GB statt über die 199 kleinen Test-Mails.
Aktuell: `src.Fetch(folder) ([]RawMessage, error)` lädt den kompletten Ordner in
eine Slice. Ein Postfach mit 50.000 Mails × 200 KB = **10 GB im RAM** → OOM.
## 1. Interface-Änderung (`04-imap-source.go`)
```go
type SourceMailbox interface {
Folders() ([]Folder, error)
Fetch(folder string, fn func(RawMessage) error) error // <— NEU: Callback
Close() error
}
```
Implementierung mit go-imap/v2 — **genau eine Mail gleichzeitig im Speicher**:
```go
func (s *imapSource) Fetch(folder string, fn func(RawMessage) error) error {
sel, err := s.c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil { return err }
if sel.NumMessages == 0 { return nil } // Empty-Guard BLEIBT (kein FETCH 1:* auf leer)
fo := &imap.FetchOptions{
Flags: true, InternalDate: true, Envelope: true,
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
}
fcmd := s.c.Fetch(imap.SeqSetRange(1, 0), fo) // 1:*
defer fcmd.Close()
for {
msg := fcmd.Next()
if msg == nil { break }
buf, err := msg.Collect() // puffert GENAU DIESE eine Mail
if err != nil { return err }
if err := fn(buildRawMessage(buf)); err != nil {
return err // nur FATAL -> Ordner abbrechen
}
// buf/raw gehen hier out of scope -> GC gibt den Body frei
}
return fcmd.Close()
}
```
## 2. `07-migrate.go` — die Pro-Mail-Schleife wandert in den Callback
**Die Reihenfolge bleibt Zeichen für Zeichen dieselbe** — sie ist die
Idempotenz-Garantie und ist live verifiziert. Nicht umsortieren:
```go
total, done, errs := 0, 0, 0
err := src.Fetch(srcFolder, func(m RawMessage) error {
total++
already, err := AlreadyCopied(a.ID, srcFolder, m.MessageID)
if err != nil { errs++; log.Printf(...); return nil }
if already { return nil }
if err := dst.Append(dstFolder, m); err != nil { errs++; log.Printf(...); return nil }
if err := mbox.Append(srcFolder, m); err != nil { errs++; log.Printf(...); return nil }
if err := MarkCopied(a.ID, srcFolder, m.MessageID); err != nil { errs++; log.Printf(...); return nil }
done++
return nil
})
```
`total` wird jetzt **während** des Streams gezählt (vorher `len(msgs)`).
Log-Zeile pro Ordner **unverändert lassen**:
`migration <konto> <src> -> <dst>: total=N copied=M errors=E` — daran hängen
meine Prüfungen und deine Abnahme.
## 3. Die fünf Regressions-Fallen (bitte ernst nehmen)
1. **Der Callback darf den Ordner NICHT abbrechen.** Ein Fehler an *einer* Mail
`errs++`, loggen, **`return nil`** (weiterstreamen). Nur ein wirklich
fataler Zustand gibt einen Fehler zurück. Sonst killt eine einzige kaputte
Mail die Migration des ganzen Ordners — heute tut sie das nicht.
2. **Im Callback NIEMALS Kommandos auf der Quell-Verbindung absetzen.** Der
FETCH läuft noch auf `s.c`; ein `Select`/`List` mittendrin zerlegt den
Protokollstrom. Der Callback fasst nur **Ziel**, **mbox** und **SQLite** an —
das ist ok, andere Verbindungen.
3. **Empty-Folder-Guard behalten** (`NumMessages == 0` → sofort `return nil`).
Der Fix darf nicht beim Refactor verloren gehen.
4. **Reihenfolge behalten:** `AlreadyCopied``dst.Append``mbox.Append`
`MarkCopied`. Ein Crash zwischen Append und MarkCopied darf lieber doppelt
kopieren als eine Mail als „erledigt" markieren, die nie ankam.
5. **`--folders`-Scoping und `--watch` müssen weiter funktionieren** —
`RunMigrationFolders` / `selectedFolders` bleiben unverändert.
Nebenbei: `10-pop3.go` muss die neue Interface-Signatur mitziehen (auch wenn es
noch ein Stub ist), sonst baut es nicht.
## 4. Abnahme
1. `go test ./...` grün.
2. **Keine Verhaltensänderung** — die bestehende Abnahme muss identisch laufen:
- `--run codex-abnahme-vdevop02-to-vdevop03` zweimal → zweiter Lauf `copied=0`
- `Gelöschte Objekte -> Papierkorb` (keine Dublette), `Entwürfe -> Entwürfe`
- Ziel-`INTERNALDATE` weiter `07-Mar-2024` (Originaldatum, nicht heute)
- Anhänge byte-identisch
3. **Der eigentliche Beweis — Speicher:** Leg in der Quelle einen Testordner mit
z. B. **200 Mails à ~1 MB** an. Alt: RSS wächst auf ~200 MB+ (ganzer Ordner
im RAM). Neu: RSS bleibt **flach** (nur eine Mail). Messen z. B. mit
`podman stats` oder `/proc/<pid>/status` `VmRSS` während des Laufs.
**Diesen Speicher-Test fahre und verifiziere ich** (ich bin Test/Review),
du musst ihn nicht selbst aufsetzen — bau nur den Code.
## 5. NICHT in diesem Schritt
- **Echtes Body-Streaming pro Mail** (Body als `io.Reader` direkt in APPEND +
mbox tee-en, statt `Collect()`). Damit wäre auch eine 500-MB-Einzelmail
unkritisch. Aktuell hält man **eine** Mail im RAM — das löst das gestellte
Problem (GB-Postfach) vollständig; Einzelmails sind serverseitig ohnehin
gedeckelt. Später bei Bedarf.
- POP3 verdrahten, DB-Namen-Altlast (`emailforwarder.db` vs. `mail-graveyard.db`
+ zwei 0-Byte-Leichen), RFC-2047-Betreff im Forward.