Author SHA1 Message Date
eSlider 1f88063412 feat(mail-admin): unified inbox view of all mailboxes (#76) 2026-09-01 15:21:24 +01:00
30 changed files with 1812 additions and 319 deletions
+56 -144
View File
@@ -7,33 +7,21 @@ 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) | | 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) | | Webmail (Roundcube) | `webmail` | 127.0.0.1:19944 / 172.17.0.1:19944 (HTTP, behind NPM) |
| Account admin | — (removed) | — | | Account admin (read-only) | `mail-admin` | 127.0.0.1:19945 / 172.17.0.1:19945 (HTTP, behind NPM) |
## Accounts ## Accounts
Source of truth is file-based: `config/postfix-accounts.cf` (SHA512-CRYPT Source of truth is file-based: `config/postfix-accounts.cf` (SHA512-CRYPT
hashes). The file is gitignored (secrets) — it lives on the host only. Current hashes). Current mailboxes:
mailboxes:
- `info@produktor.io` — human reader (Roundcube login for the whole list) - `info@produktor.io`
- `andriy.oblivantsev@produktor.io` - `andriy.oblivantsev@produktor.io`
- `oleksandra.svitelska@produktor.io`
- `ano@produktor.io` - `ano@produktor.io`
- `postmaster@produktor.io` - `postmaster@produktor.io`
- `postman@produktor.io` - `postman@produktor.io`
- `andriy.oblivantsev@wheregroup.com` — incubator mailbox of the гдеgroup
period (issue #252): the owner's **historical address**, not an abstract
source box. Nobody logs in; legacy `.eml` corpus is imported via doveadm by
the ETL connector (`bin/mail/incubator.go`, 2dph), routed to
`Sent`/`INBOX`/`INBOX/Unmatched` by the recipient headers.
- ~~`wheregroup@produktor.io`~~ — deleted: superseded A1 pilot box of the old
abstract-"source" model (issue #251); its 1000 messages were migrated to
`andriy.oblivantsev@wheregroup.com` and the account was removed (issue #252).
Passwords live in `.env` (`INFO_PASSWORD`, `ANDRIY_PASSWORD`; `ano@` uses Passwords live in `.env` (`INFO_PASSWORD`, `ANDRIY_PASSWORD`; `ano@` uses
`GATOR_MAIL_PASS` in the gator repo `.env`; `andriy.oblivantsev@wheregroup.com` `GATOR_MAIL_PASS` in the gator repo `.env`). Do not commit `.env`.
uses `ANDRIY_WG_PASSWORD`, random — no interactive login). Do not commit
`.env`.
## Web UI (Roundcube) ## Web UI (Roundcube)
@@ -43,13 +31,8 @@ image) and is reachable at **https://mail.produktor.io** (alias
`172.17.0.1:19944`, Let's Encrypt). `172.17.0.1:19944`, Let's Encrypt).
Login: any mailbox address from the table above + its real password. The UI Login: any mailbox address from the table above + its real password. The UI
shows one mailbox per login; the account list is the `postfix-accounts.cf` shows one mailbox per login; to see all accounts, log in with each one. The
file (see Accounts). account list is the `postfix-accounts.cf` file (see Accounts).
Since the shared-mailbox setup (below) `info@` additionally sees every other
mailbox under `Shared/` — one login covers the whole account list. Rights
differ per owner class: read-only for production mailboxes, read + delete for
incubator mailboxes (see Shared mailboxes).
Connection details used by the webmail (IMAP/SMTP): Connection details used by the webmail (IMAP/SMTP):
@@ -74,91 +57,67 @@ The webmail stores its sqlite database (addressbook, settings) in
`data/roundcube/db/`. `ROUNDCUBEMAIL_DES_KEY` (session encryption) must be set `data/roundcube/db/`. `ROUNDCUBEMAIL_DES_KEY` (session encryption) must be set
in `.env` — compose fails without it. in `.env` — compose fails without it.
### Manage: adding a mailbox without recreating the container ## Account admin (read-only view)
A new account is applied live without `docker compose up -d` — DMS's The `mail-admin` service is a small Go (stdlib-only) HTTP viewer for **all**
changedetector (`check-for-changes.sh`, polls every 2 s) picks up the edited accounts at once: **https://mail.produktor.io/admin/** (HTTP Basic Auth, NPM
`config/postfix-accounts.cf` and regenerates `/etc/postfix/vmailbox`, proxy host 66, location `/admin/` → `172.17.0.1:19945`).
`/etc/dovecot/userdb` and `/etc/postfix/vhost`, then reloads Postfix and
Dovecot. No mail is lost, no container recreation. 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 ```bash
# 1. random password for the new historical-address mailbox (stored in .env only) docker compose up -d # builds mail-admin from admin/Dockerfile
PW=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 32) docker compose logs -f mail-admin
printf 'ANDRIY_WG_PASSWORD=%s\n' "$PW" >> .env # never commit .env curl -u "$MAIL_ADMIN_USER:$MAIL_ADMIN_PASSWORD" https://mail.produktor.io/admin/api/accounts
# 2. add the account — password is read from stdin, never from argv/ps.
# DMS accepts any virtual user: the domain need not be serviced by
# mail.produktor.io (verified live with wheregroup.com, issue #252) — the
# account only serves IMAP/doveadm, no inbound delivery.
printf '%s\n%s\n' "$PW" "$PW" |
docker exec -i mailserver setup email add andriy.oblivantsev@wheregroup.com
# 3. changedetector applies within ~5 s; verify
docker exec mailserver doveadm user andriy.oblivantsev@wheregroup.com
# 4. give the fresh mailbox an INBOX (a brand-new owner has none — without it
# `doveadm mailbox list -u <owner>` is empty and user-patches.sh cannot
# record any share), then apply the shared/ACL policy
docker exec mailserver doveadm mailbox create -u andriy.oblivantsev@wheregroup.com INBOX
docker exec mailserver /bin/bash /tmp/docker-mailserver/user-patches.sh
``` ```
On a **fresh deployment** (empty `postfix-accounts.cf`), add the account line Credentials `MAIL_ADMIN_USER` / `MAIL_ADMIN_PASSWORD` are required in `.env`
*before* the first `docker compose up -d`: user-patches.sh runs on the (compose fails without them). The JSON API is at `/admin/api/accounts`.
container's first start and would otherwise skip owners that do not exist yet
(it logs `skip <owner>` and continues).
## Shared mailboxes (единый вход info@) ## Web UI → Все письма
`info@produktor.io` sees every other mailbox under `Shared/` — one login in **https://mail.produktor.io/admin/messages** — unified inbox of **all**
Roundcube covers the whole account list. Delivery is unchanged (no aliases, no mailboxes (info@, postmaster@, ano@, andriy.oblivantsev@, postman@) read
redirects); other accounts keep their own passwords. Two owner classes, two directly from the Maildir on disk: no IMAP, no user passwords. Each row shows
right sets for `info@`: date, from, subject, size and links to the full message view (text/html
toggle + attachment list + headers). Messages are grouped by mailbox, sorted
newest first; the search box filters by subject/from client-side.
- **production owners** (`ano@`, `andriy.oblivantsev@`, `oleksandra.svitelska@`, Read-only API (same Basic Auth):
`postmaster@`): read-only — `lookup read` (issue #79);
- **incubator owners** (one account per historical address of the owner:
`andriy.oblivantsev@wheregroup.com` for the гдеgroup period; later
`eslider@gmail.com`, `...@viscreation.de`, ...): read **and delete** —
`lookup read delete expunge write-deleted` (issue #251). The owner never
logs in; mail arrives via `doveadm import`. Deleting a message in
Roundcube = filter/exclusion from the corpus (auto-sync, epic B), so the
delete button must work in `Shared/`.
Verified on live: `write-deleted` is sufficient for the `\Deleted` flag
Roundcube sets (no extra `write` right needed), `expunge` is also what
Dovecot MOVE needs on the source side when Roundcube moves a deleted message
to the reader's Trash.
How it works (Dovecot 2.3 ACL + shared namespace): | Endpoint | Description |
|----------|-------------|
| `GET /admin/api/messages` | Unified list, newest first. Filters: `?mailbox=` (full address or localpart), `?q=` (subject/from, case-insensitive), `?limit=` (default 100, max 500) |
| `GET /admin/api/messages/<mailbox>/<filename>` | Full message: decoded headers + unfolded `text/plain`/`text/html` bodies + `attachments` (filename/size/cid) |
- `config/dovecot.cf` (→ `/etc/dovecot/local.conf`) enables the `acl` plugin, ```bash
adds a shared namespace `shared/%%u/` (`list=children`, read index per curl -u "$MAIL_ADMIN_USER:$MAIL_ADMIN_PASSWORD" \
reader via `INDEXPVT`), and points `acl_shared_dict` to "https://mail.produktor.io/admin/api/messages?limit=5"
`/var/lib/dovecot/db/shared-mailboxes.db` (persistent via `mail-state`). curl -u "$MAIL_ADMIN_USER:$MAIL_ADMIN_PASSWORD" \
- `config/user-patches.sh` re-applies the ACLs from each shared owner's "https://mail.produktor.io/admin/api/messages/ano@produktor.io/<filename>"
mailboxes to `user=info@produktor.io` via `doveadm acl set` — the ```
only way Dovecot records the share in the shared dictionary — and
pre-subscribes the shared folders for `info@`. DMS runs it on the first
start of each container instance (plain `docker compose restart` skips the
setup step by design); ACLs, the shared dict and subscriptions persist in
`mail-state`/maildirs, so nothing is lost on restarts. Idempotent — safe to
run manually: `docker exec mailserver /bin/bash /tmp/docker-mailserver/user-patches.sh`.
The script skips owners that do not exist yet and creates a missing owner
INBOX itself (the share maps to the owner's INBOX and is only recorded if
the INBOX exists).
Upgrade behavior (image `:latest`): the config survives container recreation Layout assumption: `data/mail-data/<domain>/<localpart>/{cur,new}/` (docker
because both files live in the mounted `config/`. On image upgrade the -mailserver default Maildir; message files in `cur/` + `new/`, `tmp/` is
entrypoint re-applies `dovecot.cf` and runs `user-patches.sh` again on the new transient and ignored). Mailbox and filename are validated strictly and
container's first start, so ACLs and subscriptions are recreated. The only resolved against the real directory listing — path traversal and symlink
state kept outside the repo is `shared-mailboxes.db` (inside escape from `mail-data/` are impossible. MIME multipart bodies are unfolded
`data/mail-state/`); if it is lost, the next (re)creation rebuilds it via recursively with the Go stdlib (`net/mail`, `mime/multipart`,
`doveadm acl set`. `mime/quotedprintable`); nothing is ever written or deleted.
Limitation (Dovecot semantics): new mailboxes created by an owner *after* the
last start do not inherit the share (no ACL inheritance); they appear for
`info@` after the next container start.
## Reverse proxy (NPM) ## Reverse proxy (NPM)
@@ -170,50 +129,3 @@ see the `gitea` repo): forward `http://172.17.0.1:19944`, Let's Encrypt cert
DMS uses a Let's Encrypt certificate for `mail.produktor.io` mounted from DMS uses a Let's Encrypt certificate for `mail.produktor.io` mounted from
`tls/letsencrypt/mail.produktor.io/` (`SSL_TYPE=letsencrypt`). `tls/letsencrypt/mail.produktor.io/` (`SSL_TYPE=letsencrypt`).
## DNS (live zone, arc-01)
Outbound IP is dynamic (Orange residential). SPF follows the Dynu hostname
instead of a fixed `ip4:` — `ddclient` on arc-01 keeps
`produktor.mywire.org` pointed at the current address
(`produktor/duckdns/dyndns/config/ddclient.conf`).
### produktor.io — Dynadot
| Type | Host | Value |
|------|------|-------|
| CNAME | `mail` | `produktor.mywire.org` |
| MX | `@` | `10 mail.produktor.io` |
| TXT | `@` | `v=spf1 a:produktor.mywire.org ~all` |
| TXT | `_dmarc` | `v=DMARC1; p=quarantine; adkim=r; aspf=r; pct=100` |
| TXT | `mail._domainkey` | `v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8P5kdq57uAD9r9XSxvDViVbvOaQfVEIHwS99G5PYFHcoLdhm6sAHaE94pw27BBVweed+TjevhoEaD77RV+uwsE9E+zHepnoLYcCql7vLtRy7QLrSKzNJonCin6g+kzw/2swZ+022w1W27kZgLc3LwUFaTerRI8xDOtbEUmcWGsMPW52JaKVmU3UhFMDVLpH/t1OrbZeCEReM8iK5Cc1jPno9nf3F7ang9x9o0Gyw1CP6takDQiS4X6UK23vjymaauO9PrQQpkAydhkHODq3Sxm3rgSnYjWgPl7BrVr9ujN+K12OObzquj0/Zol1Da1d0IPdzEOAa4SkpLt5FUOgQ7wIDAQAB` |
DKIM private key: `config/opendkim/keys/produktor.io/mail.private` (gitignored
on live host). Re-publish the TXT from `mail.txt` after key rotation:
`docker exec mailserver cat /etc/opendkim/keys/produktor.io/mail.txt`.
ACME DNS-01 for `mail.produktor.io` uses `scripts/dynadot-dns.sh` (Dynadot API).
### produktor.mywire.org — Dynu (dynamic A)
| Type | Host | Value |
|------|------|-------|
| A | `@` | current WAN IP (ddclient → Dynu API, ~5 min) |
As of last check: `90.169.228.16`.
### Verify
```bash
dig @1.1.1.1 +short A mail.produktor.io
dig @1.1.1.1 +short MX produktor.io
dig @1.1.1.1 +short TXT produktor.io
dig @1.1.1.1 +short TXT _dmarc.produktor.io
dig @1.1.1.1 +short TXT mail._domainkey.produktor.io
dig @1.1.1.1 +short A produktor.mywire.org
```
External deliverability smoke test: `scripts/mail-outlook-test.sh`.
**PTR** is not under our control (Orange pool) — expected mismatch; see
`~/.config/opencode/skill/mails/SKILL.md`.
+2
View File
@@ -0,0 +1,2 @@
testdata/
*_test.go
+15
View File
@@ -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"]
+37
View File
@@ -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()
}
+48
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.produktor.io/eSlider/mail-server/admin
go 1.25
+89
View File
@@ -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
}
+52
View File
@@ -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)
}
}
+39
View File
@@ -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()))
}
+393
View File
@@ -0,0 +1,393 @@
package main
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"io/fs"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// errBadMailbox marks a mailbox identifier that fails shape validation; it is
// mapped to HTTP 400, never to a filesystem lookup.
var errBadMailbox = errors.New("invalid mailbox")
// mailboxRe accepts only the characters that can appear in a real mailbox
// address: lowercase letters, digits, dot, underscore, dash and one "@".
// Anything else (slashes, "..", uppercase, control chars) is rejected before
// it can reach the filesystem.
var mailboxRe = regexp.MustCompile(`^[a-z0-9._-]+@[a-z0-9.-]+$`)
// splitMailbox validates the full address shape and returns local/domain.
func splitMailbox(mailbox string) (local, domain string, err error) {
if !mailboxRe.MatchString(mailbox) {
return "", "", fmt.Errorf("%w: %q", errBadMailbox, mailbox)
}
local, domain, _ = strings.Cut(mailbox, "@")
return local, domain, nil
}
// messageSummary is one row of the unified inbox list.
type messageSummary struct {
Mailbox string `json:"mailbox"`
Filename string `json:"filename"`
MessageID string `json:"message_id,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Subject string `json:"subject,omitempty"`
Date time.Time `json:"date"`
Size int64 `json:"size"`
Path string `json:"path"` // maildir-relative path hint
when time.Time // sort key (Date header or file mtime), not serialized
}
// messagesResponse is the JSON payload of GET /api/messages.
type messagesResponse struct {
Updated time.Time `json:"updated"`
Count int `json:"count"`
Messages []messageSummary `json:"messages"`
}
// listMessages returns the unified inbox: every message file in cur/ and new/
// of every mailbox under root (data/mail-data/<domain>/<localpart>), newest
// first. mailboxFilter matches the full address or the bare localpart; query
// is a case-insensitive substring match on subject and from.
func listMessages(root, mailboxFilter, query string, limit int) ([]messageSummary, error) {
filter := strings.ToLower(strings.TrimSpace(query))
var out []messageSummary
err := walkMailboxes(root, func(local, domain string) error {
mailbox := local + "@" + domain
if mailboxFilter != "" && mailbox != mailboxFilter && local != mailboxFilter {
return nil
}
for _, sub := range []string{"cur", "new"} {
dir := filepath.Join(root, domain, local, sub)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue // no cur/ or new/ yet
}
return err
}
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
continue
}
path := filepath.Join(dir, e.Name())
s, err := summarizeMessage(root, path, mailbox, e.Name(), sub, info)
if err != nil {
continue // unparseable file (dovecot index, lock): skip
}
if filter != "" &&
!strings.Contains(strings.ToLower(s.Subject), filter) &&
!strings.Contains(strings.ToLower(s.From), filter) {
continue
}
out = append(out, s)
}
}
return nil
})
if err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return out[i].when.After(out[j].when) })
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
// walkMailboxes visits every <domain>/<localpart> maildir under root.
func walkMailboxes(root string, fn func(local, domain string) error) error {
domains, err := os.ReadDir(root)
if err != nil {
return err
}
for _, d := range domains {
if !d.IsDir() {
continue
}
users, err := os.ReadDir(filepath.Join(root, d.Name()))
if err != nil {
continue
}
for _, u := range users {
if u.IsDir() {
if err := fn(u.Name(), d.Name()); err != nil {
return err
}
}
}
}
return nil
}
// summarizeMessage reads the headers of one message file. Files that do not
// parse as RFC 5322 (empty, binary index leftovers) still appear in the list
// with filename and size only.
func summarizeMessage(root, path, mailbox, filename, sub string, info fs.FileInfo) (messageSummary, error) {
s := messageSummary{
Mailbox: mailbox,
Filename: filename,
Size: info.Size(),
when: info.ModTime(),
}
if rel, err := filepath.Rel(root, path); err == nil {
s.Path = rel
}
f, err := os.Open(path)
if err != nil {
return s, err
}
defer f.Close()
msg, err := mail.ReadMessage(f)
if err != nil {
return s, nil // not a message: keep the row, no headers
}
h := msg.Header
s.MessageID = decodeHeader(h.Get("Message-Id"))
s.From = headerAddress(h.Get("From"))
s.To = headerAddress(h.Get("To"))
s.Subject = decodeHeader(h.Get("Subject"))
if d, err := mail.ParseDate(h.Get("Date")); err == nil {
s.Date, s.when = d, d
}
return s, nil
}
// headerAddress extracts the first address from an RFC 5322 address header,
// falling back to the raw value when parsing fails.
func headerAddress(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if addrs, err := mail.ParseAddressList(v); err == nil && len(addrs) > 0 {
return addrs[0].String()
}
return v
}
// attachmentInfo describes one MIME attachment part.
type attachmentInfo struct {
Filename string `json:"filename,omitempty"`
Size int64 `json:"size"`
CID string `json:"cid,omitempty"`
}
// headerMap keeps header values per name (multiple values allowed, e.g.
// Received) with a convenience Getter like net/http.Header.
type headerMap map[string][]string
func (h headerMap) Get(key string) string {
if v := h[key]; len(v) > 0 {
return v[0]
}
return ""
}
// messageDetail is the JSON payload of GET /api/messages/<mailbox>/<filename>:
// decoded headers, the unfolded text/plain and text/html bodies and a list of
// attachments. Read-only: the underlying message file is never modified.
type messageDetail struct {
Mailbox string `json:"mailbox"`
Filename string `json:"filename"`
Size int64 `json:"size"`
Headers headerMap `json:"headers"`
Text string `json:"text"`
HTML string `json:"html"`
Attachments []attachmentInfo `json:"attachments"`
}
// Subject, From and To expose decoded display values from the header map
// (headers are stored decoded by parseMessage).
func (d messageDetail) Subject() string { return d.Headers.Get("Subject") }
func (d messageDetail) From() string { return headerAddress(d.Headers.Get("From")) }
func (d messageDetail) To() string { return headerAddress(d.Headers.Get("To")) }
// readMessage resolves mailbox+filename against the real listing (a request
// filename can never build a path of its own) and parses the message. It
// returns fs.ErrNotExist when the file is not in cur/ or new/ of the mailbox.
func readMessage(root, mailbox, filename string) (messageDetail, error) {
local, domain, err := splitMailbox(mailbox)
if err != nil {
return messageDetail{}, err
}
full := ""
for _, sub := range []string{"cur", "new"} {
dir := filepath.Join(root, domain, local, sub)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
return messageDetail{}, err
}
for _, e := range entries {
if !e.IsDir() && e.Name() == filename {
full = filepath.Join(dir, e.Name())
break
}
}
}
if full == "" {
return messageDetail{}, os.ErrNotExist
}
info, err := os.Stat(full)
if err != nil {
return messageDetail{}, err
}
data, err := os.ReadFile(full)
if err != nil {
return messageDetail{}, err
}
d, err := parseMessage(data, mailbox, filename)
if err != nil {
return messageDetail{}, err
}
d.Size = info.Size()
return d, nil
}
// parseMessage splits a raw message into decoded headers, text/html bodies
// and attachment metadata. multipart bodies are unfolded recursively.
func parseMessage(data []byte, mailbox, filename string) (messageDetail, error) {
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
return messageDetail{}, err
}
d := messageDetail{
Mailbox: mailbox,
Filename: filename,
Headers: headerMap{},
}
for k, vs := range msg.Header {
decoded := make([]string, len(vs))
for i, v := range vs {
decoded[i] = decodeHeader(v)
}
d.Headers[k] = decoded
}
mediatype, params, _ := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if mediatype == "" {
mediatype = "text/plain"
}
if strings.HasPrefix(mediatype, "multipart/") {
if err := parseMultipart(msg.Body, params["boundary"], &d); err != nil {
return messageDetail{}, err
}
return d, nil
}
body, err := io.ReadAll(decodeTransfer(msg.Header.Get("Content-Transfer-Encoding"), msg.Body))
if err != nil {
return messageDetail{}, err
}
addPart(msg.Header, mediatype, body, &d)
return d, nil
}
// parseMultipart walks one multipart/* body, recursing into nested
// multipart parts (mixed/alternative/related).
func parseMultipart(r io.Reader, boundary string, d *messageDetail) error {
mr := multipart.NewReader(r, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
mediatype, params, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
if mediatype == "" {
mediatype = "text/plain"
}
if strings.HasPrefix(mediatype, "multipart/") {
if err := parseMultipart(part, params["boundary"], d); err != nil {
return err
}
part.Close()
continue
}
data, err := io.ReadAll(decodeTransfer(part.Header.Get("Content-Transfer-Encoding"), part))
if err != nil {
part.Close()
return err
}
addPart(part.Header, mediatype, data, d)
part.Close()
}
}
// addPart dispatches one leaf MIME part: text/plain and text/html feed the
// body views, everything else becomes an attachment entry.
func addPart(h interface{ Get(string) string }, mediatype string, data []byte, d *messageDetail) {
switch mediatype {
case "text/plain":
d.Text += string(data)
case "text/html":
d.HTML += string(data)
default:
a := attachmentInfo{Size: int64(len(data))}
if cd := h.Get("Content-Disposition"); cd != "" {
if _, p, err := mime.ParseMediaType(cd); err == nil {
a.Filename = p["filename"]
}
}
if a.Filename == "" {
if _, p, err := mime.ParseMediaType(h.Get("Content-Type")); err == nil {
a.Filename = p["name"]
}
}
if cid := h.Get("Content-Id"); cid != "" {
a.CID = strings.Trim(cid, "<>")
}
d.Attachments = append(d.Attachments, a)
}
}
// decodeTransfer wraps a part body with the decoder for its
// Content-Transfer-Encoding; base64 and quoted-printable are the only
// encodings that need one, everything else is read verbatim.
func decodeTransfer(enc string, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(enc)) {
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
return quotedprintable.NewReader(r)
default:
return r
}
}
// wordDecoder decodes RFC 2047 encoded words in header values.
var wordDecoder = &mime.WordDecoder{}
func decodeHeader(v string) string {
if v == "" {
return ""
}
if d, err := wordDecoder.DecodeHeader(v); err == nil {
return d
}
return v
}
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"encoding/json"
"errors"
"io/fs"
"net/http"
"strings"
"testing"
)
// Fixture additions (testdata/mail/produktor.io):
//
// ano/cur/2.multipart — multipart/mixed: alternative (text/plain+text/html)
// + image/png attachment (cid logo-cid-1), RFC 2047 encoded subject.
// ano/cur/3.searchme — simple message with subject/from search targets.
//
// Combined with the pre-existing fixtures the unified inbox contains:
// info 3 (cur 1.fixture1, 2.fixture2 + new 3.fixture3), ano 3, postmaster 1.
func TestListMessagesCounts(t *testing.T) {
msgs, err := listMessages("testdata/mail", "", "", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 7 {
t.Fatalf("got %d messages, want 7 (info 3 + ano 3 + postmaster 1)", len(msgs))
}
byMailbox := map[string]int{}
for _, m := range msgs {
byMailbox[m.Mailbox]++
if m.Size <= 0 {
t.Errorf("%s/%s: size = %d, want > 0", m.Mailbox, m.Filename, m.Size)
}
if m.Filename == "" {
t.Error("empty filename in listing")
}
}
want := map[string]int{
"info@produktor.io": 3,
"ano@produktor.io": 3,
"postmaster@produktor.io": 1,
}
for mb, n := range want {
if byMailbox[mb] != n {
t.Errorf("mailbox %s: got %d messages, want %d", mb, byMailbox[mb], n)
}
}
}
func TestListMessagesSortNewestFirst(t *testing.T) {
msgs, err := listMessages("testdata/mail", "", "", 500)
if err != nil {
t.Fatal(err)
}
// 3.searchme (Date 11:00 +0100) is newer than 2.multipart (10:00 +0100);
// messages without a parseable Date fall back to file mtime.
if msgs[0].Filename != "3.searchme" {
t.Errorf("first = %s/%s, want ano/3.searchme", msgs[0].Mailbox, msgs[0].Filename)
}
if msgs[1].Filename != "2.multipart" {
t.Errorf("second = %s/%s, want ano/2.multipart", msgs[1].Mailbox, msgs[1].Filename)
}
}
func TestListMessagesMailboxFilter(t *testing.T) {
msgs, err := listMessages("testdata/mail", "info@produktor.io", "", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 3 {
t.Fatalf("got %d, want 3 for info@produktor.io", len(msgs))
}
for _, m := range msgs {
if m.Mailbox != "info@produktor.io" {
t.Errorf("mailbox = %q, want info@produktor.io", m.Mailbox)
}
}
// localpart-only filter is accepted too
msgs, err = listMessages("testdata/mail", "postmaster", "", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf("localpart filter: got %d, want 1", len(msgs))
}
}
func TestListMessagesQuery(t *testing.T) {
msgs, err := listMessages("testdata/mail", "", "acme", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Filename != "3.searchme" {
t.Fatalf("q=acme: got %+v, want 1 hit (3.searchme)", msgs)
}
// case-insensitive on subject
msgs, err = listMessages("testdata/mail", "", "INVOICE", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Subject != "Invoice #42 from Acme Corp" {
t.Fatalf("q=INVOICE: got %+v, want 3.searchme", msgs)
}
msgs, err = listMessages("testdata/mail", "", "no such term", 500)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 0 {
t.Fatalf("q=no-match: got %d, want 0", len(msgs))
}
}
func TestListMessagesLimit(t *testing.T) {
msgs, err := listMessages("testdata/mail", "", "", 2)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 {
t.Fatalf("limit 2: got %d, want 2", len(msgs))
}
}
func TestReadMessageMultipart(t *testing.T) {
d, err := readMessage("testdata/mail", "ano@produktor.io", "2.multipart")
if err != nil {
t.Fatal(err)
}
if got := d.Headers.Get("Subject"); !strings.Contains(got, "Multipart fixture with") {
t.Errorf("Subject header = %q, want RFC 2047 decoded", got)
}
if !strings.Contains(d.Text, "hello plain body") {
t.Errorf("Text = %q, want plain body", d.Text)
}
if !strings.Contains(d.HTML, "<b>html</b>") {
t.Errorf("HTML = %q, want html body", d.HTML)
}
if len(d.Attachments) != 1 {
t.Fatalf("attachments = %d, want 1", len(d.Attachments))
}
a := d.Attachments[0]
if a.Filename != "logo.png" || a.CID != "logo-cid-1" || a.Size != 8 {
t.Errorf("attachment = %+v, want logo.png cid=logo-cid-1 size=8", a)
}
if d.Headers.Get("From") == "" || d.Headers.Get("To") == "" || d.Headers.Get("Date") == "" {
t.Error("expected from/to/date headers")
}
}
func TestReadMessageSimple(t *testing.T) {
d, err := readMessage("testdata/mail", "ano@produktor.io", "3.searchme")
if err != nil {
t.Fatal(err)
}
if d.Subject() != "Invoice #42 from Acme Corp" {
t.Errorf("subject = %q", d.Subject())
}
if !strings.Contains(d.Text, "Please pay") {
t.Errorf("Text = %q, want body", d.Text)
}
if d.HTML != "" {
t.Errorf("HTML = %q, want empty for plain message", d.HTML)
}
if len(d.Attachments) != 0 {
t.Errorf("attachments = %d, want 0", len(d.Attachments))
}
}
func TestReadMessagePathSafety(t *testing.T) {
cases := []struct{ mailbox, filename string }{
{"../../etc", "passwd"},
{"ano@produktor.io", "../../etc/passwd"},
{"ano@produktor.io", "..%2F1.fixtureano"},
{"ano@produktor.io", "nonexistent"},
{"ANO@produktor.io", "1.fixtureano"}, // uppercase rejected
{"ano..@produktor.io", "1.fixtureano"},
{"ano@produktor.io/../info", "1.fixture1"},
}
for _, c := range cases {
if _, err := readMessage("testdata/mail", c.mailbox, c.filename); err == nil {
t.Errorf("readMessage(%q, %q): expected error, got nil", c.mailbox, c.filename)
} else if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, errBadMailbox) {
t.Errorf("readMessage(%q, %q): err = %v, want errBadMailbox or fs.ErrNotExist", c.mailbox, c.filename, err)
}
}
}
func TestAPIMessages(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/admin/api/messages", "bot", "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, want 200: %s", rec.Code, rec.Body.String())
}
var resp messagesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Count != 7 || len(resp.Messages) != 7 {
t.Fatalf("count = %d len = %d, want 7", resp.Count, len(resp.Messages))
}
if resp.Messages[0].Filename != "3.searchme" {
t.Errorf("first = %+v, want 3.searchme (newest first)", resp.Messages[0])
}
rec = doAuth(t, h, "/admin/api/messages?mailbox=postmaster@produktor.io", "bot", "s3cret")
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Count != 1 || resp.Messages[0].Mailbox != "postmaster@produktor.io" {
t.Errorf("mailbox filter: count = %d, want 1 postmaster", resp.Count)
}
rec = doAuth(t, h, "/admin/api/messages?q=acme", "bot", "s3cret")
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Count != 1 {
t.Errorf("q filter: count = %d, want 1", resp.Count)
}
rec = doAuth(t, h, "/admin/api/messages?limit=2", "bot", "s3cret")
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Messages) != 2 {
t.Errorf("limit=2: got %d, want 2", len(resp.Messages))
}
}
func TestAPIMessageDetail(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/admin/api/messages/ano@produktor.io/2.multipart", "bot", "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, want 200: %s", rec.Code, rec.Body.String())
}
var d messageDetail
if err := json.Unmarshal(rec.Body.Bytes(), &d); err != nil {
t.Fatal(err)
}
if !strings.Contains(d.Text, "hello plain body") || !strings.Contains(d.HTML, "<b>html</b>") {
t.Errorf("detail body: text=%q html=%q", d.Text, d.HTML)
}
if len(d.Attachments) != 1 || d.Attachments[0].Filename != "logo.png" {
t.Errorf("attachments = %+v, want logo.png", d.Attachments)
}
}
func TestAPIMessageDetailUnsafe(t *testing.T) {
h := testServer(t).handler()
for _, p := range []string{
"/admin/api/messages/..%2F..%2Fetc/passwd",
"/admin/api/messages/ano@produktor.io/..%2F..%2Fetc/passwd",
"/admin/api/messages/ANO@produktor.io/1.fixtureano",
"/admin/api/messages/ano@produktor.io/nonexistent",
"/admin/api/messages/ano@produktor.io/1.fixtureano/extra",
} {
rec := doAuth(t, h, p, "bot", "s3cret")
if rec.Code == http.StatusOK {
t.Errorf("%s: code = %d, want 4xx", p, rec.Code)
}
}
}
func TestMessagesPagesRender(t *testing.T) {
h := testServer(t).handler()
rec := doAuth(t, h, "/admin/messages", "bot", "s3cret")
body := rec.Body.String()
if !strings.Contains(body, "All messages") || !strings.Contains(body, "ano@produktor.io") {
t.Error("messages page missing title/mailbox grouping")
}
rec = doAuth(t, h, "/admin/messages", "bot", "s3cret")
rec = doAuth(t, h, "/admin/message/ano@produktor.io/2.multipart", "bot", "s3cret")
body = rec.Body.String()
if rec.Code != http.StatusOK || !strings.Contains(body, "hello plain body") {
t.Errorf("message page: code = %d, body missing text: %s", rec.Code, body[:min(len(body), 200)])
}
}
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"bufio"
"io"
"strings"
)
// parseQuotas reads a docker-mailserver dovecot-quotas.cf file: one
// "user@domain:quota=<size>" 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()
}
+38
View File
@@ -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))
}
}
+507
View File
@@ -0,0 +1,507 @@
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)
}
}
+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")
}
}
+7
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
Subject: ano msg
Date: Tue, 01 Sep 2026 09:00:00 +0100
y
+33
View File
@@ -0,0 +1,33 @@
Return-Path: <sender@example.org>
From: Sender Name <sender@example.org>
To: ano@produktor.io
Subject: =?UTF-8?B?TXVsdGlwYXJ0IGZpeHR1cmUgd2l0aCDDpHR0YWNobWVudA==?=
Date: Tue, 01 Sep 2026 10:00:00 +0100
Message-ID: <multipart-fixture-1@example.org>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="----=_fixture_76"
------=_fixture_76
Content-Type: multipart/alternative; boundary="----=_fixture_alt"
------=_fixture_alt
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
hello plain body
------=_fixture_alt
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
<html><body><p>hello <b>html</b> body</p></body></html>
------=_fixture_alt--
------=_fixture_76
Content-Type: image/png; name="logo.png"
Content-Disposition: attachment; filename="logo.png"
Content-ID: <logo-cid-1>
Content-Transfer-Encoding: base64
iVBORw0KGgo=
------=_fixture_76--
+8
View File
@@ -0,0 +1,8 @@
Return-Path: <billing@acme.example>
From: Acme Billing <billing@acme.example>
To: ano@produktor.io
Subject: Invoice #42 from Acme Corp
Date: Tue, 01 Sep 2026 11:00:00 +0100
Message-ID: <invoice-42@acme.example>
Please pay the attached invoice.
@@ -0,0 +1,3 @@
Subject: sent item
x
+5
View File
@@ -0,0 +1,5 @@
Return-Path: <test@example.org>
Subject: fixture one
Date: Mon, 31 Aug 2026 09:00:00 +0100
body
+5
View File
@@ -0,0 +1,5 @@
Return-Path: <test@example.org>
Subject: fixture two
Date: Mon, 31 Aug 2026 09:05:00 +0100
body body
+5
View File
@@ -0,0 +1,5 @@
Return-Path: <test@example.org>
Subject: fixture three
Date: Mon, 31 Aug 2026 09:10:00 +0100
body
@@ -0,0 +1,4 @@
Subject: pm msg
Date: Mon, 31 Aug 2026 10:00:00 +0100
z
+3
View File
@@ -0,0 +1,3 @@
# dovecot quotas (synthetic)
info@produktor.io:quota=500M
ano@produktor.io:quota=250M
+31 -15
View File
@@ -1,10 +1,6 @@
services: services:
mailserver: mailserver:
# Pinned to v15.1.0: the config (dovecot.cf nameless shared namespace, image: docker.io/mailserver/docker-mailserver:latest
# dovecot-quotas.cf) targets the Dovecot 2.3 of v15. docker.io:latest now
# pulls v16 (Dovecot 2.4) which rejects the config — deliberate upgrade to
# v16 is a separate task (migrate dovecot.cf first).
image: docker.io/mailserver/docker-mailserver:v15.1.0
container_name: mailserver container_name: mailserver
hostname: mail.produktor.io hostname: mail.produktor.io
ports: ports:
@@ -31,14 +27,6 @@ services:
- PERMIT_DOCKER=none - PERMIT_DOCKER=none
- ONE_DIR=1 - ONE_DIR=1
- SPOOF_PROTECTION=1 - SPOOF_PROTECTION=1
# No update nag: intentionally pinned to v15 (see header comment); v16 is a
# separate migration task. Disables the periodic docker-mailserver update check.
- ENABLE_UPDATE_CHECK=0
# Historical-archive mailboxes hold legacy .eml up to ~57 MB (gator #101
# defacto import) — raise the 10M Dovecot/Postfix message cap to 200M
# (POSTFIX_MESSAGE_SIZE_LIMIT sets both quota_max_mail_size and postfix
# message_size_limit in DMS).
- POSTFIX_MESSAGE_SIZE_LIMIT=200000000
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_PTRACE - SYS_PTRACE
@@ -59,8 +47,8 @@ services:
- "127.0.0.1:19944:80" - "127.0.0.1:19944:80"
- "172.17.0.1:19944:80" - "172.17.0.1:19944:80"
volumes: volumes:
# sqlite (addressbook, settings, identities) survives container recreation # sqlite (addressbook, settings) survives container recreation
- ./data/roundcube/db:/var/roundcube/db - ./data/roundcube/db:/var/www/db
environment: environment:
- ROUNDCUBEMAIL_DB_TYPE=sqlite - ROUNDCUBEMAIL_DB_TYPE=sqlite
- ROUNDCUBEMAIL_DEFAULT_HOST=tls://mail.produktor.io - ROUNDCUBEMAIL_DEFAULT_HOST=tls://mail.produktor.io
@@ -72,3 +60,31 @@ services:
- ROUNDCUBEMAIL_SKIN=elastic - ROUNDCUBEMAIL_SKIN=elastic
- ROUNDCUBEMAIL_DES_KEY=${ROUNDCUBEMAIL_DES_KEY:?set ROUNDCUBEMAIL_DES_KEY in .env} - 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}
-40
View File
@@ -1,40 +0,0 @@
# Dovecot hardening (E2 D3).
auth_failure_delay = 2s
mail_max_userip_connections = 10
# --- Dovecot shared mailboxes (issue #79): info@ reads all mailboxes. ---
# Canonical Dovecot 2.3 shared-mailbox scheme: acl plugin + shared namespace +
# acl_shared_dict. The imap plugin list is explicit: inside a `protocol imap {}`
# filter $mail_plugins expands to the filter-level value from DMS's 20-imap.conf
# (which shadows the global), so a `$mail_plugins acl` line would silently drop
# `acl`. Keep DMS's imap_quota to not regress quota IMAP commands.
mail_plugins = " quota acl"
protocol imap {
mail_plugins = " quota acl imap_quota"
}
# Dovecot merges namespace blocks with the same identity (type+prefix); this
# makes the default (maildir) "." separator explicit "/" so it matches the
# shared namespace below ("All list=yes namespaces must use the same separator").
namespace inbox {
separator = /
}
namespace {
type = shared
separator = /
prefix = shared/%%u/
location = maildir:/var/mail/%%d/%%n:INDEXPVT=~/shared/%%u
# subscriptions=yes: Roundcube's folder list is subscribed-based (LIST-EXTENDED
# SUBSCRIBED / LSUB); without per-user subscriptions shared folders would be
# invisible in the web UI. user-patches.sh pre-subscribes them for info@.
subscriptions = yes
list = children
}
plugin {
acl = vfile
# Required for the shared namespace LIST to work: tracks "who shared to whom".
# /var/lib/dovecot is a symlink to /var/mail-state/lib-dovecot (persistent).
acl_shared_dict = file:/var/lib/dovecot/db/shared-mailboxes.db
}
-12
View File
@@ -1,12 +0,0 @@
108.174.0.0/16 PERMIT
144.2.0.0/15 PERMIT
# Google SMTP outbound (Gmail retries from rotating 74.125/209.85/...; postscreen 450 PASS NEW never completes)
74.125.0.0/16 PERMIT
209.85.128.0/17 PERMIT
64.233.160.0/19 PERMIT
66.102.0.0/20 PERMIT
66.249.80.0/20 PERMIT
72.14.192.0/18 PERMIT
173.194.0.0/16 PERMIT
207.126.144.0/20 PERMIT
216.239.32.0/19 PERMIT
-24
View File
@@ -1,24 +0,0 @@
# Postfix rate-limits + postscreen tarpit (E2 D3, soft mode, no DNSBL).
# NOTE: smtpd_client_error_rate_limit / smtpd_slow_connection_rate_limit do not
# exist in Postfix; mapped to the real anvil params smtpd_client_recipient_rate_limit
# and smtpd_client_auth_rate_limit (same values).
smtpd_client_connection_rate_limit = 10
smtpd_client_message_rate_limit = 20
smtpd_client_recipient_rate_limit = 10
smtpd_client_auth_rate_limit = 5
smtpd_hard_error_limit = 20
smtpd_soft_error_limit = 10
smtpd_error_sleep_time = 1s
smtpd_junk_command_limit = 2
postscreen_greet_action = enforce
postscreen_bare_newline_enable = yes
# Only $mydomain (produktor.io) is a LOCAL virtual mailbox domain (inbound).
# Historical / incubator addresses (gmail.com, gmx.de, gridfactor.de, rpf.de,
# viscreation.de, wheregroup.com) keep their Dovecot accounts for IMAP +
# doveadm import, but must NOT be local delivery domains: otherwise outbound
# mail to those domains short-circuits into the incubator archive (dovecot
# lmtp "Saved") and never leaves the host — e.g. a campaign test from
# info@produktor.io to eslider@gmail.com. Overridden last (DMS appends
# postfix-main.cf after its own setup). See mailing skill, Gitea #115.
virtual_mailbox_domains = $mydomain
-84
View File
@@ -1,84 +0,0 @@
#!/bin/bash
# Dovecot shared mailboxes. info@produktor.io gets access to the mailboxes of
# two owner classes (issue #79, issue #251 / epic #250):
# - production owners (ano@, andriy.oblivantsev@, oleksandra.svitelska@,
# postmaster@): read-only
# (`lookup read`) — one login in Roundcube covers the whole account list;
# - incubator owners: read + delete (`lookup read delete expunge
# write-deleted`) — the mailbox owner never logs in, mail is imported via
# doveadm; deleting a message in Roundcube = filter/exclusion from the
# corpus (autosync, epic B).
# Incubator model (corrected 2026-09-02, issue #252): the incubator mailbox IS
# the owner's HISTORICAL ADDRESS per period, not an abstract "source" mailbox.
# The wheregroup period = andriy.oblivantsev@wheregroup.com; later periods get
# their own account (eslider@gmail.com, ...@viscreation.de, ...). The A1 pilot
# box wheregroup@produktor.io (abstract "source" model) was deleted after its
# 1000 messages were migrated to the historical account — grant_share skips
# owners that are not (yet) in postfix-accounts.cf, so a not-yet-created
# account is a silent no-op.
# DMS runs this only on the FIRST start of each container instance (plain
# `docker compose restart` skips the setup step by design — /CONTAINER_START
# marker), so it must stay idempotent. ACLs, the shared dict and subscriptions
# persist in mail-state / maildirs across restarts.
set -euo pipefail
# 1. acl_shared_dict directory: must exist and be writable by the mail user.
SHARED_DB_DIR=/var/lib/dovecot/db
mkdir -p "${SHARED_DB_DIR}"
chown docker:docker "${SHARED_DB_DIR}"
chmod 0770 "${SHARED_DB_DIR}"
# 2. Grant info@ rights on every current mailbox of the shared owners.
# doveadm acl set is the only way Dovecot records the share in acl_shared_dict
# (manual dovecot-acl files do NOT populate the dictionary — Dovecot docs).
# NOTE: this Dovecot build accepts full right NAMES ("lookup read"), single
# letters ("lr") are rejected with "Invalid right". The incubator set below
# was verified on live (issue #251): `write-deleted` is enough for the
# \Deleted flag that Roundcube sets on Delete — the extra `write` right is
# NOT required; `expunge` is also what Dovecot MOVE needs on the source side
# when Roundcube moves a deleted message to Trash.
READER='info@produktor.io'
PRODUCTION_OWNERS='ano@produktor.io andriy.oblivantsev@produktor.io postmaster@produktor.io oleksandra.svitelska@produktor.io'
INCUBATOR_OWNERS='andriy.oblivantsev@wheregroup.com eslider@gmail.com viscreation@gmail.com viscreation@gmx.de andriy.oblivantsev@gridfactor.de ao@rpf.de andriy.oblivantsev@gmail.com viscreation@viscreation.de'
grant_share() { # $1=owner, remaining=right names
local owner=$1
shift
# Skip owners not (yet) in postfix-accounts.cf — e.g. right after a fresh
# clone, before `setup email add` was run for the incubator source.
if ! doveadm mailbox list -u "${owner}" >/dev/null 2>&1; then
echo "user-patches: skip ${owner}: account does not exist yet (run 'setup email add ${owner}')"
return 0
fi
# The shared mailbox "shared/<owner>" maps to the owner's INBOX (Dovecot
# shared-storage semantics) — subscribe it explicitly so Roundcube's
# subscribed folder list shows it.
doveadm mailbox subscribe -u "${READER}" "shared/${owner}"
# A brand-new mailbox owner has no INBOX yet and `doveadm mailbox list`
# above would be empty, so no share would be recorded. Ensure INBOX exists
# first (issue #251); "Mailbox already exists" is fine.
doveadm mailbox create -u "${owner}" INBOX >/dev/null 2>&1 || true
for mb in $(doveadm mailbox list -u "${owner}"); do
doveadm acl set -u "${owner}" "${mb}" "user=${READER}" "$@"
if [ "${mb}" != "INBOX" ]; then
doveadm mailbox subscribe -u "${READER}" "shared/${owner}/${mb}"
fi
done
}
# Production owners stay read-only for info@ (regression guard for #79).
for owner in ${PRODUCTION_OWNERS}; do
grant_share "${owner}" lookup read
done
# Incubator owners: info@ can read AND delete (filter semantics, epic #250).
for owner in ${INCUBATOR_OWNERS}; do
grant_share "${owner}" lookup read delete expunge write-deleted
done
# LinkedIn sender whitelist for postscreen (2026-09-04): LinkedIn mail to
# produktor.io was rejected with 450 by postscreen (new sender IPs). Keep the
# cidr file in sync with config/linkedin_whitelist.cidr.
cp /tmp/docker-mailserver/linkedin_whitelist.cidr /etc/postfix/linkedin_whitelist.cidr 2>/dev/null
chown postfix:postfix /etc/postfix/linkedin_whitelist.cidr 2>/dev/null
postconf -e 'postscreen_access_list = permit_mynetworks, cidr:/etc/postfix/linkedin_whitelist.cidr'