From c5d761db98042bb1c8a35f7db16672ed7fac302b Mon Sep 17 00:00:00 2001 From: DonVoo Date: Sun, 5 Jul 2026 23:24:00 +0200 Subject: [PATCH] Add viewer search and dual pane --- backend/00-router.go | 10 ++- backend/08-viewer.go | 140 ++++++++++++++++++++++++++++++++++--- frontend-js/dist/style.css | 47 +++++++++++-- 3 files changed, 181 insertions(+), 16 deletions(-) diff --git a/backend/00-router.go b/backend/00-router.go index 0dba23b..2492605 100644 --- a/backend/00-router.go +++ b/backend/00-router.go @@ -33,7 +33,7 @@ func homeHandler(w http.ResponseWriter, r *http.Request) { // renderShell ist das Grundgeruest im Outlook-2013-Look: // [ Ribbon: Umzug | Postfaecher | Betrachten | Weiterleiten | Einstellungen ] -// [ Ordnerbaum (links) | Nachrichtenliste (mitte) | Lesebereich (rechts) ] +// [ Ordnerbaum | Nachrichtenliste | Original | Zielkopie ] func renderShell(w http.ResponseWriter, active, readingPane string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, ` @@ -51,7 +51,7 @@ func renderShell(w http.ResponseWriter, active, readingPane string) { Einstellungen -
+
bereit
diff --git a/backend/08-viewer.go b/backend/08-viewer.go index cb130c2..f8cea4e 100644 --- a/backend/08-viewer.go +++ b/backend/08-viewer.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "html" + "io" "net/http" "net/mail" "os" @@ -19,8 +20,9 @@ import ( func viewerHandler(w http.ResponseWriter, r *http.Request) { account := r.URL.Query().Get("account") folder := r.URL.Query().Get("folder") + q := strings.TrimSpace(r.URL.Query().Get("q")) if account != "" && folder != "" { - renderMessageList(w, account, folder) + renderMessageList(w, account, folder, q) return } if r.Header.Get("HX-Request") == "true" { @@ -50,10 +52,10 @@ func messageHandler(w http.ResponseWriter, r *http.Request) { } msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { - renderReadPane(w, "(unlesbar)", "", "", string(raw)) + renderReadPanePair(w, account, folder, index, raw, "(unlesbar)", "", "", string(raw)) return } - renderReadPane(w, + renderReadPanePair(w, account, folder, index, raw, decodeHeader(msg.Header.Get("Subject")), decodeHeader(msg.Header.Get("From")), decodeHeader(msg.Header.Get("Date")), @@ -98,7 +100,7 @@ func renderViewerTree(w http.ResponseWriter) { } } -func renderMessageList(w http.ResponseWriter, account, folder string) { +func renderMessageList(w http.ResponseWriter, account, folder, q string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") path, err := mboxPath(account, folder) if err != nil { @@ -110,8 +112,18 @@ func renderMessageList(w http.ResponseWriter, account, folder string) { 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 { + filtered := entries + if q != "" { + filtered = filterEntries(path, entries, q) + } + fmt.Fprintf(w, `
%s / %s (%d)
`, html.EscapeString(account), html.EscapeString(folder), len(filtered)) + fmt.Fprintf(w, ``, html.EscapeString(account), html.EscapeString(folder), html.EscapeString(q)) + for _, entry := range filtered { subject := entry.Subject if subject == "" { subject = "(ohne Betreff)" @@ -126,16 +138,49 @@ func renderMessageList(w http.ResponseWriter, account, folder string) { } if len(entries) == 0 { fmt.Fprint(w, `
Keine Nachrichten in dieser mbox.
`) + } else if len(filtered) == 0 { + fmt.Fprint(w, `
Keine Treffer.
`) } } -func renderReadPane(w http.ResponseWriter, subject, from, date, body string) { +func renderReadPanePair(w http.ResponseWriter, account, folder string, index int, raw []byte, subject, from, date, body string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + renderReadPane(w, "Original", subject, from, date, body) + targetHTML := renderTargetPane(account, folder, index, raw) + fmt.Fprintf(w, `
%s
`, targetHTML) +} + +func renderReadPane(w io.Writer, title, subject, from, date, body string) { 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)) + fmt.Fprintf(w, `
%s

%s

Von: %s
Datum: %s
%s
`, + html.EscapeString(title), html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body)) +} + +func renderTargetPane(account, folder string, index int, raw []byte) string { + match, ok := findForwardedCopy(account, folder, index, raw) + if !ok { + id := messageIDHeader(raw) + if id == "" { + id = "(ohne Message-ID)" + } + return fmt.Sprintf(`
Zielkopie
Keine lokale Zielkopie gefunden.

Message-ID: %s
`, html.EscapeString(id)) + } + msg, err := mail.ReadMessage(bytes.NewReader(match.Raw)) + if err != nil { + var b strings.Builder + renderReadPane(&b, "Zielkopie", "(unlesbar)", match.Location(), "", string(match.Raw)) + return b.String() + } + var b strings.Builder + renderReadPane(&b, "Zielkopie: "+match.Location(), + decodeHeader(msg.Header.Get("Subject")), + decodeHeader(msg.Header.Get("From")), + decodeHeader(msg.Header.Get("Date")), + messageBody(match.Raw), + ) + return b.String() } func mboxPath(account, folder string) (string, error) { @@ -161,3 +206,80 @@ func urlEsc(s string) string { r := strings.NewReplacer("%", "%25", " ", "%20", "&", "%26", "?", "%3F", "#", "%23", "+", "%2B", "/", "%2F") return r.Replace(s) } + +func filterEntries(path string, entries []MboxEntry, q string) []MboxEntry { + needle := strings.ToLower(strings.TrimSpace(q)) + if needle == "" { + return entries + } + var out []MboxEntry + for _, entry := range entries { + hay := strings.ToLower(entry.From + " " + entry.Subject + " " + entry.Date) + if strings.Contains(hay, needle) { + out = append(out, entry) + continue + } + raw, err := ReadMboxMessage(path, entry.Index) + if err == nil && strings.Contains(strings.ToLower(messageBody(raw)), needle) { + out = append(out, entry) + } + } + return out +} + +type forwardedMatch struct { + Account string + Folder string + Index int + Raw []byte +} + +func (m forwardedMatch) Location() string { + return fmt.Sprintf("%s / %s #%d", m.Account, m.Folder, m.Index) +} + +func findForwardedCopy(account, folder string, index int, raw []byte) (forwardedMatch, bool) { + id := messageIDHeader(raw) + if id == "" { + return forwardedMatch{}, false + } + entries, err := os.ReadDir(Cfg.MboxRoot) + if err != nil { + return forwardedMatch{}, false + } + for _, entry := range entries { + if !entry.IsDir() || entry.Name() == account { + continue + } + mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, entry.Name(), "*.mbox")) + for _, path := range mboxes { + msgs, err := ReadMboxList(path) + if err != nil { + continue + } + for _, msg := range msgs { + candidate, err := ReadMboxMessage(path, msg.Index) + if err != nil { + continue + } + if messageIDHeader(candidate) == id { + return forwardedMatch{ + Account: entry.Name(), + Folder: strings.TrimSuffix(filepath.Base(path), ".mbox"), + Index: msg.Index, + Raw: candidate, + }, true + } + } + } + } + return forwardedMatch{}, false +} + +func messageIDHeader(raw []byte) string { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return "" + } + return strings.TrimSpace(msg.Header.Get("Message-ID")) +} diff --git a/frontend-js/dist/style.css b/frontend-js/dist/style.css index a4d6ebf..a8a6ced 100644 --- a/frontend-js/dist/style.css +++ b/frontend-js/dist/style.css @@ -48,13 +48,15 @@ body.ol2013 { .tab:hover { background: var(--ol-blue-dark); color: #fff; } .tab.active { background: #fff; color: var(--ol-blue); border-bottom-color: #fff; } -/* Dreispalter */ -.three-pane { +/* Vier-Spalten-Viewer */ +.three-pane, +.four-pane { flex: 1 1 auto; display: grid; - grid-template-columns: 220px 340px 1fr; min-height: 0; /* erlaubt Scrollen in den Panes */ } +.three-pane { grid-template-columns: 220px 340px 1fr; } +.four-pane { grid-template-columns: 220px 340px minmax(280px, 1fr) minmax(280px, 1fr); } .pane { border-right: 1px solid var(--ol-border); background: var(--ol-pane); @@ -62,7 +64,7 @@ body.ol2013 { min-height: 0; } .pane-tree { background: var(--ol-tree); } -.pane-read { border-right: 0; } +.pane-target { border-right: 0; } .pane-head { position: sticky; top: 0; background: #eef3f8; @@ -72,6 +74,18 @@ body.ol2013 { text-transform: none; } +.searchbar { + position: sticky; top: 30px; z-index: 2; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + padding: 8px; + background: #fff; + border-bottom: 1px solid var(--ol-border); +} +.search-input { min-width: 0; } +.search-btn { padding-left: 10px; padding-right: 10px; } + /* Ordnerbaum */ .tree-node { padding: 4px 12px 4px 24px; cursor: pointer; white-space: nowrap; } a.tree-node { display: block; color: inherit; text-decoration: none; } @@ -132,3 +146,28 @@ a.tree-node { display: block; color: inherit; text-decoration: none; } } .ok { color: #107c10; } .bad { color: #d13438; } + +@media (max-width: 1180px) { + .four-pane { + grid-template-columns: 200px 320px minmax(300px, 1fr); + } + .pane-target { + grid-column: 3; + border-top: 1px solid var(--ol-border); + } +} + +@media (max-width: 820px) { + .four-pane, + .three-pane { + grid-template-columns: 1fr; + overflow-y: auto; + } + .pane { + min-height: 220px; + border-right: 0; + border-bottom: 1px solid var(--ol-border); + } + .pane-target { grid-column: auto; } + .searchbar { top: 30px; } +}