// 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("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("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 }