diff --git a/INSTALL.md b/INSTALL.md index b4dadba..e1ce7f7 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -16,7 +16,8 @@ cp config.json.example config.json ``` `config.json` (bleibt lokal, ist ge-`.gitignore`-t) setzt nur App-Sachen: -Bind/Port, Admin-Login, DB-Pfad, mbox-Wurzel und das **Forward-SMTP** (nur fürs +Bind/Port, Admin-Login, DB-Pfad, mbox-Wurzel, optionale +`mbox_compression` (`none` oder `zstd`) und das **Forward-SMTP** (nur fürs manuelle Weiterleiten aus dem Viewer). Die **Umzugs-Konten** legst du im Browser an, nicht hier. diff --git a/backend/00-router.go b/backend/00-router.go index c402e60..07f006e 100644 --- a/backend/00-router.go +++ b/backend/00-router.go @@ -1097,7 +1097,7 @@ func transferKindLabel(kind string) string { func renderTransferFolders(b *strings.Builder, side, kind, value string) { switch kind { case "archive": - mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, value, "*.mbox")) + mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, value)) if len(mboxes) == 0 { b.WriteString(`
Keine Ordner.
`) return @@ -1187,7 +1187,7 @@ func transferEmptyReason(kind string, err error) string { func firstTransferFolder(kind, value string) (string, error) { switch kind { case "archive": - mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, value, "*.mbox")) + mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, value)) infos := archiveFolderInfos(mboxes) if len(infos) == 0 { return "", fmt.Errorf("Keine Ordner.") diff --git a/backend/01-config.go b/backend/01-config.go index b03f62f..966add2 100644 --- a/backend/01-config.go +++ b/backend/01-config.go @@ -3,6 +3,7 @@ package backend import ( "encoding/json" "os" + "strings" ) // Cfg ist die globale App-Konfiguration (config.json). Enthaelt NUR @@ -11,13 +12,14 @@ import ( var Cfg Config type Config struct { - Bind string `json:"bind"` - Port string `json:"port"` - AdminUser string `json:"admin_user"` - AdminPass string `json:"admin_pass"` - DBPath string `json:"db_path"` - MboxRoot string `json:"mbox_root"` - ForwardSMTP SMTPConfig `json:"forward_smtp"` + Bind string `json:"bind"` + Port string `json:"port"` + AdminUser string `json:"admin_user"` + AdminPass string `json:"admin_pass"` + DBPath string `json:"db_path"` + MboxRoot string `json:"mbox_root"` + MboxCompression string `json:"mbox_compression"` + ForwardSMTP SMTPConfig `json:"forward_smtp"` } // SMTPConfig ist NUR fuer das manuelle Weiterleiten einzelner Mails aus dem @@ -47,5 +49,9 @@ func LoadConfig(path string) error { if Cfg.MboxRoot == "" { Cfg.MboxRoot = "./backup" } + Cfg.MboxCompression = strings.ToLower(strings.TrimSpace(Cfg.MboxCompression)) + if Cfg.MboxCompression == "" { + Cfg.MboxCompression = "none" + } return nil } diff --git a/backend/02-database.go b/backend/02-database.go index 04ad4ce..061b2fa 100644 --- a/backend/02-database.go +++ b/backend/02-database.go @@ -118,6 +118,20 @@ func ConnectDB(initDB bool) error { copied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(account_id, folder, message_id) )`, + `CREATE TABLE IF NOT EXISTS mbox_index( + account_id INTEGER NOT NULL, + folder TEXT NOT NULL, + seq INTEGER NOT NULL, + 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, + frame_len INTEGER NOT NULL, + inner_offset INTEGER NOT NULL DEFAULT 0, + inner_len INTEGER NOT NULL, + UNIQUE(account_id, folder, message_id) + )`, `CREATE TABLE IF NOT EXISTS jobs( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, @@ -500,6 +514,68 @@ func MarkCopied(accountID int64, folder, messageID string) error { return err } +type MboxIndexEntry struct { + AccountID int64 + Folder string + Seq int + MessageID string + Subject string + From string + Date string + FileOffset int64 + FrameLen int64 + InnerOffset int64 + InnerLen int64 +} + +func SaveMboxIndex(e MboxIndexEntry) error { + if DB == nil || e.MessageID == "" { + return nil + } + var seq int + if e.Seq > 0 { + seq = e.Seq + } else { + _ = DB.QueryRow(`SELECT COALESCE(MAX(seq), -1) + 1 FROM mbox_index WHERE account_id=? AND folder=?`, e.AccountID, e.Folder).Scan(&seq) + } + _, err := DB.Exec(`INSERT OR IGNORE INTO mbox_index(account_id, folder, seq, message_id, subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`, + e.AccountID, e.Folder, seq, e.MessageID, e.Subject, e.From, e.Date, e.FileOffset, e.FrameLen, e.InnerOffset, e.InnerLen) + return err +} + +func ListMboxIndex(accountID int64, folder string) ([]MboxIndexEntry, error) { + if DB == nil || accountID == 0 { + return nil, sql.ErrNoRows + } + rows, err := DB.Query(`SELECT account_id, folder, seq, message_id, subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len + FROM mbox_index WHERE account_id=? AND folder=? ORDER BY seq`, accountID, folder) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MboxIndexEntry + for rows.Next() { + var e MboxIndexEntry + if err := rows.Scan(&e.AccountID, &e.Folder, &e.Seq, &e.MessageID, &e.Subject, &e.From, &e.Date, &e.FileOffset, &e.FrameLen, &e.InnerOffset, &e.InnerLen); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func GetMboxIndex(accountID int64, folder string, seq int) (MboxIndexEntry, error) { + if DB == nil || accountID == 0 { + return MboxIndexEntry{}, sql.ErrNoRows + } + row := DB.QueryRow(`SELECT account_id, folder, seq, message_id, subject, from_addr, date, file_offset, frame_len, inner_offset, inner_len + FROM mbox_index WHERE account_id=? AND folder=? AND seq=?`, accountID, folder, seq) + var e MboxIndexEntry + err := row.Scan(&e.AccountID, &e.Folder, &e.Seq, &e.MessageID, &e.Subject, &e.From, &e.Date, &e.FileOffset, &e.FrameLen, &e.InnerOffset, &e.InnerLen) + return e, err +} + func SeedAccountFromFile(path string) error { b, err := os.ReadFile(path) if err != nil { diff --git a/backend/06-mbox.go b/backend/06-mbox.go index 6bbc50e..a9ab5a0 100644 --- a/backend/06-mbox.go +++ b/backend/06-mbox.go @@ -14,6 +14,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/klauspost/compress/zstd" ) // mbox ist das lokale Cold-Backup-Format: eine Datei pro IMAP-Ordner, der @@ -25,6 +27,17 @@ type MboxWriter struct { root string } +type MboxAppendInfo struct { + Path string + FileOffset int64 + FrameLen int64 + InnerOffset int64 + InnerLen int64 + Subject string + From string + Date string +} + // NewMboxWriter legt // an und spiegelt den Ordnerbaum. func NewMboxWriter(dir string) (*MboxWriter, error) { if err := os.MkdirAll(dir, 0o700); err != nil { @@ -35,35 +48,80 @@ func NewMboxWriter(dir string) (*MboxWriter, error) { // Append schreibt eine Mail im mbox-Format ("From "-Trennzeile, >From-Quoting) // in .mbox. -func (w *MboxWriter) Append(folder string, m RawMessage) error { - path := filepath.Join(w.root, safeMboxName(folder)+".mbox") +func (w *MboxWriter) Append(folder string, m RawMessage) (MboxAppendInfo, error) { + record := mboxRecord(m) + info := mboxAppendInfoFromMessage(m, record) + path := filepath.Join(w.root, safeMboxName(folder)+mboxFileExt()) + info.Path = path if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return err + return info, err } f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { - return err + return info, err } defer f.Close() + offset, err := f.Seek(0, io.SeekEnd) + if err != nil { + return info, err + } + info.FileOffset = offset + var written int + if Cfg.MboxCompression == "zstd" { + enc, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedDefault)) + if err != nil { + return info, err + } + compressed := enc.EncodeAll(record, nil) + written, err = f.Write(compressed) + info.FrameLen = int64(len(compressed)) + } else { + written, err = f.Write(record) + info.FrameLen = int64(written) + } + if err != nil { + return info, err + } + if written != int(info.FrameLen) { + return info, io.ErrShortWrite + } + return info, nil +} + +func mboxFileExt() string { + if Cfg.MboxCompression == "zstd" { + return ".mbox.zst" + } + return ".mbox" +} + +func mboxRecord(m RawMessage) []byte { + var b bytes.Buffer fromDate := m.InternalDate if fromDate.IsZero() { fromDate = time.Now() } - if _, err := fmt.Fprintf(f, "From MAILER-DAEMON %s\r\n", fromDate.Format("Mon Jan _2 15:04:05 2006")); err != nil { - return err - } + fmt.Fprintf(&b, "From MAILER-DAEMON %s\r\n", fromDate.Format("Mon Jan _2 15:04:05 2006")) body := bytes.ReplaceAll(m.Body, []byte("\r\nFrom "), []byte("\r\n>From ")) body = bytes.ReplaceAll(body, []byte("\nFrom "), []byte("\n>From ")) - if _, err := f.Write(body); err != nil { - return err - } + _, _ = b.Write(body) if !bytes.HasSuffix(body, []byte("\n")) { - if _, err := f.WriteString("\r\n"); err != nil { - return err - } + _, _ = b.WriteString("\r\n") } - _, err = f.WriteString("\r\n") - return err + _, _ = b.WriteString("\r\n") + return b.Bytes() +} + +func mboxAppendInfoFromMessage(m RawMessage, record []byte) MboxAppendInfo { + info := MboxAppendInfo{InnerLen: int64(len(record))} + msg, err := mail.ReadMessage(bytes.NewReader(m.Body)) + if err != nil { + return info + } + info.Subject = decodeHeader(msg.Header.Get("Subject")) + info.From = decodeHeader(msg.Header.Get("From")) + info.Date = decodeHeader(msg.Header.Get("Date")) + return info } // --- Viewer-Seite: mbox wieder lesen fuers Browser-Betrachten (08-viewer.go) --- @@ -79,6 +137,9 @@ type MboxEntry struct { // ReadMboxList parst die Kopfzeilen aller Mails einer mbox-Datei (fuer die // Nachrichtenliste). ReadMboxMessage liefert eine einzelne Mail als Rohtext. func ReadMboxList(path string) ([]MboxEntry, error) { + if entries, ok := readMboxListFromIndex(path); ok { + return entries, nil + } msgs, err := readMboxMessages(path) if err != nil { return nil, err @@ -101,6 +162,9 @@ func ReadMboxList(path string) ([]MboxEntry, error) { } func ReadMboxMessage(path string, index int) ([]byte, error) { + if raw, ok, err := readMboxMessageFromIndex(path, index); ok || err != nil { + return raw, err + } msgs, err := readMboxMessages(path) if err != nil { return nil, err @@ -116,6 +180,21 @@ func readMboxMessages(path string) ([][]byte, error) { if err != nil { return nil, err } + if isZstdMbox(path, b) { + dec, err := zstd.NewReader(nil) + if err != nil { + return nil, err + } + defer dec.Close() + b, err = dec.DecodeAll(b, nil) + if err != nil { + return nil, err + } + } + return readMboxMessagesBytes(b), nil +} + +func readMboxMessagesBytes(b []byte) [][]byte { b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n")) lines := bytes.Split(b, []byte("\n")) var msgs [][]byte @@ -142,7 +221,106 @@ func readMboxMessages(path string) ([][]byte, error) { if inMsg && cur.Len() > 0 { msgs = append(msgs, bytes.TrimRight(cur.Bytes(), "\n")) } - return msgs, nil + return msgs +} + +func readMboxListFromIndex(path string) ([]MboxEntry, bool) { + accountID, folder, ok := mboxIndexContext(path) + if !ok { + return nil, false + } + index, err := ListMboxIndex(accountID, folder) + if err != nil || len(index) == 0 { + return nil, false + } + out := make([]MboxEntry, 0, len(index)) + for _, entry := range index { + out = append(out, MboxEntry{ + Index: entry.Seq, + From: entry.From, + Subject: entry.Subject, + Date: entry.Date, + }) + } + return out, true +} + +func readMboxMessageFromIndex(path string, index int) ([]byte, bool, error) { + accountID, folder, ok := mboxIndexContext(path) + if !ok { + return nil, false, nil + } + entry, err := GetMboxIndex(accountID, folder, index) + if err != nil { + return nil, false, nil + } + if !strings.HasSuffix(strings.ToLower(path), ".zst") { + return nil, false, nil + } + f, err := os.Open(path) + if err != nil { + return nil, true, err + } + defer f.Close() + if _, err := f.Seek(entry.FileOffset, io.SeekStart); err != nil { + return nil, true, err + } + frame := make([]byte, entry.FrameLen) + if _, err := io.ReadFull(f, frame); err != nil { + return nil, true, err + } + dec, err := zstd.NewReader(nil) + if err != nil { + return nil, true, err + } + defer dec.Close() + record, err := dec.DecodeAll(frame, nil) + if err != nil { + return nil, true, err + } + msgs := readMboxMessagesBytes(record) + if len(msgs) == 0 { + return nil, true, fmt.Errorf("message index out of range") + } + return msgs[0], true, nil +} + +func mboxIndexContext(path string) (int64, string, bool) { + if DB == nil || Cfg.MboxRoot == "" { + return 0, "", false + } + root, err := filepath.Abs(Cfg.MboxRoot) + if err != nil { + return 0, "", false + } + abs, err := filepath.Abs(path) + if err != nil { + return 0, "", false + } + rel, err := filepath.Rel(root, abs) + if err != nil || strings.HasPrefix(rel, "..") { + return 0, "", false + } + parts := strings.Split(rel, string(os.PathSeparator)) + if len(parts) < 2 { + return 0, "", false + } + account, ok := accountByMboxDir(parts[0]) + if !ok || account.ID == 0 { + return 0, "", false + } + folder := folderNameFromMboxPath(path) + if folder == "" { + return 0, "", false + } + return account.ID, folder, true +} + +func isZstdMbox(path string, b []byte) bool { + if strings.HasSuffix(strings.ToLower(path), ".zst") { + return true + } + return len(b) >= 4 && b[0] == 0x28 && b[1] == 0xb5 && b[2] == 0x2f && b[3] == 0xfd } func decodeHeader(v string) string { @@ -354,3 +532,29 @@ func safeMboxName(folder string) string { } return folder } + +func archiveMboxPaths(dir string) []string { + plain, _ := filepath.Glob(filepath.Join(dir, "*.mbox")) + compressed, _ := filepath.Glob(filepath.Join(dir, "*.mbox.zst")) + seen := map[string]bool{} + out := make([]string, 0, len(plain)+len(compressed)) + for _, path := range plain { + seen[folderNameFromMboxPath(path)] = true + out = append(out, path) + } + for _, path := range compressed { + if seen[folderNameFromMboxPath(path)] { + continue + } + out = append(out, path) + } + sortStrings(out) + return out +} + +func folderNameFromMboxPath(path string) string { + name := filepath.Base(path) + name = strings.TrimSuffix(name, ".zst") + name = strings.TrimSuffix(name, ".mbox") + return name +} diff --git a/backend/06-mbox_test.go b/backend/06-mbox_test.go new file mode 100644 index 0000000..8d62f80 --- /dev/null +++ b/backend/06-mbox_test.go @@ -0,0 +1,120 @@ +package backend + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/klauspost/compress/zstd" +) + +func TestZstdMboxRoundTripAndIndexRead(t *testing.T) { + oldCfg, oldDB := Cfg, DB + root := t.TempDir() + t.Cleanup(func() { + if DB != nil { + _ = DB.Close() + } + Cfg, DB = oldCfg, oldDB + }) + + Cfg = Config{ + DBPath: filepath.Join(root, "mail-graveyard.db"), + MboxRoot: filepath.Join(root, "backup"), + MboxCompression: "zstd", + } + if err := ConnectDB(true); err != nil { + t.Fatal(err) + } + if err := SaveArchiveMailbox("archive"); err != nil { + t.Fatal(err) + } + if err := SaveAccount(Account{ + Name: "test-account", + SrcHost: "source.example", + SrcPort: 993, + SrcSecurity: "tls", + SrcUser: "source@example.com", + SrcPass: "x", + DstHost: "target.example", + DstPort: 993, + DstSecurity: "tls", + DstUser: "target@example.com", + DstPass: "x", + MboxDir: "archive", + Active: true, + }); err != nil { + t.Fatal(err) + } + account, err := GetAccount("test-account") + if err != nil { + t.Fatal(err) + } + + msg := RawMessage{ + MessageID: "zstd-test@example.com", + InternalDate: time.Date(2026, 7, 14, 10, 11, 12, 0, time.UTC), + Body: []byte("Message-ID: \r\n" + + "From: Sender \r\n" + + "Subject: Zstd Test\r\n" + + "Date: Tue, 14 Jul 2026 10:11:12 +0000\r\n" + + "\r\n" + + "Hello\r\nFrom inside body\r\n"), + } + writer, err := NewMboxWriter(filepath.Join(Cfg.MboxRoot, "archive")) + if err != nil { + t.Fatal(err) + } + info, err := writer.Append("INBOX", msg) + if err != nil { + t.Fatal(err) + } + if err := SaveMboxIndex(MboxIndexEntry{ + AccountID: account.ID, + Folder: "INBOX", + MessageID: msg.MessageID, + Subject: info.Subject, + From: info.From, + Date: info.Date, + FileOffset: info.FileOffset, + FrameLen: info.FrameLen, + InnerOffset: info.InnerOffset, + InnerLen: info.InnerLen, + }); err != nil { + t.Fatal(err) + } + + compressed, err := os.ReadFile(info.Path) + if err != nil { + t.Fatal(err) + } + dec, err := zstd.NewReader(nil) + if err != nil { + t.Fatal(err) + } + defer dec.Close() + plain, err := dec.DecodeAll(compressed, nil) + if err != nil { + t.Fatal(err) + } + if want := mboxRecord(msg); !bytes.Equal(plain, want) { + t.Fatalf("zstd round-trip differs: got %d bytes, want %d", len(plain), len(want)) + } + + entries, err := ReadMboxList(info.Path) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Index != 0 || entries[0].Subject != "Zstd Test" { + t.Fatalf("unexpected index list: %#v", entries) + } + raw, err := ReadMboxMessage(info.Path, entries[0].Index) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw, []byte("Hello")) || bytes.Contains(raw, []byte(">From inside body")) { + t.Fatalf("unexpected message body: %q", raw) + } +} diff --git a/backend/07-migrate.go b/backend/07-migrate.go index 4bc7e01..1a74c7c 100644 --- a/backend/07-migrate.go +++ b/backend/07-migrate.go @@ -149,12 +149,30 @@ func migrateAccount(a Account, selectedFolders map[string]bool) error { log.Printf("%s %s append to %s error %s: %v", a.Name, folder.Name, dstFolder, m.MessageID, err) return nil } - if err := mbox.Append(folder.Name, m); err != nil { + appendInfo, err := mbox.Append(folder.Name, m) + if err != nil { errs++ folderErrs++ log.Printf("%s %s mbox error %s: %v", a.Name, folder.Name, m.MessageID, err) return nil } + if err := SaveMboxIndex(MboxIndexEntry{ + AccountID: a.ID, + Folder: folder.Name, + MessageID: m.MessageID, + Subject: appendInfo.Subject, + From: appendInfo.From, + Date: appendInfo.Date, + FileOffset: appendInfo.FileOffset, + FrameLen: appendInfo.FrameLen, + InnerOffset: appendInfo.InnerOffset, + InnerLen: appendInfo.InnerLen, + }); err != nil { + errs++ + folderErrs++ + log.Printf("%s %s mbox index error %s: %v", a.Name, folder.Name, m.MessageID, err) + return nil + } if err := MarkCopied(a.ID, folder.Name, m.MessageID); err != nil { errs++ folderErrs++ diff --git a/backend/08-viewer.go b/backend/08-viewer.go index aa3dfad..03ad495 100644 --- a/backend/08-viewer.go +++ b/backend/08-viewer.go @@ -138,7 +138,7 @@ func renderViewerTree(w http.ResponseWriter, selected string) { } fmt.Fprint(w, ``) - mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, selected, "*.mbox")) + mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, selected)) if len(mboxes) == 0 { fmt.Fprint(w, `
Keine Ordner in dieser Archiv-Mailbox.
`) return @@ -203,7 +203,7 @@ type archiveFolderInfo struct { func archiveFolderInfos(paths []string) []archiveFolderInfo { out := make([]archiveFolderInfo, 0, len(paths)) for _, path := range paths { - name := strings.TrimSuffix(filepath.Base(path), ".mbox") + name := folderNameFromMboxPath(path) entries, err := ReadMboxList(path) count := 0 if err == nil { @@ -409,7 +409,15 @@ func mboxPath(account, folder string) (string, error) { if err != nil { return "", err } - path, err := filepath.Abs(filepath.Join(root, account, safeMboxName(folder)+".mbox")) + plain := filepath.Join(root, account, safeMboxName(folder)+".mbox") + compressed := plain + ".zst" + selected := plain + if _, err := os.Stat(plain); err != nil && os.IsNotExist(err) { + if _, zstErr := os.Stat(compressed); zstErr == nil { + selected = compressed + } + } + path, err := filepath.Abs(selected) if err != nil { return "", err } @@ -540,7 +548,7 @@ func findForwardedCopy(account, targetAccount string, raw []byte) (forwardedMatc if targetAccount != "" && !allowedTargets[entry.Name()] { continue } - mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, entry.Name(), "*.mbox")) + mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, entry.Name())) for _, path := range mboxes { msgs, err := ReadMboxList(path) if err != nil { @@ -554,7 +562,7 @@ func findForwardedCopy(account, targetAccount string, raw []byte) (forwardedMatc if messageIDHeader(candidate) == id { return forwardedMatch{ Account: entry.Name(), - Folder: strings.TrimSuffix(filepath.Base(path), ".mbox"), + Folder: folderNameFromMboxPath(path), Index: msg.Index, Raw: candidate, }, true @@ -716,7 +724,7 @@ func viewerAccounts() []viewerAccount { if !entry.IsDir() { continue } - mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, entry.Name(), "*.mbox")) + mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, entry.Name())) if len(mboxes) == 0 { continue } diff --git a/backend/12-archive-tools.go b/backend/12-archive-tools.go index a301b5f..92a4f86 100644 --- a/backend/12-archive-tools.go +++ b/backend/12-archive-tools.go @@ -14,6 +14,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/klauspost/compress/zstd" ) type ArchiveStats struct { @@ -136,6 +138,9 @@ func archiveDownloadHandler(w http.ResponseWriter, r *http.Request) { return err } header.Name = rel + if strings.HasSuffix(strings.ToLower(rel), ".mbox.zst") { + header.Name = strings.TrimSuffix(rel, ".zst") + } header.Method = zip.Deflate dst, err := zw.CreateHeader(header) if err != nil { @@ -146,7 +151,11 @@ func archiveDownloadHandler(w http.ResponseWriter, r *http.Request) { return err } defer src.Close() - _, err = io.Copy(dst, src) + if strings.HasSuffix(strings.ToLower(path), ".mbox.zst") { + err = exportPlainMbox(src, dst) + } else { + _, err = io.Copy(dst, src) + } return err }) } @@ -221,7 +230,7 @@ func renderArchiveUploadForm(w http.ResponseWriter, archives []string) { - +
@@ -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.