Files
nexarch/mail/internal/smtp/server.go
T
sysops 1825387603 feat(mail): ARC-09 Postfach-Quota (unabhängig von Core LIC-05)
mailboxconfig (IMP-07) bekommt eine quota_bytes-Spalte statt einer
eigenen Tabelle — ein Postfach ist bereits eindeutig über
(tenant_slug, name) identifiziert. SetQuotaBytes/LimitBytes, 0 =
unbegrenzt (Standardwert, keine Migration bestehender Postfächer
nötig). LimitBytes erfüllt strukturell quota.LimitProvider.

storage.ArchiveMailboxPrefix (ARC-04-Ergänzung, Präfix ALLER Jahre
eines Postfachs) und storage.UsageCounter: realer Speicherverbrauch
durch echtes S3-Listing im physisch getrennten Mandanten-Bucket
(ARC-06) — kein separat gepflegter Zählerstand.

Neues Paket mail/internal/quota: Checker verbindet LimitProvider und
UsageProvider. Kein konfiguriertes Limit = immer erlaubt (Core-LIC-05-
Quota läuft unabhängig weiter — beide Ebenen bewusst unabhängig
durchgesetzt, bekannter Fehler vermieden).

smtp.QuotaChecker (schmale Schnittstelle, keine Paketkopplung an
quota) wird in handleRcptTo geprüft, VOR der Datenübertragung: 552
(RFC 5321 "exceeded storage allocation") bei Überschreitung, Session
bleibt nutzbar. nil-Checker erhält bisheriges Verhalten unverändert.

Alle drei Pflichtprüfungen mit echten Nachweisen: Quota-Überschreitung
liefert 552, Session bleibt funktionsfähig; ein anderes Postfach
desselben Tenants läuft währenddessen vollständig normal durch;
vollständiger Ende-zu-Ende-Integrationstest gegen reale Postgres- und
MinIO-Instanzen — 5000 echte Bytes abgelegt, real gemessen, Limit
knapp darunter/darüber gesetzt, SMTP reagiert jeweils korrekt auf den
tatsächlichen gemessenen Wert. Dabei einen echten Cleanup-Fehler
gefunden und behoben (defer schloss den Pool vor dem zugehörigen
t.Cleanup, verwaiste Testdaten blieben zurück).

go build/go vet/golangci-lint clean, gesamtes Mail-Modul
regressionsfrei getestet.
2026-09-02 23:47:32 +02:00

115 lines
5.2 KiB
Go

package smtp
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
)
// defaultMaxMessageBytes ist die Standard-Höchstgröße einer
// angenommenen Nachricht (Akzeptanzkriterium 1).
const defaultMaxMessageBytes = 25 * 1024 * 1024 // 25 MiB
// Server nimmt SMTP-Verbindungen an und bedient jede in einer eigenen
// Goroutine — gleiches Muster wie mail/internal/imap.Server und
// mail/internal/pop3.Server. tlsConfig ist optional (ING-06): nil
// bedeutet, STARTTLS ist nicht verfügbar. Protokoll-Logging ist Sache
// von ING-08 — keine dieser Kacheln.
//
// QA-04-Befund (Sicherheitsprüfung): SMTP bekam beim ursprünglichen
// ING-07 (Idle-Timeout) fälschlich KEINEN protoguard-Schutz, anders als
// IMAP/POP3 — eine Gegenstelle, die eine Kommandozeile ohne
// abschließendes CRLF öffnet und nie beendet, blockierte die Session
// unbegrenzt (bestätigter, realer Testangriff, siehe
// qa04_security_test.go). guardCfg schließt diese Lücke nachträglich,
// mit demselben Standardverhalten wie IMAP/POP3.
type Server struct {
sink MessageSink
maxMessageBytes int64
tlsConfig *tls.Config
logger *slog.Logger
guardCfg protoguard.Config
tenantID string
acceptLimiter *ratelimit.Limiter
quotaChecker QuotaChecker
}
func NewServer(sink MessageSink) *Server {
return NewServerWithMaxMessageBytes(sink, defaultMaxMessageBytes)
}
// NewServerWithMaxMessageBytes erlaubt eine abweichende
// Nachrichten-Höchstgröße, z. B. für Tests.
func NewServerWithMaxMessageBytes(sink MessageSink, maxMessageBytes int64) *Server {
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, guardCfg: protoguard.DefaultConfig()}
}
// NewServerWithMaxMessageBytesAndTLSConfig erlaubt zusätzlich, STARTTLS
// zu aktivieren (ING-06). tlsConfig sollte über tlscert.Store.Config()
// erzeugt werden.
func NewServerWithMaxMessageBytesAndTLSConfig(sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config) *Server {
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig, guardCfg: protoguard.DefaultConfig()}
}
// NewServerWithMaxMessageBytesTLSAndLogger erlaubt zusätzlich
// strukturiertes Protokoll-Logging (ING-08). logger darf nil sein
// (Logging dann deaktiviert, Rückwärtskompatibilität zu ING-01..ING-06).
func NewServerWithMaxMessageBytesTLSAndLogger(sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger) *Server {
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig, logger: logger, guardCfg: protoguard.DefaultConfig()}
}
// NewServerWithMaxMessageBytesTLSLoggerAndRateLimit erlaubt zusätzlich
// Rate-Limiting der SMTP-Annahmerate pro Absender (ING-09, MAIL FROM).
// acceptLimiter darf nil sein (Rate-Limiting dann deaktiviert).
// tenantID identifiziert diesen Server gegenüber dem Limiter
// (Akzeptanzkriterium 3).
func NewServerWithMaxMessageBytesTLSLoggerAndRateLimit(sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, acceptLimiter *ratelimit.Limiter) *Server {
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig, logger: logger, tenantID: tenantID, acceptLimiter: acceptLimiter, guardCfg: protoguard.DefaultConfig()}
}
// NewServerWithMaxMessageBytesTLSLoggerRateLimitAndGuardConfig erlaubt
// zusätzlich abweichende Idle-Timeout-Parameter (QA-04-Nachtrag zu
// ING-07), z. B. für Tests.
func NewServerWithMaxMessageBytesTLSLoggerRateLimitAndGuardConfig(sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, acceptLimiter *ratelimit.Limiter, guardCfg protoguard.Config) *Server {
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig, logger: logger, tenantID: tenantID, acceptLimiter: acceptLimiter, guardCfg: guardCfg}
}
// NewServerWithMaxMessageBytesTLSLoggerRateLimitGuardAndQuota erlaubt
// zusätzlich Postfach-Quota-Prüfung bei RCPT TO (ARC-09).
// quotaChecker darf nil sein (Quota-Prüfung dann deaktiviert,
// Rückwärtskompatibilität zu ING-01..QA-04).
func NewServerWithMaxMessageBytesTLSLoggerRateLimitGuardAndQuota(sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, acceptLimiter *ratelimit.Limiter, guardCfg protoguard.Config, quotaChecker QuotaChecker) *Server {
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig, logger: logger, tenantID: tenantID, acceptLimiter: acceptLimiter, guardCfg: guardCfg, quotaChecker: quotaChecker}
}
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
go func() {
<-ctx.Done()
_ = listener.Close()
}()
for {
conn, err := listener.Accept()
if err != nil {
if ctx.Err() != nil {
return nil
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
continue
}
return fmt.Errorf("smtp: verbindung annehmen: %w", err)
}
session := newSession(conn, srv.sink, srv.maxMessageBytes, srv.tlsConfig, srv.logger, srv.tenantID, srv.acceptLimiter, srv.guardCfg, srv.quotaChecker)
go session.Serve(ctx)
}
}