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
116 lines
2.7 KiB
Go
116 lines
2.7 KiB
Go
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
|
|
}
|
|
}
|