186 lines
5.6 KiB
Go
186 lines
5.6 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": 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
|
|
}
|