feat(mail-admin): read-only account view for mail.produktor.io (#74)
- admin/: Go stdlib-only HTTP viewer, lists accounts from config/postfix-accounts.cf with per-mailbox message counts (INBOX and total, same numbers doveadm reports) and storage; quota from dovecot-quotas.cf; Basic Auth from .env; offline tests vs fixtures (go test -race ./...) - compose: mail-admin service, build admin/Dockerfile, publishes 127.0.0.1:19945 / 172.17.0.1:19945; config and mail-data mounted :ro, no docker socket - NPM proxy host 66: location /admin/ -> 172.17.0.1:19945 - README: account admin section
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user