// 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 }