From c9bc594031292ed88648f27ca6697bf85ef2e0b6 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 1 Sep 2026 15:34:45 +0100 Subject: [PATCH] chore(mail): remove mail-admin (superseded by Dovecot shared folders) (#76) --- README.md | 33 +-- admin/.dockerignore | 2 - admin/Dockerfile | 15 -- admin/accounts.go | 37 --- admin/accounts_test.go | 48 ---- admin/go.mod | 3 - admin/maildir.go | 89 ------- admin/maildir_test.go | 52 ---- admin/main.go | 39 --- admin/quota.go | 30 --- admin/quota_test.go | 38 --- admin/server.go | 228 ------------------ admin/server_test.go | 117 --------- admin/testdata/accounts.cf | 7 - .../mail/produktor.io/ano/cur/1.fixtureano | 3 - .../produktor.io/info/.Sent/cur/9.fixturesent | 3 - .../mail/produktor.io/info/cur/1.fixture1 | 4 - .../mail/produktor.io/info/cur/2.fixture2 | 4 - .../mail/produktor.io/info/new/3.fixture3 | 4 - .../produktor.io/postmaster/new/1.fixturepm | 3 - admin/testdata/quotas.cf | 3 - compose.yaml | 28 --- 22 files changed, 1 insertion(+), 789 deletions(-) delete mode 100644 admin/.dockerignore delete mode 100644 admin/Dockerfile delete mode 100644 admin/accounts.go delete mode 100644 admin/accounts_test.go delete mode 100644 admin/go.mod delete mode 100644 admin/maildir.go delete mode 100644 admin/maildir_test.go delete mode 100644 admin/main.go delete mode 100644 admin/quota.go delete mode 100644 admin/quota_test.go delete mode 100644 admin/server.go delete mode 100644 admin/server_test.go delete mode 100644 admin/testdata/accounts.cf delete mode 100644 admin/testdata/mail/produktor.io/ano/cur/1.fixtureano delete mode 100644 admin/testdata/mail/produktor.io/info/.Sent/cur/9.fixturesent delete mode 100644 admin/testdata/mail/produktor.io/info/cur/1.fixture1 delete mode 100644 admin/testdata/mail/produktor.io/info/cur/2.fixture2 delete mode 100644 admin/testdata/mail/produktor.io/info/new/3.fixture3 delete mode 100644 admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm delete mode 100644 admin/testdata/quotas.cf diff --git a/README.md b/README.md index cf23917..8ff7f1e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Docker Compose mail stack for `mail.produktor.io` on arc-01, based on |---------|-----------|-------| | Mail server (DMS) | `mailserver` | 25 (SMTP), 465 (SMTPS), 587 (Submission STARTTLS), 143 (IMAP STARTTLS), 993 (IMAPS) | | Webmail (Roundcube) | `webmail` | 127.0.0.1:19944 / 172.17.0.1:19944 (HTTP, behind NPM) | -| Account admin (read-only) | `mail-admin` | 127.0.0.1:19945 / 172.17.0.1:19945 (HTTP, behind NPM) | +| Account admin | — (removed) | — | ## Accounts @@ -57,37 +57,6 @@ The webmail stores its sqlite database (addressbook, settings) in `data/roundcube/db/`. `ROUNDCUBEMAIL_DES_KEY` (session encryption) must be set in `.env` — compose fails without it. -## Account admin (read-only view) - -The `mail-admin` service is a small Go (stdlib-only) HTTP viewer for **all** -accounts at once: **https://mail.produktor.io/admin/** (HTTP Basic Auth, NPM -proxy host 66, location `/admin/` → `172.17.0.1:19945`). - -It shows every account from `config/postfix-accounts.cf` with: - -- INBOX message count (files in the Maildir `cur/` + `new/`, the same numbers - `doveadm mailbox status ... messages INBOX` reports), -- total messages across all mailboxes (incl. subfolders), -- storage used and the quota limit from `config/dovecot-quotas.cf`, -- timestamp of the newest message. - -Read-only by design: `./config/` and `./data/mail-data/` are mounted `:ro`, no -docker socket, no host access. Management (add/del accounts) stays in -docker-mailserver (`setup email add`, edit `postfix-accounts.cf`). - -Source: `admin/` (Go 1.25, `go test -race ./...` offline vs `admin/testdata/`). - -### Manage - -```bash -docker compose up -d # builds mail-admin from admin/Dockerfile -docker compose logs -f mail-admin -curl -u "$MAIL_ADMIN_USER:$MAIL_ADMIN_PASSWORD" https://mail.produktor.io/admin/api/accounts -``` - -Credentials `MAIL_ADMIN_USER` / `MAIL_ADMIN_PASSWORD` are required in `.env` -(compose fails without them). The JSON API is at `/admin/api/accounts`. - ## Reverse proxy (NPM) `mail.produktor.io` is a proxy host in Nginx Proxy Manager (`provider` container, diff --git a/admin/.dockerignore b/admin/.dockerignore deleted file mode 100644 index eb9a432..0000000 --- a/admin/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -testdata/ -*_test.go diff --git a/admin/Dockerfile b/admin/Dockerfile deleted file mode 100644 index a315d56..0000000 --- a/admin/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# mail-admin — read-only web view of the docker-mailserver accounts. -# Build with the Go toolchain, run as a static binary on scratch. -FROM golang:1.25-alpine AS build -WORKDIR /src -COPY go.mod ./ -COPY *.go ./ -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mail-admin . - -FROM scratch -COPY --from=build /mail-admin /mail-admin -# mail-data files are owned by uid/gid 5000 (docker-mailserver), and the -# service mounts ./config and ./data/mail-data read-only. -USER 5000:5000 -EXPOSE 8080 -ENTRYPOINT ["/mail-admin"] diff --git a/admin/accounts.go b/admin/accounts.go deleted file mode 100644 index 434b695..0000000 --- a/admin/accounts.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package main implements mail-admin, a read-only web view of the -// docker-mailserver accounts on mail.produktor.io. -package main - -import ( - "bufio" - "io" - "strings" -) - -// Account is one mailbox from postfix-accounts.cf. -type Account struct { - Email string // full address, e.g. info@produktor.io - Hash string // SHA512-CRYPT hash from the accounts file (never displayed) -} - -// parseAccounts reads a docker-mailserver postfix-accounts.cf file: one -// "email|hash" per line, '#' comments and blank lines skipped. Malformed -// lines are skipped, not fatal: a broken line must not hide the other -// accounts. -func parseAccounts(r io.Reader) ([]Account, error) { - sc := bufio.NewScanner(r) - var out []Account - for sc.Scan() { - line := strings.TrimSpace(sc.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - email, hash, ok := strings.Cut(line, "|") - email = strings.TrimSpace(email) - if !ok || email == "" { - continue - } - out = append(out, Account{Email: email, Hash: strings.TrimSpace(hash)}) - } - return out, sc.Err() -} diff --git a/admin/accounts_test.go b/admin/accounts_test.go deleted file mode 100644 index 65efff9..0000000 --- a/admin/accounts_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "os" - "strings" - "testing" -) - -func TestParseAccounts(t *testing.T) { - f, err := os.Open("testdata/accounts.cf") - if err != nil { - t.Fatal(err) - } - defer f.Close() - accounts, err := parseAccounts(f) - if err != nil { - t.Fatal(err) - } - if len(accounts) != 5 { - t.Fatalf("got %d accounts, want 5", len(accounts)) - } - want := []string{ - "info@produktor.io", - "andriy.oblivantsev@produktor.io", - "postmaster@produktor.io", - "postman@produktor.io", - "ano@produktor.io", - } - for i, w := range want { - if accounts[i].Email != w { - t.Errorf("account %d = %q, want %q", i, accounts[i].Email, w) - } - } - if accounts[0].Hash == "" { - t.Error("expected a hash to be parsed") - } -} - -func TestParseAccountsSkipsMalformed(t *testing.T) { - in := "# comment\n\nalice@example.org|hash1\nbroken-line-no-pipe\n|hash-no-email\n" - accounts, err := parseAccounts(strings.NewReader(in)) - if err != nil { - t.Fatal(err) - } - if len(accounts) != 1 || accounts[0].Email != "alice@example.org" { - t.Fatalf("got %+v, want only alice@example.org", accounts) - } -} diff --git a/admin/go.mod b/admin/go.mod deleted file mode 100644 index efaab33..0000000 --- a/admin/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module git.produktor.io/eSlider/mail-server/admin - -go 1.25 diff --git a/admin/maildir.go b/admin/maildir.go deleted file mode 100644 index 13dce69..0000000 --- a/admin/maildir.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "time" -) - -// MaildirStats are message counts and storage for one mailbox tree. -type MaildirStats struct { - Messages int64 // files in cur+new of the INBOX maildir - Total int64 // files in cur+new across all mailboxes (incl. subfolders) - Storage int64 // total bytes across all mailboxes - Newest time.Time // mtime of the newest message file -} - -// statMaildir walks one user's Maildir (e.g. /var/mail/produktor.io/info) -// and counts messages the same way doveadm reports them: every regular file -// inside a mailbox's cur/ or new/ directory is one message; tmp/ holds -// transient uploads and is ignored. doveadm.index.* files live next to the -// maildir, never in cur/ or new/, so they do not pollute the count. -func statMaildir(root string) (MaildirStats, error) { - var st MaildirStats - if _, err := os.Stat(root); err != nil { - if errors.Is(err, fs.ErrNotExist) { - return st, nil // mailbox not on disk yet: report zero, not error - } - return st, err - } - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - name := d.Name() - if d.IsDir() && (name == "cur" || name == "new") { - n, size, newest, err := countMaildirFiles(path) - if err != nil { - return err - } - st.Total += n - st.Storage += size - if newest.After(st.Newest) { - st.Newest = newest - } - if filepath.Dir(path) == root { - st.Messages += n // INBOX maildir sits directly under the user root - } - return filepath.SkipDir // cur/new contain only message files - } - return nil - }) - if err != nil { - return st, err - } - if st.Newest.IsZero() { - st.Newest = time.Unix(0, 0) - } - return st, nil -} - -// countMaildirFiles counts message files in one cur/ or new/ directory and -// sums their sizes. Only regular files count (dovecot never places anything -// else in cur/new); the newest mtime is returned for sorting by recency. -func countMaildirFiles(dir string) (n, size int64, newest time.Time, err error) { - entries, err := os.ReadDir(dir) - if err != nil { - return 0, 0, time.Time{}, err - } - for _, e := range entries { - if e.IsDir() { - continue - } - info, err := e.Info() - if err != nil { - return 0, 0, time.Time{}, err - } - if !info.Mode().IsRegular() { - continue - } - n++ - size += info.Size() - if info.ModTime().After(newest) { - newest = info.ModTime() - } - } - return n, size, newest, nil -} diff --git a/admin/maildir_test.go b/admin/maildir_test.go deleted file mode 100644 index 2f58845..0000000 --- a/admin/maildir_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "testing" -) - -// Fixture layout (testdata/mail/produktor.io): -// -// info/ cur: 2 messages, new: 1 message, .Sent/cur: 1 message -// ano/ cur: 1 message -// postmaster/ new: 1 message -// postman/ cur: (empty) -// andriy.oblivantsev/ (missing — like an account with no mail yet) -func TestStatMaildirInfo(t *testing.T) { - st, err := statMaildir("testdata/mail/produktor.io/info") - if err != nil { - t.Fatal(err) - } - if st.Messages != 3 { // INBOX: cur 2 + new 1 - t.Errorf("Messages = %d, want 3", st.Messages) - } - if st.Total != 4 { // INBOX 3 + .Sent 1 - t.Errorf("Total = %d, want 4", st.Total) - } - if st.Storage < 1 { - t.Errorf("Storage = %d, want > 0", st.Storage) - } - if st.Newest.IsZero() { - t.Error("Newest should be set") - } -} - -func TestStatMaildirEmptyMailbox(t *testing.T) { - st, err := statMaildir("testdata/mail/produktor.io/postman") - if err != nil { - t.Fatal(err) - } - if st.Messages != 0 || st.Total != 0 || st.Storage != 0 { - t.Errorf("postman should be empty, got %+v", st) - } -} - -func TestStatMaildirMissingRoot(t *testing.T) { - // An account with no Maildir on disk yet must report zero, not fail. - st, err := statMaildir("testdata/mail/produktor.io/andriy.oblivantsev") - if err != nil { - t.Fatal(err) - } - if st.Messages != 0 || st.Total != 0 || st.Storage != 0 { - t.Errorf("missing mailbox should be zero, got %+v", st) - } -} diff --git a/admin/main.go b/admin/main.go deleted file mode 100644 index ddeac55..0000000 --- a/admin/main.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "log" - "net/http" - "os" -) - -// env returns the value of key or def when unset/empty. -func env(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} - -// envRequired returns the value of key or exits: a missing credential must -// fail loudly, never run with an open door. -func envRequired(key string) string { - v := os.Getenv(key) - if v == "" { - log.Fatalf("mail-admin: %s is required (set it in mail-server/.env)", key) - } - return v -} - -func main() { - s := &server{ - accountsPath: envRequired("MAIL_ADMIN_ACCOUNTS"), - quotaPath: envRequired("MAIL_ADMIN_QUOTAS"), - maildirRoot: envRequired("MAIL_ADMIN_MAILDIR"), - basePath: env("MAIL_ADMIN_BASE_PATH", ""), - user: envRequired("MAIL_ADMIN_USER"), - pass: envRequired("MAIL_ADMIN_PASSWORD"), - } - addr := env("MAIL_ADMIN_LISTEN", ":8080") - log.Printf("mail-admin listening on %s, base path %q", addr, s.basePath) - log.Fatal(http.ListenAndServe(addr, s.handler())) -} diff --git a/admin/quota.go b/admin/quota.go deleted file mode 100644 index 307aedb..0000000 --- a/admin/quota.go +++ /dev/null @@ -1,30 +0,0 @@ -package main - -import ( - "bufio" - "io" - "strings" -) - -// parseQuotas reads a docker-mailserver dovecot-quotas.cf file: one -// "user@domain:quota=" per line (see DMS docs). Returns per-user -// quota limits as raw strings. An absent or empty file means no limits. -func parseQuotas(r io.Reader) (map[string]string, error) { - quotas := make(map[string]string) - sc := bufio.NewScanner(r) - for sc.Scan() { - line := strings.TrimSpace(sc.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - email, kv, ok := strings.Cut(line, ":") - email = strings.TrimSpace(email) - if !ok || email == "" { - continue - } - if _, quota, found := strings.Cut(kv, "="); found { - quotas[email] = strings.TrimSpace(quota) - } - } - return quotas, sc.Err() -} diff --git a/admin/quota_test.go b/admin/quota_test.go deleted file mode 100644 index 03c0735..0000000 --- a/admin/quota_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import ( - "os" - "strings" - "testing" -) - -func TestParseQuotas(t *testing.T) { - f, err := os.Open("testdata/quotas.cf") - if err != nil { - t.Fatal(err) - } - defer f.Close() - quotas, err := parseQuotas(f) - if err != nil { - t.Fatal(err) - } - if len(quotas) != 2 { - t.Fatalf("got %d quotas, want 2", len(quotas)) - } - if quotas["info@produktor.io"] != "500M" { - t.Errorf("info quota = %q, want 500M", quotas["info@produktor.io"]) - } - if quotas["ano@produktor.io"] != "250M" { - t.Errorf("ano quota = %q, want 250M", quotas["ano@produktor.io"]) - } -} - -func TestParseQuotasEmpty(t *testing.T) { - quotas, err := parseQuotas(strings.NewReader("# no limits\n\n")) - if err != nil { - t.Fatal(err) - } - if len(quotas) != 0 { - t.Fatalf("got %d quotas, want 0", len(quotas)) - } -} diff --git a/admin/server.go b/admin/server.go deleted file mode 100644 index 3c337c2..0000000 --- a/admin/server.go +++ /dev/null @@ -1,228 +0,0 @@ -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) -} diff --git a/admin/server_test.go b/admin/server_test.go deleted file mode 100644 index 52a1a1f..0000000 --- a/admin/server_test.go +++ /dev/null @@ -1,117 +0,0 @@ -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") - } -} diff --git a/admin/testdata/accounts.cf b/admin/testdata/accounts.cf deleted file mode 100644 index eee2b51..0000000 --- a/admin/testdata/accounts.cf +++ /dev/null @@ -1,7 +0,0 @@ -# docker-mailserver postfix accounts (synthetic test data, NOT real hashes) -info@produktor.io|{SHA512-CRYPT}$6$aaaaaaaa$fakehashinfo -andriy.oblivantsev@produktor.io|{SHA512-CRYPT}$6$bbbbbbbb$fakehashandriy -postmaster@produktor.io|{SHA512-CRYPT}$6$cccccccc$fakehashpostmaster -postman@produktor.io|{SHA512-CRYPT}$6$dddddddd$fakehashpostman -ano@produktor.io|{SHA512-CRYPT}$6$eeeeeeee$fakehashano -malformed-line-without-pipe diff --git a/admin/testdata/mail/produktor.io/ano/cur/1.fixtureano b/admin/testdata/mail/produktor.io/ano/cur/1.fixtureano deleted file mode 100644 index 862df4a..0000000 --- a/admin/testdata/mail/produktor.io/ano/cur/1.fixtureano +++ /dev/null @@ -1,3 +0,0 @@ -Subject: ano msg - -y diff --git a/admin/testdata/mail/produktor.io/info/.Sent/cur/9.fixturesent b/admin/testdata/mail/produktor.io/info/.Sent/cur/9.fixturesent deleted file mode 100644 index 85b5a00..0000000 --- a/admin/testdata/mail/produktor.io/info/.Sent/cur/9.fixturesent +++ /dev/null @@ -1,3 +0,0 @@ -Subject: sent item - -x diff --git a/admin/testdata/mail/produktor.io/info/cur/1.fixture1 b/admin/testdata/mail/produktor.io/info/cur/1.fixture1 deleted file mode 100644 index 778ca93..0000000 --- a/admin/testdata/mail/produktor.io/info/cur/1.fixture1 +++ /dev/null @@ -1,4 +0,0 @@ -Return-Path: -Subject: fixture one - -body diff --git a/admin/testdata/mail/produktor.io/info/cur/2.fixture2 b/admin/testdata/mail/produktor.io/info/cur/2.fixture2 deleted file mode 100644 index acf028e..0000000 --- a/admin/testdata/mail/produktor.io/info/cur/2.fixture2 +++ /dev/null @@ -1,4 +0,0 @@ -Return-Path: -Subject: fixture two - -body body diff --git a/admin/testdata/mail/produktor.io/info/new/3.fixture3 b/admin/testdata/mail/produktor.io/info/new/3.fixture3 deleted file mode 100644 index c6c308c..0000000 --- a/admin/testdata/mail/produktor.io/info/new/3.fixture3 +++ /dev/null @@ -1,4 +0,0 @@ -Return-Path: -Subject: fixture three - -body diff --git a/admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm b/admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm deleted file mode 100644 index 9ad62a1..0000000 --- a/admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm +++ /dev/null @@ -1,3 +0,0 @@ -Subject: pm msg - -z diff --git a/admin/testdata/quotas.cf b/admin/testdata/quotas.cf deleted file mode 100644 index 250117a..0000000 --- a/admin/testdata/quotas.cf +++ /dev/null @@ -1,3 +0,0 @@ -# dovecot quotas (synthetic) -info@produktor.io:quota=500M -ano@produktor.io:quota=250M diff --git a/compose.yaml b/compose.yaml index f395ee6..db9bdd8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -60,31 +60,3 @@ services: - ROUNDCUBEMAIL_SKIN=elastic - ROUNDCUBEMAIL_DES_KEY=${ROUNDCUBEMAIL_DES_KEY:?set ROUNDCUBEMAIL_DES_KEY in .env} - # Account admin (read-only view) — https://mail.produktor.io/admin/ (NPM proxy - # host 66, location /admin/ -> 172.17.0.1:19945). - # Lists the accounts from config/postfix-accounts.cf with per-mailbox message - # counts (INBOX / total) and storage, computed the same way doveadm reports - # them: every file in a mailbox's cur/ or new/ directory is one message. - # Read-only: config and mail data are mounted with :ro, no docker socket. - mail-admin: - build: - context: ./admin - image: mail-admin:local - container_name: mail-admin - restart: unless-stopped - depends_on: - - mailserver - ports: - - "127.0.0.1:19945:8080" - - "172.17.0.1:19945:8080" - volumes: - - ./config/:/config/:ro - - ./data/mail-data/:/var/mail/:ro - environment: - - MAIL_ADMIN_LISTEN=:8080 - - MAIL_ADMIN_BASE_PATH=/admin - - MAIL_ADMIN_ACCOUNTS=/config/postfix-accounts.cf - - MAIL_ADMIN_QUOTAS=/config/dovecot-quotas.cf - - MAIL_ADMIN_MAILDIR=/var/mail - - MAIL_ADMIN_USER=${MAIL_ADMIN_USER:?set MAIL_ADMIN_USER in .env} - - MAIL_ADMIN_PASSWORD=${MAIL_ADMIN_PASSWORD:?set MAIL_ADMIN_PASSWORD in .env}