Mail-Graveyard/backend/00-router.go
2026-07-13 00:56:38 +02:00

1358 lines
52 KiB
Go

package backend
import (
"database/sql"
"errors"
"fmt"
"html"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
)
func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/", homeHandler)
mux.HandleFunc("/login", loginHandler)
mux.HandleFunc("/logout", logoutHandler)
mux.HandleFunc("/users", usersHandler)
mux.HandleFunc("/users/save", userSaveHandler)
mux.HandleFunc("/users/toggle-active", userToggleActiveHandler)
mux.HandleFunc("/users/delete", userDeleteHandler)
// Umzug (Konten pflegen + starten/beobachten)
mux.HandleFunc("/accounts", accountsHandler) // TODO Codex (02/07)
mux.HandleFunc("/accounts/save", accountSaveHandler) // TODO Codex
mux.HandleFunc("/accounts/delete", accountDeleteHandler) // TODO Codex
mux.HandleFunc("/accounts/test", accountTestHandler) // TODO Codex: Quelle+Ziel-Login pruefen
mux.HandleFunc("/archives", archivesHandler)
mux.HandleFunc("/archives/create", archiveCreateHandler)
mux.HandleFunc("/archives/delete", archiveDeleteHandler)
mux.HandleFunc("/transfer", transferHandler)
mux.HandleFunc("/transfer/", transferHandler)
mux.HandleFunc("/boxes/source", emailBoxHandler)
mux.HandleFunc("/boxes/target", emailBoxHandler)
mux.HandleFunc("/migrate/run", migrateRunHandler) // TODO Codex: RunMigration im Hintergrund
mux.HandleFunc("/migrate/status", migrateStatusHandler) // TODO Codex: jobs-Fortschritt (HTMX-Poll)
// Viewer (lokale mbox betrachten + weiterleiten)
mux.HandleFunc("/view", viewerHandler)
mux.HandleFunc("/view/source/select", sourceSelectHandler)
mux.HandleFunc("/view/message", messageHandler)
mux.HandleFunc("/view/target", targetMessageHandler)
mux.HandleFunc("/view/target/select", targetSelectHandler)
mux.HandleFunc("/view/manual-copy", manualCopyHandler)
mux.HandleFunc("/view/forward", forwardHandler)
}
// homeHandler rendert die Outlook-2013-Oberflaeche (Dreispalter). Der linke
// Baum, die Nachrichtenliste und der Lesebereich werden per HTMX nachgeladen.
func homeHandler(w http.ResponseWriter, r *http.Request) {
// "active" ist die CSS-Klasse fuer den hervorgehobenen Reiter. Wenn Codex
// weitere Seiten baut, jeweils den passenden Reiter aktiv setzen.
renderShell(w, "active", `<div class="ef-empty">Konto links waehlen oder unter „Postfaecher" anlegen.</div>`)
}
// renderShell ist das Grundgeruest im Outlook-2013-Look:
//
// [ Ribbon: Email-Box | Mail-Transfer | Einstellungen | Mail-Graveyard ]
// [ 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, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
<nav class="ribbon-tabs">
%s
%s
<a class="tab" href="/users">Einstellungen</a>
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item active" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<div class="three-pane">
<aside class="pane pane-tree" id="tree"
hx-get="/view" hx-trigger="load" hx-target="#tree" hx-swap="innerHTML">
<div class="pane-head">Archiv-Mailbox</div>
</aside>
<div class="pane-resizer" data-resize-index="0"></div>
<section class="pane pane-list" id="list">
<div class="pane-head">Nachrichten</div>
</section>
<div class="pane-resizer" data-resize-index="1"></div>
<section class="pane pane-read" id="read">
%s
</section>
</div>
<footer class="statusbar"><span id="mig-status"
hx-get="/migrate/status" hx-trigger="every 3s" hx-swap="innerHTML">bereit</span></footer>
</body></html>`, renderRibbonEmailBoxMenu("archive"), renderRibbonTransferMenu(""), renderInitialReadPane(readingPane))
}
func accountsHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
accounts, err := ListAccounts()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
editName := strings.TrimSpace(r.URL.Query().Get("edit"))
var edit Account
if editName != "" {
edit, err = GetAccount(editName)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
renderAccountsPage(w, accounts, edit, r.URL.Query().Get("msg"), r.URL.Query().Get("err"))
}
func accountSaveHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
redirectAccounts(w, r, "", err.Error())
return
}
a := accountFromForm(r)
if a.Name == "" || a.SrcHost == "" || a.SrcUser == "" || a.DstHost == "" || a.DstUser == "" {
redirectAccounts(w, r, a.Name, "Name, Quelle und Ziel sind Pflichtfelder.")
return
}
if existing, err := GetAccount(a.Name); err == nil {
if a.SrcPass == "" {
a.SrcPass = existing.SrcPass
}
if a.DstPass == "" {
a.DstPass = existing.DstPass
}
}
if err := SaveAccount(a); err != nil {
redirectAccounts(w, r, a.Name, err.Error())
return
}
redirectAccounts(w, r, a.Name, "Postfach gespeichert.")
}
func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
redirectAccounts(w, r, "", "Kein Postfach gewaehlt.")
return
}
if err := DeleteAccount(name); err != nil {
redirectAccounts(w, r, name, err.Error())
return
}
redirectAccounts(w, r, "", "Postfach geloescht.")
}
func accountTestHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
redirectAccounts(w, r, "", "Kein Postfach gewaehlt.")
return
}
if err := CheckAccount(name); err != nil {
redirectAccounts(w, r, name, "Test fehlgeschlagen: "+err.Error())
return
}
redirectAccounts(w, r, name, "Quelle und Ziel erfolgreich getestet.")
}
func archivesHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
accounts, err := ListAccounts()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
renderArchivesPage(w, archiveMailboxes(accounts), accounts, r.URL.Query().Get("msg"), r.URL.Query().Get("err"))
}
func transferHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
mode := strings.TrimPrefix(r.URL.Path, "/transfer/")
if mode == "" || mode == "/transfer" {
http.Redirect(w, r, "/transfer/postfach-archiv", http.StatusSeeOther)
return
}
accounts, err := ListAccounts()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
renderTransferPage(w, transferModeBySlug(mode), accounts, archiveMailboxes(accounts))
}
func emailBoxHandler(w http.ResponseWriter, r *http.Request) {
accounts, err := ListAccounts()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
kind := strings.TrimPrefix(r.URL.Path, "/boxes/")
switch kind {
case "source":
renderSourceEmailBoxPage(w, accounts)
case "target":
renderTargetEmailBoxPage(w, accounts)
default:
http.Redirect(w, r, "/view", http.StatusSeeOther)
}
}
func archiveCreateHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name, err := safeArchiveName(r.FormValue("archive_name"))
if err != nil {
redirectArchives(w, r, err.Error())
return
}
if err := os.MkdirAll(filepath.Join(Cfg.MboxRoot, name), 0o700); err != nil {
redirectArchives(w, r, err.Error())
return
}
redirectArchives(w, r, "Archiv-Mailbox angelegt.")
}
func archiveDeleteHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name, err := safeArchiveName(r.FormValue("archive_name"))
if err != nil {
redirectArchives(w, r, err.Error())
return
}
if archiveInUse(name) {
redirectArchives(w, r, "Archiv-Mailbox ist noch einem Postfach zugeordnet.")
return
}
path := filepath.Join(Cfg.MboxRoot, name)
entries, err := os.ReadDir(path)
if os.IsNotExist(err) {
redirectArchives(w, r, "Archiv-Mailbox existiert nicht.")
return
}
if err != nil {
redirectArchives(w, r, err.Error())
return
}
if len(entries) > 0 {
redirectArchives(w, r, "Archiv-Mailbox ist nicht leer und wurde nicht entfernt.")
return
}
if err := os.Remove(path); err != nil {
redirectArchives(w, r, err.Error())
return
}
redirectArchives(w, r, "Leere Archiv-Mailbox entfernt.")
}
func migrateRunHandler(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
name = "all"
}
go func() {
if err := RunMigration(name, false, 0); err != nil {
fmt.Printf("migration %s failed: %v\n", name, err)
}
}()
redirectAccounts(w, r, name, "Umzug gestartet.")
}
func migrateStatusHandler(w http.ResponseWriter, r *http.Request) {
type job struct {
Account string
Started string
Finished sql.NullString
Total int
Done int
Errors int
State string
}
var j job
err := DB.QueryRow(`SELECT a.name,j.started,j.finished,j.total,j.done,j.errors,j.state
FROM jobs j JOIN accounts a ON a.id=j.account_id
ORDER BY j.id DESC LIMIT 1`).Scan(&j.Account, &j.Started, &j.Finished, &j.Total, &j.Done, &j.Errors, &j.State)
if errors.Is(err, sql.ErrNoRows) {
fmt.Fprint(w, "bereit")
return
}
if err != nil {
fmt.Fprintf(w, `<span class="bad">%s</span>`, html.EscapeString(err.Error()))
return
}
label := "laeuft"
if j.State != "" {
label = j.State
}
fmt.Fprintf(w, `%s: %s %d/%d Fehler %d`, html.EscapeString(j.Account), html.EscapeString(label), j.Done, j.Total, j.Errors)
}
func usersHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
users, err := ListAppUsers()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
editName := strings.TrimSpace(r.URL.Query().Get("edit"))
var edit AppUser
if editName != "" {
edit, err = GetAppUser(editName)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
renderUsersPage(w, r, users, edit, r.URL.Query().Get("msg"), r.URL.Query().Get("err"))
}
func userSaveHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := strings.TrimSpace(r.FormValue("username"))
displayName := strings.TrimSpace(r.FormValue("display_name"))
password := r.FormValue("password")
role := strings.TrimSpace(r.FormValue("role"))
active := r.FormValue("active") == "1"
if username == "" {
redirectUsers(w, r, "", "Benutzername fehlt.")
return
}
existing, existingErr := GetAppUser(username)
if !IsAdmin(r) && existingErr == nil && existing.Role != roleUser {
redirectUsers(w, r, username, "Nur Admins duerfen Verwalter oder Admins bearbeiten.")
return
}
if !IsAdmin(r) {
role = roleUser
}
if errors.Is(existingErr, sql.ErrNoRows) && password == "" {
redirectUsers(w, r, username, "Passwort fuer neuen Benutzer fehlt.")
return
}
var hash string
if password != "" {
var err error
hash, err = HashPassword(password)
if err != nil {
redirectUsers(w, r, username, err.Error())
return
}
}
if err := SaveAppUser(username, displayName, hash, role, active); err != nil {
redirectUsers(w, r, username, err.Error())
return
}
redirectUsers(w, r, username, "Benutzer gespeichert.")
}
func userDeleteHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := strings.TrimSpace(r.FormValue("username"))
if username == "" {
redirectUsers(w, r, "", "Kein Benutzer gewaehlt.")
return
}
current := CurrentUser(r)
if strings.EqualFold(username, current.Username) {
redirectUsers(w, r, username, "Eigenen Benutzer nicht loeschen.")
return
}
target, err := GetAppUser(username)
if err != nil {
redirectUsers(w, r, username, err.Error())
return
}
if !IsAdmin(r) && target.Role != roleUser {
redirectUsers(w, r, username, "Nur Admins duerfen Verwalter oder Admins loeschen.")
return
}
if err := DeleteAppUser(username); err != nil {
redirectUsers(w, r, username, err.Error())
return
}
redirectUsers(w, r, "", "Benutzer geloescht.")
}
func userToggleActiveHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := strings.TrimSpace(r.FormValue("username"))
active := r.FormValue("active") == "1"
if username == "" {
redirectUsers(w, r, "", "Kein Benutzer gewaehlt.")
return
}
current := CurrentUser(r)
if strings.EqualFold(username, current.Username) && !active {
redirectUsers(w, r, username, "Eigenen Benutzer nicht sperren.")
return
}
target, err := GetAppUser(username)
if err != nil {
redirectUsers(w, r, username, err.Error())
return
}
if !IsAdmin(r) && target.Role != roleUser {
redirectUsers(w, r, username, "Nur Admins duerfen Verwalter oder Admins sperren.")
return
}
if err := SaveAppUser(username, target.DisplayName, "", target.Role, active); err != nil {
redirectUsers(w, r, username, err.Error())
return
}
if !active {
_, _ = DB.Exec(`DELETE FROM app_sessions WHERE user_id=?`, target.ID)
redirectUsers(w, r, username, "Benutzer gesperrt.")
return
}
redirectUsers(w, r, username, "Benutzer aktiviert.")
}
func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account, msg, errMsg string) {
if edit.Name == "" {
edit = Account{SrcPort: 993, SrcSecurity: "tls", SrcProto: "imap", DstPort: 993, DstSecurity: "tls", Active: true}
}
archives := archiveMailboxes(accounts)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Konten-Verwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
<nav class="ribbon-tabs">
%s
%s
<a class="tab" href="/users">Einstellungen</a>
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a>
<a class="backstage-item active" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
<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">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
if msg != "" {
fmt.Fprintf(w, `<div class="notice ok">%s</div>`, html.EscapeString(msg))
}
if errMsg != "" {
fmt.Fprintf(w, `<div class="notice bad">%s</div>`, html.EscapeString(errMsg))
}
fmt.Fprint(w, `<section class="account-layout"><div class="account-list"><div class="pane-head">Eingetragene Postfaecher</div>`)
renderAccountsTable(w, accounts, edit.Name)
fmt.Fprint(w, `</div><div class="account-form-panel"><div class="pane-head">Postfach eintragen</div>`)
renderAccountForm(w, edit, archives)
fmt.Fprint(w, `</div></section></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>`)
}
func renderArchivesPage(w http.ResponseWriter, archives []string, accounts []Account, msg, errMsg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Archiv-mbox Verwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
<nav class="ribbon-tabs">
%s
%s
<a class="tab" href="/users">Einstellungen</a>
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item active" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
<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 archive-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
if msg != "" {
fmt.Fprintf(w, `<div class="notice ok">%s</div>`, html.EscapeString(msg))
}
if errMsg != "" {
fmt.Fprintf(w, `<div class="notice bad">%s</div>`, html.EscapeString(errMsg))
}
fmt.Fprint(w, `<section class="archive-page-layout"><div class="archive-list-panel"><div class="pane-head">Archiv-mboxen</div>`)
renderArchiveList(w, archives, accounts)
fmt.Fprint(w, `</div><div class="archive-form-panel"><div class="pane-head">Archiv-mbox anlegen</div>`)
renderArchiveForm(w)
fmt.Fprint(w, `</div></section></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>`)
}
func renderUsersPage(w http.ResponseWriter, r *http.Request, users []AppUser, edit AppUser, msg, errMsg string) {
if edit.Username == "" {
edit = AppUser{Role: roleUser, Active: true}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Benutzerverwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
<nav class="ribbon-tabs">
%s
%s
<a class="tab active" href="/users">Einstellungen</a>
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item active" href="/users">Benutzerverwaltung</a>
<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 users-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
if msg != "" {
fmt.Fprintf(w, `<div class="notice ok">%s</div>`, html.EscapeString(msg))
}
if errMsg != "" {
fmt.Fprintf(w, `<div class="notice bad">%s</div>`, html.EscapeString(errMsg))
}
fmt.Fprint(w, `<section class="account-layout"><div class="account-list"><div class="pane-head">Benutzer</div>`)
renderUsersTable(w, users, edit.Username)
fmt.Fprint(w, `</div><div class="account-form-panel"><div class="pane-head">Benutzer eintragen</div>`)
renderUserForm(w, r, edit)
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span>angemeldet als `)
fmt.Fprint(w, html.EscapeString(userDisplayLabel(CurrentUser(r))+" / "+CurrentUser(r).Role))
fmt.Fprint(w, `</span></footer></body></html>`)
}
type transferMode struct {
Slug string
Title string
LeftTitle string
RightTitle string
LeftKind string
RightKind string
}
func transferModeBySlug(slug string) transferMode {
modes := []transferMode{
{Slug: "postfach-archiv", Title: "Postfach <--> Archiv-MBox", LeftTitle: "Postfach", RightTitle: "Archiv-MBox", LeftKind: "source", RightKind: "archive"},
{Slug: "archiv-postfach", Title: "Archiv-MBox <--> Postfach", LeftTitle: "Archiv-MBox", RightTitle: "Postfach", LeftKind: "archive", RightKind: "target"},
{Slug: "postfach-postfach", Title: "Postfach <--> Postfach", LeftTitle: "Quell-Postfach", RightTitle: "Ziel-Postfach", LeftKind: "source", RightKind: "target"},
}
for _, mode := range modes {
if mode.Slug == slug {
return mode
}
}
return modes[0]
}
func renderTransferPage(w http.ResponseWriter, mode transferMode, accounts []Account, archives []string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
<nav class="ribbon-tabs">
%s
%s
<a class="tab" href="/users">Einstellungen</a>
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item active" href="/transfer/postfach-archiv">Mail-Transfer</a>
%s
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
<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>
<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), transferMenuItems(mode.Slug),
html.EscapeString(mode.LeftTitle), renderTransferPicker(mode.LeftKind, accounts, archives),
html.EscapeString(mode.RightTitle), renderTransferPicker(mode.RightKind, accounts, archives))
}
func renderSourceEmailBoxPage(w http.ResponseWriter, accounts []Account) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quell-Postfach - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
<nav class="ribbon-tabs">
%s
%s
<a class="tab" href="/users">Einstellungen</a>
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<div class="three-pane email-box-view">
<aside class="pane pane-tree">%s</aside>
<div class="pane-resizer" data-resize-index="0"></div>
<section class="pane pane-list"><div class="pane-head">Nachrichten</div><div class="ef-empty">Quell-Postfach waehlen.</div></section>
<div class="pane-resizer" data-resize-index="1"></div>
<section class="pane pane-read"><div class="ef-empty">Nachricht waehlen.</div></section>
</div>
<footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`,
renderRibbonEmailBoxMenu("source"), renderRibbonTransferMenu(""), renderEmailBoxSourcePane(accounts))
}
func renderTargetEmailBoxPage(w http.ResponseWriter, accounts []Account) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ziel-Postfach - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-17" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
<nav class="ribbon-tabs">
%s
%s
<a class="tab" href="/users">Einstellungen</a>
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<div class="three-pane email-box-view email-box-target-view">
<section class="pane pane-list"><div class="pane-head">Nachrichten</div><div class="ef-empty">Ziel-Postfach rechts waehlen.</div></section>
<div class="pane-resizer" data-resize-index="0"></div>
<section class="pane pane-read"><div class="ef-empty">Nachricht waehlen.</div></section>
<div class="pane-resizer" data-resize-index="1"></div>
<aside class="pane pane-tree">%s</aside>
</div>
<footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`,
renderRibbonEmailBoxMenu("target"), renderRibbonTransferMenu(""), renderEmailBoxTargetPane(accounts))
}
func renderEmailBoxSourcePane(accounts []Account) string {
var b strings.Builder
b.WriteString(`<div class="pane-head archive-head"><span>Quell-Postfach</span><form class="archive-select-form"><select class="archive-select" name="source">`)
sourceAccounts := accountsBySource(accounts)
if len(sourceAccounts) == 0 {
b.WriteString(`<option>Keine Quellen</option>`)
}
for _, a := range sourceAccounts {
label := fmt.Sprintf("%s (%s)", a.SrcUser, strings.ToUpper(accountProto(a)))
fmt.Fprintf(&b, `<option value="%s">%s</option>`, html.EscapeString(a.Name), html.EscapeString(label))
}
b.WriteString(`</select></form></div>`)
if len(sourceAccounts) == 0 {
b.WriteString(`<div class="ef-empty">Keine Quell-Postfaecher eingetragen.</div>`)
return b.String()
}
for _, a := range sourceAccounts {
fmt.Fprintf(&b, `<div class="tree-node account">%s</div>`, html.EscapeString(a.SrcUser))
fmt.Fprintf(&b, `<div class="tree-node folder"><span class="tree-label">%s</span><span class="tree-count">%s</span></div>`,
html.EscapeString(strings.ToUpper(accountProto(a))), html.EscapeString(a.SrcHost))
}
return b.String()
}
func renderEmailBoxTargetPane(accounts []Account) string {
var b strings.Builder
b.WriteString(`<div class="pane-head archive-head"><span>Ziel-Postfach</span><form class="archive-select-form"><select class="archive-select" name="target">`)
targets := uniqueAccountValues(accounts, "target")
if len(targets) == 0 {
b.WriteString(`<option>Keine Ziele</option>`)
}
for _, target := range targets {
fmt.Fprintf(&b, `<option value="%s">%s</option>`, html.EscapeString(target), html.EscapeString(target))
}
b.WriteString(`</select></form></div>`)
if len(targets) == 0 {
b.WriteString(`<div class="ef-empty">Keine Ziel-Postfaecher eingetragen.</div>`)
return b.String()
}
for _, target := range targets {
fmt.Fprintf(&b, `<div class="tree-node account">%s</div>`, html.EscapeString(target))
}
return b.String()
}
func accountsBySource(accounts []Account) []Account {
out := append([]Account{}, accounts...)
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
left := strings.ToLower(out[i].SrcUser)
right := strings.ToLower(out[j].SrcUser)
if right < left {
out[i], out[j] = out[j], out[i]
}
}
}
return out
}
func accountProto(a Account) string {
if strings.TrimSpace(a.SrcProto) != "" {
return a.SrcProto
}
return "imap"
}
func transferMenuItems(active string) string {
items := []transferMode{
{Slug: "postfach-archiv", Title: "Postfach <--> Archiv-MBox"},
{Slug: "archiv-postfach", Title: "Archiv-MBox <--> Postfach"},
{Slug: "postfach-postfach", Title: "Postfach <--> Postfach"},
}
var b strings.Builder
for _, item := range items {
class := "backstage-item transfer-sub"
if item.Slug == active {
class += " active"
}
fmt.Fprintf(&b, `<a class="%s" href="/transfer/%s">%s</a>`, class, item.Slug, html.EscapeString(item.Title))
}
return b.String()
}
func renderRibbonEmailBoxMenu(active string) string {
items := []transferMode{
{Slug: "archive", Title: "Archiv-Postfach"},
{Slug: "source", Title: "Quell-Postfach"},
{Slug: "target", Title: "Ziel-Postfach"},
}
paths := map[string]string{
"archive": "/view",
"source": "/boxes/source",
"target": "/boxes/target",
}
buttonClass := "tab ribbon-menu-button"
if active != "" {
buttonClass += " active"
}
var b strings.Builder
fmt.Fprintf(&b, `<div class="ribbon-menu"><button class="%s" type="button" aria-expanded="false" data-ribbon-menu-toggle>Email-Box</button><div class="ribbon-dropdown">`, buttonClass)
for _, item := range items {
class := "ribbon-dropdown-item"
if item.Slug == active {
class += " active"
}
fmt.Fprintf(&b, `<a class="%s" href="%s">%s</a>`, class, paths[item.Slug], html.EscapeString(item.Title))
}
b.WriteString(`</div></div>`)
return b.String()
}
func renderRibbonTransferMenu(active string) string {
items := []transferMode{
{Slug: "postfach-archiv", Title: "Postfach <--> Archiv-MBox"},
{Slug: "archiv-postfach", Title: "Archiv-MBox <--> Postfach"},
{Slug: "postfach-postfach", Title: "Postfach <--> Postfach"},
}
buttonClass := "tab ribbon-menu-button"
if active != "" {
buttonClass += " active"
}
var b strings.Builder
fmt.Fprintf(&b, `<div class="ribbon-menu"><button class="%s" type="button" aria-expanded="false" data-ribbon-menu-toggle>Mail-Transfer</button><div class="ribbon-dropdown">`, buttonClass)
for _, item := range items {
class := "ribbon-dropdown-item"
if item.Slug == active {
class += " active"
}
fmt.Fprintf(&b, `<a class="%s" href="/transfer/%s">%s</a>`, class, item.Slug, html.EscapeString(item.Title))
}
b.WriteString(`</div></div>`)
return b.String()
}
func renderTransferPicker(kind string, accounts []Account, archives []string) string {
var b strings.Builder
b.WriteString(`<div class="transfer-picker">`)
switch kind {
case "archive":
fmt.Fprintf(&b, `<label class="label">Archiv-MBox%s</label>`, transferSelect("archive", archives))
case "target":
fmt.Fprintf(&b, `<label class="label">Ziel-Postfach%s</label>`, transferSelect("target", uniqueAccountValues(accounts, "target")))
default:
fmt.Fprintf(&b, `<label class="label">Quell-Postfach%s</label>`, transferSelect("source", uniqueAccountValues(accounts, "source")))
}
b.WriteString(`</div>`)
return b.String()
}
func transferSelect(name string, values []string) string {
if len(values) == 0 {
return `<select disabled><option>Keine Eintraege</option></select>`
}
var b strings.Builder
fmt.Fprintf(&b, `<select name="%s">`, name)
for _, v := range values {
fmt.Fprintf(&b, `<option value="%s">%s</option>`, html.EscapeString(v), html.EscapeString(v))
}
b.WriteString(`</select>`)
return b.String()
}
func uniqueAccountValues(accounts []Account, field string) []string {
seen := map[string]bool{}
var out []string
for _, a := range accounts {
value := a.SrcUser
if field == "target" {
value = a.DstUser
}
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sortStrings(out)
return out
}
func renderAccountsTable(w http.ResponseWriter, accounts []Account, editName string) {
if len(accounts) == 0 {
fmt.Fprint(w, `<div class="ef-empty">Noch keine Postfaecher eingetragen.</div>`)
return
}
fmt.Fprint(w, `<table class="account-table"><thead><tr><th>Name</th><th>Original</th><th>Archiv-mbox</th><th>Ziel</th><th>Status</th><th class="actions-col"></th></tr></thead><tbody>`)
for _, a := range accounts {
activeClass := ""
if a.Name == editName {
activeClass = ` class="selected"`
}
status := "aktiv"
if !a.Active {
status = "inaktiv"
}
fmt.Fprintf(w, `<tr%s><td><a href="/accounts?edit=%s">%s</a></td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td class="row-actions">
<form method="post" action="/accounts/test"><input type="hidden" name="name" value="%s"><button class="btn compact secondary" type="submit">Test</button></form>
<form method="post" action="/migrate/run"><input type="hidden" name="name" value="%s"><button class="btn compact secondary" type="submit">Umzug</button></form>
<form method="post" action="/accounts/delete"><input type="hidden" name="name" value="%s"><button class="btn compact secondary danger" type="submit">Loeschen</button></form>
</td></tr>`,
activeClass, urlQuery(a.Name), html.EscapeString(a.Name), html.EscapeString(a.SrcUser),
html.EscapeString(accountMboxDir(a)), html.EscapeString(a.DstUser), status,
html.EscapeString(a.Name), html.EscapeString(a.Name), html.EscapeString(a.Name))
}
fmt.Fprint(w, `</tbody></table>`)
}
func renderUsersTable(w http.ResponseWriter, users []AppUser, editName string) {
if len(users) == 0 {
fmt.Fprint(w, `<div class="ef-empty">Noch keine Benutzer eingetragen.</div>`)
return
}
fmt.Fprint(w, `<table class="account-table user-table"><thead><tr><th>Name</th><th>Login/E-Mail</th><th>Rolle</th><th>Status</th><th class="actions-col"></th></tr></thead><tbody>`)
for _, u := range users {
activeClass := ""
if u.Username == editName {
activeClass = ` class="selected"`
}
status := "aktiv"
if !u.Active {
status = "gesperrt"
}
nextActive := "0"
toggleLabel := "Sperren"
toggleClass := "btn compact secondary"
if !u.Active {
nextActive = "1"
toggleLabel = "Aktivieren"
}
editButton := fmt.Sprintf(`<a class="btn compact secondary" href="/users?edit=%s">Bearbeiten</a>`, urlQuery(u.Username))
toggleButton := fmt.Sprintf(`<form method="post" action="/users/toggle-active"><input type="hidden" name="username" value="%s"><input type="hidden" name="active" value="%s"><button class="%s" type="submit">%s</button></form>`,
html.EscapeString(u.Username), nextActive, toggleClass, toggleLabel)
deleteButton := fmt.Sprintf(`<form method="post" action="/users/delete"><input type="hidden" name="username" value="%s"><button class="btn compact secondary danger" type="submit">Loeschen</button></form>`, html.EscapeString(u.Username))
fmt.Fprintf(w, `<tr%s><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td class="row-actions">%s%s%s</td></tr>`,
activeClass, html.EscapeString(userDisplayName(u)), html.EscapeString(u.Username), html.EscapeString(u.Role), status, editButton, toggleButton, deleteButton)
}
fmt.Fprint(w, `</tbody></table>`)
}
func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) {
roleField := roleSelect(u.Role, IsAdmin(r))
if !IsAdmin(r) {
roleField = `<input type="hidden" name="role" value="user"><input class="input" value="user" disabled>`
}
fmt.Fprintf(w, `<form class="account-form user-form" method="post" action="/users/save">
<div class="form-section">
<label class="label">Login/E-Mail<input class="input" name="username" value="%s" required></label>
<label class="check-row"><input type="checkbox" name="active" value="1" %s> Aktiv</label>
</div>
<div class="form-columns">
<fieldset><legend>Zugang</legend>
<label class="label">Anzeigename<input class="input" name="display_name" value="%s" placeholder="z.B. Max Mustermann"></label>
<div class="form-hint">%s</div>
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="new-password" placeholder="%s"></label>
<label class="label">Rolle%s</label>
</fieldset>
</div>
<div class="form-actions">
<button class="btn" type="submit">Speichern</button>
<a class="btn secondary" href="/users">Neu</a>
</div>
</form>`,
html.EscapeString(u.Username), checked(u.Active), html.EscapeString(u.DisplayName), passwordHint(u.Username), passwordPlaceholder(u.Username), roleField)
}
func userDisplayName(u AppUser) string {
name := strings.TrimSpace(u.DisplayName)
if name != "" {
return name
}
return u.Username
}
func userDisplayLabel(u AppUser) string {
name := strings.TrimSpace(u.DisplayName)
if name != "" {
return name + " <" + u.Username + ">"
}
return u.Username
}
func roleSelect(current string, admin bool) string {
if current == "" {
current = roleUser
}
values := []string{roleUser}
if admin {
values = []string{roleUser, roleManager, roleAdmin}
}
return selectHTML("role", current, values)
}
func passwordPlaceholder(username string) string {
if username == "" {
return "Pflicht fuer neuen Benutzer"
}
return "leer lassen = behalten"
}
func passwordHint(username string) string {
if username == "" {
return "Neuer Benutzer: Passwort eintragen."
}
return "Bearbeiten: Neues Passwort eintragen oder leer lassen, um das bestehende Passwort zu behalten."
}
func renderArchiveList(w http.ResponseWriter, archives []string, accounts []Account) {
if len(archives) == 0 {
fmt.Fprint(w, `<div class="ef-empty">Noch keine Archiv-Mailbox angelegt.</div>`)
return
}
fmt.Fprint(w, `<table class="archive-table"><thead><tr><th>Archiv-Mailbox</th><th>Zuordnung</th><th></th></tr></thead><tbody>`)
for _, archive := range archives {
uses := archiveUses(archive, accounts)
action := `<span class="muted">belegt</span>`
if uses == "" {
action = fmt.Sprintf(`<form method="post" action="/archives/delete"><input type="hidden" name="archive_name" value="%s"><button class="btn compact secondary danger" type="submit">Entfernen</button></form>`, html.EscapeString(archive))
uses = "frei"
}
fmt.Fprintf(w, `<tr><td>%s</td><td>%s</td><td class="archive-action">%s</td></tr>`, html.EscapeString(archive), html.EscapeString(uses), action)
}
fmt.Fprint(w, `</tbody></table>`)
}
func renderArchiveForm(w http.ResponseWriter) {
fmt.Fprint(w, `<form class="archive-create-panel" method="post" action="/archives/create">
<label class="label">Neue Archiv-Mailbox<input class="input" name="archive_name" placeholder="z.B. kunde-altarchiv"></label>
<button class="btn" type="submit">Erstellen</button>
</form>
<div class="archive-help">
<div class="archive-help-title">Schutzlogik</div>
<p>Archiv-mboxen werden als lokale Ordner unter dem Backup-Root angelegt.</p>
<p>Entfernen ist nur moeglich, wenn die Mailbox keinem Konto zugeordnet und leer ist.</p>
</div>`)
}
func renderAccountForm(w http.ResponseWriter, a Account, archives []string) {
fmt.Fprintf(w, `<form class="account-form" method="post" action="/accounts/save">
<div class="form-section">
<label class="label">Kontoname<input class="input" name="name" value="%s" required></label>
<label class="check-row"><input type="checkbox" name="active" value="1" %s> Aktiv</label>
</div>
<div class="form-columns">
<fieldset><legend>Original</legend>
<label class="label">Server<input class="input" name="src_host" value="%s" required></label>
<div class="field-row">
<label class="label">Port<input class="input" name="src_port" type="number" min="1" max="65535" value="%d"></label>
<label class="label">Sicherheit%s</label>
</div>
<label class="label">Benutzer<input class="input" name="src_user" value="%s" required></label>
<label class="label">Passwort<input class="input" name="src_pass" type="password" autocomplete="new-password" placeholder="leer lassen = behalten"></label>
<label class="label">Protokoll%s</label>
<label class="check-row"><input type="checkbox" name="src_insecure" value="1" %s> Zertifikat nicht pruefen</label>
</fieldset>
<fieldset><legend>Archiv-mbox</legend>
<label class="label">Lokale Archiv-Mailbox%s</label>
</fieldset>
<fieldset><legend>Ziel</legend>
<label class="label">Server<input class="input" name="dst_host" value="%s" required></label>
<div class="field-row">
<label class="label">Port<input class="input" name="dst_port" type="number" min="1" max="65535" value="%d"></label>
<label class="label">Sicherheit%s</label>
</div>
<label class="label">Benutzer<input class="input" name="dst_user" value="%s" required></label>
<label class="label">Passwort<input class="input" name="dst_pass" type="password" autocomplete="new-password" placeholder="leer lassen = behalten"></label>
<label class="check-row"><input type="checkbox" name="dst_insecure" value="1" %s> Zertifikat nicht pruefen</label>
</fieldset>
</div>
<div class="form-actions">
<button class="btn" type="submit">Speichern</button>
<a class="btn secondary" href="/accounts">Neu</a>
</div>
</form>`,
html.EscapeString(a.Name), checked(a.Active),
html.EscapeString(a.SrcHost), a.SrcPort, securitySelect("src_security", a.SrcSecurity),
html.EscapeString(a.SrcUser), protoSelect(a.SrcProto), checked(a.SrcInsecure),
archiveSelect(accountMboxDir(a), archives),
html.EscapeString(a.DstHost), a.DstPort, securitySelect("dst_security", a.DstSecurity),
html.EscapeString(a.DstUser), checked(a.DstInsecure))
}
func accountFromForm(r *http.Request) Account {
return Account{
Name: strings.TrimSpace(r.FormValue("name")),
SrcHost: strings.TrimSpace(r.FormValue("src_host")),
SrcPort: parsePort(r.FormValue("src_port")),
SrcSecurity: strings.TrimSpace(r.FormValue("src_security")),
SrcInsecure: r.FormValue("src_insecure") == "1",
SrcUser: strings.TrimSpace(r.FormValue("src_user")),
SrcPass: r.FormValue("src_pass"),
SrcProto: strings.TrimSpace(r.FormValue("src_proto")),
DstHost: strings.TrimSpace(r.FormValue("dst_host")),
DstPort: parsePort(r.FormValue("dst_port")),
DstSecurity: strings.TrimSpace(r.FormValue("dst_security")),
DstInsecure: r.FormValue("dst_insecure") == "1",
DstUser: strings.TrimSpace(r.FormValue("dst_user")),
DstPass: r.FormValue("dst_pass"),
MboxDir: strings.TrimSpace(r.FormValue("mbox_dir")),
Active: r.FormValue("active") == "1",
}
}
func securitySelect(name, current string) string {
if current == "" {
current = "tls"
}
return selectHTML(name, current, []string{"tls", "starttls", "none"})
}
func protoSelect(current string) string {
if current == "" {
current = "imap"
}
return selectHTML("src_proto", current, []string{"imap", "pop3"})
}
func archiveSelect(current string, archives []string) string {
values := append([]string{}, archives...)
found := false
for _, archive := range values {
if archive == current {
found = true
break
}
}
if current != "" && !found {
values = append([]string{current}, values...)
}
if len(values) == 0 {
return `<input class="input" name="mbox_dir" placeholder="erst Archiv-Mailbox erstellen">`
}
var b strings.Builder
b.WriteString(`<select name="mbox_dir">`)
for _, v := range values {
sel := ""
if v == current {
sel = ` selected`
}
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, html.EscapeString(v), sel, html.EscapeString(v))
}
b.WriteString(`</select>`)
return b.String()
}
func selectHTML(name, current string, values []string) string {
var b strings.Builder
fmt.Fprintf(&b, `<select name="%s">`, name)
for _, v := range values {
sel := ""
if v == current {
sel = ` selected`
}
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, v, sel, v)
}
b.WriteString(`</select>`)
return b.String()
}
func checked(v bool) string {
if v {
return "checked"
}
return ""
}
func parsePort(value string) int {
port, _ := strconv.Atoi(strings.TrimSpace(value))
return port
}
func archiveMailboxes(accounts []Account) []string {
seen := map[string]bool{}
for _, a := range accounts {
name := accountMboxDir(a)
if name != "" {
seen[name] = true
}
}
entries, err := os.ReadDir(Cfg.MboxRoot)
if err == nil {
for _, entry := range entries {
if entry.IsDir() {
seen[entry.Name()] = true
}
}
}
out := make([]string, 0, len(seen))
for name := range seen {
out = append(out, name)
}
sortStrings(out)
return out
}
func archiveUses(archive string, accounts []Account) string {
var uses []string
for _, a := range accounts {
if accountMboxDir(a) == archive {
uses = append(uses, a.Name)
}
}
return strings.Join(uses, ", ")
}
func archiveInUse(archive string) bool {
accounts, err := ListAccounts()
if err != nil {
return true
}
return archiveUses(archive, accounts) != ""
}
func safeArchiveName(value string) (string, error) {
name := strings.TrimSpace(value)
if name == "" {
return "", fmt.Errorf("Name der Archiv-Mailbox fehlt.")
}
if name == "." || name == ".." || strings.ContainsAny(name, `/\:`) || filepath.Clean(name) != name {
return "", fmt.Errorf("Ungueltiger Archiv-Mailbox-Name.")
}
return name, nil
}
func sortStrings(values []string) {
for i := 0; i < len(values); i++ {
for j := i + 1; j < len(values); j++ {
if strings.ToLower(values[j]) < strings.ToLower(values[i]) {
values[i], values[j] = values[j], values[i]
}
}
}
}
func redirectAccounts(w http.ResponseWriter, r *http.Request, edit, msg string) {
key := "msg"
if strings.Contains(strings.ToLower(msg), "fehl") || strings.Contains(strings.ToLower(msg), "pflicht") || strings.Contains(strings.ToLower(msg), "error") {
key = "err"
}
target := "/accounts?" + key + "=" + urlQuery(msg)
if edit != "" {
target += "&edit=" + urlQuery(edit)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func redirectArchives(w http.ResponseWriter, r *http.Request, msg string) {
key := "msg"
if strings.Contains(strings.ToLower(msg), "fehl") || strings.Contains(strings.ToLower(msg), "ungueltig") || strings.Contains(strings.ToLower(msg), "nicht") || strings.Contains(strings.ToLower(msg), "error") {
key = "err"
}
http.Redirect(w, r, "/archives?"+key+"="+urlQuery(msg), http.StatusSeeOther)
}
func redirectUsers(w http.ResponseWriter, r *http.Request, edit, msg string) {
key := "msg"
if strings.Contains(strings.ToLower(msg), "fehl") || strings.Contains(strings.ToLower(msg), "pflicht") || strings.Contains(strings.ToLower(msg), "nicht") || strings.Contains(strings.ToLower(msg), "error") {
key = "err"
}
target := "/users?" + key + "=" + urlQuery(msg)
if edit != "" {
target += "&edit=" + urlQuery(edit)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func urlQuery(value string) string {
return url.QueryEscape(value)
}