diff --git a/backend/00-router.go b/backend/00-router.go index fc9c072..a2fc6f1 100644 --- a/backend/00-router.go +++ b/backend/00-router.go @@ -21,6 +21,7 @@ func RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/view", viewerHandler) mux.HandleFunc("/view/message", messageHandler) mux.HandleFunc("/view/target", targetMessageHandler) + mux.HandleFunc("/view/manual-copy", manualCopyHandler) mux.HandleFunc("/view/forward", forwardHandler) } @@ -34,7 +35,7 @@ func homeHandler(w http.ResponseWriter, r *http.Request) { // renderShell ist das Grundgeruest im Outlook-2013-Look: // [ Ribbon: Postfaecher | Betrachten | Betrachen | Einstellungen | Mail-Graveyard ] -// [ Ordnerbaum | Nachrichtenliste | Original | Zielkopie ] +// [ Archiv-mbox | Nachrichtenliste | Quelle/Target ] func renderShell(w http.ResponseWriter, active, readingPane string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, ` @@ -53,7 +54,7 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
Mail-Graveyard
-
+
-`, active, readingPane, renderInitialTargetPane()) +`, active, readingPane) } // --- Stubs fuer die noch nicht gebauten Handler (TODO Codex) --- diff --git a/backend/08-viewer.go b/backend/08-viewer.go index 0945958..d064e69 100644 --- a/backend/08-viewer.go +++ b/backend/08-viewer.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) // Der Viewer betrachtet die LOKALEN mbox-Dateien im Browser (Outlook-2013- @@ -53,10 +54,10 @@ func messageHandler(w http.ResponseWriter, r *http.Request) { } msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { - renderReadPanePair(w, account, folder, targetAccount, index, raw, "(unlesbar)", "", "", string(raw)) + renderReadPaneWithSource(w, account, folder, targetAccount, index, "(unlesbar)", "", "", string(raw)) return } - renderReadPanePair(w, account, folder, targetAccount, index, raw, + renderReadPaneWithSource(w, account, folder, targetAccount, index, decodeHeader(msg.Header.Get("Subject")), decodeHeader(msg.Header.Get("From")), decodeHeader(msg.Header.Get("Date")), @@ -84,7 +85,7 @@ func targetMessageHandler(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw)) + fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw, "")) } func forwardHandler(w http.ResponseWriter, r *http.Request) { @@ -157,7 +158,8 @@ func renderMessageList(w http.ResponseWriter, account, folder, q string) { if from == "" { from = "(ohne Absender)" } - fmt.Fprintf(w, `%s%s%s`, + fmt.Fprintf(w, `%s%s%s`, + html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), entry.Index, urlEsc(sourceDisplayLabel(account)), urlEsc(folder), entry.Index, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(entry.Date)) } @@ -168,24 +170,60 @@ func renderMessageList(w http.ResponseWriter, account, folder, q string) { } } -func renderReadPanePair(w http.ResponseWriter, account, folder, targetAccount string, index int, raw []byte, subject, from, date, body string) { +func manualCopyHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + account := resolveViewerAccount(r.FormValue("account"), r.FormValue("source")) + folder := r.FormValue("folder") + targetAccount := r.FormValue("target_account") + index, err := strconv.Atoi(r.FormValue("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 + } + note, err := appendManualTargetCopy(account, folder, targetAccount, raw) + if err != nil { + note = "Fehler beim manuellen Umzug: " + err.Error() + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - renderReadPane(w, "Original", subject, from, date, body) - targetHTML := renderTargetPane(account, folder, targetAccount, index, raw) - fmt.Fprintf(w, `
%s
`, targetHTML) + fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw, note)) } -func renderReadPane(w io.Writer, title, subject, from, date, body string) { +func renderReadPaneWithSource(w http.ResponseWriter, account, folder, targetAccount string, index int, subject, from, date, body string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + renderReadPane(w, "Quelle", account, folder, targetAccount, index, subject, from, date, body) +} + +func renderReadPane(w io.Writer, title, account, folder, targetAccount string, index int, subject, from, date, body string) { if subject == "" { subject = "(ohne Betreff)" } - 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)) + fmt.Fprintf(w, `
%s

