393 lines
15 KiB
Go
393 lines
15 KiB
Go
package backend
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
func RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/", homeHandler)
|
|
mux.HandleFunc("/login", loginHandler)
|
|
|
|
// 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("/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: Archiv-Boxen | Import/Export | Postfach-Verwaltung | 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-4">
|
|
<script src="/static/htmx.min.js"></script>
|
|
<script src="/static/app.js?v=20260712-4" defer></script></head>
|
|
<body class="ol2013">
|
|
<header class="ribbon">
|
|
<nav class="ribbon-tabs">
|
|
<a class="tab %s" 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" 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>
|
|
<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>`, active, renderInitialReadPane(readingPane))
|
|
}
|
|
|
|
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 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-4">
|
|
<script src="/static/htmx.min.js"></script>
|
|
<script src="/static/app.js?v=20260712-4" 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 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(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)
|
|
}
|