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("/password/forgot", forgotPasswordHandler) mux.HandleFunc("/password/reset", resetPasswordHandler) 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", `
Konto links waehlen oder unter „Postfaecher" anlegen.
`) } // 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, ` Mail-Graveyard
Mail-Graveyard
Nachrichten
%s
`, 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, `%s`, 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 } oldUsername := strings.TrimSpace(r.FormValue("old_username")) 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 } lookupUsername := username if oldUsername != "" { lookupUsername = oldUsername } existing, existingErr := GetAppUser(lookupUsername) if !IsAdmin(r) && existingErr == nil && existing.Role != roleUser { redirectUsers(w, r, oldUsername, "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 := UpdateAppUser(oldUsername, username, displayName, hash, role, active); err != nil { redirectUsers(w, r, oldUsername, 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, ` Konten-Verwaltung - Mail-Graveyard
Mail-Graveyard
`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu("")) if msg != "" { fmt.Fprintf(w, `
%s
`, html.EscapeString(msg)) } if errMsg != "" { fmt.Fprintf(w, `
%s
`, html.EscapeString(errMsg)) } fmt.Fprint(w, `
`) } 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, ` Archiv-mbox Verwaltung - Mail-Graveyard
Mail-Graveyard
`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu("")) if msg != "" { fmt.Fprintf(w, `
%s
`, html.EscapeString(msg)) } if errMsg != "" { fmt.Fprintf(w, `
%s
`, html.EscapeString(errMsg)) } fmt.Fprint(w, `
Archiv-mboxen
`) renderArchiveList(w, archives, accounts) fmt.Fprint(w, `
Archiv-mbox anlegen
`) renderArchiveForm(w) fmt.Fprint(w, `
`) } 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, ` Benutzerverwaltung - Mail-Graveyard
Mail-Graveyard
`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu("")) if msg != "" { fmt.Fprintf(w, `
%s
`, html.EscapeString(msg)) } if errMsg != "" { fmt.Fprintf(w, `
%s
`, html.EscapeString(errMsg)) } fmt.Fprint(w, `
`) } 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, ` %s - Mail-Graveyard
Mail-Graveyard
%s
%s
%s
%s
`, 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, ` Quell-Postfach - Mail-Graveyard
Mail-Graveyard
Nachrichten
Quell-Postfach waehlen.
Nachricht waehlen.
`, 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, ` Ziel-Postfach - Mail-Graveyard
Mail-Graveyard
Nachrichten
Ziel-Postfach rechts waehlen.
Nachricht waehlen.
`, renderRibbonEmailBoxMenu("target"), renderRibbonTransferMenu(""), renderEmailBoxTargetPane(accounts)) } func renderEmailBoxSourcePane(accounts []Account) string { var b strings.Builder b.WriteString(`
Quell-Postfach
`) if len(sourceAccounts) == 0 { b.WriteString(`
Keine Quell-Postfaecher eingetragen.
`) return b.String() } for _, a := range sourceAccounts { fmt.Fprintf(&b, `
%s
`, html.EscapeString(a.SrcUser)) fmt.Fprintf(&b, `
%s%s
`, html.EscapeString(strings.ToUpper(accountProto(a))), html.EscapeString(a.SrcHost)) } return b.String() } func renderEmailBoxTargetPane(accounts []Account) string { var b strings.Builder b.WriteString(`
Ziel-Postfach
`) if len(targets) == 0 { b.WriteString(`
Keine Ziel-Postfaecher eingetragen.
`) return b.String() } for _, target := range targets { fmt.Fprintf(&b, `
%s
`, 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, `%s`, 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, `
`, buttonClass) for _, item := range items { class := "ribbon-dropdown-item" if item.Slug == active { class += " active" } fmt.Fprintf(&b, `%s`, class, paths[item.Slug], html.EscapeString(item.Title)) } b.WriteString(`
`) 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, `
`, buttonClass) for _, item := range items { class := "ribbon-dropdown-item" if item.Slug == active { class += " active" } fmt.Fprintf(&b, `%s`, class, item.Slug, html.EscapeString(item.Title)) } b.WriteString(`
`) return b.String() } func renderTransferPicker(kind string, accounts []Account, archives []string) string { var b strings.Builder b.WriteString(`
`) switch kind { case "archive": fmt.Fprintf(&b, ``, transferSelect("archive", archives)) case "target": fmt.Fprintf(&b, ``, transferSelect("target", uniqueAccountValues(accounts, "target"))) default: fmt.Fprintf(&b, ``, transferSelect("source", uniqueAccountValues(accounts, "source"))) } b.WriteString(`
`) return b.String() } func transferSelect(name string, values []string) string { if len(values) == 0 { return `` } var b strings.Builder fmt.Fprintf(&b, ``) 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, `
Noch keine Postfaecher eingetragen.
`) return } fmt.Fprint(w, ``) for _, a := range accounts { activeClass := "" if a.Name == editName { activeClass = ` class="selected"` } status := "aktiv" if !a.Active { status = "inaktiv" } fmt.Fprintf(w, ``, 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, `
NameOriginalArchiv-mboxZielStatus
%s%s%s%s%s
`) } func renderUsersTable(w http.ResponseWriter, users []AppUser, editName string) { if len(users) == 0 { fmt.Fprint(w, `
Noch keine Benutzer eingetragen.
`) return } fmt.Fprint(w, ``) 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(`Bearbeiten`, urlQuery(u.Username)) toggleButton := fmt.Sprintf(``, html.EscapeString(u.Username), nextActive, toggleClass, toggleLabel) deleteButton := fmt.Sprintf(``, html.EscapeString(u.Username)) fmt.Fprintf(w, ``, activeClass, html.EscapeString(userDisplayName(u)), html.EscapeString(u.Username), html.EscapeString(u.Role), status, editButton, toggleButton, deleteButton) } fmt.Fprint(w, `
NameLogin/E-MailRolleStatus
%s%s%s%s%s%s%s
`) } func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) { roleField := roleSelect(u.Role, IsAdmin(r)) if !IsAdmin(r) { roleField = `` } fmt.Fprintf(w, `
Zugang
%s
Neu
`, html.EscapeString(u.Username), 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, `
Noch keine Archiv-Mailbox angelegt.
`) return } fmt.Fprint(w, ``) for _, archive := range archives { uses := archiveUses(archive, accounts) action := `belegt` if uses == "" { action = fmt.Sprintf(``, html.EscapeString(archive)) uses = "frei" } fmt.Fprintf(w, ``, html.EscapeString(archive), html.EscapeString(uses), action) } fmt.Fprint(w, `
Archiv-MailboxZuordnung
%s%s%s
`) } func renderArchiveForm(w http.ResponseWriter) { fmt.Fprint(w, `
Schutzlogik

Archiv-mboxen werden als lokale Ordner unter dem Backup-Root angelegt.

Entfernen ist nur moeglich, wenn die Mailbox keinem Konto zugeordnet und leer ist.

`) } func renderAccountForm(w http.ResponseWriter, a Account, archives []string) { fmt.Fprintf(w, `
Original
Archiv-mbox
Ziel
Neu
`, 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 `` } var b strings.Builder b.WriteString(``) return b.String() } func selectHTML(name, current string, values []string) string { var b strings.Builder fmt.Fprintf(&b, ``) 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) }