Files
mail-server/admin/maildir.go
T
eSlider 5a46ba4b33 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
2026-09-01 15:07:55 +01:00

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
}