Behebt drei Fehler derselben Klasse: an jeder Stelle wurde derselbe Wert zweimal berechnet, statt einmal berechnet und weitergereicht - und die beiden Berechnungen liefen auseinander. 1. Instabiler Ersatzschluessel (bug-hashkey-instabil.md) Mails ohne Message-ID bekamen sha256 ueber die IMAP-Rohbytes, der Reindex hashte dieselbe Mail ueber die mbox-gespeicherten Bytes (>From-Quoting, andere Zeilenenden) -> zwei Schluessel, 4.024 Doppel-Eintraege in copied. Fix: canonicalMessageBytes() bringt beide Seiten auf eine Form (>From zurueckdrehen, CRLF->LF, Trailing-Newlines weg). Alle Pfade (Index, Migration, Viewer, Dedup) nutzen dieselbe Funktion. body_sha256 in copied + mbox_index, UNIQUE erweitert. 2. Index-Drift (bug-index-drift.md) Der plain-mbox-Reader las nach Datei-Position statt nach dem gespeicherten file_offset. Weil SQLITE_BUSY (busy_timeout=0) je Ordner einen Index-Eintrag verschluckt hatte, war ab dieser Luecke alles um 1 verschoben: Klick auf Mail X zeigte Mail X+1. Betroffen genau die 6 Ordner mit Busy-Fehler beim Rettungslauf. Fix: busy_timeout=10000, Lesen ueber file_offset, --reindex --rebuild. 3. HTML-Vorschau-Panic (bug-htmltotext-panic.md) replaceCaseInsensitive/stripHTMLBlock indizierten mit Offsets aus strings.ToLower(s) in s - ToLower ist nicht byte-laengen-erhaltend (z.B. U+0130). ~0,1% der Mails brachten die Vorschau zum Absturz. Fix: asciiFoldIndex() sucht direkt auf den Original-Bytes; zusaetzlich Rohtext-Fallback, damit keine archivierte Mail unsichtbar wird. Weiter: Archiv-zuerst-Reihenfolge (mbox_done/target_done) - ein sterbendes Ziel kostet keine Archiv-Kopie mehr; Dedup vergleicht zusaetzlich den Inhalt und schuetzt byte-verschiedene Varianten. WICHTIG: Die 62.073 alten Alias-Zeilen in copied bleiben bewusst erhalten. Sie sehen wie Muell aus, sind aber die Zeilen, auf die der alte Schluessel matcht - ein Loeschen wuerde Mails erneut in fremde Postfaecher kopieren. Verifiziert: go test ./... gruen; 58.051 Archivmails, 0 ohne copied-Zeile; Drift 0 ueber alle 69 Ordner; 2.000 HTTP-Stichproben ueber 8 Ordner: 0 falsche Zuordnung, 0 Panics; Watcher 220 + 60 Mails archived=0 target=0 errors=0. Live als Image fix-identity2-20260716. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
888 lines
21 KiB
Go
888 lines
21 KiB
Go
package backend
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"mime/multipart"
|
|
"mime/quotedprintable"
|
|
"net/mail"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
)
|
|
|
|
// mbox ist das lokale Cold-Backup-Format: eine Datei pro IMAP-Ordner, der
|
|
// Ordnerbaum 1:1 auf der Platte. eM Client / Thunderbird importieren mbox
|
|
// direkt. (Kein PST -- das laesst sich in Go nicht lean schreiben.)
|
|
|
|
// MboxWriter haengt Nachrichten an die mbox-Datei eines Ordners an.
|
|
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 {
|
|
return nil, err
|
|
}
|
|
return &MboxWriter{root: dir}, nil
|
|
}
|
|
|
|
// Append schreibt eine Mail im mbox-Format ("From "-Trennzeile, >From-Quoting)
|
|
// in <ordner>.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 info, err
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
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()
|
|
}
|
|
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 "))
|
|
_, _ = b.Write(body)
|
|
if !bytes.HasSuffix(body, []byte("\n")) {
|
|
_, _ = b.WriteString("\r\n")
|
|
}
|
|
_, _ = b.WriteString("\r\n")
|
|
return b.Bytes()
|
|
}
|
|
|
|
func mboxAppendInfoFromMessage(m RawMessage, record []byte) MboxAppendInfo {
|
|
info := MboxAppendInfo{InnerLen: int64(len(record))}
|
|
parts := splitMboxRecordsWithOffsets(record)
|
|
if len(parts) > 0 {
|
|
info.InnerOffset = parts[0].Offset
|
|
info.InnerLen = int64(parts[0].Length)
|
|
}
|
|
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) ---
|
|
|
|
// MboxEntry ist eine Nachricht wie im Listen-Pane angezeigt.
|
|
type MboxEntry struct {
|
|
Index int
|
|
From string
|
|
Subject string
|
|
Date string
|
|
}
|
|
|
|
// 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 err := EnsureMboxIndexed(path); err != nil {
|
|
return nil, err
|
|
}
|
|
if entries, ok := readMboxListFromIndex(path); ok {
|
|
return entries, nil
|
|
}
|
|
msgs, err := readMboxMessages(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]MboxEntry, 0, len(msgs))
|
|
for i, raw := range msgs {
|
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
out = append(out, MboxEntry{Index: i, From: "(unlesbar)", Subject: "Unlesbare Nachricht"})
|
|
continue
|
|
}
|
|
out = append(out, MboxEntry{
|
|
Index: i,
|
|
From: decodeHeader(msg.Header.Get("From")),
|
|
Subject: decodeHeader(msg.Header.Get("Subject")),
|
|
Date: decodeHeader(msg.Header.Get("Date")),
|
|
})
|
|
}
|
|
sortMboxEntriesByDateDesc(out)
|
|
return out, nil
|
|
}
|
|
|
|
func ReadMboxMessage(path string, index int) ([]byte, error) {
|
|
if err := EnsureMboxIndexed(path); err != nil {
|
|
return nil, err
|
|
}
|
|
if raw, ok, err := readMboxMessageFromIndex(path, index); ok || err != nil {
|
|
return raw, err
|
|
}
|
|
msgs, err := readMboxMessages(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if index < 0 || index >= len(msgs) {
|
|
return nil, fmt.Errorf("message index out of range")
|
|
}
|
|
return msgs[index], nil
|
|
}
|
|
|
|
func readMboxMessages(path string) ([][]byte, error) {
|
|
b, err := os.ReadFile(path)
|
|
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 EnsureMboxIndexed(path string) error {
|
|
accountID, folder, ok := mboxIndexContext(path)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
indexed, err := MboxIndexedBytes(accountID, folder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if indexed == info.Size() {
|
|
return nil
|
|
}
|
|
return ReindexMboxFile(accountID, folder, path)
|
|
}
|
|
|
|
func ReindexMboxFile(accountID int64, folder, path string) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var entries []MboxIndexEntry
|
|
if strings.HasSuffix(strings.ToLower(path), ".zst") {
|
|
entries, err = reindexZstdMbox(path)
|
|
} else {
|
|
entries, err = reindexPlainMbox(path)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ReplaceMboxIndex(accountID, folder, entries, info.Size())
|
|
}
|
|
|
|
func ReindexArchives(name string) error {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return fmt.Errorf("reindex ziel fehlt")
|
|
}
|
|
accounts, err := ListAccounts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
matched := 0
|
|
for _, account := range accounts {
|
|
archive := accountMboxDir(account)
|
|
if archive == "" {
|
|
continue
|
|
}
|
|
if name != "all" && !strings.EqualFold(name, account.Name) && !strings.EqualFold(name, archive) {
|
|
continue
|
|
}
|
|
if err := reindexAccountArchive(account); err != nil {
|
|
return err
|
|
}
|
|
matched++
|
|
}
|
|
if matched == 0 {
|
|
return fmt.Errorf("kein Konto/Archiv fuer reindex %q gefunden", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func reindexAccountArchive(account Account) error {
|
|
dir := filepath.Join(Cfg.MboxRoot, accountMboxDir(account))
|
|
canonicalFolders := map[string]bool{}
|
|
for _, path := range archiveMboxPaths(dir) {
|
|
folder := folderNameFromMboxPath(path)
|
|
if folder == "" {
|
|
continue
|
|
}
|
|
if err := ReindexMboxFile(account.ID, folder, path); err != nil {
|
|
return fmt.Errorf("%s %s: %w", account.Name, folder, err)
|
|
}
|
|
canonicalFolders[folder] = true
|
|
}
|
|
return RemoveMboxIndexFolderAliases(account.ID, canonicalFolders)
|
|
}
|
|
|
|
func reindexPlainMbox(path string) ([]MboxIndexEntry, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []MboxIndexEntry
|
|
for _, part := range splitMboxRecordsWithOffsets(b) {
|
|
entry := indexEntryFromMboxRecord(part.Message, part.Offset, int64(part.Length), 0, int64(part.Length))
|
|
if entry.MessageID != "" || entry.BodySHA256 != "" {
|
|
out = append(out, entry)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func reindexZstdMbox(path string) ([]MboxIndexEntry, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dec, err := zstd.NewReader(nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer dec.Close()
|
|
var out []MboxIndexEntry
|
|
for _, frame := range splitZstdFrames(b) {
|
|
record, err := dec.DecodeAll(frame.Data, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, part := range splitMboxRecordsWithOffsets(record) {
|
|
entry := indexEntryFromMboxRecord(part.Message, frame.Offset, int64(frame.Length), part.Offset, int64(part.Length))
|
|
if entry.MessageID != "" || entry.BodySHA256 != "" {
|
|
out = append(out, entry)
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type mboxRecordPart struct {
|
|
Offset int64
|
|
Length int
|
|
Message []byte
|
|
}
|
|
|
|
func splitMboxRecordsWithOffsets(b []byte) []mboxRecordPart {
|
|
var out []mboxRecordPart
|
|
messageStart := -1
|
|
appendRecord := func(end int) {
|
|
if messageStart < 0 || end <= messageStart {
|
|
return
|
|
}
|
|
raw := b[messageStart:end]
|
|
message := unescapeMboxMessage(raw)
|
|
if len(message) == 0 {
|
|
return
|
|
}
|
|
out = append(out, mboxRecordPart{
|
|
Offset: int64(messageStart),
|
|
Length: end - messageStart,
|
|
Message: append([]byte(nil), message...),
|
|
})
|
|
}
|
|
for lineStart := 0; lineStart < len(b); {
|
|
relNL := bytes.IndexByte(b[lineStart:], '\n')
|
|
lineEnd, nextLine := len(b), len(b)
|
|
if relNL >= 0 {
|
|
lineEnd = lineStart + relNL
|
|
nextLine = lineEnd + 1
|
|
}
|
|
contentEnd := lineEnd
|
|
if contentEnd > lineStart && b[contentEnd-1] == '\r' {
|
|
contentEnd--
|
|
}
|
|
if bytes.HasPrefix(b[lineStart:contentEnd], []byte("From ")) {
|
|
appendRecord(lineStart)
|
|
messageStart = nextLine
|
|
}
|
|
if nextLine >= len(b) {
|
|
break
|
|
}
|
|
lineStart = nextLine
|
|
}
|
|
appendRecord(len(b))
|
|
return out
|
|
}
|
|
|
|
type zstdFramePart struct {
|
|
Offset int64
|
|
Length int
|
|
Data []byte
|
|
}
|
|
|
|
func splitZstdFrames(b []byte) []zstdFramePart {
|
|
magic := []byte{0x28, 0xb5, 0x2f, 0xfd}
|
|
var starts []int
|
|
for i := 0; i <= len(b)-len(magic); i++ {
|
|
if bytes.Equal(b[i:i+len(magic)], magic) {
|
|
starts = append(starts, i)
|
|
}
|
|
}
|
|
if len(starts) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]zstdFramePart, 0, len(starts))
|
|
for i, start := range starts {
|
|
end := len(b)
|
|
if i+1 < len(starts) {
|
|
end = starts[i+1]
|
|
}
|
|
out = append(out, zstdFramePart{Offset: int64(start), Length: end - start, Data: b[start:end]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func indexEntryFromMboxRecord(raw []byte, fileOffset, frameLen, innerOffset, innerLen int64) MboxIndexEntry {
|
|
entry := MboxIndexEntry{BodySHA256: bodySHA256(raw), FileOffset: fileOffset, FrameLen: frameLen, InnerOffset: innerOffset, InnerLen: innerLen}
|
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return entry
|
|
}
|
|
entry.MessageID = normalizeMessageID(msg.Header.Get("Message-ID"))
|
|
entry.Subject = decodeHeader(msg.Header.Get("Subject"))
|
|
entry.From = decodeHeader(msg.Header.Get("From"))
|
|
entry.Date = decodeHeader(msg.Header.Get("Date"))
|
|
return entry
|
|
}
|
|
|
|
func unescapeMboxMessage(raw []byte) []byte {
|
|
return canonicalMessageBytes(raw)
|
|
}
|
|
|
|
func readMboxMessagesBytes(b []byte) [][]byte {
|
|
b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
|
|
lines := bytes.Split(b, []byte("\n"))
|
|
var msgs [][]byte
|
|
var cur bytes.Buffer
|
|
inMsg := false
|
|
for _, line := range lines {
|
|
if bytes.HasPrefix(line, []byte("From ")) {
|
|
if inMsg && cur.Len() > 0 {
|
|
msgs = append(msgs, cloneTrimmedMessage(cur.Bytes()))
|
|
cur.Reset()
|
|
}
|
|
inMsg = true
|
|
continue
|
|
}
|
|
if !inMsg {
|
|
continue
|
|
}
|
|
if bytes.HasPrefix(line, []byte(">From ")) {
|
|
line = line[1:]
|
|
}
|
|
_, _ = cur.Write(line)
|
|
_ = cur.WriteByte('\n')
|
|
}
|
|
if inMsg && cur.Len() > 0 {
|
|
msgs = append(msgs, cloneTrimmedMessage(cur.Bytes()))
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func cloneTrimmedMessage(b []byte) []byte {
|
|
trimmed := bytes.TrimRight(b, "\n")
|
|
return append([]byte(nil), trimmed...)
|
|
}
|
|
|
|
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
|
|
}
|
|
sort.SliceStable(index, func(i, j int) bool {
|
|
di := parseMboxIndexDate(index[i].Date)
|
|
dj := parseMboxIndexDate(index[j].Date)
|
|
if !di.Equal(dj) {
|
|
return di.After(dj)
|
|
}
|
|
return index[i].Seq > index[j].Seq
|
|
})
|
|
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 sortMboxEntriesByDateDesc(entries []MboxEntry) {
|
|
sort.SliceStable(entries, func(i, j int) bool {
|
|
di := parseMboxIndexDate(entries[i].Date)
|
|
dj := parseMboxIndexDate(entries[j].Date)
|
|
if !di.Equal(dj) {
|
|
return di.After(dj)
|
|
}
|
|
return entries[i].Index > entries[j].Index
|
|
})
|
|
}
|
|
|
|
func parseMboxIndexDate(value string) time.Time {
|
|
t, err := mail.ParseDate(strings.TrimSpace(value))
|
|
if err == nil {
|
|
return t
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
if entry.FrameLen <= 0 || entry.FrameLen > int64(^uint(0)>>1) {
|
|
return nil, true, fmt.Errorf("invalid indexed mbox length %d", entry.FrameLen)
|
|
}
|
|
frame := make([]byte, entry.FrameLen)
|
|
if _, err := io.ReadFull(f, frame); err != nil {
|
|
return nil, true, err
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(path), ".zst") {
|
|
// Entries written during migration point at the complete mbox record
|
|
// (separator included, InnerOffset > 0). Entries produced by a full
|
|
// rebuild point directly at the raw message bytes (InnerOffset == 0).
|
|
// Both variants are read by their persisted byte range, never by the
|
|
// message's positional number in a freshly split file.
|
|
if entry.InnerOffset > 0 {
|
|
messages := readMboxMessagesBytes(frame)
|
|
if len(messages) != 1 {
|
|
return nil, true, fmt.Errorf("invalid indexed mbox record at offset %d", entry.FileOffset)
|
|
}
|
|
return messages[0], true, nil
|
|
}
|
|
return unescapeMboxMessage(frame), true, nil
|
|
}
|
|
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
|
|
}
|
|
start := entry.InnerOffset
|
|
end := entry.InnerOffset + entry.InnerLen
|
|
if start < 0 || end > int64(len(record)) || start >= end {
|
|
return nil, true, fmt.Errorf("message index out of range")
|
|
}
|
|
return unescapeMboxMessage(record[start:end]), 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 {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
decoded, err := new(mime.WordDecoder).DecodeHeader(v)
|
|
if err != nil {
|
|
return v
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
func messageBody(raw []byte) string {
|
|
return messageBodyWithContext(raw, "")
|
|
}
|
|
|
|
func messageBodyWithContext(raw []byte, context string) string {
|
|
return renderMessageBodySafely(raw, context, func() string {
|
|
return messageBodyUnsafe(raw)
|
|
})
|
|
}
|
|
|
|
func renderMessageBodySafely(raw []byte, context string, render func() string) (body string) {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
if strings.TrimSpace(context) == "" {
|
|
context = "unbekannter Vorschaupfad"
|
|
}
|
|
log.Printf("mail preview rendering panic (%s): %v", context, recovered)
|
|
body = "Darstellung fehlgeschlagen; Rohfassung:\n\n" + string(raw)
|
|
}
|
|
}()
|
|
return render()
|
|
}
|
|
|
|
func messageBodyUnsafe(raw []byte) string {
|
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return string(raw)
|
|
}
|
|
body, err := io.ReadAll(msg.Body)
|
|
if err != nil {
|
|
return string(raw)
|
|
}
|
|
return extractTextBody(msg.Header, body)
|
|
}
|
|
|
|
type headerGetter interface {
|
|
Get(string) string
|
|
}
|
|
|
|
func extractTextBody(h headerGetter, body []byte) string {
|
|
mediaType, params, _ := mime.ParseMediaType(h.Get("Content-Type"))
|
|
decoded := decodeTransfer(body, h.Get("Content-Transfer-Encoding"))
|
|
if strings.HasPrefix(mediaType, "multipart/") {
|
|
boundary := params["boundary"]
|
|
if boundary == "" {
|
|
return string(decoded)
|
|
}
|
|
mr := multipart.NewReader(bytes.NewReader(decoded), boundary)
|
|
var htmlBody string
|
|
for {
|
|
part, err := mr.NextPart()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
partBody, _ := io.ReadAll(part)
|
|
text := extractTextBody(part.Header, partBody)
|
|
partType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
|
if strings.HasPrefix(partType, "text/plain") && strings.TrimSpace(text) != "" {
|
|
return text
|
|
}
|
|
if strings.HasPrefix(partType, "text/html") && htmlBody == "" {
|
|
htmlBody = text
|
|
}
|
|
}
|
|
if htmlBody != "" {
|
|
return htmlBody
|
|
}
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(mediaType, "text/html") || looksLikeHTML(decoded) {
|
|
return htmlToText(string(decoded))
|
|
}
|
|
return string(decoded)
|
|
}
|
|
|
|
func looksLikeHTML(body []byte) bool {
|
|
s := strings.ToLower(strings.TrimSpace(string(body)))
|
|
return strings.HasPrefix(s, "<!doctype html") || strings.HasPrefix(s, "<html") || strings.Contains(s, "<body")
|
|
}
|
|
|
|
func htmlToText(s string) string {
|
|
s = stripHTMLBlock(s, "script")
|
|
s = stripHTMLBlock(s, "style")
|
|
s = markHTMLBreaks(s)
|
|
var b strings.Builder
|
|
inTag := false
|
|
lastSpace := false
|
|
for _, r := range s {
|
|
switch r {
|
|
case '<':
|
|
inTag = true
|
|
if !lastSpace {
|
|
b.WriteByte(' ')
|
|
lastSpace = true
|
|
}
|
|
case '>':
|
|
inTag = false
|
|
default:
|
|
if inTag {
|
|
continue
|
|
}
|
|
if r == '\n' {
|
|
b.WriteByte('\n')
|
|
lastSpace = true
|
|
continue
|
|
}
|
|
if r == '\r' || r == '\t' || r == ' ' {
|
|
if !lastSpace {
|
|
b.WriteByte(' ')
|
|
lastSpace = true
|
|
}
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
lastSpace = false
|
|
}
|
|
}
|
|
text := html.UnescapeString(b.String())
|
|
lines := strings.Split(text, "\n")
|
|
out := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
out = append(out, line)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return strings.TrimSpace(text)
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|
|
|
|
func markHTMLBreaks(s string) string {
|
|
repls := []struct {
|
|
Needle string
|
|
With string
|
|
}{
|
|
{"<br>", "\n"},
|
|
{"<br/>", "\n"},
|
|
{"<br />", "\n"},
|
|
{"</p>", "\n"},
|
|
{"</div>", "\n"},
|
|
{"</tr>", "\n"},
|
|
{"</td>", " "},
|
|
{"</th>", " "},
|
|
{"</li>", "\n"},
|
|
}
|
|
for _, repl := range repls {
|
|
s = replaceCaseInsensitive(s, repl.Needle, repl.With)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func replaceCaseInsensitive(s, old, new string) string {
|
|
if old == "" {
|
|
return s
|
|
}
|
|
var b strings.Builder
|
|
for {
|
|
i := asciiFoldIndex(s, old)
|
|
if i < 0 {
|
|
b.WriteString(s)
|
|
return b.String()
|
|
}
|
|
b.WriteString(s[:i])
|
|
b.WriteString(new)
|
|
cut := i + len(old)
|
|
s = s[cut:]
|
|
}
|
|
}
|
|
|
|
func stripHTMLBlock(s, tag string) string {
|
|
open := "<" + tag
|
|
close := "</" + tag + ">"
|
|
for {
|
|
start := asciiFoldIndex(s, open)
|
|
if start < 0 {
|
|
return s
|
|
}
|
|
end := asciiFoldIndex(s[start:], close)
|
|
if end < 0 {
|
|
return s[:start]
|
|
}
|
|
end += start + len(close)
|
|
s = s[:start] + " " + s[end:]
|
|
}
|
|
}
|
|
|
|
// asciiFoldIndex searches an ASCII needle case-insensitively in s. It scans
|
|
// the original bytes, so the returned index is always valid for slicing s even
|
|
// when s contains Unicode characters whose lowercase form has another byte
|
|
// length (for example Turkish dotted I or the Kelvin sign).
|
|
func asciiFoldIndex(s, needle string) int {
|
|
if needle == "" || len(s) < len(needle) {
|
|
return -1
|
|
}
|
|
for i := 0; i+len(needle) <= len(s); i++ {
|
|
matched := true
|
|
for j := 0; j < len(needle); j++ {
|
|
if asciiLower(s[i+j]) != asciiLower(needle[j]) {
|
|
matched = false
|
|
break
|
|
}
|
|
}
|
|
if matched {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func asciiLower(c byte) byte {
|
|
if c >= 'A' && c <= 'Z' {
|
|
return c + ('a' - 'A')
|
|
}
|
|
return c
|
|
}
|
|
|
|
func decodeTransfer(body []byte, enc string) []byte {
|
|
switch strings.ToLower(strings.TrimSpace(enc)) {
|
|
case "quoted-printable":
|
|
b, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(body)))
|
|
if err == nil {
|
|
return b
|
|
}
|
|
case "base64":
|
|
cleaned := strings.NewReplacer("\r", "", "\n", "", " ", "", "\t", "").Replace(string(body))
|
|
b, err := base64.StdEncoding.DecodeString(cleaned)
|
|
if err == nil {
|
|
return b
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func safeMboxName(folder string) string {
|
|
folder = strings.TrimSpace(folder)
|
|
folder = strings.ReplaceAll(folder, "\\", "_")
|
|
folder = strings.ReplaceAll(folder, "/", "_")
|
|
folder = strings.ReplaceAll(folder, ":", "_")
|
|
if folder == "" {
|
|
return "INBOX"
|
|
}
|
|
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
|
|
}
|