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

5.3 KiB
Raw Permalink Blame History

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)

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:

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:

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: AlreadyCopieddst.Appendmbox.AppendMarkCopied. 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 funktionierenRunMigrationFolders / 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.