- 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
90 lines
2.4 KiB
Go
90 lines
2.4 KiB
Go
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
|
|
}
|