package smtp import ( "bufio" "context" "crypto/tls" "errors" "io" "log/slog" "net" "strings" "gitea.perlbach24.de/scripte/nexarch/mail/internal/protolog" ) // 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 // 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) state State from string to []string } func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config, logger *slog.Logger) *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"), state: Greeting, } } // 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 } } } func (s *Session) readLine() (string, error) { 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(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 } } }