package pop3 import ( "bufio" "context" "crypto/tls" "errors" "io" "net" "strings" "gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard" ) // phaseAuthorization/phaseTransaction sind die protoguard-Phasen dieser // Sitzung (ING-07 Akzeptanzkriterium 2: Timeouts pro Protokollphase // konfigurierbar). const ( phaseAuthorization protoguard.Phase = "authorization" phaseTransaction protoguard.Phase = "transaction" ) // maxCommandLineBytes begrenzt eine einzelne Kommandozeile (defensive // Fehlerbehandlung bei nicht-konformen Gegenstellen, gleiche Konvention // wie mail/internal/imap). const maxCommandLineBytes = 8192 // Session ist eine einzelne POP3-Verbindung mit eigener Zustandsmaschine // (Akzeptanzkriterium 1). type Session struct { conn net.Conn reader *bufio.Reader writer *bufio.Writer auth Authenticator store MailboxStore guard *protoguard.Guard // tlsConfig ist nil, wenn dieser Server ohne TLS/STLS betrieben wird // (Akzeptanzkriterium 1 greift dann nicht — Rückwärtskompatibilität // zu ING-01/ING-02). tlsActive ist von Anfang an true, wenn die // Verbindung bereits über einen impliziten TLS-Listener hereinkam. tlsConfig *tls.Config tlsActive bool state State pendingUsername string // nach USER, vor erfolgreichem PASS username string // nach erfolgreichem PASS deleted map[int]bool } func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config, tlsConfig *tls.Config) *Session { _, alreadyTLS := conn.(*tls.Conn) return &Session{ conn: conn, reader: bufio.NewReaderSize(conn, maxCommandLineBytes), writer: bufio.NewWriter(conn), auth: auth, store: store, guard: protoguard.New(guardCfg), tlsConfig: tlsConfig, tlsActive: alreadyTLS, state: Authorization, deleted: map[int]bool{}, } } // currentPhase liefert die protoguard-Phase des aktuellen Sitzungszustands. func (s *Session) currentPhase() protoguard.Phase { if s.state == Authorization { return phaseAuthorization } return phaseTransaction } // 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 := writeOK(s.writer, "POP3 server ready"); err != nil { return } for { // Akzeptanzkriterium 2 (ING-07): Idle-Timeout pro Protokollphase, // vor jedem Lesevorgang neu gesetzt, da ein Zustandswechsel die // Phase (und damit den geltenden Timeout) ändern kann. if err := s.guard.ApplyReadDeadline(s.conn, s.currentPhase()); err != nil { return } line, err := s.readLine() if err != nil { // Verbindung endet OHNE QUIT (Timeout, Netzwerkabbruch oder // harter Verbindungsabbruch) — Akzeptanzkriterium 1: die // Session-Ressourcen (Verbindung, Reader/Writer) werden über // das defer conn.Close() oben zuverlässig freigegeben. // Zusätzlich Pflichtprüfung 3: als Deleted markierte // Nachrichten dürfen dadurch NICHT gelöscht werden. Da // store.Delete nur im regulären handleQuit aufgerufen wird, // ist das hier bereits strukturell garantiert (kein Aufruf, // keine Löschung). return } if line == "" { continue } cmd := parseCommandLine(line) if cmd.Name == "" { if err := writeErr(s.writer, "unrecognized command"); err != nil { return } continue } if !s.dispatch(ctx, cmd) { 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 Schreibfehler). func (s *Session) dispatch(ctx context.Context, cmd command) bool { switch cmd.Name { case "USER": return s.handleUser(cmd) case "PASS": return s.handlePass(ctx, cmd) case "STAT": return s.handleStat(ctx) case "LIST": return s.handleList(ctx, cmd) case "RETR": return s.handleRetr(ctx, cmd) case "DELE": return s.handleDele(cmd) case "QUIT": return s.handleQuit(ctx) case "STLS": return s.handleStls(ctx) default: return writeErr(s.writer, "unknown command") == nil } }