Files
nexarch/mail/internal/pop3/session.go
T
sysops 16c4ad0075 feat(mail): ING-07 einheitliche Fehlerbehandlung & Wiederverbindung IMAP/POP3
Neues Paket mail/internal/protoguard kapselt die für IMAP- und
POP3-Sessions gemeinsam benötigte Timeout- und Backoff-Logik einer
einzelnen Verbindung:

- Pro Protokollphase konfigurierbarer Idle-Read-Timeout (POP3:
  Authorization/Transaction, IMAP: NotAuthenticated/Selected), vor
  jedem Lesevorgang neu gesetzt.
- Sich verdoppelnder Backoff bei wiederholten Anmeldefehlversuchen
  einer Verbindung (BackoffBase bis BackoffMax), Verbindungstrennung
  nach konfigurierbarer Höchstzahl statt Dauerschleife.

Server.NewServer bleibt unverändert (Standardkonfiguration);
NewServerWithGuardConfig erlaubt abweichende Werte. Ressourcenaufräumung
bei Verbindungsabbruch war bereits durch defer conn.Close() strukturell
gegeben — der Timeout sorgt dafür, dass dieser Pfad auch bei hängenden
oder böswilligen Gegenstellen zuverlässig erreicht wird.

Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt:
Chaos-Test mit 30 hart gekappten Verbindungen während aktiver
Übertragung (kein Goroutine-Leck), Timeout-Auslösung in jeder
Protokollphase beider Server, steigender Backoff mit definierter
Verbindungstrennung nach Höchstzahl an Fehlversuchen.

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~24 Pakete)
regressionsfrei getestet.
2026-09-01 00:51:52 +02:00

146 lines
3.9 KiB
Go

package pop3
import (
"bufio"
"context"
"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
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) *Session {
return &Session{
conn: conn,
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
writer: bufio.NewWriter(conn),
auth: auth,
store: store,
guard: protoguard.New(guardCfg),
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)
default:
return writeErr(s.writer, "unknown command") == nil
}
}