From e00c6950ed2544692dd8fa8f3ca590cdd44bd35e Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 1 Sep 2026 14:21:36 +0100 Subject: [PATCH 1/2] feat(webmail): add Roundcube web UI for mail.produktor.io (#74) - webmail service (roundcube/roundcubemail) in compose: IMAP/SMTP STARTTLS against the DMS container via mail.produktor.io FQDN (container alias fails TLS peer-name verification) - NPM proxy host 66 -> 172.17.0.1:19944, port 19944 published on 127.0.0.1 + 172.17.0.1 - ROUNDCUBEMAIL_DES_KEY from .env (required) - README: Web UI section, account list, client settings --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ compose.yaml | 28 ++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fe3d7a --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# mail-server + +Docker Compose mail stack for `mail.produktor.io` on arc-01, based on +[docker-mailserver](https://docker-mailserver.github.io/docker-mailserver/) (DMS). + +| Service | Container | Ports | +|---------|-----------|-------| +| 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) | + +## Accounts + +Source of truth is file-based: `config/postfix-accounts.cf` (SHA512-CRYPT +hashes). Current mailboxes: + +- `info@produktor.io` +- `andriy.oblivantsev@produktor.io` +- `ano@produktor.io` +- `postmaster@produktor.io` +- `postman@produktor.io` + +Passwords live in `.env` (`INFO_PASSWORD`, `ANDRIY_PASSWORD`; `ano@` uses +`GATOR_MAIL_PASS` in the gator repo `.env`). Do not commit `.env`. + +## Web UI (Roundcube) + +Webmail runs as the `webmail` service (official `roundcube/roundcubemail` +image) and is reachable at **https://mail.produktor.io** (alias +**https://webmail.produktor.io**) via Nginx Proxy Manager (proxy host 66 → +`172.17.0.1:19944`, Let's Encrypt). + +Login: any mailbox address from the table above + its real password. The UI +shows one mailbox per login; to see all accounts, log in with each one. The +account list is the `postfix-accounts.cf` file (see Accounts). + +Connection details used by the webmail (IMAP/SMTP): + +- IMAP: `mail.produktor.io:143` STARTTLS (or `:993` SSL) +- SMTP submission: `mail.produktor.io:587` STARTTLS, AUTH required + +Host note: the webmail must connect to the DMS container via the FQDN +`mail.produktor.io` (Docker embedded DNS resolves it to the `mailserver` +container inside the compose network). Connecting to the bare container alias +`mailserver` fails TLS peer-name verification, because the DMS certificate is +issued for `mail.produktor.io`. + +### Manage + +```bash +docker compose up -d # start mailserver + webmail +docker compose logs -f webmail # webmail logs +docker exec webmail sh # shell into webmail +``` + +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. + +## Reverse proxy (NPM) + +`mail.produktor.io` is a proxy host in Nginx Proxy Manager (`provider` container, +see the `gitea` repo): forward `http://172.17.0.1:19944`, Let's Encrypt cert +(SAN: `mail.produktor.io`, `webmail.produktor.io`), SSL forced, HTTP/2. + +## TLS + +DMS uses a Let's Encrypt certificate for `mail.produktor.io` mounted from +`tls/letsencrypt/mail.produktor.io/` (`SSL_TYPE=letsencrypt`). diff --git a/compose.yaml b/compose.yaml index 9fa263a..8ea2fdf 100644 --- a/compose.yaml +++ b/compose.yaml @@ -31,3 +31,31 @@ services: - NET_ADMIN - SYS_PTRACE restart: unless-stopped + + # Webmail UI (Roundcube) — https://mail.produktor.io (NPM proxy host 66 -> 172.17.0.1:19944) + # IMAP STARTTLS 143 / SMTP submission STARTTLS 587 against the DMS container (same compose network). + # Host must be mail.produktor.io (not the container alias `mailserver`): the DMS cert is CN/SAN + # mail.produktor.io and PHP's TLS peer-name verification rejects the bare container name. + # Docker's embedded DNS resolves mail.produktor.io to the mailserver container inside the network. + webmail: + image: docker.io/roundcube/roundcubemail:latest + container_name: webmail + restart: unless-stopped + depends_on: + - mailserver + ports: + - "127.0.0.1:19944:80" + - "172.17.0.1:19944:80" + volumes: + # sqlite (addressbook, settings) survives container recreation + - ./data/roundcube/db:/var/www/db + environment: + - ROUNDCUBEMAIL_DB_TYPE=sqlite + - ROUNDCUBEMAIL_DEFAULT_HOST=tls://mail.produktor.io + - ROUNDCUBEMAIL_DEFAULT_PORT=143 + - ROUNDCUBEMAIL_SMTP_SERVER=tls://mail.produktor.io + - ROUNDCUBEMAIL_SMTP_PORT=587 + - ROUNDCUBEMAIL_SMTP_AUTH=LOGIN + - ROUNDCUBEMAIL_USERNAME_DOMAIN=produktor.io + - ROUNDCUBEMAIL_SKIN=elastic + - ROUNDCUBEMAIL_DES_KEY=${ROUNDCUBEMAIL_DES_KEY:?set ROUNDCUBEMAIL_DES_KEY in .env} From 5a46ba4b3342c44bba7245effee2c087346ed03a Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 1 Sep 2026 15:07:55 +0100 Subject: [PATCH 2/2] 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 --- README.md | 32 +++ 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 | 29 +++ 22 files changed, 790 insertions(+) create mode 100644 admin/.dockerignore create mode 100644 admin/Dockerfile create mode 100644 admin/accounts.go create mode 100644 admin/accounts_test.go create mode 100644 admin/go.mod create mode 100644 admin/maildir.go create mode 100644 admin/maildir_test.go create mode 100644 admin/main.go create mode 100644 admin/quota.go create mode 100644 admin/quota_test.go create mode 100644 admin/server.go create mode 100644 admin/server_test.go create mode 100644 admin/testdata/accounts.cf create mode 100644 admin/testdata/mail/produktor.io/ano/cur/1.fixtureano create mode 100644 admin/testdata/mail/produktor.io/info/.Sent/cur/9.fixturesent create mode 100644 admin/testdata/mail/produktor.io/info/cur/1.fixture1 create mode 100644 admin/testdata/mail/produktor.io/info/cur/2.fixture2 create mode 100644 admin/testdata/mail/produktor.io/info/new/3.fixture3 create mode 100644 admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm create mode 100644 admin/testdata/quotas.cf diff --git a/README.md b/README.md index 0fe3d7a..cf23917 100644 --- a/README.md +++ b/README.md @@ -7,6 +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) | ## Accounts @@ -56,6 +57,37 @@ 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 new file mode 100644 index 0000000..eb9a432 --- /dev/null +++ b/admin/.dockerignore @@ -0,0 +1,2 @@ +testdata/ +*_test.go diff --git a/admin/Dockerfile b/admin/Dockerfile new file mode 100644 index 0000000..a315d56 --- /dev/null +++ b/admin/Dockerfile @@ -0,0 +1,15 @@ +# 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 new file mode 100644 index 0000000..434b695 --- /dev/null +++ b/admin/accounts.go @@ -0,0 +1,37 @@ +// 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 new file mode 100644 index 0000000..65efff9 --- /dev/null +++ b/admin/accounts_test.go @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..efaab33 --- /dev/null +++ b/admin/go.mod @@ -0,0 +1,3 @@ +module git.produktor.io/eSlider/mail-server/admin + +go 1.25 diff --git a/admin/maildir.go b/admin/maildir.go new file mode 100644 index 0000000..13dce69 --- /dev/null +++ b/admin/maildir.go @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000..2f58845 --- /dev/null +++ b/admin/maildir_test.go @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..ddeac55 --- /dev/null +++ b/admin/main.go @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..307aedb --- /dev/null +++ b/admin/quota.go @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..03c0735 --- /dev/null +++ b/admin/quota_test.go @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..3c337c2 --- /dev/null +++ b/admin/server.go @@ -0,0 +1,228 @@ +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 new file mode 100644 index 0000000..52a1a1f --- /dev/null +++ b/admin/server_test.go @@ -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") + } +} diff --git a/admin/testdata/accounts.cf b/admin/testdata/accounts.cf new file mode 100644 index 0000000..eee2b51 --- /dev/null +++ b/admin/testdata/accounts.cf @@ -0,0 +1,7 @@ +# 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 new file mode 100644 index 0000000..862df4a --- /dev/null +++ b/admin/testdata/mail/produktor.io/ano/cur/1.fixtureano @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..85b5a00 --- /dev/null +++ b/admin/testdata/mail/produktor.io/info/.Sent/cur/9.fixturesent @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..778ca93 --- /dev/null +++ b/admin/testdata/mail/produktor.io/info/cur/1.fixture1 @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..acf028e --- /dev/null +++ b/admin/testdata/mail/produktor.io/info/cur/2.fixture2 @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..c6c308c --- /dev/null +++ b/admin/testdata/mail/produktor.io/info/new/3.fixture3 @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..9ad62a1 --- /dev/null +++ b/admin/testdata/mail/produktor.io/postmaster/new/1.fixturepm @@ -0,0 +1,3 @@ +Subject: pm msg + +z diff --git a/admin/testdata/quotas.cf b/admin/testdata/quotas.cf new file mode 100644 index 0000000..250117a --- /dev/null +++ b/admin/testdata/quotas.cf @@ -0,0 +1,3 @@ +# dovecot quotas (synthetic) +info@produktor.io:quota=500M +ano@produktor.io:quota=250M diff --git a/compose.yaml b/compose.yaml index 8ea2fdf..f395ee6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -59,3 +59,32 @@ services: - ROUNDCUBEMAIL_USERNAME_DOMAIN=produktor.io - 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}