Build mailbox management page

This commit is contained in:
DonVoo 2026-07-12 17:38:36 +02:00
parent cff776dc8c
commit c87880c544
3 changed files with 420 additions and 12 deletions

View file

@ -1,8 +1,14 @@
package backend
import (
"database/sql"
"errors"
"fmt"
"html"
"net/http"
"net/url"
"strconv"
"strings"
)
func RegisterRoutes(mux *http.ServeMux) {
@ -44,9 +50,9 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
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-2">
<link rel="stylesheet" href="/static/style.css?v=20260712-3">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-2" defer></script></head>
<script src="/static/app.js?v=20260712-3" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<nav class="ribbon-tabs">
@ -77,11 +83,311 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
</body></html>`, active, renderInitialReadPane(readingPane))
}
// --- Stubs fuer die noch nicht gebauten Handler (TODO Codex) ---
func accountsHandler(w http.ResponseWriter, r *http.Request) {
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 accountsHandler(w http.ResponseWriter, r *http.Request) {}
func accountSaveHandler(w http.ResponseWriter, r *http.Request) {}
func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {}
func accountTestHandler(w http.ResponseWriter, r *http.Request) {}
func migrateRunHandler(w http.ResponseWriter, r *http.Request) {}
func migrateStatusHandler(w http.ResponseWriter, r *http.Request) {}
func accountSaveHandler(w http.ResponseWriter, r *http.Request) {
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 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 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 migrateRunHandler(w http.ResponseWriter, r *http.Request) {
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 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}
}
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>Postfach-Verwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-3">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-3" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<nav class="ribbon-tabs">
<a class="tab" href="/view"><span class="mail-logo" aria-hidden="true"></span><span>Archiv-Boxen</span></a>
<a class="tab" href="/view">Import/Export</a>
<a class="tab active" href="/accounts">Postfach-Verwaltung</a>
<a class="tab" href="/view">Mail-Transfer</a>
<a class="tab" href="/accounts">Einstellungen</a>
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<main class="accounts-page">`)
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)
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 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>Quelle</th><th>Ziel</th><th>Archiv</th><th>Status</th><th></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(a.DstUser), html.EscapeString(a.MboxDir), status,
html.EscapeString(a.Name), html.EscapeString(a.Name), html.EscapeString(a.Name))
}
fmt.Fprint(w, `</tbody></table>`)
}
func renderAccountForm(w http.ResponseWriter, a Account) {
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>Quelle</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>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="label">Archiv-Mailbox<input class="input" name="mbox_dir" value="%s" placeholder="leer = Kontoname"></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),
html.EscapeString(a.DstHost), a.DstPort, securitySelect("dst_security", a.DstSecurity),
html.EscapeString(a.DstUser), html.EscapeString(a.MboxDir), 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 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 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 urlQuery(value string) string {
return url.QueryEscape(value)
}

View file

@ -240,9 +240,6 @@ func normalizeAccount(a *Account) {
if a.MboxDir == "" {
a.MboxDir = a.Name
}
if !a.Active {
a.Active = true
}
}
func boolInt(v bool) int {

View file

@ -270,6 +270,105 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 16px; }
.label { display: block; font-size: 12px; color: var(--ol-muted); margin-bottom: 3px; }
/* Postfach-Verwaltung */
.accounts-page {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
background: #fff;
}
.notice {
padding: 8px 16px;
border-bottom: 1px solid var(--ol-border);
background: #f8fbfd;
}
.account-layout {
display: grid;
grid-template-columns: minmax(520px, 1fr) minmax(460px, 560px);
min-height: 100%;
}
.account-list,
.account-form-panel {
min-width: 0;
border-right: 1px solid var(--ol-border);
background: #fff;
}
.account-form-panel { border-right: 0; }
.account-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.account-table th,
.account-table td {
padding: 8px 10px;
border-bottom: 1px solid #ececec;
text-align: left;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-table th {
color: var(--ol-muted);
font-weight: 600;
background: #fafafa;
}
.account-table tr.selected { background: var(--ol-sel); }
.account-table a { color: var(--ol-blue-dark); font-weight: 600; text-decoration: none; }
.row-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.row-actions form { margin: 0; }
.account-form {
padding: 14px;
}
.form-section {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: end;
margin-bottom: 14px;
}
.form-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.account-form fieldset {
border: 1px solid var(--ol-border);
padding: 12px;
margin: 0;
min-width: 0;
}
.account-form legend {
color: var(--ol-blue-dark);
font-weight: 700;
padding: 0 4px;
}
.field-row {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px;
}
.check-row {
display: flex;
gap: 7px;
align-items: center;
color: var(--ol-text);
min-height: 28px;
}
.check-row input { width: auto; }
.form-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.form-actions a { text-decoration: none; }
.btn.danger { color: #8a1f11; }
/* Statusleiste */
.statusbar {
flex: 0 0 auto;
@ -302,4 +401,10 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
.pane-resizer { display: none; }
.pane-target { grid-column: auto; }
.searchbar { top: 30px; }
.account-layout,
.form-columns,
.form-section {
grid-template-columns: 1fr;
}
.account-table { min-width: 760px; }
}