Files
mailgoserver/internal/webui/webui.go
T
2026-08-13 10:40:27 +01:00

230 lines
12 KiB
Go

// Package webui is the admin web interface, mirroring email_server/server_web_ui/.
// It's mounted at /pymta-manager, matching the Flask blueprint's url_prefix exactly.
package webui
import (
"html/template"
"io/fs"
"net/http"
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/acmecert"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/mailstore"
"mailgoserver/internal/toolbox"
)
const Prefix = "/pymta-manager"
// App holds every dependency the web routes need, mirroring the module-level
// singletons (Session, DKIMManager, settings) that server_web_ui/*.py imports.
type App struct {
DB *db.DB
DKIM *dkim.Manager
Mailstore *mailstore.Store
ACME *acmecert.Manager
Cfg *ini.File
ConfigPath string
Logger *toolbox.Logger
SMTPUp func() bool // reports whether the SMTP listeners are currently running
templates map[string]*template.Template
}
// New builds the web UI. Templates and static assets come from the embedded
// filesystem (embed.go), not disk, so no directory paths are needed for them.
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr *acmecert.Manager, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool) (*App, error) {
a := &App{DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp}
if err := a.loadTemplates(); err != nil {
return nil, err
}
return a, nil
}
type healthStatus struct {
Status string
Timestamp string
Services map[string]string
}
// checkHealth mirrors app.py's SMTPServerApp.check_health.
func (a *App) checkHealth() healthStatus {
dbStatus := "ok"
if err := a.DB.Ping(); err != nil {
dbStatus = "error"
}
smtpStatus := "stopped"
if a.SMTPUp != nil && a.SMTPUp() {
smtpStatus = "running"
}
overall := "healthy"
if smtpStatus == "stopped" || dbStatus == "error" {
overall = "degraded"
}
return healthStatus{
Status: overall,
Timestamp: time.Now().Format(time.RFC3339),
Services: map[string]string{"smtp_server": smtpStatus, "web_frontend": "running", "database": dbStatus},
}
}
// Mux builds the *http.ServeMux for the whole admin UI, mirroring routes.py's
// blueprint registration plus every route file's own routes. Everything except
// login/MFA and static assets requires a valid, fully-verified session — see
// requireAuth in auth.go.
func (a *App) Mux() *http.ServeMux {
outer := http.NewServeMux()
staticFS, err := fs.Sub(assets, "static")
if err != nil {
panic(err) // embed.go's directive is malformed if this ever fails
}
outer.Handle("GET "+Prefix+"/static/", http.StripPrefix(Prefix+"/static/", http.FileServerFS(staticFS)))
outer.HandleFunc("GET "+Prefix+"/login", a.loginForm)
outer.HandleFunc("POST "+Prefix+"/login", a.loginSubmit)
outer.HandleFunc("GET "+Prefix+"/login/mfa", a.mfaForm)
outer.HandleFunc("POST "+Prefix+"/login/mfa", a.mfaSubmit)
outer.HandleFunc("GET "+Prefix+"/login/passkey/begin", a.passkeyLoginBegin)
outer.HandleFunc("POST "+Prefix+"/login/passkey/finish", a.passkeyLoginFinish)
outer.HandleFunc("POST "+Prefix+"/logout", a.logout)
// Self-service webmail portal — entirely separate prefix, session cookie, and
// context keys from the admin dashboard above (see webmail_auth.go).
outer.HandleFunc("GET "+MailboxPrefix+"/login", a.webmailLoginForm)
outer.HandleFunc("POST "+MailboxPrefix+"/login", a.webmailLoginSubmit)
outer.HandleFunc("GET "+MailboxPrefix+"/login/mfa", a.webmailMFAForm)
outer.HandleFunc("POST "+MailboxPrefix+"/login/mfa", a.webmailMFASubmit)
outer.HandleFunc("GET "+MailboxPrefix+"/login/passkey/begin", a.webmailPasskeyLoginBegin)
outer.HandleFunc("POST "+MailboxPrefix+"/login/passkey/finish", a.webmailPasskeyLoginFinish)
outer.HandleFunc("POST "+MailboxPrefix+"/logout", a.webmailLogout)
webmailMux := http.NewServeMux()
webmailMux.HandleFunc("GET "+MailboxPrefix+"/", a.webmailDashboard)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/disable", a.webmailTOTPDisable)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/passkey/begin", a.webmailPasskeyRegisterBegin)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/passkey/finish", a.webmailPasskeyRegisterFinish)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/passkey/{id}/remove", a.webmailPasskeyRemove)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/apppasswords/add", a.webmailAddAppPassword)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/apppasswords/{pw_id}/revoke", a.webmailRevokeAppPassword)
outer.Handle(MailboxPrefix+"/", a.requireMailboxAuth(webmailMux))
mux := http.NewServeMux()
mux.HandleFunc("GET "+Prefix+"/", a.dashboard)
mux.HandleFunc("GET "+Prefix+"/account", a.accountPage)
mux.HandleFunc("GET "+Prefix+"/mfa-setup", a.mfaSetupRequiredPage)
mux.HandleFunc("POST "+Prefix+"/account/password", a.changePassword)
mux.HandleFunc("POST "+Prefix+"/account/totp/setup", a.totpSetupBegin)
mux.HandleFunc("POST "+Prefix+"/account/totp/confirm", a.totpSetupConfirm)
mux.HandleFunc("POST "+Prefix+"/account/totp/disable", a.totpDisable)
mux.HandleFunc("POST "+Prefix+"/account/passkey/begin", a.passkeyRegisterBegin)
mux.HandleFunc("POST "+Prefix+"/account/passkey/finish", a.passkeyRegisterFinish)
mux.HandleFunc("POST "+Prefix+"/account/passkey/{id}/remove", a.passkeyRemove)
mux.HandleFunc("GET "+Prefix+"/first-login", a.firstLoginForm)
mux.HandleFunc("POST "+Prefix+"/first-login", a.firstLoginSubmit)
mux.HandleFunc("GET "+Prefix+"/admins", a.adminsList)
mux.HandleFunc("GET "+Prefix+"/admins/add", a.addAdminForm)
mux.HandleFunc("POST "+Prefix+"/admins/add", a.addAdmin)
mux.HandleFunc("GET "+Prefix+"/admins/{id}/edit", a.editAdminDomainsForm)
mux.HandleFunc("POST "+Prefix+"/admins/{id}/edit", a.editAdminDomains)
mux.HandleFunc("POST "+Prefix+"/admins/{id}/reset_mfa", a.resetAdminMFA)
mux.HandleFunc("POST "+Prefix+"/admins/{id}/remove", a.removeAdmin)
mux.HandleFunc("GET "+Prefix+"/domains", a.domainsList)
mux.HandleFunc("GET "+Prefix+"/domains/add", a.addDomainForm)
mux.HandleFunc("POST "+Prefix+"/domains/add", a.addDomain)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/delete", a.toggleDomainOff)
mux.HandleFunc("GET "+Prefix+"/domains/{id}/edit", a.editDomainForm)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/edit", a.editDomain)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/toggle", a.toggleDomain)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/remove", a.removeDomain)
mux.HandleFunc("POST "+Prefix+"/domains/{id}/verify_check", a.verifyDomainCheck)
mux.HandleFunc("GET "+Prefix+"/senders", a.sendersList)
mux.HandleFunc("GET "+Prefix+"/senders/add", a.addSenderForm)
mux.HandleFunc("POST "+Prefix+"/senders/add", a.addSender)
mux.HandleFunc("POST "+Prefix+"/senders/{id}/delete", a.disableSender)
mux.HandleFunc("POST "+Prefix+"/senders/{id}/enable", a.enableSender)
mux.HandleFunc("POST "+Prefix+"/senders/{id}/remove", a.removeSender)
mux.HandleFunc("GET "+Prefix+"/senders/{id}/edit", a.editSenderForm)
mux.HandleFunc("POST "+Prefix+"/senders/{id}/edit", a.editSender)
mux.HandleFunc("GET "+Prefix+"/mailboxes", a.mailboxesList)
mux.HandleFunc("GET "+Prefix+"/mailboxes/add", a.addMailboxForm)
mux.HandleFunc("POST "+Prefix+"/mailboxes/add", a.addMailbox)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/delete", a.disableMailbox)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/enable", a.enableMailbox)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/reset_mfa", a.resetMailboxMFA)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/remove", a.removeMailbox)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/edit", a.editMailboxForm)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/edit", a.editMailbox)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/apppasswords", a.appPasswordsList)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/apppasswords/add", a.addAppPassword)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/apppasswords/{pw_id}/revoke", a.revokeAppPassword)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/aliases", a.aliasesList)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/aliases/add", a.addAlias)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/aliases/{alias_id}/remove", a.removeAlias)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/lists", a.listsPage)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/lists/add", a.addAllowBlockEntry)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/lists/{entry_id}/remove", a.removeAllowBlockEntry)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/rules", a.rulesList)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/rules/add", a.addRule)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/rules/{rule_id}/remove", a.removeRule)
mux.HandleFunc("GET "+Prefix+"/ips", a.ipsList)
mux.HandleFunc("GET "+Prefix+"/ips/add", a.addIPForm)
mux.HandleFunc("POST "+Prefix+"/ips/add", a.addIP)
mux.HandleFunc("POST "+Prefix+"/ips/{id}/delete", a.disableIP)
mux.HandleFunc("POST "+Prefix+"/ips/{id}/enable", a.enableIP)
mux.HandleFunc("POST "+Prefix+"/ips/{id}/remove", a.removeIP)
mux.HandleFunc("GET "+Prefix+"/ips/{id}/edit", a.editIPForm)
mux.HandleFunc("POST "+Prefix+"/ips/{id}/edit", a.editIP)
mux.HandleFunc("GET "+Prefix+"/dkim", a.dkimList)
mux.HandleFunc("POST "+Prefix+"/dkim/create", a.createDKIM)
mux.HandleFunc("POST "+Prefix+"/dkim/{id}/regenerate", a.regenerateDKIM)
mux.HandleFunc("GET "+Prefix+"/dkim/{id}/edit", a.editDKIMForm)
mux.HandleFunc("POST "+Prefix+"/dkim/{id}/edit", a.editDKIM)
mux.HandleFunc("POST "+Prefix+"/dkim/{id}/toggle", a.toggleDKIM)
mux.HandleFunc("POST "+Prefix+"/dkim/{id}/remove", a.removeDKIM)
mux.HandleFunc("POST "+Prefix+"/dkim/check_dns", a.checkDKIMDNS)
mux.HandleFunc("POST "+Prefix+"/dkim/check_spf", a.checkSPFDNS)
// Server-wide config, not scoped to any domain — a domain-scoped admin has no
// business reading or changing these, so every route here is global-admin-only.
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.requireGlobalAdmin(a.letsEncryptPage))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.requireGlobalAdmin(a.letsEncryptSave))
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.requireGlobalAdmin(a.letsEncryptObtainNow))
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.requireGlobalAdmin(a.uploadGCloudServiceAccount))
mux.HandleFunc("GET "+Prefix+"/logs", a.logs)
mux.HandleFunc("GET "+Prefix+"/settings", a.requireGlobalAdmin(a.settingsPage))
mux.HandleFunc("POST "+Prefix+"/settings_update", a.requireGlobalAdmin(a.settingsUpdate))
mux.HandleFunc("POST "+Prefix+"/api/settings/test_database", a.requireGlobalAdmin(a.testDatabaseConnection))
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_cert", a.requireGlobalAdmin(a.uploadCert))
mux.HandleFunc("POST "+Prefix+"/api/settings/upload_key", a.requireGlobalAdmin(a.uploadKey))
mux.HandleFunc("GET "+Prefix+"/api/settings/get_public_ip", a.requireGlobalAdmin(a.getServerIP))
mux.HandleFunc("POST "+Prefix+"/test_attachments_path", a.requireGlobalAdmin(a.testAttachmentsPath))
mux.HandleFunc("GET "+Prefix+"/msg/content/{id}", a.viewMessageContent)
mux.HandleFunc("GET "+Prefix+"/msg/attachment/{id}/download", a.downloadAttachment)
mux.HandleFunc("GET "+Prefix+"/msg/attachment/{id}/delete", a.deleteAttachment)
mux.HandleFunc("POST "+Prefix+"/msg/attachment/{id}/delete", a.deleteAttachment)
mux.HandleFunc("GET "+Prefix, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, Prefix+"/", http.StatusFound)
})
outer.Handle(Prefix+"/", a.requireAuth(mux))
return outer
}