Add optional zstd mbox archives
This commit is contained in:
parent
21844688ac
commit
e612896e06
14 changed files with 620 additions and 39 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
_, 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
|
||||
}
|
||||
|
|
|
|||
120
backend/06-mbox_test.go
Normal file
120
backend/06-mbox_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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++
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
1
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
114
zstd-brief.md
Normal file
114
zstd-brief.md
Normal 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: 2–4×.)
|
||||
|
||||
## 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue