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": func(layout string, t time.Time) string { if t.IsZero() { return "" } return t.Format(pyToGoLayout(layout)) }, "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 }, // 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", "ips.html", "add_ip.html", "edit_ip.html", "dkim.html", "edit_dkim.html", "settings.html", "logs.html", "view_message_content.html", "error.html", "account.html", "first_login.html", "totp_setup.html", "admins.html", "add_admin.html", "edit_admin.html", } // standalonePages are pre-login screens — they intentionally don't use base.html's // sidebar/dashboard chrome, since the visitor isn't authenticated yet. var standalonePages = []string{"login.html", "login_mfa.html"} // 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/"+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()) t, err := t.ParseFS(assets, "templates/"+page) 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{} } 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() 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 }