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

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