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
Address INBOX Total Storage Quota Last message
{{range .Accounts}}
{{.Email}} {{.Messages}} {{.Total}} {{.Storage | humanBytes}} {{if .Quota}}{{.Quota}}{{else}}—{{end}} {{if .LastMessage.IsZero}}—{{else}}{{.LastMessage | formatTime}}{{end}}
{{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)
`))
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}}
text html
{{if .Msg.Text}}{{.Msg.Text}} {{else}}(no text part) {{end}}
{{if .Msg.HTML}}{{.Msg.HTML}} {{else}}(no html part) {{end}}
Attachments
{{range .Msg.Attachments}}{{if .Filename}}{{.Filename}}{{else}}(unnamed) {{end}} · {{.Size | humanBytes}}{{if .CID}} · cid: {{.CID}}{{end}} {{else}}none {{end}}
Headers
`))
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)
}
}