Files
nexarch/mail/internal/pop3/commands.go
sysopsandClaude Sonnet 5 bd1f52648c 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
2026-09-01 00:42:36 +02:00

203 lines
6.1 KiB
Go

package pop3
import (
"context"
"fmt"
"strconv"
"strings"
)
// genericAuthFailure ist bewusst IMMER derselbe Text, unabhängig davon,
// ob der Benutzername unbekannt oder nur das Passwort falsch war
// (Akzeptanzkriterium 3: fehlerhafte Anmeldeversuche ohne
// Informationspreisgabe).
const genericAuthFailure = "authentication failed"
func (s *Session) handleUser(cmd command) bool {
if s.state != Authorization {
return writeErr(s.writer, "command not valid in this state") == nil
}
if len(cmd.Args) != 1 {
return writeErr(s.writer, "USER requires a username") == nil
}
// RFC 1939: USER antwortet immer mit +OK, unabhängig davon, ob der
// Name existiert — die eigentliche Prüfung passiert erst bei PASS
// (Akzeptanzkriterium 3: keine Informationspreisgabe schon an dieser
// Stelle).
s.pendingUsername = cmd.Args[0]
return writeOK(s.writer, "send PASS") == nil
}
func (s *Session) handlePass(ctx context.Context, cmd command) bool {
if s.state != Authorization {
return writeErr(s.writer, "command not valid in this state") == nil
}
if s.pendingUsername == "" {
return writeErr(s.writer, genericAuthFailure) == nil
}
if len(cmd.Args) != 1 {
return writeErr(s.writer, "PASS requires a password") == nil
}
if s.auth == nil {
return writeErr(s.writer, genericAuthFailure) == nil
}
ok, err := s.auth.Authenticate(ctx, s.pendingUsername, cmd.Args[0])
if err != nil || !ok {
// Immer derselbe generische Text, egal ob unbekannter Nutzer,
// falsches Passwort oder interner Fehler (Akzeptanzkriterium 3).
return writeErr(s.writer, genericAuthFailure) == nil
}
s.username = s.pendingUsername
s.state = Transaction
return writeOK(s.writer, "maildrop locked and ready") == nil
}
func (s *Session) handleStat(ctx context.Context) bool {
if s.state != Transaction {
return writeErr(s.writer, "command not valid in this state") == nil
}
messages, err := s.activeMessages(ctx)
if err != nil {
return writeErr(s.writer, "unable to read maildrop") == nil
}
var totalSize int64
for _, m := range messages {
totalSize += m.Size
}
return writeOK(s.writer, fmt.Sprintf("%d %d", len(messages), totalSize)) == nil
}
func (s *Session) handleList(ctx context.Context, cmd command) bool {
if s.state != Transaction {
return writeErr(s.writer, "command not valid in this state") == nil
}
messages, err := s.activeMessages(ctx)
if err != nil {
return writeErr(s.writer, "unable to read maildrop") == nil
}
if len(cmd.Args) == 1 {
n, convErr := strconv.Atoi(cmd.Args[0])
if convErr != nil {
return writeErr(s.writer, "invalid message number") == nil
}
for _, m := range messages {
if m.Number == n {
return writeOK(s.writer, fmt.Sprintf("%d %d", m.Number, m.Size)) == nil
}
}
return writeErr(s.writer, "no such message") == nil
}
var totalSize int64
lines := make([]string, 0, len(messages))
for _, m := range messages {
totalSize += m.Size
lines = append(lines, fmt.Sprintf("%d %d", m.Number, m.Size))
}
return writeMultiline(s.writer, fmt.Sprintf("%d messages (%d octets)", len(messages), totalSize), strings.Join(lines, "\n")) == nil
}
func (s *Session) handleRetr(ctx context.Context, cmd command) bool {
if s.state != Transaction {
return writeErr(s.writer, "command not valid in this state") == nil
}
n, err := s.parseActiveMessageNumber(ctx, cmd)
if err != nil {
return writeErr(s.writer, err.Error()) == nil
}
content, err := s.store.Retrieve(ctx, s.username, n)
if err != nil {
return writeErr(s.writer, "unable to retrieve message") == nil
}
// Akzeptanzkriterium 2: RETR liefert die VOLLSTÄNDIGE Nachricht.
return writeMultiline(s.writer, fmt.Sprintf("%d octets", len(content)), string(content)) == nil
}
func (s *Session) handleDele(cmd command) bool {
if s.state != Transaction {
return writeErr(s.writer, "command not valid in this state") == nil
}
if len(cmd.Args) != 1 {
return writeErr(s.writer, "DELE requires a message number") == nil
}
n, err := strconv.Atoi(cmd.Args[0])
if err != nil {
return writeErr(s.writer, "invalid message number") == nil
}
if s.deleted[n] {
return writeErr(s.writer, "message already deleted") == nil
}
// NUR innerhalb der Sitzung markiert — endgültig gelöscht wird
// ausschließlich in handleQuit (Akzeptanzkriterium 2/Pflichtprüfung 3).
s.deleted[n] = true
return writeOK(s.writer, fmt.Sprintf("message %d deleted", n)) == nil
}
func (s *Session) handleQuit(ctx context.Context) bool {
if s.state != Transaction {
// Aus Authorization: keine Update-Phase, keine Löschungen möglich
// (es wurde noch nichts markiert).
_ = writeOK(s.writer, "goodbye")
return false
}
s.state = Update
if len(s.deleted) > 0 {
numbers := make([]int, 0, len(s.deleted))
for n := range s.deleted {
numbers = append(numbers, n)
}
if err := s.store.Delete(ctx, s.username, numbers); err != nil {
_ = writeErr(s.writer, "unable to update maildrop, changes not committed")
return false
}
}
_ = writeOK(s.writer, "goodbye")
return false
}
// activeMessages liefert alle Nachrichten, die in DIESER Sitzung noch
// nicht per DELE markiert wurden (RFC 1939: gelöschte Nachrichten sind
// für STAT/LIST/RETR ab dem Zeitpunkt der Markierung nicht mehr sichtbar,
// auch wenn die Löschung selbst erst bei QUIT endgültig wird).
func (s *Session) activeMessages(ctx context.Context) ([]Message, error) {
all, err := s.store.List(ctx, s.username)
if err != nil {
return nil, err
}
active := make([]Message, 0, len(all))
for _, m := range all {
if !s.deleted[m.Number] {
active = append(active, m)
}
}
return active, nil
}
func (s *Session) parseActiveMessageNumber(ctx context.Context, cmd command) (int, error) {
if len(cmd.Args) != 1 {
return 0, fmt.Errorf("requires a message number")
}
n, err := strconv.Atoi(cmd.Args[0])
if err != nil {
return 0, fmt.Errorf("invalid message number")
}
if s.deleted[n] {
return 0, fmt.Errorf("message deleted")
}
messages, err := s.activeMessages(ctx)
if err != nil {
return 0, fmt.Errorf("unable to read maildrop")
}
for _, m := range messages {
if m.Number == n {
return n, nil
}
}
return 0, fmt.Errorf("no such message")
}