Build five pane mail transfer workspace

This commit is contained in:
DonVoo 2026-07-13 08:32:01 +02:00
parent e46c25cb72
commit 4f0493b5c2
2 changed files with 386 additions and 7 deletions

View file

@ -41,6 +41,9 @@ func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/import-export/download", archiveDownloadHandler) mux.HandleFunc("/import-export/download", archiveDownloadHandler)
mux.HandleFunc("/transfer", transferHandler) mux.HandleFunc("/transfer", transferHandler)
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", emailBoxHandler)
mux.HandleFunc("/boxes/source/messages", sourceMailboxMessagesHandler) mux.HandleFunc("/boxes/source/messages", sourceMailboxMessagesHandler)
mux.HandleFunc("/boxes/source/message", sourceMailboxMessageHandler) mux.HandleFunc("/boxes/source/message", sourceMailboxMessageHandler)
@ -883,16 +886,383 @@ func renderTransferPage(w http.ResponseWriter, r *http.Request, mode transferMod
<a class="backstage-item" href="/logout">Logout</a> <a class="backstage-item" href="/logout">Logout</a>
</aside> </aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button> <button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<main class="accounts-page transfer-page"> <main class="transfer-workbench">
<section class="transfer-layout"> <aside class="pane pane-tree" id="left-folders">%s</aside>
<div class="transfer-panel"><div class="pane-head">%s</div>%s</div> <section class="pane pane-list" id="left-list">%s</section>
<div class="transfer-panel"><div class="pane-head">%s</div>%s</div> <section class="pane pane-read" id="transfer-preview"><div class="pane-head mode-head"><span class="mode-label">Vorschau</span></div><div class="read-body"><div class="ef-empty">Nachricht links oder rechts waehlen.</div></div></section>
</section> <section class="pane pane-list" id="right-list">%s</section>
<aside class="pane pane-tree" id="right-folders">%s</aside>
</main> </main>
<footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`, <footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`,
html.EscapeString(mode.Title), renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(mode.Slug), renderRibbonActions(false, user), transferMenuItems(mode.Slug), html.EscapeString(mode.Title), renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(mode.Slug), renderRibbonActions(false, user), transferMenuItems(mode.Slug),
html.EscapeString(mode.LeftTitle), renderTransferPicker(mode.LeftKind, accounts, archives), renderTransferFolderPane("left", mode.LeftKind, accounts, archives, ""),
html.EscapeString(mode.RightTitle), renderTransferPicker(mode.RightKind, 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, `<div id="%s-list" hx-swap-oob="innerHTML">%s</div>`, side, renderInitialTransferList(side, kind, accounts, archives, value))
fmt.Fprint(w, `<div id="transfer-preview" hx-swap-oob="innerHTML"><div class="pane-head mode-head"><span class="mode-label">Vorschau</span></div><div class="read-body"><div class="ef-empty">Nachricht links oder rechts waehlen.</div></div></div>`)
}
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, `<div id="transfer-preview" hx-swap-oob="innerHTML">%s</div>`, 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, `<div class="pane-head archive-head"><span>%s</span><form class="archive-select-form" hx-get="/transfer/side" hx-target="#%s-folders" hx-swap="innerHTML" hx-trigger="change from:#transfer-%s-select"><input type="hidden" name="side" value="%s"><input type="hidden" name="kind" value="%s"><select id="transfer-%s-select" class="archive-select" name="value">`,
html.EscapeString(transferKindLabel(kind)), side, side, side, html.EscapeString(kind), side)
if len(options) == 0 {
b.WriteString(`<option>Keine Eintraege</option>`)
} else {
for _, opt := range options {
attr := ""
if opt.Value == selected {
attr = ` selected`
}
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, html.EscapeString(opt.Value), attr, html.EscapeString(opt.Label))
}
}
b.WriteString(`</select></form></div>`)
if len(options) == 0 {
b.WriteString(`<div class="ef-empty">Keine Eintraege.</div>`)
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(`<div class="ef-empty">Keine Ordner.</div>`)
return
}
for _, folder := range archiveFolderInfos(mboxes) {
fmt.Fprintf(b, `<a class="tree-node folder" href="#" hx-get="/transfer/messages?side=%s&amp;kind=%s&amp;value=%s&amp;folder=%s" hx-target="#%s-list" hx-swap="innerHTML"><span class="tree-label">%s</span><span class="tree-count">%d</span></a>`,
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, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return
}
fmt.Fprintf(b, `<div class="tree-node account">%s</div>`, html.EscapeString(account.DstUser))
dst, err := OpenIMAPTarget(account)
if err != nil {
fmt.Fprintf(b, `<div class="ef-empty">Ordner konnten nicht geladen werden: %s</div>`, html.EscapeString(err.Error()))
return
}
defer dst.Close()
folders, err := dst.Folders()
if err != nil {
fmt.Fprintf(b, `<div class="ef-empty">Ordner konnten nicht geladen werden: %s</div>`, html.EscapeString(err.Error()))
return
}
for _, folder := range folders {
fmt.Fprintf(b, `<a class="tree-node folder" href="#" hx-get="/transfer/messages?side=%s&amp;kind=%s&amp;value=%s&amp;folder=%s" hx-target="#%s-list" hx-swap="innerHTML"><span class="tree-label">%s</span><span class="tree-count">%s</span></a>`,
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, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return
}
fmt.Fprintf(b, `<div class="tree-node account">%s</div>`, html.EscapeString(account.SrcUser))
if accountProto(account) == "pop3" {
fmt.Fprintf(b, `<a class="tree-node folder" href="#" hx-get="/transfer/messages?side=%s&amp;kind=%s&amp;value=%s&amp;folder=INBOX" hx-target="#%s-list" hx-swap="innerHTML"><span class="tree-label">INBOX</span><span class="tree-count">POP3</span></a>`, side, html.EscapeString(kind), urlEsc(value), side)
return
}
src, err := OpenIMAPSource(account)
if err != nil {
fmt.Fprintf(b, `<div class="ef-empty">Ordner konnten nicht geladen werden: %s</div>`, html.EscapeString(err.Error()))
return
}
defer src.Close()
folders, err := src.Folders()
if err != nil {
fmt.Fprintf(b, `<div class="ef-empty">Ordner konnten nicht geladen werden: %s</div>`, html.EscapeString(err.Error()))
return
}
for _, folder := range folders {
fmt.Fprintf(b, `<a class="tree-node folder" href="#" hx-get="/transfer/messages?side=%s&amp;kind=%s&amp;value=%s&amp;folder=%s" hx-target="#%s-list" hx-swap="innerHTML"><span class="tree-label">%s</span><span class="tree-count">%s</span></a>`,
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 `<div class="pane-head">Nachrichten</div><div class="ef-empty">Postfach waehlen.</div>`
}
folder, err := firstTransferFolder(kind, selected)
if err != nil {
return fmt.Sprintf(`<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, 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(`<div class="pane-head">Nachrichten</div>`)
switch kind {
case "archive":
path, err := mboxPath(value, folder)
if err != nil {
fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return b.String()
}
entries, err := ReadMboxList(path)
if err != nil {
fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, 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, `<a class="msg-row" href="#" hx-get="/transfer/message?side=%s&amp;kind=%s&amp;value=%s&amp;folder=%s&amp;index=%d" hx-target="#transfer-preview" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
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(`<div class="ef-empty">Keine Nachrichten.</div>`)
}
case "target":
account, err := GetAccount(value)
if err != nil {
fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return b.String()
}
messages, err := fetchTargetFolderMessages(account, folder)
if err != nil {
fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, 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, `<div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
return b.String()
}
messages, err := fetchSourceFolderMessages(account, folder)
if err != nil {
fmt.Fprintf(&b, `<div class="ef-empty">%s</div>`, 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, `<a class="msg-row" href="#" hx-get="/transfer/message?side=%s&amp;kind=%s&amp;value=%s&amp;folder=%s&amp;index=%d" hx-target="#transfer-preview" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
side, html.EscapeString(kind), urlEsc(value), urlEsc(folder), i, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date))
}
if len(messages) == 0 {
b.WriteString(`<div class="ef-empty">Keine Nachrichten.</div>`)
}
}
func renderTransferPreview(kind, value, folder string, index int) string {
raw, label, err := transferRawMessage(kind, value, folder, index)
if err != nil {
return fmt.Sprintf(`<div class="pane-head mode-head"><span class="mode-label">Vorschau</span></div><div class="read-body"><div class="ef-empty">%s</div></div>`, 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) { func renderSourceEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts []Account, selected string) {

View file

@ -489,6 +489,15 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
grid-template-columns: minmax(380px, 1fr) minmax(380px, 1fr); grid-template-columns: minmax(380px, 1fr) minmax(380px, 1fr);
min-height: 100%; 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-list-panel,
.archive-form-panel, .archive-form-panel,
.transfer-panel { .transfer-panel {