Build five pane mail transfer workspace
This commit is contained in:
parent
e46c25cb72
commit
4f0493b5c2
2 changed files with 386 additions and 7 deletions
|
|
@ -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
|
|||
<a class="backstage-item" href="/logout">Logout</a>
|
||||
</aside>
|
||||
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
|
||||
<main class="accounts-page transfer-page">
|
||||
<section class="transfer-layout">
|
||||
<div class="transfer-panel"><div class="pane-head">%s</div>%s</div>
|
||||
<div class="transfer-panel"><div class="pane-head">%s</div>%s</div>
|
||||
</section>
|
||||
<main class="transfer-workbench">
|
||||
<aside class="pane pane-tree" id="left-folders">%s</aside>
|
||||
<section class="pane pane-list" id="left-list">%s</section>
|
||||
<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 class="pane pane-list" id="right-list">%s</section>
|
||||
<aside class="pane pane-tree" id="right-folders">%s</aside>
|
||||
</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>`,
|
||||
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, `<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&kind=%s&value=%s&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&kind=%s&value=%s&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&kind=%s&value=%s&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&kind=%s&value=%s&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&kind=%s&value=%s&folder=%s&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&kind=%s&value=%s&folder=%s&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) {
|
||||
|
|
|
|||
9
frontend-js/dist/style.css
vendored
9
frontend-js/dist/style.css
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue