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

227 lines
7.5 KiB
Go

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