feat(mail-admin): read-only account view for mail.produktor.io (#74)

- admin/: Go stdlib-only HTTP viewer, lists accounts from
  config/postfix-accounts.cf with per-mailbox message counts (INBOX and
  total, same numbers doveadm reports) and storage; quota from
  dovecot-quotas.cf; Basic Auth from .env; offline tests vs fixtures
  (go test -race ./...)
- compose: mail-admin service, build admin/Dockerfile, publishes
  127.0.0.1:19945 / 172.17.0.1:19945; config and mail-data mounted :ro,
  no docker socket
- NPM proxy host 66: location /admin/ -> 172.17.0.1:19945
- README: account admin section
This commit is contained in:
2026-09-01 15:07:55 +01:00
parent e00c6950ed
commit 5a46ba4b33
22 changed files with 790 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func testServer(t *testing.T) *server {
t.Helper()
return &server{
accountsPath: "testdata/accounts.cf",
quotaPath: "testdata/quotas.cf",
maildirRoot: "testdata/mail",
basePath: "/admin",
user: "bot",
pass: "s3cret",
}
}
func doAuth(t *testing.T, h http.Handler, path, user, pass string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if user != "" {
req.SetBasicAuth(user, pass)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestBasicAuth(t *testing.T) {
h := testServer(t).handler()
if rec := doAuth(t, h, "/admin/", "", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("no creds: code = %d, want 401", rec.Code)
}
if rec := doAuth(t, h, "/admin/", "bot", "wrong"); rec.Code != http.StatusUnauthorized {
t.Errorf("wrong pass: code = %d, want 401", rec.Code)
}
if rec := doAuth(t, h, "/admin/", "bot", "s3cret"); rec.Code != http.StatusOK {
t.Errorf("right creds: code = %d, want 200", rec.Code)
}
}
func TestBasePathRedirect(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/", "bot", "s3cret")
if rec.Code != http.StatusFound {
t.Fatalf("code = %d, want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/admin/" {
t.Errorf("Location = %q, want /admin/", loc)
}
}
func TestNotFoundOutsideBasePath(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/other", "bot", "s3cret")
if rec.Code != http.StatusNotFound {
t.Errorf("code = %d, want 404", rec.Code)
}
}
func TestAPIAccounts(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/admin/api/accounts", "bot", "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, want 200: %s", rec.Code, rec.Body.String())
}
var resp accountsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Accounts) != 5 {
t.Fatalf("got %d accounts, want 5", len(resp.Accounts))
}
byEmail := map[string]viewAccount{}
for _, a := range resp.Accounts {
byEmail[a.Email] = a
}
info, ok := byEmail["info@produktor.io"]
if !ok {
t.Fatal("info@produktor.io missing")
}
if info.Messages != 3 {
t.Errorf("info Messages = %d, want 3 (fixture)", info.Messages)
}
if info.Total != 4 {
t.Errorf("info Total = %d, want 4 (incl. .Sent)", info.Total)
}
if info.Quota != "500M" {
t.Errorf("info Quota = %q, want 500M", info.Quota)
}
if byEmail["postman@produktor.io"].Messages != 0 {
t.Errorf("postman Messages = %d, want 0", byEmail["postman@produktor.io"].Messages)
}
if byEmail["andriy.oblivantsev@produktor.io"].Messages != 0 {
t.Errorf("andriy Messages = %d, want 0 (no maildir)", byEmail["andriy.oblivantsev@produktor.io"].Messages)
}
if byEmail["postmaster@produktor.io"].Messages != 1 {
t.Errorf("postmaster Messages = %d, want 1", byEmail["postmaster@produktor.io"].Messages)
}
}
func TestIndexPageRenders(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/admin/", "bot", "s3cret")
body := rec.Body.String()
if !strings.Contains(body, "info@produktor.io") {
t.Error("page does not list info@produktor.io")
}
if !strings.Contains(body, "Mail accounts") {
t.Error("page title missing")
}
}