Files
mail-server/admin/server.go
T

508 lines
17 KiB
Go

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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mail admin — produktor.io</title>
<style>
:root { color-scheme: light dark; }
body { font: 14px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 56rem; padding: 0 1rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid #4446; }
th { border-bottom-width: 2px; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
a { color: #3b82f6; }
.meta { color: #888; margin: .5rem 0 1.5rem; }
</style>
</head>
<body>
<h1>Mail accounts — mail.produktor.io</h1>
<p class="meta">Source: <code>config/postfix-accounts.cf</code> (read-only). Updated {{.Updated | formatTime}} · <a href="{{.Base}}api/accounts">JSON</a> · <a href="{{.Base}}messages">All messages</a></p>
<table>
<thead><tr><th>Address</th><th class="num">INBOX</th><th class="num">Total</th><th class="num">Storage</th><th>Quota</th><th>Last message</th></tr></thead>
<tbody>
{{range .Accounts}}
<tr><td>{{.Email}}</td><td class="num">{{.Messages}}</td><td class="num">{{.Total}}</td><td class="num">{{.Storage | humanBytes}}</td><td>{{if .Quota}}{{.Quota}}{{else}}—{{end}}</td><td>{{if .LastMessage.IsZero}}—{{else}}{{.LastMessage | formatTime}}{{end}}</td></tr>
{{end}}
</tbody>
</table>
<p class="meta">Read-only view. Mailbox contents are managed via docker-mailserver (doveadm / Roundcube login).</p>
</body>
</html>`))
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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>All messages — mail.produktor.io</title>
<style>
:root { color-scheme: light dark; }
body { font: 14px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 64rem; padding: 0 1rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid #4446; }
th { border-bottom-width: 2px; }
td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
tr.mb td { background: #3b82f61a; font-weight: 600; border-top: 2px solid #4446; }
a { color: #3b82f6; }
.meta { color: #888; margin: .5rem 0 1rem; }
#q { width: 100%; box-sizing: border-box; padding: .5rem .6rem; font: inherit; margin-bottom: 1rem; }
</style>
</head>
<body>
<h1>All messages — mail.produktor.io</h1>
<p class="meta"><a href="{{.Base}}">Accounts</a> · <a href="{{.Base}}api/messages">JSON</a> · updated {{.Updated | formatTime}} · read-only view of the Maildir on disk (no IMAP)</p>
<input id="q" placeholder="Filter by subject / from…" autofocus>
<table id="msgs">
<thead><tr><th>Date</th><th>From</th><th>Subject</th><th class="num">Size</th></tr></thead>
<tbody>
{{range .Groups}}
<tr class="mb"><td colspan="4">{{.Mailbox}} · {{.Count}}</td></tr>
{{range .Messages}}
<tr class="row" data-s="{{lower .Subject}} {{lower .From}}">
<td>{{if .Date.IsZero}}—{{else}}{{.Date | formatTime}}{{end}}</td>
<td>{{.From}}</td>
<td><a href="{{$.Base}}message/{{urlPath .Mailbox}}/{{urlPath .Filename}}">{{if .Subject}}{{.Subject}}{{else}}<i>(no subject)</i>{{end}}</a></td>
<td class="num">{{.Size | humanBytes}}</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
<script>
const q = document.getElementById('q');
q.addEventListener('input', () => {
const t = q.value.trim().toLowerCase();
for (const row of document.querySelectorAll('#msgs tr.row')) {
row.style.display = row.dataset.s.includes(t) ? '' : 'none';
}
for (const g of document.querySelectorAll('#msgs tr.mb')) {
let any = false;
for (let n = g.nextElementSibling; n && !n.classList.contains('mb'); n = n.nextElementSibling) {
if (n.style.display !== 'none') any = true;
}
g.style.display = any ? '' : 'none';
}
});
</script>
</body>
</html>`))
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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Msg.Subject}}{{.Msg.Subject}}{{else}}Message{{end}} — mail.produktor.io</title>
<style>
:root { color-scheme: light dark; }
body { font: 14px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 56rem; padding: 0 1rem; }
a { color: #3b82f6; }
.meta { color: #888; margin: .5rem 0 1rem; }
h1 { margin-bottom: .25rem; }
pre { background: #00000022; border: 1px solid #4446; padding: .8rem; white-space: pre-wrap; word-break: break-word; }
table.headers { border-collapse: collapse; width: 100%; }
table.headers th, table.headers td { text-align: left; vertical-align: top; padding: .3rem .6rem; border-bottom: 1px solid #4446; }
table.headers th { width: 12rem; color: #888; font-weight: 600; }
.tabs { margin: 1rem 0 .5rem; }
.tabs button { font: inherit; padding: .35rem .9rem; cursor: pointer; border: 1px solid #4446; background: #00000022; }
.tabs button.on { background: #3b82f6; color: #fff; }
</style>
</head>
<body>
<p class="meta"><a href="{{.Base}}messages">← All messages</a></p>
<h1>{{if .Msg.Subject}}{{.Msg.Subject}}{{else}}<i>(no subject)</i>{{end}}</h1>
<p class="meta">{{.Msg.From}} → {{.Msg.To}} · {{.Mailbox}} · {{.Size | humanBytes}}</p>
<div class="tabs"><button id="tab-text" class="on">text</button><button id="tab-html">html</button></div>
{{if .Msg.Text}}<pre id="view-text">{{.Msg.Text}}</pre>{{else}}<pre id="view-text"><i>(no text part)</i></pre>{{end}}
{{if .Msg.HTML}}<pre id="view-html" hidden>{{.Msg.HTML}}</pre>{{else}}<pre id="view-html" hidden><i>(no html part)</i></pre>{{end}}
<h2>Attachments</h2>
<ul>
{{range .Msg.Attachments}}<li>{{if .Filename}}{{.Filename}}{{else}}<i>(unnamed)</i>{{end}} · {{.Size | humanBytes}}{{if .CID}} · cid: {{.CID}}{{end}}</li>{{else}}<li>none</li>{{end}}
</ul>
<h2>Headers</h2>
<table class="headers">
{{range .Rows}}<tr><th>{{.Name}}</th><td>{{.Value}}</td></tr>{{end}}
</table>
<script>
const text = document.getElementById('view-text');
const html = document.getElementById('view-html');
const bt = document.getElementById('tab-text');
const bh = document.getElementById('tab-html');
function show(which) {
text.hidden = which !== 'text';
html.hidden = which !== 'html';
bt.classList.toggle('on', which === 'text');
bh.classList.toggle('on', which === 'html');
}
bt.addEventListener('click', () => show('text'));
bh.addEventListener('click', () => show('html'));
</script>
</body>
</html>`))
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)
}
}