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)
// 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/create", archiveCreateHandler)
mux.HandleFunc("/archives/delete", archiveDeleteHandler)
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: 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, `
Mail-Graveyard
`, 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 archiveCreateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name, err := safeArchiveName(r.FormValue("archive_name"))
if err != nil {
redirectAccounts(w, r, "", err.Error())
return
}
if err := os.MkdirAll(filepath.Join(Cfg.MboxRoot, name), 0o700); err != nil {
redirectAccounts(w, r, "", err.Error())
return
}
redirectAccounts(w, r, "", "Archiv-Mailbox angelegt.")
}
func archiveDeleteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name, err := safeArchiveName(r.FormValue("archive_name"))
if err != nil {
redirectAccounts(w, r, "", err.Error())
return
}
if archiveInUse(name) {
redirectAccounts(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) {
redirectAccounts(w, r, "", "Archiv-Mailbox existiert nicht.")
return
}
if err != nil {
redirectAccounts(w, r, "", err.Error())
return
}
if len(entries) > 0 {
redirectAccounts(w, r, "", "Archiv-Mailbox ist nicht leer und wurde nicht entfernt.")
return
}
if err := os.Remove(path); err != nil {
redirectAccounts(w, r, "", err.Error())
return
}
redirectAccounts(w, r, "", "Leere Archiv-Mailbox entfernt.")
}
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, `%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 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, `
Postfach-Verwaltung - Mail-Graveyard
`)
if msg != "" {
fmt.Fprintf(w, `%s
`, html.EscapeString(msg))
}
if errMsg != "" {
fmt.Fprintf(w, `%s
`, html.EscapeString(errMsg))
}
fmt.Fprint(w, `Eingetragene Postfaecher
`)
renderAccountsTable(w, accounts, edit.Name)
fmt.Fprint(w, `
Archiv-mbox verwalten
`)
renderArchiveManager(w, archives, accounts)
fmt.Fprint(w, `
`)
}
func renderAccountsTable(w http.ResponseWriter, accounts []Account, editName string) {
if len(accounts) == 0 {
fmt.Fprint(w, `Noch keine Postfaecher eingetragen.
`)
return
}
fmt.Fprint(w, `Name Original Archiv-mbox Ziel Status `)
for _, a := range accounts {
activeClass := ""
if a.Name == editName {
activeClass = ` class="selected"`
}
status := "aktiv"
if !a.Active {
status = "inaktiv"
}
fmt.Fprintf(w, `%s %s %s %s %s
`,
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, `
`)
}
func renderArchiveManager(w http.ResponseWriter, archives []string, accounts []Account) {
fmt.Fprint(w, ``)
if len(archives) == 0 {
fmt.Fprint(w, `
Noch keine Archiv-Mailbox angelegt.
`)
return
}
fmt.Fprint(w, `Archiv-Mailbox Zuordnung `)
for _, archive := range archives {
uses := archiveUses(archive, accounts)
action := `belegt `
if uses == "" {
action = fmt.Sprintf(``, html.EscapeString(archive))
uses = "frei"
}
fmt.Fprintf(w, `%s %s %s `, html.EscapeString(archive), html.EscapeString(uses), action)
}
fmt.Fprint(w, `
`)
}
func renderAccountForm(w http.ResponseWriter, a Account, archives []string) {
fmt.Fprintf(w, ``,
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(``)
for _, v := range values {
sel := ""
if v == current {
sel = ` selected`
}
fmt.Fprintf(&b, `%s `, html.EscapeString(v), sel, html.EscapeString(v))
}
b.WriteString(` `)
return b.String()
}
func selectHTML(name, current string, values []string) string {
var b strings.Builder
fmt.Fprintf(&b, ``, name)
for _, v := range values {
sel := ""
if v == current {
sel = ` selected`
}
fmt.Fprintf(&b, `%s `, v, sel, v)
}
b.WriteString(` `)
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 urlQuery(value string) string {
return url.QueryEscape(value)
}