Build mailbox management page
This commit is contained in:
parent
cff776dc8c
commit
c87880c544
3 changed files with 420 additions and 12 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue