From 4f0493b5c24225224b2fb35de918b044749c0ebb Mon Sep 17 00:00:00 2001 From: DonVoo Date: Mon, 13 Jul 2026 08:32:01 +0200 Subject: [PATCH] Build five pane mail transfer workspace --- backend/00-router.go | 384 ++++++++++++++++++++++++++++++++++++- frontend-js/dist/style.css | 9 + 2 files changed, 386 insertions(+), 7 deletions(-) diff --git a/backend/00-router.go b/backend/00-router.go index 19bc4ea..9dadc5d 100644 --- a/backend/00-router.go +++ b/backend/00-router.go @@ -41,6 +41,9 @@ func RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/import-export/download", archiveDownloadHandler) mux.HandleFunc("/transfer", transferHandler) mux.HandleFunc("/transfer/", transferHandler) + mux.HandleFunc("/transfer/side", transferSideHandler) + mux.HandleFunc("/transfer/messages", transferMessagesHandler) + mux.HandleFunc("/transfer/message", transferMessageHandler) mux.HandleFunc("/boxes/source", emailBoxHandler) mux.HandleFunc("/boxes/source/messages", sourceMailboxMessagesHandler) mux.HandleFunc("/boxes/source/message", sourceMailboxMessageHandler) @@ -883,16 +886,383 @@ func renderTransferPage(w http.ResponseWriter, r *http.Request, mode transferMod Logout -
-
-
%s
%s
-
%s
%s
-
+
+ +
%s
+
Vorschau
Nachricht links oder rechts waehlen.
+
%s
+
bereit
`, html.EscapeString(mode.Title), renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(mode.Slug), renderRibbonActions(false, user), transferMenuItems(mode.Slug), - html.EscapeString(mode.LeftTitle), renderTransferPicker(mode.LeftKind, accounts, archives), - html.EscapeString(mode.RightTitle), renderTransferPicker(mode.RightKind, accounts, archives)) + renderTransferFolderPane("left", mode.LeftKind, accounts, archives, ""), + renderInitialTransferList("left", mode.LeftKind, accounts, archives, ""), + renderInitialTransferList("right", mode.RightKind, accounts, archives, ""), + renderTransferFolderPane("right", mode.RightKind, accounts, archives, "")) +} + +func transferSideHandler(w http.ResponseWriter, r *http.Request) { + side := transferSide(r.URL.Query().Get("side")) + kind := strings.TrimSpace(r.URL.Query().Get("kind")) + value := strings.TrimSpace(r.URL.Query().Get("value")) + accounts, err := ListAccounts() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + archives, err := ListArchiveMailboxes() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderTransferFolderPane(side, kind, accounts, archives, value)) + fmt.Fprintf(w, `
%s
`, side, renderInitialTransferList(side, kind, accounts, archives, value)) + fmt.Fprint(w, `
Vorschau
Nachricht links oder rechts waehlen.
`) +} + +func transferMessagesHandler(w http.ResponseWriter, r *http.Request) { + side := transferSide(r.URL.Query().Get("side")) + kind := strings.TrimSpace(r.URL.Query().Get("kind")) + value := strings.TrimSpace(r.URL.Query().Get("value")) + folder := strings.TrimSpace(r.URL.Query().Get("folder")) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderTransferMessageList(side, kind, value, folder)) + fmt.Fprintf(w, `
%s
`, renderTransferPreview(kind, value, folder, 0)) +} + +func transferMessageHandler(w http.ResponseWriter, r *http.Request) { + kind := strings.TrimSpace(r.URL.Query().Get("kind")) + value := strings.TrimSpace(r.URL.Query().Get("value")) + folder := strings.TrimSpace(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 + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, renderTransferPreview(kind, value, folder, index)) +} + +func transferSide(value string) string { + if value == "right" { + return "right" + } + return "left" +} + +func renderTransferFolderPane(side, kind string, accounts []Account, archives []string, selected string) string { + options := transferEntityOptions(kind, accounts, archives) + if selected == "" && len(options) > 0 { + selected = options[0].Value + } + var b strings.Builder + fmt.Fprintf(&b, `
%s
`) + if len(options) == 0 { + b.WriteString(`
Keine Eintraege.
`) + return b.String() + } + renderTransferFolders(&b, side, kind, selected) + return b.String() +} + +type transferOption struct { + Value string + Label string +} + +func transferEntityOptions(kind string, accounts []Account, archives []string) []transferOption { + switch kind { + case "archive": + out := make([]transferOption, 0, len(archives)) + for _, archive := range archives { + out = append(out, transferOption{Value: archive, Label: archive}) + } + return out + case "target": + targets := accountsByTarget(accounts) + out := make([]transferOption, 0, len(targets)) + for _, a := range targets { + out = append(out, transferOption{Value: a.Name, Label: a.DstUser}) + } + return out + default: + sources := accountsBySource(accounts) + out := make([]transferOption, 0, len(sources)) + for _, a := range sources { + out = append(out, transferOption{Value: a.Name, Label: a.SrcUser}) + } + return out + } +} + +func transferKindLabel(kind string) string { + switch kind { + case "archive": + return "Archiv-MBox" + case "target": + return "Ziel-Postfach" + default: + return "Quell-Postfach" + } +} + +func renderTransferFolders(b *strings.Builder, side, kind, value string) { + switch kind { + case "archive": + mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, value, "*.mbox")) + if len(mboxes) == 0 { + b.WriteString(`
Keine Ordner.
`) + return + } + for _, folder := range archiveFolderInfos(mboxes) { + fmt.Fprintf(b, `%s%d`, + side, html.EscapeString(kind), urlEsc(value), urlEsc(folder.Name), side, html.EscapeString(folder.Label), folder.Count) + } + case "target": + account, err := GetAccount(value) + if err != nil { + fmt.Fprintf(b, `
%s
`, html.EscapeString(err.Error())) + return + } + fmt.Fprintf(b, ``, html.EscapeString(account.DstUser)) + dst, err := OpenIMAPTarget(account) + if err != nil { + fmt.Fprintf(b, `
Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error())) + return + } + defer dst.Close() + folders, err := dst.Folders() + if err != nil { + fmt.Fprintf(b, `
Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error())) + return + } + for _, folder := range folders { + fmt.Fprintf(b, `%s%s`, + side, html.EscapeString(kind), urlEsc(value), urlEsc(folder.Name), side, html.EscapeString(folder.Name), html.EscapeString(targetFolderRole(folder))) + } + default: + account, err := GetAccount(value) + if err != nil { + fmt.Fprintf(b, `
%s
`, html.EscapeString(err.Error())) + return + } + fmt.Fprintf(b, ``, html.EscapeString(account.SrcUser)) + if accountProto(account) == "pop3" { + fmt.Fprintf(b, `INBOXPOP3`, side, html.EscapeString(kind), urlEsc(value), side) + return + } + src, err := OpenIMAPSource(account) + if err != nil { + fmt.Fprintf(b, `
Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error())) + return + } + defer src.Close() + folders, err := src.Folders() + if err != nil { + fmt.Fprintf(b, `
Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error())) + return + } + for _, folder := range folders { + fmt.Fprintf(b, `%s%s`, + side, html.EscapeString(kind), urlEsc(value), urlEsc(folder.Name), side, html.EscapeString(folder.Name), html.EscapeString(sourceFolderRole(folder))) + } + } +} + +func renderInitialTransferList(side, kind string, accounts []Account, archives []string, selected string) string { + options := transferEntityOptions(kind, accounts, archives) + if selected == "" && len(options) > 0 { + selected = options[0].Value + } + if selected == "" { + return `
Nachrichten
Postfach waehlen.
` + } + folder, err := firstTransferFolder(kind, selected) + if err != nil { + return fmt.Sprintf(`
Nachrichten
%s
`, html.EscapeString(err.Error())) + } + return renderTransferMessageList(side, kind, selected, folder) +} + +func firstTransferFolder(kind, value string) (string, error) { + switch kind { + case "archive": + mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, value, "*.mbox")) + infos := archiveFolderInfos(mboxes) + if len(infos) == 0 { + return "", fmt.Errorf("Keine Ordner.") + } + for _, folder := range infos { + if strings.EqualFold(folder.Name, "INBOX") { + return folder.Name, nil + } + } + return infos[0].Name, nil + case "target": + account, err := GetAccount(value) + if err != nil { + return "", err + } + return firstTargetFolder(account) + default: + account, err := GetAccount(value) + if err != nil { + return "", err + } + return firstSourceFolder(account) + } +} + +func renderTransferMessageList(side, kind, value, folder string) string { + var b strings.Builder + b.WriteString(`
Nachrichten
`) + switch kind { + case "archive": + path, err := mboxPath(value, folder) + if err != nil { + fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) + return b.String() + } + entries, err := ReadMboxList(path) + if err != nil { + fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) + return b.String() + } + for _, entry := range entries { + subject := entry.Subject + if subject == "" { + subject = "(ohne Betreff)" + } + from := entry.From + if from == "" { + from = "(ohne Absender)" + } + fmt.Fprintf(&b, `%s%s%s`, + side, html.EscapeString(kind), urlEsc(value), urlEsc(folder), entry.Index, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(entry.Date)) + } + if len(entries) == 0 { + b.WriteString(`
Keine Nachrichten.
`) + } + case "target": + account, err := GetAccount(value) + if err != nil { + fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) + return b.String() + } + messages, err := fetchTargetFolderMessages(account, folder) + if err != nil { + fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) + return b.String() + } + renderTransferRawMessages(&b, side, kind, value, folder, messages) + default: + account, err := GetAccount(value) + if err != nil { + fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) + return b.String() + } + messages, err := fetchSourceFolderMessages(account, folder) + if err != nil { + fmt.Fprintf(&b, `
%s
`, html.EscapeString(err.Error())) + return b.String() + } + renderTransferRawMessages(&b, side, kind, value, folder, messages) + } + return b.String() +} + +func renderTransferRawMessages(b *strings.Builder, side, kind, value, folder string, messages []RawMessage) { + for i, raw := range messages { + subject := "(ohne Betreff)" + from := "(ohne Absender)" + date := "" + if msg, err := mail.ReadMessage(bytes.NewReader(raw.Body)); err == nil { + if v := decodeHeader(msg.Header.Get("Subject")); v != "" { + subject = v + } + if v := decodeHeader(msg.Header.Get("From")); v != "" { + from = v + } + date = decodeHeader(msg.Header.Get("Date")) + } + fmt.Fprintf(b, `%s%s%s`, + side, html.EscapeString(kind), urlEsc(value), urlEsc(folder), i, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date)) + } + if len(messages) == 0 { + b.WriteString(`
Keine Nachrichten.
`) + } +} + +func renderTransferPreview(kind, value, folder string, index int) string { + raw, label, err := transferRawMessage(kind, value, folder, index) + if err != nil { + return fmt.Sprintf(`
Vorschau
%s
`, html.EscapeString(err.Error())) + } + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + var b strings.Builder + if err != nil { + renderMailboxReadPane(&b, "Vorschau", label, folder, index, "(unlesbar)", "", "", string(raw)) + return b.String() + } + renderMailboxReadPane(&b, "Vorschau", label, folder, index, + decodeHeader(msg.Header.Get("Subject")), + decodeHeader(msg.Header.Get("From")), + decodeHeader(msg.Header.Get("Date")), + messageBody(raw), + ) + return b.String() +} + +func transferRawMessage(kind, value, folder string, index int) ([]byte, string, error) { + switch kind { + case "archive": + path, err := mboxPath(value, folder) + if err != nil { + return nil, value, err + } + raw, err := ReadMboxMessage(path, index) + return raw, value, err + case "target": + account, err := GetAccount(value) + if err != nil { + return nil, value, err + } + messages, err := fetchTargetFolderMessages(account, folder) + if err != nil { + return nil, account.DstUser, err + } + if index < 0 || index >= len(messages) { + return nil, account.DstUser, fmt.Errorf("Nachricht waehlen.") + } + return messages[index].Body, account.DstUser, nil + default: + account, err := GetAccount(value) + if err != nil { + return nil, value, err + } + messages, err := fetchSourceFolderMessages(account, folder) + if err != nil { + return nil, account.SrcUser, err + } + if index < 0 || index >= len(messages) { + return nil, account.SrcUser, fmt.Errorf("Nachricht waehlen.") + } + return messages[index].Body, account.SrcUser, nil + } } func renderSourceEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts []Account, selected string) { diff --git a/frontend-js/dist/style.css b/frontend-js/dist/style.css index b44dd6d..681f2d1 100644 --- a/frontend-js/dist/style.css +++ b/frontend-js/dist/style.css @@ -489,6 +489,15 @@ a.tree-node { display: block; color: inherit; text-decoration: none; } grid-template-columns: minmax(380px, 1fr) minmax(380px, 1fr); min-height: 100%; } +.transfer-workbench { + flex: 1 1 auto; + display: grid; + grid-template-columns: 250px minmax(260px, 1fr) minmax(360px, 1.2fr) minmax(260px, 1fr) 250px; + min-height: 0; +} +.transfer-workbench .pane:last-child { + border-right: 0; +} .archive-list-panel, .archive-form-panel, .transfer-panel {