%s

Von: %s
Datum: %s
%s
`, + html.EscapeString(title), urlEsc(sourceDisplayLabel(account)), urlEsc(folder), index, urlEsc(targetAccount), html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body)) } -func renderTargetPane(account, folder, targetAccount string, index int, raw []byte) string { +func renderTargetPane(account, folder, targetAccount string, index int, raw []byte, note string) string { var b strings.Builder b.WriteString(renderTargetAccountSelect(account, folder, targetAccount, index)) + if note != "" { + class := "ok" + if strings.HasPrefix(note, "Fehler") { + class = "bad" + } + fmt.Fprintf(&b, `
%s
`, class, html.EscapeString(note)) + } if match, ok, err := findTargetIMAPCopy(account, targetAccount, raw); ok { renderTargetMatch(&b, match) return b.String() @@ -220,10 +258,7 @@ func renderTargetMatch(b *strings.Builder, match forwardedMatch) { } func renderInitialTargetPane() string { - var b strings.Builder - b.WriteString(renderTargetAccountSelect("", "", "", -1)) - b.WriteString(`
Nachricht waehlen.
`) - return b.String() + return `
Quelle
Nachricht waehlen.
` } func renderReadContent(w io.Writer, subject, from, date, body string) { @@ -399,9 +434,9 @@ func messageIDHeader(raw []byte) string { func renderTargetAccountSelect(account, folder, selected string, index int) string { options := targetAccountOptions() var b strings.Builder - fmt.Fprintf(&b, `
Zielkopie
Target= 0 { - fmt.Fprintf(&b, ` hx-get="/view/target" hx-target="#read-target" hx-swap="innerHTML" hx-trigger="change from:#target-account-select"`) + fmt.Fprintf(&b, ` hx-get="/view/target" hx-target="#read" hx-swap="innerHTML" hx-trigger="change from:#target-account-select"`) } fmt.Fprintf(&b, `> @@ -416,10 +451,73 @@ func renderTargetAccountSelect(account, folder, selected string, index int) stri } fmt.Fprintf(&b, ``, html.EscapeString(opt.Value), attr, html.EscapeString(opt.Label)) } - b.WriteString(`
`) + fmt.Fprintf(&b, `
`, + urlEsc(sourceDisplayLabel(account)), urlEsc(folder), index, urlEsc(selected)) + if account != "" && folder != "" && index >= 0 { + fmt.Fprintf(&b, `
Mail hier ablegen fuer manuellen Umzug
`, + html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), index) + } return b.String() } +func appendManualTargetCopy(account, folder, targetAccount string, raw []byte) (string, error) { + id := messageIDHeader(raw) + if id == "" { + return "", fmt.Errorf("mail ohne Message-ID kann nicht sauber dedupliziert werden") + } + if _, ok, err := findTargetIMAPCopy(account, targetAccount, raw); ok { + return "Schon vorhanden: Zielkopie wurde per Message-ID gefunden.", nil + } else if err != nil { + return "", err + } + a, ok := accountByMboxDir(account) + if !ok { + return "", fmt.Errorf("kein Konto zur mbox %s gefunden", account) + } + if strings.TrimSpace(targetAccount) != "" && !strings.EqualFold(strings.TrimSpace(a.DstUser), strings.TrimSpace(targetAccount)) { + candidates := accountsForTargetUser(targetAccount) + if len(candidates) == 0 { + return "", fmt.Errorf("kein Zielkonto %s gefunden", targetAccount) + } + a = candidates[0] + } + dst, err := OpenIMAPTarget(a) + if err != nil { + return "", err + } + defer dst.Close() + targets, err := dst.Folders() + if err != nil { + return "", err + } + dstFolder := MapSourceToTarget(folder, nil, "/", dst.Delim(), targets, "") + if dstFolder == "" { + dstFolder = folder + } + if err := dst.EnsureFolder(dstFolder); err != nil { + return "", err + } + msg := RawMessage{MessageID: id, Body: raw, InternalDate: messageDate(raw)} + if err := dst.Append(dstFolder, msg); err != nil { + return "", err + } + if a.ID != 0 { + _ = MarkCopied(a.ID, folder, id) + } + return "Manuell kopiert nach " + strings.TrimSpace(a.DstUser) + " / " + dstFolder + ".", nil +} + +func messageDate(raw []byte) time.Time { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return time.Time{} + } + if t, err := mail.ParseDate(msg.Header.Get("Date")); err == nil { + return t + } + return time.Time{} +} + type viewerAccount struct { Name string SourceLabel string diff --git a/frontend-js/dist/app.js b/frontend-js/dist/app.js index f897eb1..b038f82 100644 --- a/frontend-js/dist/app.js +++ b/frontend-js/dist/app.js @@ -1,6 +1,6 @@ (function () { - const key = "mail-graveyard-pane-widths"; - const minWidths = [160, 220, 260, 260]; + const key = "mail-graveyard-pane-widths-v2"; + const minWidths = [160, 220, 300]; const handleWidth = 6; function panes(container) { @@ -13,7 +13,7 @@ function clampWidths(widths, total) { const next = widths.map((w, i) => Math.max(minWidths[i], Math.round(w || minWidths[i]))); - const max = Math.max(total - handleWidth * 3, minWidths.reduce((a, b) => a + b, 0)); + const max = Math.max(total - handleWidth * (next.length - 1), minWidths.reduce((a, b) => a + b, 0)); const sum = next.reduce((a, b) => a + b, 0); if (sum > max) { let extra = sum - max; @@ -31,14 +31,14 @@ const total = container.getBoundingClientRect().width; const w = clampWidths(widths, total); container.style.gridTemplateColumns = - `${w[0]}px ${handleWidth}px ${w[1]}px ${handleWidth}px ${w[2]}px ${handleWidth}px ${w[3]}px`; + `${w[0]}px ${handleWidth}px ${w[1]}px ${handleWidth}px minmax(${w[2]}px, 1fr)`; localStorage.setItem(key, JSON.stringify(w)); } function restore(container) { try { const saved = JSON.parse(localStorage.getItem(key) || "null"); - if (Array.isArray(saved) && saved.length === 4) { + if (Array.isArray(saved) && saved.length === 3) { applyWidths(container, saved); } } catch (_) { @@ -90,4 +90,79 @@ } window.addEventListener("DOMContentLoaded", () => bind(document.querySelector(".four-pane"))); + window.addEventListener("DOMContentLoaded", () => bind(document.querySelector(".three-pane"))); +})(); + +(function () { + function rowPayload(row) { + return { + source: row.dataset.source || "", + folder: row.dataset.folder || "", + index: row.dataset.index || "" + }; + } + + document.addEventListener("dragstart", (event) => { + const row = event.target.closest(".msg-row[draggable='true']"); + if (!row || !event.dataTransfer) return; + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData("application/json", JSON.stringify(rowPayload(row))); + event.dataTransfer.setData("text/plain", `${row.dataset.source || ""}/${row.dataset.folder || ""}#${row.dataset.index || ""}`); + row.classList.add("dragging"); + }); + + document.addEventListener("dragend", (event) => { + const row = event.target.closest(".msg-row"); + if (row) row.classList.remove("dragging"); + }); + + document.addEventListener("dragover", (event) => { + const zone = event.target.closest("[data-drop-target='manual-copy']"); + if (!zone) return; + event.preventDefault(); + zone.classList.add("drag-over"); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + }); + + document.addEventListener("dragleave", (event) => { + const zone = event.target.closest("[data-drop-target='manual-copy']"); + if (zone) zone.classList.remove("drag-over"); + }); + + document.addEventListener("drop", async (event) => { + const zone = event.target.closest("[data-drop-target='manual-copy']"); + if (!zone) return; + event.preventDefault(); + zone.classList.remove("drag-over"); + + let payload = {}; + try { + payload = JSON.parse(event.dataTransfer.getData("application/json") || "{}"); + } catch (_) { + payload = {}; + } + if (!payload.source) { + payload = { + source: zone.dataset.source || "", + folder: zone.dataset.folder || "", + index: zone.dataset.index || "" + }; + } + + const select = document.querySelector("#target-account-select"); + const form = new URLSearchParams(); + form.set("source", payload.source); + form.set("folder", payload.folder); + form.set("index", payload.index); + form.set("target_account", select ? select.value : ""); + zone.textContent = "Kopiere..."; + + const response = await fetch("/view/manual-copy", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" }, + body: form.toString() + }); + const html = await response.text(); + document.querySelector("#read").innerHTML = html; + }); })(); diff --git a/frontend-js/dist/style.css b/frontend-js/dist/style.css index b8ffadf..ef75f72 100644 --- a/frontend-js/dist/style.css +++ b/frontend-js/dist/style.css @@ -48,14 +48,14 @@ body.ol2013 { .tab:hover { background: var(--ol-blue-dark); color: #fff; } .tab.active { background: #fff; color: var(--ol-blue); border-bottom-color: #fff; } -/* Vier-Spalten-Viewer */ +/* Viewer */ .three-pane, .four-pane { flex: 1 1 auto; display: grid; min-height: 0; /* erlaubt Scrollen in den Panes */ } -.three-pane { grid-template-columns: 220px 340px 1fr; } +.three-pane { grid-template-columns: 220px 6px 340px 6px minmax(300px, 1fr); } .four-pane { grid-template-columns: 220px 6px 340px 6px minmax(280px, 1fr) 6px minmax(280px, 1fr); } .pane { border-right: 1px solid var(--ol-border); @@ -84,7 +84,7 @@ body.resizing { cursor: col-resize; user-select: none; } } .target-head { display: grid; - grid-template-columns: auto minmax(0, 1fr); + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; } @@ -124,6 +124,7 @@ a.tree-node { display: block; color: inherit; text-decoration: none; } display: flex; flex-direction: column; gap: 2px; } .msg-row:hover { background: #eef6fd; } +.msg-row.dragging { opacity: .55; } .msg-row.active { background: var(--ol-sel); border-left: 3px solid var(--ol-blue); padding-left: 9px; } .msg-from { font-weight: 600; } .msg-subject { color: var(--ol-text); } @@ -133,6 +134,12 @@ a.tree-node { display: block; color: inherit; text-decoration: none; } /* Lesebereich */ .read-body { padding: 16px 20px; } +.read-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} .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; } @@ -145,6 +152,19 @@ a.tree-node { display: block; color: inherit; text-decoration: none; } } .ef-empty { color: var(--ol-muted); padding: 24px; } .audit-note { padding: 10px 16px; border-bottom: 1px solid var(--ol-border); font-size: 12px; } +.drop-zone { + margin: 10px 16px 0; + padding: 12px; + border: 1px dashed var(--ol-sel-bd); + background: #f6fbff; + color: var(--ol-muted); + text-align: center; +} +.drop-zone.drag-over { + background: var(--ol-sel); + color: var(--ol-text); + border-color: var(--ol-blue); +} /* Buttons / Formulare (flach, 2013er) */ .btn { @@ -154,6 +174,7 @@ a.tree-node { display: block; color: inherit; text-decoration: none; } .btn:hover { background: var(--ol-blue-dark); } .btn.secondary { background: #e1e1e1; color: var(--ol-text); border: 1px solid var(--ol-border); } .btn.secondary:hover { background: #d5d5d5; } +.btn.compact { padding: 3px 9px; } .input, select, textarea { border: 1px solid #abadb3; padding: 5px 7px; font: inherit; width: 100%; background: #fff; @@ -172,6 +193,9 @@ a.tree-node { display: block; color: inherit; text-decoration: none; } .bad { color: #d13438; } @media (max-width: 1180px) { + .three-pane { + grid-template-columns: 200px 6px 320px 6px minmax(300px, 1fr); + } .four-pane { grid-template-columns: 200px 6px 320px 6px minmax(280px, 1fr) 6px minmax(280px, 1fr); }