package webui import ( "fmt" "html/template" "net/http" "strconv" "strings" "time" "gopkg.in/ini.v1" "mailgoserver/internal/db" ) // M is the per-page template data map — mirrors the kwargs Flask's render_template(...) // is called with. Using a map (not per-page structs) keeps 18 template contexts from // needing 18 Go struct types. type M map[string]any func (a *App) funcMap() template.FuncMap { return template.FuncMap{ "formatDatetime": func(t time.Time) string { return formatDatetimeInZone(t, a.Cfg) }, // strftime accepts either time.Time or *time.Time (nullable DB columns like // MailboxAppPassword.LastUsedAt) so callers don't need a separate deref helper. "strftime": func(layout string, v any) string { var t time.Time switch tv := v.(type) { case time.Time: t = tv case *time.Time: if tv == nil { return "" } t = *tv default: return "" } if t.IsZero() { return "" } return t.Format(pyToGoLayout(layout)) }, // isPast reports whether a nullable expiry timestamp has already passed — // used to badge an app password as "Expired" even while is_active is // still 1 (expiry and revocation are independent states). "isPast": func(t *time.Time) bool { return t != nil && t.Before(time.Now()) }, "ruleSummary": summarizeConditions, "title": strings.Title, "upper": strings.ToUpper, "lower": strings.ToLower, "safe": func(s string) template.HTML { return template.HTML(s) }, "filesize": humanFileSize, "dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "eq2": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) }, // dget looks up an optional map key, returning "" if absent — mirrors Jinja's // `x if x is defined else ''` pattern used for context vars only some pages set // (e.g. sidebar badge counts, which only dashboard passes). "dget": func(m M, key string) any { if v, ok := m[key]; ok { return v } return "" }, "list": func(items ...string) []string { return items }, "isStandardFolder": isStandardFolder, // emailOverallStatus mirrors the delivered/failed selectattr computation // dashboard.html and logs.html both do in the Python templates. "emailOverallStatus": func(recipients []db.EmailRecipientLog) string { delivered, failed := 0, 0 for _, r := range recipients { if r.Status == "success" { delivered++ } else { failed++ } } switch { case delivered > 0 && failed > 0: return "partial" case delivered > 0: return "relayed" default: return "failed" } }, } } // pyToGoLayout converts the handful of Python strftime directives this app actually // uses into Go's reference-time layout. func pyToGoLayout(py string) string { r := strings.NewReplacer( "%Y", "2006", "%m", "01", "%d", "02", "%H", "15", "%M", "04", "%S", "05", ) return r.Replace(py) } func formatDatetimeInZone(t time.Time, cfg *ini.File) string { if t.IsZero() { return "" } tzName := cfg.Section("Server").Key("time_zone").MustString("UTC") loc, err := time.LoadLocation(tzName) if err != nil { loc = time.UTC } return t.In(loc).Format("2006-01-02 15:04:05") } func humanFileSize(size int64) string { const unit = 1024 if size < unit { return fmt.Sprintf("%d B", size) } div, exp := int64(unit), 0 for n := size / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp]) } // pages lists every template that extends base.html — each gets its own isolated // template set (base.html + sidebar_email.html + itself) so their same-named // {{define "content"}} blocks don't collide with each other (see loadTemplates). var pages = []string{ "dashboard.html", "domains.html", "add_domain.html", "edit_domain.html", "senders.html", "add_sender.html", "edit_sender.html", "mailboxes.html", "add_mailbox.html", "edit_mailbox.html", "mailbox_apppasswords.html", "mailbox_aliases.html", "mailbox_lists.html", "mailbox_rules.html", "ips.html", "add_ip.html", "edit_ip.html", "blacklist.html", "dkim.html", "edit_dkim.html", "settings.html", "letsencrypt.html", "logs.html", "view_message_content.html", "error.html", "account.html", "first_login.html", "admins.html", "add_admin.html", "edit_admin.html", } // standalonePages don't use base.html's sidebar/dashboard chrome: pre-login screens // (not authenticated yet) and the forced-MFA-setup flow (totp_setup.html included — // deliberately isolated so an account with MFA enforced but not yet configured has // no visible navigation to anything else, matching the enforcement gate in // requireAuth/requireMailboxAuth that blocks every other route anyway). var standalonePages = []string{ "login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html", "webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html", "webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html", } // pagesWithComposeWidget are the standalone pages that show a Compose/Reply/Forward // entry point and so need webmail_compose_widget.html's floating-popup markup+JS // parsed alongside them (see webmail_compose_widget.html's {{define "compose_widget"}}). // webmail_compose.html itself is excluded — it's what opens inside the popup, not // something that opens a popup of its own. var pagesWithComposeWidget = []string{ "webmail_folder.html", "webmail_message.html", "webmail_rules.html", "webmail_certs.html", "webmail_account.html", } func hasComposeWidget(page string) bool { for _, p := range pagesWithComposeWidget { if p == page { return true } } return false } // pagesWithShortcuts are the two pages keyboard shortcuts make sense on — the // message list (j/k/Enter/o) and a single open message (r/a/f/#). See // webmail_shortcuts.html's {{define "webmail_shortcuts"}}. var pagesWithShortcuts = []string{"webmail_folder.html", "webmail_message.html"} func hasShortcuts(page string) bool { for _, p := range pagesWithShortcuts { if p == page { return true } } return false } // loadTemplates parses from the embedded assets FS (see embed.go), not the // filesystem — the binary carries its own templates, so it runs from any working // directory without needing the source tree alongside it. func (a *App) loadTemplates() error { a.templates = map[string]*template.Template{} for _, page := range pages { t := template.New("base.html").Funcs(a.funcMap()) t, err := t.ParseFS(assets, "templates/base.html", "templates/sidebar_email.html", "templates/csrf_script.html", "templates/"+page) if err != nil { return fmt.Errorf("parse %s: %w", page, err) } a.templates[page] = t } for _, page := range standalonePages { t := template.New(page).Funcs(a.funcMap()) files := []string{"templates/" + page, "templates/csrf_script.html"} if hasComposeWidget(page) { files = append(files, "templates/webmail_compose_widget.html") } if hasShortcuts(page) { files = append(files, "templates/webmail_shortcuts.html") } t, err := t.ParseFS(assets, files...) if err != nil { return fmt.Errorf("parse %s: %w", page, err) } a.templates[page] = t } return nil } func isStandalonePage(page string) bool { for _, p := range standalonePages { if p == page { return true } } return false } // render executes the named page template — as itself for standalone (pre-login) // pages, or as "base.html" for everything else — mirroring flask.render_template. func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M) { t, ok := a.templates[page] if !ok { http.Error(w, "template not found: "+page, http.StatusInternalServerError) return } if data == nil { data = M{} } // Set unconditionally for every page — pages with no session cookie yet (login) // just get "", which csrf_script.html's injected script treats as a no-op. data["csrf_token"] = a.csrfTokenFor(r) if isStandalonePage(page) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, page, data); err != nil { a.Logger.Error("template render error (%s): %v", page, err) } return } data["flashes"] = popFlashes(w, r) data["health"] = a.checkHealth() // Drives the sidebar hiding Server Settings/Let's Encrypt for scoped admins (see // requireGlobalAdmin, which is the actual enforcement — this only controls the // link's visibility). if u := userFromContext(r); u != nil { data["is_global_admin"] = u.IsGlobalAdmin } // Sidebar badge counts (Domains/Senders/Mailboxes/IPs/DKIM Keys) — computed here, // centrally, so every authenticated page shows them, not just the dashboard (which // used to compute these itself and nowhere else did). counts := a.computeNavCounts(r) data["domain_count"] = counts.DomainCount data["sender_count"] = counts.SenderCount data["mailbox_count"] = counts.MailboxCount data["ip_count"] = counts.IPCount data["dkim_count"] = counts.DKIMCount data["blacklist_count"] = counts.BlacklistCount w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, "base.html", data); err != nil { a.Logger.Error("template render error (%s): %v", page, err) } } func atoi(s string) int { n, _ := strconv.Atoi(s) return n }