package backend
import (
"bytes"
"database/sql"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/mail"
"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("/archives/dedup", archiveDedupHandler)
mux.HandleFunc("/import-export", importExportHandler)
mux.HandleFunc("/import-export/upload", archiveUploadHandler)
mux.HandleFunc("/import-export/download", archiveDownloadHandler)
mux.HandleFunc("/transfer", transferHandler)
mux.HandleFunc("/transfer/", transferHandler)
mux.HandleFunc("/transfer/side", transferSideHandler)
mux.HandleFunc("/transfer/messages", transferMessagesHandler)
mux.HandleFunc("/transfer/message", transferMessageHandler)
mux.HandleFunc("/boxes/source", emailBoxHandler)
mux.HandleFunc("/boxes/source/messages", sourceMailboxMessagesHandler)
mux.HandleFunc("/boxes/source/message", sourceMailboxMessageHandler)
mux.HandleFunc("/boxes/target/messages", targetMailboxMessagesHandler)
mux.HandleFunc("/boxes/target/message", targetMailboxMessageHandler)
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)
mux.HandleFunc("/mail/send", mailSendHandler)
}
func mailSendHandler(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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
to, err := splitRecipients(r.FormValue("to"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
subject := strings.TrimSpace(r.FormValue("subject"))
body := r.FormValue("body")
if len(to) == 0 {
http.Error(w, "Bitte mindestens einen Empfaenger eintragen.", http.StatusBadRequest)
return
}
if subject == "" {
subject = "(ohne Betreff)"
}
if err := SendPlainMail(to, subject, body); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, "gesendet")
}
func splitRecipients(value string) ([]string, error) {
normalized := strings.NewReplacer(";", ",", "\r\n", ",", "\n", ",", "\r", ",").Replace(strings.TrimSpace(value))
if normalized == "" {
return nil, nil
}
parsed, err := mail.ParseAddressList(normalized)
if err != nil {
return nil, fmt.Errorf("Ungueltiger Empfaenger: %s", err)
}
recipients := make([]string, 0, len(parsed))
seen := make(map[string]bool, len(parsed))
for _, address := range parsed {
addr := strings.TrimSpace(address.Address)
key := strings.ToLower(addr)
if addr == "" || seen[key] {
continue
}
recipients = append(recipients, addr)
seen[key] = true
}
return recipients, nil
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
if !CanManageAccounts(r) {
http.Redirect(w, r, "/view", http.StatusSeeOther)
return
}
renderStartPage(w, r)
}
func renderStartPage(w http.ResponseWriter, r *http.Request) {
user := CurrentUser(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `
Mail-Graveyard
%s
Willkommen
Mail-Graveyard
Angemeldet als %s. Waehle eine Email-Box zum Sichten, starte einen Mail-Transfer oder pflege Benutzer und Postfaecher.
`, renderAppMenuButton(user), renderRibbonEmailBoxMenuForUser("home", user), renderRibbonTransferMenuForUser("", user), renderRibbonActions(false, user), renderBackstageForUser("home", user), html.EscapeString(userDisplayLabel(user)))
}
// 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, r *http.Request, active, readingPane string) {
user := CurrentUser(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `
Mail-Graveyard
%s
`, renderAppMenuButton(user), renderRibbonEmailBoxMenuForUser("archive", user), renderRibbonTransferMenuForUser("", user), renderRibbonActions(false, user), renderBackstageForUser("archive", user), 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
}
archives, err := ListArchiveMailboxes()
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, r, accounts, archives, 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
}
archives, err := ListArchiveMailboxes()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
renderArchivesPage(w, r, archives, accounts, archiveStatsMap(archives), 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
}
archives, err := ListArchiveMailboxes()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
renderTransferPage(w, r, transferModeBySlug(mode), accounts, archives)
}
func emailBoxHandler(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
}
kind := strings.TrimPrefix(r.URL.Path, "/boxes/")
switch kind {
case "source":
if r.Header.Get("HX-Request") == "true" {
selected := selectedSourceAccount(accounts, r.URL.Query().Get("source"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderEmailBoxSourcePane(accounts, selected.Name))
fmt.Fprintf(w, `%s
`, renderInitialSourceMailboxList(selected))
fmt.Fprintf(w, `%s
`, renderInitialSourceMailboxRead(selected))
return
}
renderSourceEmailBoxPage(w, r, accounts, r.URL.Query().Get("source"))
case "target":
if r.Header.Get("HX-Request") == "true" {
selected := selectedTargetAccount(accounts, r.URL.Query().Get("target"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderEmailBoxTargetPane(accounts, selected.Name))
fmt.Fprintf(w, `%s
`, renderInitialTargetMailboxList(selected))
fmt.Fprintf(w, `%s
`, renderInitialTargetMailboxRead(selected))
return
}
renderTargetEmailBoxPage(w, r, accounts, r.URL.Query().Get("target"))
default:
http.Redirect(w, r, "/view", http.StatusSeeOther)
}
}
func sourceMailboxMessagesHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
accountName := strings.TrimSpace(r.URL.Query().Get("source"))
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if accountName == "" || folder == "" {
fmt.Fprint(w, `Nachrichten
Quell-Postfach und Ordner waehlen.
`)
return
}
account, err := GetAccount(accountName)
if err != nil {
fmt.Fprintf(w, `Nachrichten
%s
`, html.EscapeString(err.Error()))
return
}
messages, err := fetchSourceFolderMessages(account, folder)
if err != nil {
fmt.Fprintf(w, `Nachrichten
%s
`, html.EscapeString(err.Error()))
return
}
renderSourceMailboxMessageList(w, account, folder, messages)
fmt.Fprintf(w, `%s
`, renderSourceMailboxRead(account, folder, 0, messages))
}
func sourceMailboxMessageHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
accountName := strings.TrimSpace(r.URL.Query().Get("source"))
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
index, err := strconv.Atoi(r.URL.Query().Get("index"))
if err != nil {
http.Error(w, "bad index", http.StatusBadRequest)
return
}
account, err := GetAccount(accountName)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
messages, err := fetchSourceFolderMessages(account, folder)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderSourceMailboxRead(account, folder, index, messages))
}
func targetMailboxMessagesHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
accountName := strings.TrimSpace(r.URL.Query().Get("target"))
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if accountName == "" || folder == "" {
fmt.Fprint(w, `Nachrichten
Ziel-Postfach und Ordner waehlen.
`)
return
}
account, err := GetAccount(accountName)
if err != nil {
fmt.Fprintf(w, `Nachrichten
%s
`, html.EscapeString(err.Error()))
return
}
messages, err := fetchTargetFolderMessages(account, folder)
if err != nil {
fmt.Fprintf(w, `Nachrichten
%s
`, html.EscapeString(err.Error()))
return
}
renderTargetMailboxMessageList(w, account, folder, messages)
fmt.Fprintf(w, `%s
`, renderTargetMailboxRead(account, folder, 0, messages))
}
func targetMailboxMessageHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
accountName := strings.TrimSpace(r.URL.Query().Get("target"))
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
index, err := strconv.Atoi(r.URL.Query().Get("index"))
if err != nil {
http.Error(w, "bad index", http.StatusBadRequest)
return
}
account, err := GetAccount(accountName)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
messages, err := fetchTargetFolderMessages(account, folder)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderTargetMailboxRead(account, folder, index, messages))
}
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
}
if err := SaveArchiveMailbox(name); 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) {
if err := DeleteArchiveMailbox(name); err != nil {
redirectArchives(w, r, err.Error())
return
}
redirectArchives(w, r, "Archiv-Mailbox entfernt.")
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
}
if err := DeleteArchiveMailbox(name); 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) {
if !CanManageAccounts(r) {
fmt.Fprint(w, "bereit")
return
}
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, r *http.Request, accounts []Account, archives []string, edit Account, msg, errMsg string) {
user := CurrentUser(r)
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, `
Konten-Verwaltung - Mail-Graveyard
`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""), renderRibbonActions(false, user))
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, `
`)
}
func renderArchivesPage(w http.ResponseWriter, r *http.Request, archives []string, accounts []Account, stats map[string]ArchiveStats, msg, errMsg string) {
user := CurrentUser(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `
Archiv-mbox Verwaltung - Mail-Graveyard
`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""), renderRibbonActions(false, user))
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, stats)
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
`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""), renderRibbonActions(true, CurrentUser(r)))
if msg != "" {
fmt.Fprintf(w, `%s
`, html.EscapeString(msg))
}
if errMsg != "" {
fmt.Fprintf(w, `%s
`, html.EscapeString(errMsg))
}
fmt.Fprint(w, `Benutzer
`)
renderUsersTable(w, users, edit.Username)
fmt.Fprint(w, `
angemeldet als `)
fmt.Fprint(w, html.EscapeString(userDisplayLabel(CurrentUser(r))+" / "+CurrentUser(r).Role))
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"},
{Slug: "archiv-archiv", Title: "Archiv-MBox <--> Archiv-MBox", LeftTitle: "Archiv-MBox", RightTitle: "Archiv-MBox", LeftKind: "archive", RightKind: "archive"},
}
for _, mode := range modes {
if mode.Slug == slug {
return mode
}
}
return modes[0]
}
func renderTransferPage(w http.ResponseWriter, r *http.Request, mode transferMode, accounts []Account, archives []string) {
user := CurrentUser(r)
leftSelected := defaultTransferValue(mode.LeftKind, accounts, archives)
rightSelected := defaultTransferValue(mode.RightKind, accounts, archives)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `
%s - Mail-Graveyard
`,
html.EscapeString(mode.Title), renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(mode.Slug), renderRibbonActions(false, user), transferMenuItems(mode.Slug),
renderTransferFolderPane("left", mode.LeftKind, accounts, archives, leftSelected),
renderInitialTransferList("left", mode.LeftKind, accounts, archives, leftSelected),
renderInitialTransferPreview(mode.LeftKind, leftSelected, mode.RightKind, rightSelected),
renderInitialTransferList("right", mode.RightKind, accounts, archives, rightSelected),
renderTransferFolderPane("right", mode.RightKind, accounts, archives, rightSelected))
}
func transferSideHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
side := transferSide(r.URL.Query().Get("side"))
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
value := strings.TrimSpace(r.URL.Query().Get("value"))
accounts, err := ListAccounts()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
archives, err := ListArchiveMailboxes()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderTransferFolderPane(side, kind, accounts, archives, value))
fmt.Fprintf(w, `%s
`, side, renderInitialTransferList(side, kind, accounts, archives, value))
fmt.Fprintf(w, `%s
`, renderInitialTransferPreview(kind, value, "", ""))
}
func transferMessagesHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
side := transferSide(r.URL.Query().Get("side"))
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
value := strings.TrimSpace(r.URL.Query().Get("value"))
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderTransferMessageList(side, kind, value, folder))
fmt.Fprintf(w, `%s
`, renderTransferPreview(kind, value, folder, 0))
}
func transferMessageHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
value := strings.TrimSpace(r.URL.Query().Get("value"))
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
index, err := strconv.Atoi(r.URL.Query().Get("index"))
if err != nil {
http.Error(w, "bad index", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, renderTransferPreview(kind, value, folder, index))
}
func transferSide(value string) string {
if value == "right" {
return "right"
}
return "left"
}
func defaultTransferValue(kind string, accounts []Account, archives []string) string {
options := transferEntityOptions(kind, accounts, archives)
if len(options) == 0 {
return ""
}
return options[0].Value
}
func renderTransferFolderPane(side, kind string, accounts []Account, archives []string, selected string) string {
options := transferEntityOptions(kind, accounts, archives)
if selected == "" && len(options) > 0 {
selected = options[0].Value
}
var b strings.Builder
fmt.Fprintf(&b, `%s
`)
if len(options) == 0 {
b.WriteString(`Keine Eintraege.
`)
return b.String()
}
renderTransferFolders(&b, side, kind, selected)
return b.String()
}
type transferOption struct {
Value string
Label string
}
func transferEntityOptions(kind string, accounts []Account, archives []string) []transferOption {
switch kind {
case "archive":
out := make([]transferOption, 0, len(archives))
for _, archive := range archives {
out = append(out, transferOption{Value: archive, Label: archive})
}
return out
case "target":
targets := accountsByTarget(accounts)
out := make([]transferOption, 0, len(targets))
for _, a := range targets {
out = append(out, transferOption{Value: a.Name, Label: a.DstUser})
}
return out
default:
sources := accountsBySource(accounts)
out := make([]transferOption, 0, len(sources))
for _, a := range sources {
out = append(out, transferOption{Value: a.Name, Label: a.SrcUser})
}
return out
}
}
func transferKindLabel(kind string) string {
switch kind {
case "archive":
return "Archiv-MBox"
case "target":
return "Ziel-Postfach"
default:
return "Quell-Postfach"
}
}
func renderTransferFolders(b *strings.Builder, side, kind, value string) {
switch kind {
case "archive":
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, value, "*.mbox"))
if len(mboxes) == 0 {
b.WriteString(`Keine Ordner.
`)
return
}
for _, folder := range archiveFolderInfos(mboxes) {
fmt.Fprintf(b, `%s %d `,
side, html.EscapeString(kind), urlEsc(value), urlEsc(folder.Name), side, html.EscapeString(folder.Label), folder.Count)
}
case "target":
account, err := GetAccount(value)
if err != nil {
fmt.Fprintf(b, `%s
`, html.EscapeString(err.Error()))
return
}
fmt.Fprintf(b, `%s
`, html.EscapeString(account.DstUser))
dst, err := OpenIMAPTarget(account)
if err != nil {
fmt.Fprintf(b, `Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error()))
return
}
defer dst.Close()
folders, err := dst.Folders()
if err != nil {
fmt.Fprintf(b, `Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error()))
return
}
for _, folder := range folders {
fmt.Fprintf(b, `%s %s `,
side, html.EscapeString(kind), urlEsc(value), urlEsc(folder.Name), side, html.EscapeString(folder.Name), html.EscapeString(targetFolderRole(folder)))
}
default:
account, err := GetAccount(value)
if err != nil {
fmt.Fprintf(b, `%s
`, html.EscapeString(err.Error()))
return
}
fmt.Fprintf(b, `%s
`, html.EscapeString(account.SrcUser))
if accountProto(account) == "pop3" {
fmt.Fprintf(b, `INBOX POP3 `, side, html.EscapeString(kind), urlEsc(value), side)
return
}
src, err := OpenIMAPSource(account)
if err != nil {
fmt.Fprintf(b, `Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error()))
return
}
defer src.Close()
folders, err := src.Folders()
if err != nil {
fmt.Fprintf(b, `Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error()))
return
}
for _, folder := range folders {
fmt.Fprintf(b, `%s %s `,
side, html.EscapeString(kind), urlEsc(value), urlEsc(folder.Name), side, html.EscapeString(folder.Name), html.EscapeString(sourceFolderRole(folder)))
}
}
}
func renderInitialTransferList(side, kind string, accounts []Account, archives []string, selected string) string {
options := transferEntityOptions(kind, accounts, archives)
if selected == "" && len(options) > 0 {
selected = options[0].Value
}
if selected == "" {
return `Nachrichten
Postfach waehlen.
`
}
folder, err := firstTransferFolder(kind, selected)
if err != nil {
return fmt.Sprintf(`Nachrichten
%s
`, html.EscapeString(transferEmptyReason(kind, err)))
}
return renderTransferMessageList(side, kind, selected, folder)
}
func renderInitialTransferPreview(leftKind, leftValue, rightKind, rightValue string) string {
if leftValue != "" {
if folder, err := firstTransferFolder(leftKind, leftValue); err == nil {
return renderTransferPreview(leftKind, leftValue, folder, 0)
}
}
if rightValue != "" {
if folder, err := firstTransferFolder(rightKind, rightValue); err == nil {
return renderTransferPreview(rightKind, rightValue, folder, 0)
}
}
return renderEmptyPreviewPane("Nachricht links oder rechts waehlen.")
}
func transferEmptyReason(kind string, err error) string {
msg := err.Error()
if kind == "archive" && strings.Contains(msg, "Keine Ordner") {
return "Diese Archiv-MBox ist noch leer."
}
return msg
}
func firstTransferFolder(kind, value string) (string, error) {
switch kind {
case "archive":
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, value, "*.mbox"))
infos := archiveFolderInfos(mboxes)
if len(infos) == 0 {
return "", fmt.Errorf("Keine Ordner.")
}
for _, folder := range infos {
if strings.EqualFold(folder.Name, "INBOX") {
return folder.Name, nil
}
}
return infos[0].Name, nil
case "target":
account, err := GetAccount(value)
if err != nil {
return "", err
}
return firstTargetFolder(account)
default:
account, err := GetAccount(value)
if err != nil {
return "", err
}
return firstSourceFolder(account)
}
}
func renderTransferMessageList(side, kind, value, folder string) string {
var b strings.Builder
b.WriteString(`Nachrichten
`)
switch kind {
case "archive":
path, err := mboxPath(value, folder)
if err != nil {
fmt.Fprintf(&b, `%s
`, html.EscapeString(err.Error()))
return b.String()
}
entries, err := ReadMboxList(path)
if err != nil {
fmt.Fprintf(&b, `%s
`, html.EscapeString(err.Error()))
return b.String()
}
for _, entry := range entries {
subject := entry.Subject
if subject == "" {
subject = "(ohne Betreff)"
}
from := entry.From
if from == "" {
from = "(ohne Absender)"
}
active := ""
if entry.Index == 0 {
active = " active"
}
fmt.Fprintf(&b, `%s %s %s `,
active, side, html.EscapeString(kind), urlEsc(value), urlEsc(folder), entry.Index, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(entry.Date))
}
if len(entries) == 0 {
b.WriteString(`Keine Nachrichten.
`)
}
case "target":
account, err := GetAccount(value)
if err != nil {
fmt.Fprintf(&b, `%s
`, html.EscapeString(err.Error()))
return b.String()
}
messages, err := fetchTargetFolderMessages(account, folder)
if err != nil {
fmt.Fprintf(&b, `%s
`, html.EscapeString(err.Error()))
return b.String()
}
renderTransferRawMessages(&b, side, kind, value, folder, messages)
default:
account, err := GetAccount(value)
if err != nil {
fmt.Fprintf(&b, `%s
`, html.EscapeString(err.Error()))
return b.String()
}
messages, err := fetchSourceFolderMessages(account, folder)
if err != nil {
fmt.Fprintf(&b, `%s
`, html.EscapeString(err.Error()))
return b.String()
}
renderTransferRawMessages(&b, side, kind, value, folder, messages)
}
return b.String()
}
func renderTransferRawMessages(b *strings.Builder, side, kind, value, folder string, messages []RawMessage) {
for i, raw := range messages {
subject := "(ohne Betreff)"
from := "(ohne Absender)"
date := ""
if msg, err := mail.ReadMessage(bytes.NewReader(raw.Body)); err == nil {
if v := decodeHeader(msg.Header.Get("Subject")); v != "" {
subject = v
}
if v := decodeHeader(msg.Header.Get("From")); v != "" {
from = v
}
date = decodeHeader(msg.Header.Get("Date"))
}
active := ""
if i == 0 {
active = " active"
}
fmt.Fprintf(b, `%s %s %s `,
active, side, html.EscapeString(kind), urlEsc(value), urlEsc(folder), i, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date))
}
if len(messages) == 0 {
b.WriteString(`Keine Nachrichten.
`)
}
}
func renderTransferPreview(kind, value, folder string, index int) string {
raw, label, err := transferRawMessage(kind, value, folder, index)
if err != nil {
return renderEmptyPreviewPane(err.Error())
}
msg, err := mail.ReadMessage(bytes.NewReader(raw))
var b strings.Builder
if err != nil {
renderMailboxReadPane(&b, "Vorschau", label, folder, index, "(unlesbar)", "", "", string(raw))
return b.String()
}
renderMailboxReadPane(&b, "Vorschau", label, folder, index,
decodeHeader(msg.Header.Get("Subject")),
decodeHeader(msg.Header.Get("From")),
decodeHeader(msg.Header.Get("Date")),
messageBody(raw),
)
return b.String()
}
func transferRawMessage(kind, value, folder string, index int) ([]byte, string, error) {
switch kind {
case "archive":
path, err := mboxPath(value, folder)
if err != nil {
return nil, value, err
}
raw, err := ReadMboxMessage(path, index)
return raw, value, err
case "target":
account, err := GetAccount(value)
if err != nil {
return nil, value, err
}
messages, err := fetchTargetFolderMessages(account, folder)
if err != nil {
return nil, account.DstUser, err
}
if index < 0 || index >= len(messages) {
return nil, account.DstUser, fmt.Errorf("Nachricht waehlen.")
}
return messages[index].Body, account.DstUser, nil
default:
account, err := GetAccount(value)
if err != nil {
return nil, value, err
}
messages, err := fetchSourceFolderMessages(account, folder)
if err != nil {
return nil, account.SrcUser, err
}
if index < 0 || index >= len(messages) {
return nil, account.SrcUser, fmt.Errorf("Nachricht waehlen.")
}
return messages[index].Body, account.SrcUser, nil
}
}
func renderSourceEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts []Account, selected string) {
user := CurrentUser(r)
selectedAccount := selectedSourceAccount(accounts, selected)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `
Quell-Postfach - Mail-Graveyard
`,
renderRibbonEmailBoxMenu("source"), renderRibbonTransferMenu(""), renderRibbonActions(false, user), renderEmailBoxSourcePane(accounts, selectedAccount.Name), renderInitialSourceMailboxList(selectedAccount), renderInitialSourceMailboxRead(selectedAccount))
}
func renderTargetEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts []Account, selected string) {
user := CurrentUser(r)
selectedAccount := selectedTargetAccount(accounts, selected)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `
Ziel-Postfach - Mail-Graveyard
`,
renderRibbonEmailBoxMenu("target"), renderRibbonTransferMenu(""), renderRibbonActions(false, user), renderInitialTargetMailboxList(selectedAccount), renderInitialTargetMailboxRead(selectedAccount), renderEmailBoxTargetPane(accounts, selectedAccount.Name))
}
func renderEmailBoxSourcePane(accounts []Account, selected string) string {
var b strings.Builder
sourceAccounts := accountsBySource(accounts)
if len(sourceAccounts) == 0 {
b.WriteString(`Quell-Postfach
Keine Quell-Postfaecher eingetragen.
`)
return b.String()
}
selectedAccount := selectedSourceAccount(accounts, selected)
b.WriteString(`Quell-Postfach
`)
renderSourceFolders(&b, selectedAccount)
return b.String()
}
func selectedSourceAccount(accounts []Account, selected string) Account {
sourceAccounts := accountsBySource(accounts)
if len(sourceAccounts) == 0 {
return Account{}
}
if selected != "" {
for _, a := range sourceAccounts {
if a.Name == selected {
return a
}
}
}
return sourceAccounts[0]
}
func renderSourceFolders(b *strings.Builder, account Account) {
fmt.Fprintf(b, `%s
`, html.EscapeString(account.SrcUser))
if accountProto(account) == "pop3" {
b.WriteString(`INBOX POP3
`)
return
}
src, err := OpenIMAPSource(account)
if err != nil {
fmt.Fprintf(b, `Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error()))
return
}
defer src.Close()
folders, err := src.Folders()
if err != nil {
fmt.Fprintf(b, `Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error()))
return
}
if len(folders) == 0 {
b.WriteString(`Keine Ordner in diesem Quell-Postfach.
`)
return
}
for _, folder := range folders {
fmt.Fprintf(b, `%s %s `,
html.EscapeString(strings.Join(folder.Attrs, " ")), urlEsc(account.Name), urlEsc(folder.Name), html.EscapeString(folder.Name), html.EscapeString(sourceFolderRole(folder)))
}
}
func sourceFolderRole(folder Folder) string {
for _, attr := range folder.Attrs {
switch strings.ToLower(attr) {
case `\sent`:
return "Gesendet"
case `\drafts`:
return "Entwurf"
case `\trash`:
return "Papierkorb"
case `\junk`:
return "Spam"
}
}
return ""
}
func fetchSourceFolderMessages(account Account, folder string) ([]RawMessage, error) {
if accountProto(account) == "pop3" {
src, err := OpenPOP3Source(account)
if err != nil {
return nil, err
}
defer src.Close()
return src.Fetch(folder)
}
src, err := OpenIMAPSource(account)
if err != nil {
return nil, err
}
defer src.Close()
return src.Fetch(folder)
}
func renderInitialSourceMailboxList(account Account) string {
if account.Name == "" {
return `Nachrichten
Quell-Postfach waehlen.
`
}
folder, err := firstSourceFolder(account)
if err != nil {
return fmt.Sprintf(`Nachrichten
%s
`, html.EscapeString(err.Error()))
}
messages, err := fetchSourceFolderMessages(account, folder)
if err != nil {
return fmt.Sprintf(`Nachrichten
%s
`, html.EscapeString(err.Error()))
}
var b strings.Builder
renderSourceMailboxMessageList(&b, account, folder, messages)
return b.String()
}
func renderInitialSourceMailboxRead(account Account) string {
if account.Name == "" {
return `Nachricht waehlen.
`
}
folder, err := firstSourceFolder(account)
if err != nil {
return `Nachricht waehlen.
`
}
messages, err := fetchSourceFolderMessages(account, folder)
if err != nil {
return `Nachricht waehlen.
`
}
return renderSourceMailboxRead(account, folder, 0, messages)
}
func firstSourceFolder(account Account) (string, error) {
if accountProto(account) == "pop3" {
return "INBOX", nil
}
src, err := OpenIMAPSource(account)
if err != nil {
return "", err
}
defer src.Close()
folders, err := src.Folders()
if err != nil {
return "", err
}
for _, folder := range folders {
if strings.EqualFold(folder.Name, "INBOX") {
return folder.Name, nil
}
}
if len(folders) == 0 {
return "", fmt.Errorf("Keine Ordner in diesem Quell-Postfach.")
}
return folders[0].Name, nil
}
func renderSourceMailboxMessageList(w io.Writer, account Account, folder string, messages []RawMessage) {
fmt.Fprint(w, `Nachrichten
`)
for i, raw := range messages {
subject := "(ohne Betreff)"
from := "(ohne Absender)"
date := ""
if msg, err := mail.ReadMessage(bytes.NewReader(raw.Body)); err == nil {
if v := decodeHeader(msg.Header.Get("Subject")); v != "" {
subject = v
}
if v := decodeHeader(msg.Header.Get("From")); v != "" {
from = v
}
date = decodeHeader(msg.Header.Get("Date"))
}
active := ""
if i == 0 {
active = " active"
}
fmt.Fprintf(w, `%s %s %s `,
active,
html.EscapeString(account.Name), html.EscapeString(folder), i,
urlEsc(account.Name), urlEsc(folder), i,
html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date))
}
if len(messages) == 0 {
fmt.Fprint(w, `Keine Nachrichten in diesem Quell-Ordner.
`)
}
}
func renderSourceMailboxRead(account Account, folder string, index int, messages []RawMessage) string {
if index < 0 || index >= len(messages) {
return `Nachricht waehlen.
`
}
raw := messages[index].Body
msg, err := mail.ReadMessage(bytes.NewReader(raw))
var b strings.Builder
if err != nil {
renderMailboxReadPane(&b, "Quelle", account.Name, folder, index, "(unlesbar)", "", "", string(raw))
return b.String()
}
renderMailboxReadPane(&b, "Quelle", account.Name, folder, index,
decodeHeader(msg.Header.Get("Subject")),
decodeHeader(msg.Header.Get("From")),
decodeHeader(msg.Header.Get("Date")),
messageBody(raw),
)
return b.String()
}
func renderEmailBoxTargetPane(accounts []Account, selected string) string {
var b strings.Builder
targetAccounts := accountsByTarget(accounts)
if len(targetAccounts) == 0 {
b.WriteString(`Ziel-Postfach
Keine Ziel-Postfaecher eingetragen.
`)
return b.String()
}
selectedAccount := selectedTargetAccount(accounts, selected)
b.WriteString(`Ziel-Postfach
`)
renderTargetFolders(&b, selectedAccount)
return b.String()
}
func selectedTargetAccount(accounts []Account, selected string) Account {
targetAccounts := accountsByTarget(accounts)
if len(targetAccounts) == 0 {
return Account{}
}
if selected != "" {
for _, a := range targetAccounts {
if a.Name == selected {
return a
}
}
}
return targetAccounts[0]
}
func renderTargetFolders(b *strings.Builder, account Account) {
fmt.Fprintf(b, `%s
`, html.EscapeString(account.DstUser))
dst, err := OpenIMAPTarget(account)
if err != nil {
fmt.Fprintf(b, `Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error()))
return
}
defer dst.Close()
folders, err := dst.Folders()
if err != nil {
fmt.Fprintf(b, `Ordner konnten nicht geladen werden: %s
`, html.EscapeString(err.Error()))
return
}
if len(folders) == 0 {
b.WriteString(`Keine Ordner in diesem Ziel-Postfach.
`)
return
}
for _, folder := range folders {
fmt.Fprintf(b, `%s %s `,
html.EscapeString(strings.Join(folder.Attrs, " ")), urlEsc(account.Name), urlEsc(folder.Name), html.EscapeString(folder.Name), html.EscapeString(targetFolderRole(folder)))
}
}
func targetFolderRole(folder TargetFolder) string {
for _, attr := range folder.Attrs {
switch strings.ToLower(attr) {
case `\sent`:
return "Gesendet"
case `\drafts`:
return "Entwurf"
case `\trash`:
return "Papierkorb"
case `\junk`:
return "Spam"
}
}
return ""
}
func fetchTargetFolderMessages(account Account, folder string) ([]RawMessage, error) {
dst, err := OpenIMAPTarget(account)
if err != nil {
return nil, err
}
defer dst.Close()
return dst.Fetch(folder)
}
func renderInitialTargetMailboxList(account Account) string {
if account.Name == "" {
return `Nachrichten
Ziel-Postfach waehlen.
`
}
folder, err := firstTargetFolder(account)
if err != nil {
return fmt.Sprintf(`Nachrichten
%s
`, html.EscapeString(err.Error()))
}
messages, err := fetchTargetFolderMessages(account, folder)
if err != nil {
return fmt.Sprintf(`Nachrichten
%s
`, html.EscapeString(err.Error()))
}
var b strings.Builder
renderTargetMailboxMessageList(&b, account, folder, messages)
return b.String()
}
func renderInitialTargetMailboxRead(account Account) string {
if account.Name == "" {
return `Nachricht waehlen.
`
}
folder, err := firstTargetFolder(account)
if err != nil {
return `Nachricht waehlen.
`
}
messages, err := fetchTargetFolderMessages(account, folder)
if err != nil {
return `Nachricht waehlen.
`
}
return renderTargetMailboxRead(account, folder, 0, messages)
}
func firstTargetFolder(account Account) (string, error) {
dst, err := OpenIMAPTarget(account)
if err != nil {
return "", err
}
defer dst.Close()
folders, err := dst.Folders()
if err != nil {
return "", err
}
for _, folder := range folders {
if strings.EqualFold(folder.Name, "INBOX") {
return folder.Name, nil
}
}
if len(folders) == 0 {
return "", fmt.Errorf("Keine Ordner in diesem Ziel-Postfach.")
}
return folders[0].Name, nil
}
func renderTargetMailboxMessageList(w io.Writer, account Account, folder string, messages []RawMessage) {
fmt.Fprint(w, `Nachrichten
`)
for i, raw := range messages {
subject := "(ohne Betreff)"
from := "(ohne Absender)"
date := ""
if msg, err := mail.ReadMessage(bytes.NewReader(raw.Body)); err == nil {
if v := decodeHeader(msg.Header.Get("Subject")); v != "" {
subject = v
}
if v := decodeHeader(msg.Header.Get("From")); v != "" {
from = v
}
date = decodeHeader(msg.Header.Get("Date"))
}
active := ""
if i == 0 {
active = " active"
}
fmt.Fprintf(w, `%s %s %s `,
active,
html.EscapeString(account.Name), html.EscapeString(folder), i,
urlEsc(account.Name), urlEsc(folder), i,
html.EscapeString(from), html.EscapeString(subject), html.EscapeString(date))
}
if len(messages) == 0 {
fmt.Fprint(w, `Keine Nachrichten in diesem Ziel-Ordner.
`)
}
}
func renderTargetMailboxRead(account Account, folder string, index int, messages []RawMessage) string {
if index < 0 || index >= len(messages) {
return `Nachricht waehlen.
`
}
raw := messages[index].Body
msg, err := mail.ReadMessage(bytes.NewReader(raw))
var b strings.Builder
if err != nil {
renderMailboxReadPane(&b, "Ziel", account.Name, folder, index, "(unlesbar)", "", "", string(raw))
return b.String()
}
renderMailboxReadPane(&b, "Ziel", account.Name, folder, index,
decodeHeader(msg.Header.Get("Subject")),
decodeHeader(msg.Header.Get("From")),
decodeHeader(msg.Header.Get("Date")),
messageBody(raw),
)
return b.String()
}
func renderMailboxReadPane(w io.Writer, title, account, folder string, index int, subject, from, date, body string) {
if subject == "" {
subject = "(ohne Betreff)"
}
fmt.Fprint(w, `Vorschau ⛶
`)
fmt.Fprintf(w, `%s Von: %s Datum: %s Postfach: %s / %s / #%d
%s `,
html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(account), html.EscapeString(folder), index+1, html.EscapeString(body))
}
func renderEmptyPreviewPane(message string) string {
return fmt.Sprintf(`Vorschau
`, html.EscapeString(message))
}
func accountsByTarget(accounts []Account) []Account {
seen := map[string]bool{}
out := make([]Account, 0, len(accounts))
for _, a := range accounts {
if strings.TrimSpace(a.DstUser) == "" || seen[a.DstUser] {
continue
}
seen[a.DstUser] = true
out = append(out, a)
}
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if strings.ToLower(out[j].DstUser) < strings.ToLower(out[i].DstUser) {
out[i], out[j] = out[j], out[i]
}
}
}
return out
}
func renderEmailBoxTargetPaneLegacy(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"},
{Slug: "archiv-archiv", Title: "Archiv-MBox <--> Archiv-MBox"},
}
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 != "" {
if active == "home" {
buttonClass = "tab ribbon-menu-button"
} else {
buttonClass += " active"
}
}
var b strings.Builder
homeClass := "tab app-tab"
if active == "home" {
homeClass += " active"
}
fmt.Fprintf(&b, `Mail-Graveyard `, homeClass)
fmt.Fprintf(&b, ``)
return b.String()
}
func renderAppMenuButton(user AppUser) string {
if !hasRole(user, roleManager) {
return ""
}
return ``
}
func renderRibbonEmailBoxMenuForUser(active string, user AppUser) string {
if hasRole(user, roleManager) {
return renderRibbonEmailBoxMenu(active)
}
var b strings.Builder
homeClass := "tab app-tab"
if active == "home" {
homeClass += " active"
}
archiveClass := "tab"
if active == "archive" {
archiveClass += " active"
}
fmt.Fprintf(&b, `Mail-Graveyard `, homeClass)
fmt.Fprintf(&b, `Archiv-Boxen `, archiveClass)
return b.String()
}
func renderRibbonTransferMenuForUser(active string, user AppUser) string {
if !hasRole(user, roleManager) {
return ""
}
return renderRibbonTransferMenu(active)
}
func renderRibbonActions(settingsActive bool, user AppUser) string {
settingsClass := "ribbon-icon"
if settingsActive {
settingsClass += " active"
}
if !hasRole(user, roleManager) {
return fmt.Sprintf(`
%s ⏻ `,
html.EscapeString(user.Username), html.EscapeString(userDisplayName(user)))
}
return fmt.Sprintf(`
%s ⚙ ⏻ `,
html.EscapeString(user.Username), html.EscapeString(userDisplayName(user)), settingsClass)
}
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"},
{Slug: "archiv-archiv", Title: "Archiv-MBox <--> Archiv-MBox"},
}
buttonClass := "tab ribbon-menu-button"
if active != "" {
buttonClass += " active"
}
var b strings.Builder
fmt.Fprintf(&b, ``)
return b.String()
}
func renderBackstage(active string) string {
items := []struct {
Key string
Href string
Label string
}{
{"home", "/", "Start"},
{"archive", "/view", "Archiv-Boxen"},
{"import-export", "/import-export", "Import/Export"},
{"accounts", "/accounts", "Konten-Verwaltung"},
{"archives", "/archives", "Archiv-mbox Verwaltung"},
{"transfer", "/transfer/postfach-archiv", "Mail-Transfer"},
{"users", "/users", "Benutzerverwaltung"},
{"logout", "/logout", "Logout"},
}
var b strings.Builder
b.WriteString(` `)
return b.String()
}
func renderBackstageForUser(active string, user AppUser) string {
if !hasRole(user, roleManager) {
return ""
}
return renderBackstage(active)
}
func renderTransferPicker(kind string, accounts []Account, archives []string) string {
var b strings.Builder
b.WriteString(``)
switch kind {
case "archive":
fmt.Fprintf(&b, `Archiv-MBox%s `, transferSelect("archive", archives))
case "target":
fmt.Fprintf(&b, `Ziel-Postfach%s `, transferSelect("target", uniqueAccountValues(accounts, "target")))
default:
fmt.Fprintf(&b, `Quell-Postfach%s `, transferSelect("source", uniqueAccountValues(accounts, "source")))
}
b.WriteString(`
`)
return b.String()
}
func transferSelect(name string, values []string) string {
if len(values) == 0 {
return `Keine Eintraege `
}
var b strings.Builder
fmt.Fprintf(&b, ``, name)
for _, v := range values {
fmt.Fprintf(&b, `%s `, html.EscapeString(v), html.EscapeString(v))
}
b.WriteString(` `)
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, `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 renderUsersTable(w http.ResponseWriter, users []AppUser, editName string) {
if len(users) == 0 {
fmt.Fprint(w, `Noch keine Benutzer eingetragen.
`)
return
}
fmt.Fprint(w, `Name Login/E-Mail Rolle Status `)
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, `%s %s %s %s %s%s%s `,
activeClass, html.EscapeString(userDisplayName(u)), html.EscapeString(u.Username), html.EscapeString(u.Role), status, editButton, toggleButton, deleteButton)
}
fmt.Fprint(w, `
`)
}
func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) {
roleField := roleSelect(u.Role, IsAdmin(r))
if !IsAdmin(r) {
roleField = ` `
}
fmt.Fprintf(w, ``,
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, stats map[string]ArchiveStats) {
if len(archives) == 0 {
fmt.Fprint(w, `Noch keine Archiv-Mailbox angelegt.
`)
return
}
fmt.Fprint(w, `Archiv-Mailbox Zuordnung Speicher Mails Dubletten `)
for _, archive := range archives {
uses := archiveUses(archive, accounts)
stat := stats[archive]
var actions strings.Builder
fmt.Fprintf(&actions, `Download `, urlQuery(archive))
if stat.Duplicates > 0 {
fmt.Fprintf(&actions, ``, html.EscapeString(archive))
}
if uses == "" {
fmt.Fprintf(&actions, ``, html.EscapeString(archive))
uses = "frei"
}
fmt.Fprintf(w, `%s %s %s %d %d %s `,
html.EscapeString(archive), html.EscapeString(uses), html.EscapeString(formatBytes(stat.Bytes)), stat.Messages, stat.Duplicates, actions.String())
}
fmt.Fprint(w, `
`)
}
func renderArchiveForm(w http.ResponseWriter) {
fmt.Fprint(w, `
Archivlogik
Archiv-mboxen sind eigenstaendige Mailboxen und nicht von einem Quellkonto abhaengig.
Sie koennen leer angelegt oder spaeter per Import mit mbox/PST-Inhalten gefuellt werden.
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, ``,
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 `erst Archiv-Mailbox erstellen `
}
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 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)
}