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,202 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user