From 955c1e0f92d9cb71681abc0e238c306d748b0fe8 Mon Sep 17 00:00:00 2001 From: DonVoo Date: Sun, 5 Jul 2026 21:45:15 +0200 Subject: [PATCH] Add mbox viewer panes --- backend/06-mbox.go | 95 ++++++++++++++++++++++- backend/08-viewer.go | 150 +++++++++++++++++++++++++++++++++++-- frontend-js/dist/style.css | 8 ++ 3 files changed, 246 insertions(+), 7 deletions(-) diff --git a/backend/06-mbox.go b/backend/06-mbox.go index e2d41c8..7349294 100644 --- a/backend/06-mbox.go +++ b/backend/06-mbox.go @@ -3,6 +3,9 @@ package backend import ( "bytes" "fmt" + "io" + "mime" + "net/mail" "os" "path/filepath" "strings" @@ -71,8 +74,96 @@ 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) { return nil, nil } // TODO Codex -func ReadMboxMessage(path string, index int) ([]byte, error) { return nil, nil } // TODO Codex +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 string(body) +} func safeMboxName(folder string) string { folder = strings.TrimSpace(folder) diff --git a/backend/08-viewer.go b/backend/08-viewer.go index 4508ccc..cb130c2 100644 --- a/backend/08-viewer.go +++ b/backend/08-viewer.go @@ -1,19 +1,64 @@ package backend -import "net/http" +import ( + "bytes" + "fmt" + "html" + "net/http" + "net/mail" + "os" + "path/filepath" + "strconv" + "strings" +) // Der Viewer betrachtet die LOKALEN mbox-Dateien im Browser (Outlook-2013- // Dreispalter): links Konto/Ordner-Baum, Mitte Nachrichtenliste, rechts // Lesebereich. Optional Weiterleiten einzelner Mails per SMTP (09-smtp.go). func viewerHandler(w http.ResponseWriter, r *http.Request) { - // TODO Codex: Konto+Ordner aus Query, ReadMboxList -> Nachrichtenliste - // (Mitte) als HTMX-Fragment rendern. + account := r.URL.Query().Get("account") + folder := r.URL.Query().Get("folder") + if account != "" && folder != "" { + renderMessageList(w, account, folder) + return + } + if r.Header.Get("HX-Request") == "true" { + renderViewerTree(w) + return + } + renderShell(w, "", `
Backup-Postfach links waehlen.
`) } func messageHandler(w http.ResponseWriter, r *http.Request) { - // TODO Codex: ReadMboxMessage -> Header + Body (Text/HTML sanitisiert) - // in den Lesebereich (rechts) rendern. + account := r.URL.Query().Get("account") + folder := r.URL.Query().Get("folder") + index, err := strconv.Atoi(r.URL.Query().Get("index")) + if err != nil { + http.Error(w, "bad index", http.StatusBadRequest) + return + } + path, err := mboxPath(account, folder) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + raw, err := ReadMboxMessage(path, index) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + renderReadPane(w, "(unlesbar)", "", "", string(raw)) + return + } + renderReadPane(w, + decodeHeader(msg.Header.Get("Subject")), + decodeHeader(msg.Header.Get("From")), + decodeHeader(msg.Header.Get("Date")), + messageBody(raw), + ) } func forwardHandler(w http.ResponseWriter, r *http.Request) { @@ -21,3 +66,98 @@ func forwardHandler(w http.ResponseWriter, r *http.Request) { // ForwardMessage() aufrufen. NUR hier kommt SMTP zum Einsatz -- der Umzug // selbst nutzt IMAP APPEND. } + +func renderViewerTree(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `
Backups
`) + entries, err := os.ReadDir(Cfg.MboxRoot) + if err != nil { + fmt.Fprintf(w, `
%s
`, html.EscapeString(err.Error())) + return + } + found := false + for _, entry := range entries { + if !entry.IsDir() { + continue + } + account := entry.Name() + mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, account, "*.mbox")) + if len(mboxes) == 0 { + continue + } + found = true + fmt.Fprintf(w, `
%s
`, html.EscapeString(account)) + for _, path := range mboxes { + folder := strings.TrimSuffix(filepath.Base(path), ".mbox") + fmt.Fprintf(w, `%s`, + urlEsc(account), urlEsc(folder), urlEsc(account), urlEsc(folder), html.EscapeString(folder)) + } + } + if !found { + fmt.Fprint(w, `
Noch keine mbox-Backups.
`) + } +} + +func renderMessageList(w http.ResponseWriter, account, folder string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + path, err := mboxPath(account, folder) + if err != nil { + fmt.Fprintf(w, `
Nachrichten
%s
`, html.EscapeString(err.Error())) + return + } + entries, err := ReadMboxList(path) + if err != nil { + fmt.Fprintf(w, `
Nachrichten
%s
`, html.EscapeString(err.Error())) + return + } + fmt.Fprintf(w, `
%s / %s (%d)
`, html.EscapeString(account), html.EscapeString(folder), len(entries)) + for _, entry := range entries { + subject := entry.Subject + if subject == "" { + subject = "(ohne Betreff)" + } + from := entry.From + if from == "" { + from = "(ohne Absender)" + } + fmt.Fprintf(w, `%s%s%s`, + urlEsc(account), urlEsc(folder), entry.Index, urlEsc(account), urlEsc(folder), entry.Index, + html.EscapeString(from), html.EscapeString(subject), html.EscapeString(entry.Date)) + } + if len(entries) == 0 { + fmt.Fprint(w, `
Keine Nachrichten in dieser mbox.
`) + } +} + +func renderReadPane(w http.ResponseWriter, subject, from, date, body string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if subject == "" { + subject = "(ohne Betreff)" + } + fmt.Fprintf(w, `
Lesebereich

