feat(mail): ING-02 POP3-Server (RFC 1939) mit Zustandsmaschine
Vollständiger POP3-Server von Grund auf implementiert, analog zum bestehenden IMAP-Server (ING-01): TCP-Listener mit einer Goroutine pro Verbindung, CRLF/Byte-Stuffing-sichere Response-Writer, Zustandsmaschine (Authorization/Transaction/Update), Kommandos USER, PASS, STAT, LIST, RETR, DELE, QUIT. Zentrale Designentscheidungen: - USER antwortet immer +OK (RFC-konform), Prüfung erst bei PASS - Fehlgeschlagene Anmeldung liefert für unbekannten Benutzer und falsches Passwort denselben generischen Text (keine Informationspreisgabe, Akzeptanzkriterium 3) - DELE markiert Nachrichten nur sitzungslokal; store.Delete wird strukturell ausschließlich in QUIT (Transaction -> Update) aufgerufen, wodurch ein Verbindungsabbruch ohne QUIT nichts endgültig löscht (Pflichtprüfung 3) Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt: Zustandsübergangs-Tests gegen realen TCP-Server, manuelle Session mit Python-Standardbibliothek poplib (echtes Transkript im Prüfprotokoll), automatisierter Test für DELE-ohne-QUIT. Zusätzlich: 20 parallele reale Sessions (Akzeptanzkriterium 1), vollständiger RETR+DELE+QUIT-Zyklus (Akzeptanzkriterium 2). go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~24 Pakete) regressionsfrei getestet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5dcfa36f99
commit
bd1f52648c
@@ -0,0 +1,115 @@
|
||||
package pop3
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
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) *Session {
|
||||
return &Session{
|
||||
conn: conn,
|
||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||
writer: bufio.NewWriter(conn),
|
||||
auth: auth,
|
||||
store: store,
|
||||
state: Authorization,
|
||||
deleted: map[int]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
// Verbindung endet OHNE QUIT — Akzeptanzkriterium/
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user