package smtp import ( "bufio" "context" "crypto/tls" "errors" "io" "net" "strings" ) // 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 state State from string to []string } func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config) *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, 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() }() 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) 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 } } }