Add optional zstd mbox archives

This commit is contained in:
DonVoo 2026-07-14 20:20:30 +02:00
parent 21844688ac
commit e612896e06
14 changed files with 620 additions and 39 deletions

View file

@ -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.

View file

@ -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(`<div class="ef-empty">Keine Ordner.</div>`)
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.")

View file

@ -3,6 +3,7 @@ package backend
import (
"encoding/json"
"os"
"strings"
)
// Cfg ist die globale App-Konfiguration (config.json). Enthaelt NUR
@ -17,6 +18,7 @@ type Config struct {
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"`
}
@ -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
}

View file

@ -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 {

View file

@ -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 <mboxRoot>/<account>/ 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 <ordner>.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")
}
_, _ = 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
}
_, err = f.WriteString("\r\n")
return err
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
}

120
backend/06-mbox_test.go Normal file
View file

@ -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: <zstd-test@example.com>\r\n" +
"From: Sender <sender@example.com>\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)
}
}

View file

@ -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++

View file

@ -138,7 +138,7 @@ func renderViewerTree(w http.ResponseWriter, selected string) {
}
fmt.Fprint(w, `</select></form></div>`)
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, selected, "*.mbox"))
mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, selected))
if len(mboxes) == 0 {
fmt.Fprint(w, `<div class="ef-empty">Keine Ordner in dieser Archiv-Mailbox.</div>`)
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
}

View file

@ -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()
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) {
<label class="label">Archiv-Mailbox`)
fmt.Fprint(w, archiveUploadSelect(archives))
fmt.Fprint(w, `</label>
<label class="label">Datei<input class="input" type="file" name="mailfile" accept=".mbox,.pst" required></label>
<label class="label">Datei<input class="input" type="file" name="mailfile" accept=".mbox,.pst,.zst" required></label>
<button class="btn" type="submit">Upload</button>
</form>
<div class="archive-help">
@ -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 {

View file

@ -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,

View file

@ -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,

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

114
zstd-brief.md Normal file
View file

@ -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: 24×.)
## Format
`<ordner>.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.