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, `
+
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
`,
- html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body))
+ fmt.Fprintf(w, `
%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; }
+}