// 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" "net/netip" "time" "gopkg.in/ini.v1" "mailgoserver/internal/acmecert" "mailgoserver/internal/db" "mailgoserver/internal/dkim" "mailgoserver/internal/mailstore" "mailgoserver/internal/relay" "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 Relay *relay.Relay // used by the webmail client's compose/send (see webmail_compose.go) Cfg *ini.File ConfigPath string Logger *toolbox.Logger SMTPUp func() bool // reports whether the SMTP listeners are currently running // pgpKeys caches unlocked PGP identities for the rest of a login session — purely // in-memory server state, not an external dependency, so it's built internally // rather than threaded in as a New(...) parameter. S/MIME has no equivalent cache // since its private keys are stored plain, not passphrase-protected. pgpKeys *pgpKeyCache // trustedProxies gates requestIP's use of forwarded headers — see trusted_proxy.go. trustedProxies []netip.Prefix // loginLimiter throttles login POSTs per source IP — see ratelimit.go. Separate // from the per-account lockout (login.go/webmail_login.go), which uses // esrv_auth_logs via CountRecentFailedAttempts instead of in-memory state. loginLimiter *ipRateLimiter // appSecret signs CSRF tokens — see csrf.go and LoadOrCreateAppSecret (secret.go). appSecret []byte 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. // appSecret is loaded by the caller via LoadOrCreateAppSecret, mirroring how // mailstore's master key is loaded in main.go and threaded in rather than resolved // internally (both are file paths relative to the app's root working directory, // which this package doesn't otherwise know). func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) { trustedProxies := parseTrustedProxies(cfg.Section("Server").Key("trusted_proxies").MustString(""), logger) a := &App{ DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp, pgpKeys: newPGPKeyCache(), trustedProxies: trustedProxies, loginLimiter: newIPRateLimiter(20, time.Minute), appSecret: appSecret, } 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))) // Webmail templates reference the same vendored assets (Bootstrap/Quill/etc. — // see static/vendor/) but live under a different URL prefix, so they need their // own route to the identical embedded files rather than reaching across into the // admin prefix. outer.Handle("GET "+MailboxPrefix+"/static/", http.StripPrefix(MailboxPrefix+"/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.webmailMailRoot) webmailMux.HandleFunc("GET "+MailboxPrefix+"/account", 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) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail", a.webmailMailRoot) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/compose", a.webmailComposeForm) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/compose", a.webmailComposeSend) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/save-draft", a.webmailComposeSaveDraft) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/search", a.webmailSearch) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/recipients", a.webmailRecipientSuggest) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}", a.webmailFolderView) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}", a.webmailMessageView) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/delete", a.webmailMessageDelete) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove) webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder) webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder) webmailMux.HandleFunc("GET "+MailboxPrefix+"/rules", a.webmailRulesList) webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/add", a.webmailAddRule) webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/{rule_id}/remove", a.webmailRemoveRule) webmailMux.HandleFunc("GET "+MailboxPrefix+"/certs", a.webmailCertsPage) webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/generate", a.webmailSMIMEGenerate) webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/import", a.webmailSMIMEImport) webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/{identity_id}/remove", a.webmailSMIMERemoveIdentity) webmailMux.HandleFunc("GET "+MailboxPrefix+"/smime/identity/{identity_id}/download", a.webmailSMIMEDownloadCert) webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/contacts/add", a.webmailSMIMEAddContact) webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/contacts/{contact_id}/remove", a.webmailSMIMERemoveContact) webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/identity/generate", a.webmailPGPGenerate) webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/identity/import", a.webmailPGPImport) webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/identity/{identity_id}/remove", a.webmailPGPRemoveIdentity) webmailMux.HandleFunc("GET "+MailboxPrefix+"/pgp/identity/{identity_id}/download", a.webmailPGPDownloadKey) webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/unlock", a.webmailPGPUnlock) webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/contacts/add", a.webmailPGPAddContact) webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/contacts/{contact_id}/remove", a.webmailPGPRemoveContact) 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+"/blacklist", a.requireGlobalAdmin(a.blacklistPage)) mux.HandleFunc("POST "+Prefix+"/blacklist/add", a.requireGlobalAdmin(a.addBlacklistEntry)) mux.HandleFunc("POST "+Prefix+"/blacklist/{id}/remove", a.requireGlobalAdmin(a.removeBlacklistEntry)) mux.HandleFunc("POST "+Prefix+"/blacklist/{id}/whitelist", a.requireGlobalAdmin(a.whitelistBlacklistedIP)) mux.HandleFunc("POST "+Prefix+"/abuse-whitelist/add", a.requireGlobalAdmin(a.addAbuseWhitelistEntry)) mux.HandleFunc("POST "+Prefix+"/abuse-whitelist/{id}/remove", a.requireGlobalAdmin(a.removeAbuseWhitelistEntry)) 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("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 }