227 lines
7.5 KiB
Go
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
|
|
}
|