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.
199 lines
6.0 KiB
Go
199 lines
6.0 KiB
Go
package smtp
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"strings"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog"
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/ratelimit"
|
|
)
|
|
|
|
// QuotaChecker prüft, ob ein Postfach noch innerhalb seines
|
|
// konfigurierten Speicherlimits ist (ARC-09). Erfüllt von
|
|
// *quota.Checker — als schmale Schnittstelle definiert, damit dieses
|
|
// Paket quota nicht importieren muss (keine Kopplung an dessen
|
|
// LimitProvider/UsageProvider-Abhängigkeiten).
|
|
type QuotaChecker interface {
|
|
Allowed(ctx context.Context, tenantSlug, mailbox string) (bool, error)
|
|
}
|
|
|
|
// maxCommandLineBytes begrenzt eine einzelne Kommando-/DATA-Zeile
|
|
// (defensive Fehlerbehandlung bei nicht-konformen Gegenstellen statt
|
|
// optimistischem Parsing, gleiche Konvention wie mail/internal/imap und
|
|
// mail/internal/pop3).
|
|
const maxCommandLineBytes = 8192
|
|
|
|
// phaseSMTP ist die (einzige) protoguard-Phase dieser Sitzung
|
|
// (QA-04-Nachtrag zu ING-07): SMTP hat — anders als IMAP/POP3 — keine
|
|
// sicherheitsrelevant unterschiedlichen Phasen (Anmeldephase
|
|
// existiert in der aktuellen Implementierung nicht, siehe ING-03), ein
|
|
// einziger Idle-Timeout für jeden Lesevorgang genügt, um eine
|
|
// Gegenstelle ohne abschließendes CRLF nicht unbegrenzt zu blockieren.
|
|
const phaseSMTP protoguard.Phase = "smtp"
|
|
|
|
// Session ist eine einzelne SMTP-Verbindung mit eigener
|
|
// Zustandsmaschine (Akzeptanzkriterium 1).
|
|
type Session struct {
|
|
conn net.Conn
|
|
reader *bufio.Reader
|
|
writer *bufio.Writer
|
|
sink MessageSink
|
|
|
|
maxMessageBytes int64
|
|
|
|
tlsConfig *tls.Config // nil = kein STARTTLS angeboten (ING-06)
|
|
tlsActive bool
|
|
|
|
log *protolog.SessionLogger // ING-08, nie nil (log.Event() ist nil-sicher)
|
|
|
|
tenantID string
|
|
acceptLimiter *ratelimit.Limiter // ING-09, nil = kein Rate-Limiting
|
|
quotaChecker QuotaChecker // ARC-09, nil = keine Quota-Prüfung
|
|
|
|
guard *protoguard.Guard // QA-04-Nachtrag zu ING-07
|
|
|
|
state State
|
|
from string
|
|
to []string
|
|
}
|
|
|
|
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger, tenantID string, acceptLimiter *ratelimit.Limiter, guardCfg protoguard.Config, quotaChecker QuotaChecker) *Session {
|
|
_, alreadyTLS := conn.(*tls.Conn)
|
|
return &Session{
|
|
conn: conn,
|
|
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
|
writer: bufio.NewWriter(conn),
|
|
sink: sink,
|
|
maxMessageBytes: maxMessageBytes,
|
|
tlsConfig: tlsConfig,
|
|
tlsActive: alreadyTLS,
|
|
log: protolog.NewSessionLogger(logger, "smtp"),
|
|
tenantID: tenantID,
|
|
acceptLimiter: acceptLimiter,
|
|
quotaChecker: quotaChecker,
|
|
guard: protoguard.New(guardCfg),
|
|
state: Greeting,
|
|
}
|
|
}
|
|
|
|
// sourceAddr liefert die Quell-IP dieser Verbindung ohne Portanteil
|
|
// (ING-09).
|
|
func (s *Session) sourceAddr() string {
|
|
host, _, err := net.SplitHostPort(s.conn.RemoteAddr().String())
|
|
if err != nil {
|
|
return s.conn.RemoteAddr().String()
|
|
}
|
|
return host
|
|
}
|
|
|
|
// State liefert den aktuellen Sitzungszustand (für Tests).
|
|
func (s *Session) State() State { return s.state }
|
|
|
|
// Serve führt die Sitzung bis QUIT oder Verbindungsende aus.
|
|
func (s *Session) Serve(ctx context.Context) {
|
|
defer func() { _ = s.conn.Close() }()
|
|
|
|
// Akzeptanzkriterium 1 (ING-08): strukturierte Logs mit
|
|
// Korrelations-ID über die gesamte Verbindungsdauer.
|
|
s.log.Event(ctx, "session_start", slog.String("remote_addr", s.conn.RemoteAddr().String()))
|
|
defer s.log.Event(ctx, "session_end")
|
|
|
|
if err := s.reply(220, "nexarch-mail SMTP server ready"); err != nil {
|
|
return
|
|
}
|
|
|
|
for {
|
|
line, err := s.readLine()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
verb, arg := parseCommand(line)
|
|
// Akzeptanzkriterium 2 (ING-08): sensible Argumente (z. B. ein
|
|
// künftiges AUTH) erscheinen über RedactCommandLine nie im
|
|
// Klartext im Log. DATA-Nachrichteninhalte werden hier NICHT
|
|
// erfasst — nur das Kommando "DATA" selbst, der Body wird an
|
|
// keiner Stelle geloggt.
|
|
var args []string
|
|
if arg != "" {
|
|
args = strings.Fields(arg)
|
|
}
|
|
s.log.Event(ctx, "command", slog.String("command", protolog.RedactCommandLine(verb, args)))
|
|
|
|
if !s.dispatch(ctx, verb, arg) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// readLine liest eine Zeile — mit Idle-Timeout VOR jedem Lesevorgang
|
|
// (QA-04-Nachtrag zu ING-07): ein einziger Ort für alle Aufrufer
|
|
// (Haupt-Serve-Schleife, handleData, drainUntilDot), damit eine
|
|
// Gegenstelle, die eine Zeile ohne abschließendes CRLF nie beendet,
|
|
// die Session nicht unbegrenzt blockieren kann.
|
|
func (s *Session) readLine() (string, error) {
|
|
if err := s.guard.ApplyReadDeadline(s.conn, phaseSMTP); err != nil {
|
|
return "", err
|
|
}
|
|
line, err := s.reader.ReadString('\n')
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) && line != "" {
|
|
return strings.TrimRight(line, "\r"), nil
|
|
}
|
|
return "", err
|
|
}
|
|
return strings.TrimRight(line, "\r\n"), nil
|
|
}
|
|
|
|
// dispatch verarbeitet EIN geparstes Kommando. false bedeutet: Sitzung
|
|
// beenden (QUIT abgeschlossen oder nicht behebbarer Schreibfehler).
|
|
func (s *Session) dispatch(ctx context.Context, verb, arg string) bool {
|
|
switch verb {
|
|
case "HELO", "EHLO":
|
|
return s.handleHelo(verb, arg)
|
|
case "MAIL":
|
|
return s.handleMailFrom(arg)
|
|
case "RCPT":
|
|
return s.handleRcptTo(ctx, arg)
|
|
case "DATA":
|
|
return s.handleData(ctx)
|
|
case "RSET":
|
|
return s.handleRset()
|
|
case "STARTTLS":
|
|
return s.handleStartTLS(ctx)
|
|
case "NOOP":
|
|
return s.reply(250, "OK") == nil
|
|
case "QUIT":
|
|
_ = s.reply(221, "Bye")
|
|
return false
|
|
default:
|
|
return s.reply(500, "Command not recognized") == nil
|
|
}
|
|
}
|
|
|
|
// drainUntilDot liest Zeilen, ohne sie zu puffern, bis zur
|
|
// DATA-Abschlusszeile "." — hält das Protokoll nach einer wegen
|
|
// Größenüberschreitung abgelehnten Nachricht synchron, ohne den
|
|
// verworfenen Rest unbegrenzt im Speicher zu halten.
|
|
func (s *Session) drainUntilDot() error {
|
|
for {
|
|
line, err := s.readLine()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if line == "." {
|
|
return nil
|
|
}
|
|
}
|
|
}
|