Mail-Graveyard/backend/06-mbox.go
2026-07-05 21:46:35 +02:00

237 lines
5.8 KiB
Go

package backend
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"os"
"path/filepath"
"strings"
"time"
)
// 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
}
// 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) error {
path := filepath.Join(w.root, safeMboxName(folder)+".mbox")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
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
}
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
}
if !bytes.HasSuffix(body, []byte("\n")) {
if _, err := f.WriteString("\r\n"); err != nil {
return err
}
}
_, err = f.WriteString("\r\n")
return err
}
// --- 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) {
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")),
})
}
return out, nil
}
func ReadMboxMessage(path string, index int) ([]byte, error) {
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
}
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, bytes.TrimRight(cur.Bytes(), "\n"))
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, bytes.TrimRight(cur.Bytes(), "\n"))
}
return msgs, nil
}
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 {
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 ""
}
return string(decoded)
}
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
}