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
`,
+ 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) */