package main import ( "crypto/subtle" "encoding/json" "errors" "html/template" "io/fs" "log" "net/http" "net/url" "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") }, "urlPath": url.PathEscape, // safe embedding of mailbox/filename in a URL path segment "lower": strings.ToLower, } 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 · All messages

{{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 == "/messages": s.handleMessagesPage(w, r) case strings.HasPrefix(path, "/message/"): s.handleMessagePage(w, r, strings.TrimPrefix(path, "/message/")) case path == "/api/accounts": s.handleAPI(w, r) case path == "/api/messages": s.handleMessagesAPI(w, r) case strings.HasPrefix(path, "/api/messages/"): s.handleMessageAPI(w, r, strings.TrimPrefix(path, "/api/messages/")) 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) } // parseLimit turns a limit query value into an int within [1, max]; // empty/invalid values fall back to def. func parseLimit(v string, def, max int) int { if v == "" { return def } n, err := strconv.Atoi(v) if err != nil || n < 1 { return def } if n > max { return max } return n } // splitPath2 splits the remainder of a two-segment route into exactly two // non-empty parts; anything else (traversal, extra segments) fails. func splitPath2(rest string) (a, b string, ok bool) { parts := strings.Split(rest, "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "", false } return parts[0], parts[1], true } func (s *server) handleMessagesAPI(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() msgs, err := listMessages(s.maildirRoot, q.Get("mailbox"), q.Get("q"), parseLimit(q.Get("limit"), 100, 500)) if err != nil { log.Printf("listMessages: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } resp := messagesResponse{Updated: time.Now().UTC(), Count: len(msgs), Messages: msgs} w.Header().Set("Content-Type", "application/json; charset=utf-8") enc := json.NewEncoder(w) enc.SetIndent("", " ") enc.Encode(resp) } func (s *server) handleMessageAPI(w http.ResponseWriter, r *http.Request, rest string) { mailbox, filename, ok := splitPath2(rest) if !ok { http.NotFound(w, r) return } d, err := readMessage(s.maildirRoot, mailbox, filename) switch { case errors.Is(err, errBadMailbox): http.Error(w, "invalid mailbox", http.StatusBadRequest) case errors.Is(err, fs.ErrNotExist): http.NotFound(w, r) case err != nil: log.Printf("readMessage(%s, %s): %v", mailbox, filename, err) http.Error(w, err.Error(), http.StatusInternalServerError) default: w.Header().Set("Content-Type", "application/json; charset=utf-8") enc := json.NewEncoder(w) enc.SetIndent("", " ") enc.Encode(d) } } // messagesGroup renders one mailbox section of the unified inbox page. type messagesGroup struct { Mailbox string Count int Messages []messageSummary } var messagesTmpl = template.Must(template.New("messages").Funcs(templateFuncs).Parse(` All messages — mail.produktor.io

All messages — mail.produktor.io

Accounts · JSON · updated {{.Updated | formatTime}} · read-only view of the Maildir on disk (no IMAP)

{{range .Groups}} {{range .Messages}} {{end}} {{end}}
DateFromSubjectSize
{{.Mailbox}} · {{.Count}}
{{if .Date.IsZero}}—{{else}}{{.Date | formatTime}}{{end}} {{.From}} {{if .Subject}}{{.Subject}}{{else}}(no subject){{end}} {{.Size | humanBytes}}
`)) func (s *server) handleMessagesPage(w http.ResponseWriter, r *http.Request) { msgs, err := listMessages(s.maildirRoot, "", "", 500) if err != nil { log.Printf("listMessages: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } var groups []messagesGroup for _, m := range msgs { if len(groups) == 0 || groups[len(groups)-1].Mailbox != m.Mailbox { groups = append(groups, messagesGroup{Mailbox: m.Mailbox}) } g := &groups[len(groups)-1] g.Count++ g.Messages = append(g.Messages, m) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := messagesTmpl.Execute(w, struct { Updated time.Time Base string Groups []messagesGroup }{time.Now().UTC(), s.basePath + "/", groups}); err != nil { log.Printf("render messages: %v", err) } } // headerPair is one row of the header table on the message page. type headerPair struct { Name, Value string } // preferredHeaderOrder lists the most useful headers first; the rest follow // sorted alphabetically. var preferredHeaderOrder = []string{"From", "To", "Subject", "Date", "Message-Id", "Mime-Version", "Content-Type"} func orderedHeaders(h headerMap) []headerPair { seen := map[string]bool{} var rows []headerPair for _, name := range preferredHeaderOrder { if v := h.Get(name); v != "" { rows = append(rows, headerPair{name, v}) seen[name] = true } } var rest []string for name := range h { if !seen[name] { rest = append(rest, name) } } sort.Strings(rest) for _, name := range rest { rows = append(rows, headerPair{name, h.Get(name)}) } return rows } var messageTmpl = template.Must(template.New("message").Funcs(templateFuncs).Parse(` {{if .Msg.Subject}}{{.Msg.Subject}}{{else}}Message{{end}} — mail.produktor.io

← All messages

{{if .Msg.Subject}}{{.Msg.Subject}}{{else}}(no subject){{end}}

{{.Msg.From}} → {{.Msg.To}} · {{.Mailbox}} · {{.Size | humanBytes}}

{{if .Msg.Text}}
{{.Msg.Text}}
{{else}}
(no text part)
{{end}} {{if .Msg.HTML}}{{else}}{{end}}

Attachments

Headers

{{range .Rows}}{{end}}
{{.Name}}{{.Value}}
`)) func (s *server) handleMessagePage(w http.ResponseWriter, r *http.Request, rest string) { mailbox, filename, ok := splitPath2(rest) if !ok { http.NotFound(w, r) return } d, err := readMessage(s.maildirRoot, mailbox, filename) switch { case errors.Is(err, errBadMailbox): http.Error(w, "invalid mailbox", http.StatusBadRequest) case errors.Is(err, fs.ErrNotExist): http.NotFound(w, r) case err != nil: log.Printf("readMessage(%s, %s): %v", mailbox, filename, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := messageTmpl.Execute(w, struct { Base string Mailbox string Size int64 Msg messageDetail Rows []headerPair }{s.basePath + "/", mailbox, d.Size, d, orderedHeaders(d.Headers)}); err != nil { log.Printf("render message: %v", err) } }