Add mbox viewer panes
This commit is contained in:
parent
4e51aee1a9
commit
955c1e0f92
3 changed files with 246 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, "", `<div class="ef-empty">Backup-Postfach links waehlen.</div>`)
|
||||
}
|
||||
|
||||
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, `<div class="pane-head">Backups</div>`)
|
||||
entries, err := os.ReadDir(Cfg.MboxRoot)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, `<div class="ef-empty">%s</div>`, 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, `<div class="tree-node account">%s</div>`, html.EscapeString(account))
|
||||
for _, path := range mboxes {
|
||||
folder := strings.TrimSuffix(filepath.Base(path), ".mbox")
|
||||
fmt.Fprintf(w, `<a class="tree-node" href="/view?account=%s&folder=%s" hx-get="/view?account=%s&folder=%s" hx-target="#list" hx-swap="innerHTML">%s</a>`,
|
||||
urlEsc(account), urlEsc(folder), urlEsc(account), urlEsc(folder), html.EscapeString(folder))
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Fprint(w, `<div class="ef-empty">Noch keine mbox-Backups.</div>`)
|
||||
}
|
||||
}
|
||||
|
||||
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, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
|
||||
return
|
||||
}
|
||||
entries, err := ReadMboxList(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `<div class="pane-head">%s / %s (%d)</div>`, 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, `<a class="msg-row" href="/view/message?account=%s&folder=%s&index=%d" hx-get="/view/message?account=%s&folder=%s&index=%d" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
|
||||
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, `<div class="ef-empty">Keine Nachrichten in dieser mbox.</div>`)
|
||||
}
|
||||
}
|
||||
|
||||
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, `<div class="pane-head">Lesebereich</div><div class="read-body"><div class="read-head"><h1 class="read-subject">%s</h1><div class="read-meta">Von: %s<br>Datum: %s</div></div><pre class="read-pre">%s</pre></div>`,
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
8
frontend-js/dist/style.css
vendored
8
frontend-js/dist/style.css
vendored
|
|
@ -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) */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue