IMAP-Server-Grundgerüst: TCP-Listener, Command-Parser, Session- Zustandsmaschine (Not Authenticated/Authenticated/Selected), Grundbefehle CAPABILITY/LOGIN/SELECT/FETCH/LOGOUT. - parser.go: Tag+Kommando+Argumente (Atome, zitierte Zeichenketten), keine IMAP-Literalsyntax (kleinste Lösung). - response.go: sanitizeResponseText entfernt eingebettete CR/LF vor jeder Antwortzeile — bekannten archivmail-Fehler (Header-/Zeilen-Injection durch Stringkonkatenation ohne CRLF-Prüfung) strukturell vermieden. - session.go/commands.go: strikte Zustandsprüfung je Kommando, verbotene Übergänge und fehlerhafte Zeilen liefern BAD/NO statt Verbindungsabbruch. maxCommandLineBytes begrenzt Pufferwachstum defensiv. - server.go: TCP-Accept-Schleife, eine Goroutine je Verbindung. - Authenticator/MailboxStore als schmale Schnittstellen — echte Benutzerverwaltungs-/Postfach-Anbindung ist Sache von IMP-01 u. a. Prüfungen (alle real durchgeführt, siehe mail/docs/ING-01-PRUEFPROTOKOLL.md): 1. Manuelle Session mit Pythons imaplib gegen den echten laufenden Server: alle Grundbefehle real beantwortet, ungültiges SELECT liefert real NO ohne Verbindungsabbruch. 2. TestSession_StateTransitionsAndForbiddenTransitions: alle drei Zustandsübergänge und deren verbotene Übergänge real über TCP geprüft. 3. TestServer_50ParallelSessionsNoLeak: 50 reale parallele Sessions, 0 Fehler. Kein Umbau: alle bestehenden Pakete unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
116 lines
3.1 KiB
Go
116 lines
3.1 KiB
Go
package imap
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
)
|
|
|
|
// maxCommandLineBytes begrenzt eine einzelne Kommandozeile (Defensive
|
|
// Fehlerbehandlung bei nicht-konformen Gegenstellen statt optimistischem
|
|
// Parsing, siehe Ticket-Vorbild Dovecot) — verhindert unbegrenztes
|
|
// Pufferwachstum durch eine Gegenstelle, die niemals CRLF sendet.
|
|
const maxCommandLineBytes = 8192
|
|
|
|
// Session ist eine einzelne IMAP-Verbindung mit eigener
|
|
// Zustandsmaschine (Akzeptanzkriterium 1).
|
|
type Session struct {
|
|
conn net.Conn
|
|
reader *bufio.Reader
|
|
writer *bufio.Writer
|
|
auth Authenticator
|
|
store MailboxStore
|
|
state State
|
|
mailbox string // gewähltes Postfach im Zustand Selected
|
|
mailboxSize uint32 // Nachrichtenzahl aus dem letzten erfolgreichen SELECT
|
|
}
|
|
|
|
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: NotAuthenticated,
|
|
}
|
|
}
|
|
|
|
// State liefert den aktuellen Sitzungszustand (für Tests).
|
|
func (s *Session) State() State { return s.state }
|
|
|
|
// Serve führt die Sitzung bis LOGOUT oder Verbindungsende aus.
|
|
func (s *Session) Serve(ctx context.Context) {
|
|
defer func() { _ = s.conn.Close() }()
|
|
|
|
if err := writeUntagged(s.writer, "OK IMAP4rev1 Service Ready"); err != nil {
|
|
return
|
|
}
|
|
|
|
for {
|
|
line, err := s.readLine()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
cmd, parseErr := parseCommandLine(line)
|
|
if parseErr != nil {
|
|
// Akzeptanzkriterium 3: ungültige Kommandosequenz -> Fehlercode,
|
|
// KEIN Verbindungsabbruch.
|
|
if err := writeUntagged(s.writer, "BAD Error in IMAP command received by server."); err != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
if !s.dispatch(ctx, cmd) {
|
|
return // LOGOUT oder nicht behebbarer Schreibfehler
|
|
}
|
|
}
|
|
}
|
|
|
|
// readLine liest eine CRLF- (oder LF-)terminierte Zeile ohne
|
|
// Zeilenumbruch. Überlange Zeilen (siehe maxCommandLineBytes) werden als
|
|
// Fehler behandelt statt unbegrenzt zu puffern.
|
|
func (s *Session) readLine() (string, error) {
|
|
line, err := s.reader.ReadString('\n')
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) && line != "" {
|
|
// Letzte Zeile ohne abschließendes LF — als vollständige Zeile
|
|
// behandeln, danach ohnehin Verbindungsende.
|
|
return strings.TrimRight(line, "\r"), nil
|
|
}
|
|
return "", err
|
|
}
|
|
return strings.TrimRight(line, "\r\n"), nil
|
|
}
|
|
|
|
// dispatch verarbeitet EIN geparstes Kommando. Rückgabewert false
|
|
// bedeutet: Sitzung beenden (LOGOUT abgeschlossen oder Schreibfehler).
|
|
func (s *Session) dispatch(ctx context.Context, cmd command) bool {
|
|
switch cmd.Name {
|
|
case "CAPABILITY":
|
|
return s.handleCapability(cmd)
|
|
case "LOGIN":
|
|
return s.handleLogin(ctx, cmd)
|
|
case "SELECT":
|
|
return s.handleSelect(ctx, cmd)
|
|
case "FETCH":
|
|
return s.handleFetch(ctx, cmd)
|
|
case "LOGOUT":
|
|
return s.handleLogout(cmd)
|
|
default:
|
|
return s.writeErr(cmd.Tag, "BAD", "Unknown command")
|
|
}
|
|
}
|
|
|
|
func (s *Session) writeErr(tag, status, text string) bool {
|
|
return writeTagged(s.writer, tag, status, text) == nil
|
|
}
|