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:
|
`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
|
manuelle Weiterleiten aus dem Viewer). Die **Umzugs-Konten** legst du im Browser
|
||||||
an, nicht hier.
|
an, nicht hier.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1097,7 +1097,7 @@ func transferKindLabel(kind string) string {
|
||||||
func renderTransferFolders(b *strings.Builder, side, kind, value string) {
|
func renderTransferFolders(b *strings.Builder, side, kind, value string) {
|
||||||
switch kind {
|
switch kind {
|
||||||
case "archive":
|
case "archive":
|
||||||
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, value, "*.mbox"))
|
mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, value))
|
||||||
if len(mboxes) == 0 {
|
if len(mboxes) == 0 {
|
||||||
b.WriteString(`<div class="ef-empty">Keine Ordner.</div>`)
|
b.WriteString(`<div class="ef-empty">Keine Ordner.</div>`)
|
||||||
return
|
return
|
||||||
|
|
@ -1187,7 +1187,7 @@ func transferEmptyReason(kind string, err error) string {
|
||||||
func firstTransferFolder(kind, value string) (string, error) {
|
func firstTransferFolder(kind, value string) (string, error) {
|
||||||
switch kind {
|
switch kind {
|
||||||
case "archive":
|
case "archive":
|
||||||
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, value, "*.mbox"))
|
mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, value))
|
||||||
infos := archiveFolderInfos(mboxes)
|
infos := archiveFolderInfos(mboxes)
|
||||||
if len(infos) == 0 {
|
if len(infos) == 0 {
|
||||||
return "", fmt.Errorf("Keine Ordner.")
|
return "", fmt.Errorf("Keine Ordner.")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package backend
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cfg ist die globale App-Konfiguration (config.json). Enthaelt NUR
|
// Cfg ist die globale App-Konfiguration (config.json). Enthaelt NUR
|
||||||
|
|
@ -17,6 +18,7 @@ type Config struct {
|
||||||
AdminPass string `json:"admin_pass"`
|
AdminPass string `json:"admin_pass"`
|
||||||
DBPath string `json:"db_path"`
|
DBPath string `json:"db_path"`
|
||||||
MboxRoot string `json:"mbox_root"`
|
MboxRoot string `json:"mbox_root"`
|
||||||
|
MboxCompression string `json:"mbox_compression"`
|
||||||
ForwardSMTP SMTPConfig `json:"forward_smtp"`
|
ForwardSMTP SMTPConfig `json:"forward_smtp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,5 +49,9 @@ func LoadConfig(path string) error {
|
||||||
if Cfg.MboxRoot == "" {
|
if Cfg.MboxRoot == "" {
|
||||||
Cfg.MboxRoot = "./backup"
|
Cfg.MboxRoot = "./backup"
|
||||||
}
|
}
|
||||||
|
Cfg.MboxCompression = strings.ToLower(strings.TrimSpace(Cfg.MboxCompression))
|
||||||
|
if Cfg.MboxCompression == "" {
|
||||||
|
Cfg.MboxCompression = "none"
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,20 @@ func ConnectDB(initDB bool) error {
|
||||||
copied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
copied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(account_id, folder, message_id)
|
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(
|
`CREATE TABLE IF NOT EXISTS jobs(
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
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
|
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 {
|
func SeedAccountFromFile(path string) error {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mbox ist das lokale Cold-Backup-Format: eine Datei pro IMAP-Ordner, der
|
// mbox ist das lokale Cold-Backup-Format: eine Datei pro IMAP-Ordner, der
|
||||||
|
|
@ -25,6 +27,17 @@ type MboxWriter struct {
|
||||||
root string
|
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.
|
// NewMboxWriter legt <mboxRoot>/<account>/ an und spiegelt den Ordnerbaum.
|
||||||
func NewMboxWriter(dir string) (*MboxWriter, error) {
|
func NewMboxWriter(dir string) (*MboxWriter, error) {
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
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)
|
// Append schreibt eine Mail im mbox-Format ("From "-Trennzeile, >From-Quoting)
|
||||||
// in <ordner>.mbox.
|
// in <ordner>.mbox.
|
||||||
func (w *MboxWriter) Append(folder string, m RawMessage) error {
|
func (w *MboxWriter) Append(folder string, m RawMessage) (MboxAppendInfo, error) {
|
||||||
path := filepath.Join(w.root, safeMboxName(folder)+".mbox")
|
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 {
|
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)
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return info, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
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
|
fromDate := m.InternalDate
|
||||||
if fromDate.IsZero() {
|
if fromDate.IsZero() {
|
||||||
fromDate = time.Now()
|
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 {
|
fmt.Fprintf(&b, "From MAILER-DAEMON %s\r\n", fromDate.Format("Mon Jan _2 15:04:05 2006"))
|
||||||
return err
|
|
||||||
}
|
|
||||||
body := bytes.ReplaceAll(m.Body, []byte("\r\nFrom "), []byte("\r\n>From "))
|
body := bytes.ReplaceAll(m.Body, []byte("\r\nFrom "), []byte("\r\n>From "))
|
||||||
body = bytes.ReplaceAll(body, []byte("\nFrom "), []byte("\n>From "))
|
body = bytes.ReplaceAll(body, []byte("\nFrom "), []byte("\n>From "))
|
||||||
if _, err := f.Write(body); err != nil {
|
_, _ = b.Write(body)
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !bytes.HasSuffix(body, []byte("\n")) {
|
if !bytes.HasSuffix(body, []byte("\n")) {
|
||||||
if _, err := f.WriteString("\r\n"); err != nil {
|
_, _ = b.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
|
||||||
}
|
}
|
||||||
_, err = f.WriteString("\r\n")
|
info.Subject = decodeHeader(msg.Header.Get("Subject"))
|
||||||
return err
|
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) ---
|
// --- 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
|
// ReadMboxList parst die Kopfzeilen aller Mails einer mbox-Datei (fuer die
|
||||||
// Nachrichtenliste). ReadMboxMessage liefert eine einzelne Mail als Rohtext.
|
// Nachrichtenliste). ReadMboxMessage liefert eine einzelne Mail als Rohtext.
|
||||||
func ReadMboxList(path string) ([]MboxEntry, error) {
|
func ReadMboxList(path string) ([]MboxEntry, error) {
|
||||||
|
if entries, ok := readMboxListFromIndex(path); ok {
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
msgs, err := readMboxMessages(path)
|
msgs, err := readMboxMessages(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -101,6 +162,9 @@ func ReadMboxList(path string) ([]MboxEntry, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadMboxMessage(path string, index int) ([]byte, 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)
|
msgs, err := readMboxMessages(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -116,6 +180,21 @@ func readMboxMessages(path string) ([][]byte, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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"))
|
b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
|
||||||
lines := bytes.Split(b, []byte("\n"))
|
lines := bytes.Split(b, []byte("\n"))
|
||||||
var msgs [][]byte
|
var msgs [][]byte
|
||||||
|
|
@ -142,7 +221,106 @@ func readMboxMessages(path string) ([][]byte, error) {
|
||||||
if inMsg && cur.Len() > 0 {
|
if inMsg && cur.Len() > 0 {
|
||||||
msgs = append(msgs, bytes.TrimRight(cur.Bytes(), "\n"))
|
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 {
|
func decodeHeader(v string) string {
|
||||||
|
|
@ -354,3 +532,29 @@ func safeMboxName(folder string) string {
|
||||||
}
|
}
|
||||||
return folder
|
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)
|
log.Printf("%s %s append to %s error %s: %v", a.Name, folder.Name, dstFolder, m.MessageID, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := mbox.Append(folder.Name, m); err != nil {
|
appendInfo, err := mbox.Append(folder.Name, m)
|
||||||
|
if err != nil {
|
||||||
errs++
|
errs++
|
||||||
folderErrs++
|
folderErrs++
|
||||||
log.Printf("%s %s mbox error %s: %v", a.Name, folder.Name, m.MessageID, err)
|
log.Printf("%s %s mbox error %s: %v", a.Name, folder.Name, m.MessageID, err)
|
||||||
return nil
|
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 {
|
if err := MarkCopied(a.ID, folder.Name, m.MessageID); err != nil {
|
||||||
errs++
|
errs++
|
||||||
folderErrs++
|
folderErrs++
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ func renderViewerTree(w http.ResponseWriter, selected string) {
|
||||||
}
|
}
|
||||||
fmt.Fprint(w, `</select></form></div>`)
|
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 {
|
if len(mboxes) == 0 {
|
||||||
fmt.Fprint(w, `<div class="ef-empty">Keine Ordner in dieser Archiv-Mailbox.</div>`)
|
fmt.Fprint(w, `<div class="ef-empty">Keine Ordner in dieser Archiv-Mailbox.</div>`)
|
||||||
return
|
return
|
||||||
|
|
@ -203,7 +203,7 @@ type archiveFolderInfo struct {
|
||||||
func archiveFolderInfos(paths []string) []archiveFolderInfo {
|
func archiveFolderInfos(paths []string) []archiveFolderInfo {
|
||||||
out := make([]archiveFolderInfo, 0, len(paths))
|
out := make([]archiveFolderInfo, 0, len(paths))
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
name := strings.TrimSuffix(filepath.Base(path), ".mbox")
|
name := folderNameFromMboxPath(path)
|
||||||
entries, err := ReadMboxList(path)
|
entries, err := ReadMboxList(path)
|
||||||
count := 0
|
count := 0
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -409,7 +409,15 @@ func mboxPath(account, folder string) (string, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -540,7 +548,7 @@ func findForwardedCopy(account, targetAccount string, raw []byte) (forwardedMatc
|
||||||
if targetAccount != "" && !allowedTargets[entry.Name()] {
|
if targetAccount != "" && !allowedTargets[entry.Name()] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, entry.Name(), "*.mbox"))
|
mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, entry.Name()))
|
||||||
for _, path := range mboxes {
|
for _, path := range mboxes {
|
||||||
msgs, err := ReadMboxList(path)
|
msgs, err := ReadMboxList(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -554,7 +562,7 @@ func findForwardedCopy(account, targetAccount string, raw []byte) (forwardedMatc
|
||||||
if messageIDHeader(candidate) == id {
|
if messageIDHeader(candidate) == id {
|
||||||
return forwardedMatch{
|
return forwardedMatch{
|
||||||
Account: entry.Name(),
|
Account: entry.Name(),
|
||||||
Folder: strings.TrimSuffix(filepath.Base(path), ".mbox"),
|
Folder: folderNameFromMboxPath(path),
|
||||||
Index: msg.Index,
|
Index: msg.Index,
|
||||||
Raw: candidate,
|
Raw: candidate,
|
||||||
}, true
|
}, true
|
||||||
|
|
@ -716,7 +724,7 @@ func viewerAccounts() []viewerAccount {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, entry.Name(), "*.mbox"))
|
mboxes := archiveMboxPaths(filepath.Join(Cfg.MboxRoot, entry.Name()))
|
||||||
if len(mboxes) == 0 {
|
if len(mboxes) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ArchiveStats struct {
|
type ArchiveStats struct {
|
||||||
|
|
@ -136,6 +138,9 @@ func archiveDownloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
header.Name = rel
|
header.Name = rel
|
||||||
|
if strings.HasSuffix(strings.ToLower(rel), ".mbox.zst") {
|
||||||
|
header.Name = strings.TrimSuffix(rel, ".zst")
|
||||||
|
}
|
||||||
header.Method = zip.Deflate
|
header.Method = zip.Deflate
|
||||||
dst, err := zw.CreateHeader(header)
|
dst, err := zw.CreateHeader(header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -146,7 +151,11 @@ func archiveDownloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer src.Close()
|
defer src.Close()
|
||||||
|
if strings.HasSuffix(strings.ToLower(path), ".mbox.zst") {
|
||||||
|
err = exportPlainMbox(src, dst)
|
||||||
|
} else {
|
||||||
_, err = io.Copy(dst, src)
|
_, err = io.Copy(dst, src)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +230,7 @@ func renderArchiveUploadForm(w http.ResponseWriter, archives []string) {
|
||||||
<label class="label">Archiv-Mailbox`)
|
<label class="label">Archiv-Mailbox`)
|
||||||
fmt.Fprint(w, archiveUploadSelect(archives))
|
fmt.Fprint(w, archiveUploadSelect(archives))
|
||||||
fmt.Fprint(w, `</label>
|
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>
|
<button class="btn" type="submit">Upload</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="archive-help">
|
<div class="archive-help">
|
||||||
|
|
@ -278,7 +287,7 @@ func ArchiveStatsFor(name string) (ArchiveStats, error) {
|
||||||
}
|
}
|
||||||
stat.Files++
|
stat.Files++
|
||||||
stat.Bytes += info.Size()
|
stat.Bytes += info.Size()
|
||||||
if strings.EqualFold(filepath.Ext(path), ".mbox") {
|
if isMboxArchivePath(path) {
|
||||||
msgs, err := readMboxMessages(path)
|
msgs, err := readMboxMessages(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -414,17 +423,37 @@ func safeArchiveFileName(value string) (string, error) {
|
||||||
if name == "" || name == "." || name == ".." {
|
if name == "" || name == "." || name == ".." {
|
||||||
return "", fmt.Errorf("Dateiname fehlt.")
|
return "", fmt.Errorf("Dateiname fehlt.")
|
||||||
}
|
}
|
||||||
|
lower := strings.ToLower(name)
|
||||||
ext := strings.ToLower(filepath.Ext(name))
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
if ext != ".mbox" && ext != ".pst" {
|
if ext != ".mbox" && ext != ".pst" && !strings.HasSuffix(lower, ".mbox.zst") {
|
||||||
return "", fmt.Errorf("Nur mbox- und PST-Dateien koennen importiert werden.")
|
return "", fmt.Errorf("Nur mbox-, mbox.zst- und PST-Dateien koennen importiert werden.")
|
||||||
}
|
}
|
||||||
clean := strings.NewReplacer("\\", "_", "/", "_", ":", "_").Replace(name)
|
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
|
clean = "import" + ext
|
||||||
}
|
}
|
||||||
return clean, nil
|
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 {
|
func formatBytes(n int64) string {
|
||||||
const unit = 1024
|
const unit = 1024
|
||||||
if n < unit {
|
if n < unit {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"admin_pass": "CHANGE-ME-lokal",
|
"admin_pass": "CHANGE-ME-lokal",
|
||||||
"db_path": "mail-graveyard.db",
|
"db_path": "mail-graveyard.db",
|
||||||
"mbox_root": "./backup",
|
"mbox_root": "./backup",
|
||||||
|
"mbox_compression": "none",
|
||||||
"forward_smtp": {
|
"forward_smtp": {
|
||||||
"host": "",
|
"host": "",
|
||||||
"port": 587,
|
"port": 587,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"admin_pass": "CHANGE-ME-lokal",
|
"admin_pass": "CHANGE-ME-lokal",
|
||||||
"db_path": "/app/data/mail-graveyard.db",
|
"db_path": "/app/data/mail-graveyard.db",
|
||||||
"mbox_root": "/app/backup",
|
"mbox_root": "/app/backup",
|
||||||
|
"mbox_compression": "none",
|
||||||
"forward_smtp": {
|
"forward_smtp": {
|
||||||
"host": "",
|
"host": "",
|
||||||
"port": 587,
|
"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-message v0.18.2 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/google/uuid v1.6.0 // 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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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=
|
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