%s

Von: %s
Datum: %s
%s
`, + html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body)) +} + +func mboxPath(account, folder string) (string, error) { + if account == "" || folder == "" || strings.Contains(account, "..") || strings.Contains(folder, "..") { + return "", fmt.Errorf("ungueltiges Postfach") + } + root, err := filepath.Abs(Cfg.MboxRoot) + if err != nil { + return "", err + } + path, err := filepath.Abs(filepath.Join(root, account, safeMboxName(folder)+".mbox")) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, path) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("ungueltiger mbox-Pfad") + } + return path, nil +} + +func urlEsc(s string) string { + r := strings.NewReplacer("%", "%25", " ", "%20", "&", "%26", "?", "%3F", "#", "%23", "+", "%2B", "/", "%2F") + return r.Replace(s) +} diff --git a/frontend-js/dist/style.css b/frontend-js/dist/style.css index 21893b0..a4d6ebf 100644 --- a/frontend-js/dist/style.css +++ b/frontend-js/dist/style.css @@ -74,6 +74,7 @@ body.ol2013 { /* Ordnerbaum */ .tree-node { padding: 4px 12px 4px 24px; cursor: pointer; white-space: nowrap; } +a.tree-node { display: block; color: inherit; text-decoration: none; } .tree-node.account { padding-left: 12px; font-weight: 600; color: var(--ol-blue-dark); } .tree-node:hover { background: #e5eff8; } .tree-node.active { background: var(--ol-sel); box-shadow: inset 2px 0 0 var(--ol-blue); } @@ -98,6 +99,13 @@ body.ol2013 { .read-head { border-bottom: 1px solid var(--ol-border); padding-bottom: 10px; margin-bottom: 12px; } .read-subject { font-size: 17px; font-weight: 600; margin: 0 0 6px; } .read-meta { color: var(--ol-muted); font-size: 12px; } +.read-pre { + white-space: pre-wrap; + word-break: break-word; + font: 12px Consolas, "Courier New", monospace; + line-height: 1.45; + margin: 0; +} .ef-empty { color: var(--ol-muted); padding: 24px; } /* Buttons / Formulare (flach, 2013er) */