@@ -278,7 +287,7 @@ func ArchiveStatsFor(name string) (ArchiveStats, error) {
}
stat.Files++
stat.Bytes += info.Size()
- if strings.EqualFold(filepath.Ext(path), ".mbox") {
+ if isMboxArchivePath(path) {
msgs, err := readMboxMessages(path)
if err != nil {
return nil
@@ -414,17 +423,37 @@ func safeArchiveFileName(value string) (string, error) {
if name == "" || name == "." || name == ".." {
return "", fmt.Errorf("Dateiname fehlt.")
}
+ lower := strings.ToLower(name)
ext := strings.ToLower(filepath.Ext(name))
- if ext != ".mbox" && ext != ".pst" {
- return "", fmt.Errorf("Nur mbox- und PST-Dateien koennen importiert werden.")
+ if ext != ".mbox" && ext != ".pst" && !strings.HasSuffix(lower, ".mbox.zst") {
+ return "", fmt.Errorf("Nur mbox-, mbox.zst- und PST-Dateien koennen importiert werden.")
}
clean := strings.NewReplacer("\\", "_", "/", "_", ":", "_").Replace(name)
- if strings.TrimSuffix(clean, ext) == "" {
+ if strings.HasSuffix(strings.ToLower(clean), ".mbox.zst") {
+ if strings.TrimSuffix(strings.TrimSuffix(clean, ".zst"), ".mbox") == "" {
+ clean = "import.mbox.zst"
+ }
+ } else if strings.TrimSuffix(clean, ext) == "" {
clean = "import" + ext
}
return clean, nil
}
+func isMboxArchivePath(path string) bool {
+ lower := strings.ToLower(path)
+ return strings.HasSuffix(lower, ".mbox") || strings.HasSuffix(lower, ".mbox.zst")
+}
+
+func exportPlainMbox(src io.Reader, dst io.Writer) error {
+ dec, err := zstd.NewReader(src)
+ if err != nil {
+ return err
+ }
+ defer dec.Close()
+ _, err = io.Copy(dst, dec)
+ return err
+}
+
func formatBytes(n int64) string {
const unit = 1024
if n < unit {
diff --git a/config.json.example b/config.json.example
index 8b86beb..8b6056e 100644
--- a/config.json.example
+++ b/config.json.example
@@ -5,6 +5,7 @@
"admin_pass": "CHANGE-ME-lokal",
"db_path": "mail-graveyard.db",
"mbox_root": "./backup",
+ "mbox_compression": "none",
"forward_smtp": {
"host": "",
"port": 587,
diff --git a/deploy/goldpi-test-container/mail-graveyard-pod/config.json.example b/deploy/goldpi-test-container/mail-graveyard-pod/config.json.example
index 37e4f90..660e2a9 100644
--- a/deploy/goldpi-test-container/mail-graveyard-pod/config.json.example
+++ b/deploy/goldpi-test-container/mail-graveyard-pod/config.json.example
@@ -5,6 +5,7 @@
"admin_pass": "CHANGE-ME-lokal",
"db_path": "/app/data/mail-graveyard.db",
"mbox_root": "/app/backup",
+ "mbox_compression": "none",
"forward_smtp": {
"host": "",
"port": 587,
diff --git a/go.mod b/go.mod
index 300ecb6..4103cf4 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/google/uuid v1.6.0 // indirect
+ github.com/klauspost/compress v1.19.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
diff --git a/go.sum b/go.sum
index a1888fe..28fca59 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
+github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
diff --git a/zstd-brief.md b/zstd-brief.md
new file mode 100644
index 0000000..0cdbbea
--- /dev/null
+++ b/zstd-brief.md
@@ -0,0 +1,114 @@
+# Codex-Brief — mbox-Kompression mit zstd-3
+
+Ziel: Das Datengrab schrumpfen, **ohne** den verifizierten Append-only-Pfad und
+die Vorschau kaputtzumachen. Danach vergleichen wir gemessen: Größe und
+Vorschau-Latenz, komprimiert vs. plain.
+
+## Gemessene Ausgangslage (echte mbox auf GoldPi)
+
+| mbox | roh | zstd-3 | zstd-19 |
+|---|---|---|---|
+| INBOX.mbox | 4.372.002 | 113.564 (**38,5×**) | 111.017 (39,4×) |
+| Test-Haupt-Ordner | 51.699 | 11.316 (**4,6×**) | 10.677 (4,8×) |
+
+**zstd-19 bringt nur ~2 % mehr als zstd-3, kostet aber ein Vielfaches an Zeit →
+Level 3.** (Die 38× sind ein Artefakt: die INBOX besteht großteils aus fast
+identischen Benachrichtigungsmails. Realistisch bei echten Postfächern: 2–4×.)
+
+## Format
+
+`.mbox.zst` = **Folge von zstd-Frames**. Konkatenierte Frames sind eine
+gültige `.zst` — `zstd -d datei.mbox.zst` liefert **exakt** die mbox, die wir
+sonst plain geschrieben hätten. Damit bleibt der Append-only-Charakter erhalten:
+neue Mail = neuer Frame hinten dran.
+
+**Start mit: ein Frame pro Mail.** Das macht die Vorschau trivial schnell (genau
+ein Frame entpacken). Nachteil: keine Redundanz *zwischen* Mails → schlechtere
+Rate. Falls die gemessene Rate enttäuscht, schalten wir auf **Batch-Frames mit
+8-MB-Deckel** um (bessere Rate, Vorschau entpackt dann ≤ 8 MB ≈ 30 ms). **Der
+Index unten ist so gebaut, dass dieser Wechsel nichts kostet** — bitte die
+Felder auch dann schon so anlegen.
+
+Bibliothek: **`github.com/klauspost/compress/zstd`** — pure Go, kein cgo,
+bleibt single-binary.
+
+## Offset-Index (der Kern)
+
+Neue Tabelle, geschrieben im selben Schritt wie der mbox-Append:
+
+```sql
+CREATE TABLE IF NOT EXISTS mbox_index(
+ account_id INTEGER NOT NULL,
+ folder TEXT NOT NULL,
+ seq INTEGER NOT NULL, -- laufende Nummer im Ordner
+ message_id TEXT NOT NULL,
+ subject TEXT NOT NULL DEFAULT '',
+ from_addr TEXT NOT NULL DEFAULT '',
+ date TEXT NOT NULL DEFAULT '',
+ file_offset INTEGER NOT NULL, -- Byte-Offset des FRAMES in der Datei
+ frame_len INTEGER NOT NULL, -- Laenge des Frames (komprimiert)
+ inner_offset INTEGER NOT NULL DEFAULT 0, -- Offset der Mail IM entpackten Frame
+ inner_len INTEGER NOT NULL, -- Laenge der Mail entpackt
+ UNIQUE(account_id, folder, message_id)
+);
+```
+(`inner_offset` ist bei „ein Frame pro Mail" immer 0 — er ist die Vorbereitung
+für Batch-Frames.)
+
+**Das schenkt uns nebenbei einen Bug-Fix:** Die Liste im Viewer parst heute die
+*ganze* mbox-Datei (`ReadMboxList`). Künftig kommt sie aus dem Index → **kein
+Dateizugriff mehr, schneller als heute**, auch bei einem 10-GB-Archiv.
+
+## Schreibpfad — die Reihenfolge bleibt heilig
+
+In `07-migrate.go` **nicht umsortieren**. Neu ist nur, dass der mbox-Append
+Offsets zurückgibt und danach der Index geschrieben wird:
+
+```
+AlreadyCopied → dst.Append → mbox.Append (liefert offset/len) → Index-Zeile → MarkCopied
+```
+`MarkCopied` bleibt **das Letzte**. Ein Absturz davor kopiert die Mail beim
+nächsten Lauf erneut — dieselbe bewusste Entscheidung wie bisher.
+
+## Lesepfad
+
+- **Liste** → nur SQLite (`mbox_index`), keine Datei anfassen.
+- **Eine Mail öffnen** → `file_offset` seeken, **genau diesen Frame** entpacken,
+ `inner_offset`/`inner_len` herausschneiden.
+- **Abwärtskompatibel:** Bestehende **plain `.mbox`** müssen weiter funktionieren.
+ Erkennung über Endung bzw. zstd-Magic (`0xFD2FB528`). Wenn für eine Datei keine
+ Index-Zeilen existieren → alter Parse-Pfad.
+
+## Umschaltbar (wichtig für den Vergleich)
+
+`config.json`: `"mbox_compression": "none" | "zstd"` (Default zunächst `none`,
+damit nichts überrascht). So können wir **dasselbe Postfach zweimal sichern** —
+einmal plain, einmal zstd — und sauber vergleichen.
+
+## Export (die harte Anforderung bleibt)
+
+Eine Funktion/Route **„Archiv als plain mbox exportieren"**: entpackt die
+`.mbox.zst` zu einer normalen `.mbox`. Damit bleibt die Zusage erhalten, die der
+Grund für mbox war: **man kann sie in eM Client ziehen** bzw. einem Anwalt oder
+Prüfer in die Hand geben. Ein Handgriff statt null — akzeptabel.
+
+## Abnahme
+
+1. `go test ./...` grün.
+2. **Round-trip byte-identisch** — das ist die wichtigste Prüfung für ein
+ Beweis-Archiv: Denselben Quell-Ordner einmal mit `none` und einmal mit `zstd`
+ sichern. Dann muss gelten:
+ `zstd -d ordner.mbox.zst` == `ordner.mbox` **Byte für Byte** (`cmp`).
+ Wenn nicht: **Stopp.** Ein Archiv, das nicht exakt zurückkommt, ist wertlos.
+3. **Rate messen** an einer echten mbox (nicht an meinem Testkorpus — der ist
+ durch die vielen identischen Benachrichtigungsmails unrealistisch gut).
+4. Idempotenz unverändert: zweiter Lauf `copied=0`.
+5. Anhänge (PDF/PNG/ZIP) kommen aus dem komprimierten Archiv byte-identisch
+ wieder heraus.
+6. **Vorschau-Latenz**: Mail öffnen aus plain vs. aus zstd — Zeit messen.
+ (Diesen Vergleich fahre ich, Claude.)
+
+## NICHT in diesem Schritt
+- Bestehende plain-mbox nachträglich komprimieren (Migration alter Archive) —
+ eigener, späterer Schritt.
+- Batch-Frames mit 8-MB-Deckel — nur falls die gemessene Rate enttäuscht.