package main import ( "crypto/subtle" "encoding/json" "errors" "html/template" "io/fs" "log" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" ) // server is the read-only mail accounts viewer. type server struct { accountsPath string // config/postfix-accounts.cf quotaPath string // config/dovecot-quotas.cf maildirRoot string // data/mail-data basePath string // URL prefix when served behind NPM ("" = root) user, pass string // Basic Auth credentials } // viewAccount is one row of the account table. type viewAccount struct { Email string `json:"email"` Messages int64 `json:"messages"` // INBOX messages Total int64 `json:"total"` // messages across all mailboxes Storage int64 `json:"storage"` // bytes across all mailboxes Quota string `json:"quota"` // limit from dovecot-quotas.cf, "" = none LastMessage time.Time `json:"last_message"` // newest message mtime } type accountsResponse struct { Updated time.Time `json:"updated"` Accounts []viewAccount `json:"accounts"` } var templateFuncs = template.FuncMap{ "humanBytes": humanBytes, "formatTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 MST") }, } var indexTmpl = template.Must(template.New("index").Funcs(templateFuncs).Parse(` Mail admin — produktor.io

Mail accounts — mail.produktor.io

Source: config/postfix-accounts.cf (read-only). Updated {{.Updated | formatTime}} · JSON

{{range .Accounts}} {{end}}
AddressINBOXTotalStorageQuotaLast message
{{.Email}}{{.Messages}}{{.Total}}{{.Storage | humanBytes}}{{if .Quota}}{{.Quota}}{{else}}—{{end}}{{if .LastMessage.IsZero}}—{{else}}{{.LastMessage | formatTime}}{{end}}

Read-only view. Mailbox contents are managed via docker-mailserver (doveadm / Roundcube login).

`)) func humanBytes(b int64) string { switch { case b >= 1<<30: return trimFrac(float64(b)/(1<<30)) + " GiB" case b >= 1<<20: return trimFrac(float64(b)/(1<<20)) + " MiB" case b >= 1<<10: return trimFrac(float64(b)/(1<<10)) + " KiB" default: return trimFrac(float64(b)) + " B" } } func trimFrac(f float64) string { s := strings.TrimRight(strings.TrimRight(strconv.FormatFloat(f, 'f', 1, 64), "0"), ".") if s == "-0" || s == "" { return "0" } return s } func (s *server) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", s.route) return s.basicAuth(mux) } // route strips the configured basePath prefix (NPM location /admin/) and // dispatches to the page or the JSON API. Direct access without the prefix // is redirected there. func (s *server) route(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if s.basePath != "" { switch { case path == "/": http.Redirect(w, r, s.basePath+"/", http.StatusFound) return case path == s.basePath: http.Redirect(w, r, s.basePath+"/", http.StatusFound) return case strings.HasPrefix(path, s.basePath+"/"): path = strings.TrimPrefix(path, s.basePath) default: http.NotFound(w, r) return } } switch { case path == "/" || path == "/index.html": s.handleIndex(w, r) case path == "/api/accounts": s.handleAPI(w, r) default: http.NotFound(w, r) } } // basicAuth protects every route with HTTP Basic Auth credentials from the // environment (the same pattern as the other produktor internal UIs). func (s *server) basicAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() userOK := subtle.ConstantTimeCompare([]byte(user), []byte(s.user)) == 1 passOK := subtle.ConstantTimeCompare([]byte(pass), []byte(s.pass)) == 1 if !ok || !userOK || !passOK { w.Header().Set("WWW-Authenticate", `Basic realm="mail-admin"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } // collect builds the full account view: accounts file + per-mailbox stats. func (s *server) collect() (accountsResponse, error) { f, err := os.Open(s.accountsPath) if err != nil { return accountsResponse{}, err } accounts, err := parseAccounts(f) f.Close() if err != nil { return accountsResponse{}, err } quotas, err := s.loadQuotas() if err != nil { return accountsResponse{}, err } resp := accountsResponse{Updated: time.Now().UTC()} for _, a := range accounts { va := viewAccount{Email: a.Email, Quota: quotas[a.Email]} local, domain, ok := strings.Cut(a.Email, "@") if ok && local != "" && domain != "" { st, err := statMaildir(filepath.Join(s.maildirRoot, domain, local)) if err != nil { log.Printf("statMaildir(%s): %v", a.Email, err) continue } va.Messages, va.Total, va.Storage, va.LastMessage = st.Messages, st.Total, st.Storage, st.Newest } resp.Accounts = append(resp.Accounts, va) } sort.Slice(resp.Accounts, func(i, j int) bool { return resp.Accounts[i].Email < resp.Accounts[j].Email }) return resp, nil } func (s *server) loadQuotas() (map[string]string, error) { f, err := os.Open(s.quotaPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { return map[string]string{}, nil // no quota file: no limits } return nil, err } defer f.Close() return parseQuotas(f) } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { resp, err := s.collect() if err != nil { log.Printf("collect: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := indexTmpl.Execute(w, struct { Updated time.Time Accounts []viewAccount Base string }{resp.Updated, resp.Accounts, s.basePath + "/"}); err != nil { log.Printf("render: %v", err) } } func (s *server) handleAPI(w http.ResponseWriter, r *http.Request) { resp, err := s.collect() if err != nil { log.Printf("collect: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") enc := json.NewEncoder(w) enc.SetIndent("", " ") enc.Encode(resp) }