feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen

- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg)
- Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist
- Feature-Status auf In Review gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 11:43:19 +01:00
parent a893084a88
commit d360c9a5ba
68 changed files with 11938 additions and 435 deletions
+283
View File
@@ -0,0 +1,283 @@
// Package smtpd implements an embedded receive-only SMTP daemon for archivmail.
// It accepts incoming emails (e.g. from Postfix via always_bcc) and hands them
// off to the storage coordinator. No AUTH, no relay, no outbound mail.
package smtpd
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"io"
"log/slog"
"net"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/emersion/go-smtp"
"github.com/archivmail/config"
"github.com/archivmail/internal/storage"
)
// Stats holds runtime statistics for the SMTP daemon.
type Stats struct {
Received atomic.Int64 // total emails successfully stored
Rejected atomic.Int64 // rejected (IP, size, etc.)
LastMailAt atomic.Value // time.Time of last accepted mail
}
// Daemon is the embedded receive-only SMTP server.
type Daemon struct {
cfg config.SMTPConfig
store *storage.Store
logger *slog.Logger
stats Stats
server *smtp.Server
mu sync.Mutex
running bool
}
// New creates a new SMTP Daemon. Call Start() to begin accepting connections.
func New(cfg config.SMTPConfig, store *storage.Store, logger *slog.Logger) *Daemon {
d := &Daemon{
cfg: cfg,
store: store,
logger: logger,
}
d.stats.LastMailAt.Store(time.Time{})
return d
}
// Start launches the SMTP daemon in a background goroutine.
// It returns immediately; use Stop() for graceful shutdown.
func (d *Daemon) Start() error {
if !d.cfg.Enabled {
d.logger.Info("SMTP daemon disabled via config")
return nil
}
bind := d.cfg.Bind
if bind == "" {
bind = ":2525"
}
domain := d.cfg.Domain
if domain == "" {
domain = "archivmail"
}
maxBytes := int64(d.cfg.MaxSizeMB) * 1024 * 1024
if maxBytes <= 0 {
maxBytes = 50 * 1024 * 1024 // 50 MB default
}
backend := &backend{daemon: d}
srv := smtp.NewServer(backend)
srv.Addr = bind
srv.Domain = domain
srv.MaxMessageBytes = maxBytes
srv.ReadTimeout = 5 * time.Minute
srv.WriteTimeout = 30 * time.Second
srv.AllowInsecureAuth = false // no AUTH offered at all
// TLS / STARTTLS
if d.cfg.TLSCert != "" && d.cfg.TLSKey != "" {
cert, err := tls.LoadX509KeyPair(d.cfg.TLSCert, d.cfg.TLSKey)
if err != nil {
return fmt.Errorf("smtpd: load TLS cert: %w", err)
}
srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
}
d.mu.Lock()
d.server = srv
d.running = true
d.mu.Unlock()
go func() {
d.logger.Info("SMTP daemon starting", "addr", bind, "domain", domain,
"max_size_mb", d.cfg.MaxSizeMB, "tls", d.cfg.TLSCert != "")
if err := srv.ListenAndServe(); err != nil {
if !errors.Is(err, smtp.ErrServerClosed) {
d.logger.Error("SMTP daemon error", "err", err)
}
}
d.mu.Lock()
d.running = false
d.mu.Unlock()
}()
return nil
}
// Stop shuts down the SMTP daemon gracefully.
func (d *Daemon) Stop() {
d.mu.Lock()
srv := d.server
d.mu.Unlock()
if srv != nil {
srv.Close()
}
}
// Status returns a snapshot of the daemon's current state.
func (d *Daemon) Status() StatusResponse {
d.mu.Lock()
running := d.running
d.mu.Unlock()
lastMail, _ := d.stats.LastMailAt.Load().(time.Time)
var lastMailStr string
if !lastMail.IsZero() {
lastMailStr = lastMail.UTC().Format(time.RFC3339)
}
bind := d.cfg.Bind
if bind == "" {
bind = ":2525"
}
return StatusResponse{
Running: running,
Enabled: d.cfg.Enabled,
Bind: bind,
Domain: d.cfg.Domain,
TLS: d.cfg.TLSCert != "",
MaxSizeMB: d.cfg.MaxSizeMB,
AllowedIPs: d.cfg.AllowedIPs,
Received: d.stats.Received.Load(),
Rejected: d.stats.Rejected.Load(),
LastMailAt: lastMailStr,
}
}
// StatusResponse is returned by GET /api/admin/smtp/status.
type StatusResponse struct {
Running bool `json:"running"`
Enabled bool `json:"enabled"`
Bind string `json:"bind"`
Domain string `json:"domain"`
TLS bool `json:"tls"`
MaxSizeMB int `json:"max_size_mb"`
AllowedIPs []string `json:"allowed_ips"`
Received int64 `json:"received"`
Rejected int64 `json:"rejected"`
LastMailAt string `json:"last_mail_at,omitempty"`
}
// ── go-smtp Backend / Session ─────────────────────────────────────────────
type backend struct {
daemon *Daemon
}
func (b *backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
remoteIP := extractIP(c.Conn().RemoteAddr().String())
if !b.daemon.isAllowed(remoteIP) {
b.daemon.stats.Rejected.Add(1)
b.daemon.logger.Warn("SMTP: rejected connection from unlisted IP", "ip", remoteIP)
return nil, &smtp.SMTPError{
Code: 554,
EnhancedCode: smtp.EnhancedCode{5, 7, 1},
Message: "IP not in allowlist",
}
}
b.daemon.logger.Debug("SMTP: new session", "ip", remoteIP)
return &session{
daemon: b.daemon,
remoteIP: remoteIP,
}, nil
}
type session struct {
daemon *Daemon
remoteIP string
from string
rcpts []string
}
// AuthPlain never called because server doesn't advertise AUTH.
func (s *session) AuthPlain(_, _ string) error {
return smtp.ErrAuthUnsupported
}
func (s *session) Mail(from string, _ *smtp.MailOptions) error {
s.from = from
return nil
}
func (s *session) Rcpt(to string, _ *smtp.RcptOptions) error {
s.rcpts = append(s.rcpts, to)
return nil
}
func (s *session) Data(r io.Reader) error {
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
s.daemon.stats.Rejected.Add(1)
return fmt.Errorf("smtpd: read data: %w", err)
}
raw := buf.Bytes()
id, err := s.daemon.store.Save(raw, time.Now())
if err != nil {
s.daemon.stats.Rejected.Add(1)
s.daemon.logger.Error("SMTP: storage failed", "from", s.from, "err", err)
return &smtp.SMTPError{
Code: 554,
EnhancedCode: smtp.EnhancedCode{4, 6, 0},
Message: "Storage failure, please retry",
}
}
s.daemon.stats.Received.Add(1)
s.daemon.stats.LastMailAt.Store(time.Now())
s.daemon.logger.Info("SMTP: mail stored", "id", id, "from", s.from,
"rcpts", strings.Join(s.rcpts, ","), "bytes", len(raw), "ip", s.remoteIP)
return nil
}
func (s *session) Reset() {
s.from = ""
s.rcpts = nil
}
func (s *session) Logout() error {
return nil
}
// ── Helpers ───────────────────────────────────────────────────────────────
// isAllowed returns true if the IP is in the allowlist, or if the allowlist
// is empty (allow-all mode for development).
func (d *Daemon) isAllowed(ip string) bool {
if len(d.cfg.AllowedIPs) == 0 {
return true // no restriction configured
}
for _, allowed := range d.cfg.AllowedIPs {
// Support CIDR notation (e.g. 192.168.1.0/24)
if strings.Contains(allowed, "/") {
_, network, err := net.ParseCIDR(allowed)
if err == nil && network.Contains(net.ParseIP(ip)) {
return true
}
continue
}
if allowed == ip {
return true
}
}
return false
}
// extractIP strips port from "ip:port" or "[::1]:port" strings.
func extractIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}