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