Neues Paket mail/internal/ratelimit: Token-Bucket je (Mandant, Quelle)-Schlüssel. ConfigProvider/StaticConfig liefern Burst/Nachfüllrate je Mandant mit Fallback auf eine Default-Konfiguration (Akzeptanzkriterium 2/3: begrenzt, konfigurierbar, je Mandant getrennt). Limiter.Allow liefert bei Ablehnung eine konkrete positive Wartezeit statt nur false — Grundlage für die geforderte definierte Ablehnung MIT Wartezeit-Hinweis statt Verbindungsabbruch (Akzeptanzkriterium 3). IMAP LOGIN und POP3 PASS begrenzen Anmeldeversuche pro (Mandant, Quell-IP). SMTP MAIL FROM begrenzt die Annahmerate pro (Mandant, Absenderadresse+Quell-IP). Bei Überschreitung: POP3 -ERR, IMAP NO, SMTP 451 (temporärer Fehlercode) mit Wartezeit in Sekunden, Verbindung bleibt in allen drei Fällen offen und weiter nutzbar. loginLimiter/ acceptLimiter sind optional (nil = deaktiviert, Rückwärtskompatibilität zu ING-01..ING-08) — neue Konstruktoren NewServerWithGuardTLSLoggerAndRateLimit (IMAP/POP3) und NewServerWithMaxMessageBytesTLSLoggerAndRateLimit (SMTP). Jeder Server bekommt eine tenantID, konsistent mit dem in ING-10 etablierten Muster "ein Server je Mandant". Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt, jeweils in IMAP, POP3 und SMTP einzeln: 20 reale Versuche bei Burst=5 -> exakt 5 akzeptiert, 15 mit Wartezeit-Hinweis abgelehnt, keine Verbindung getrennt; legitime Nutzung unterhalb der Schwelle unbeeinträchtigt; zwei Mandanten mit unterschiedlichem Burst über denselben Limiter unabhängig und exakt wirksam (2 vs. 8 akzeptierte Versuche). go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~31 Pakete) regressionsfrei getestet — insbesondere QA-07-Lasttests bleiben grün, da Rate-Limiting standardmäßig deaktiviert ist.
252 lines
8.7 KiB
Go
252 lines
8.7 KiB
Go
package imap
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
|
)
|
|
|
|
// handleCapability ist in jedem Zustand erlaubt (RFC 3501 §6.1.1).
|
|
func (s *Session) handleCapability(cmd command) bool {
|
|
caps := "CAPABILITY IMAP4rev1"
|
|
if s.tlsConfig != nil && !s.tlsActive {
|
|
caps += " STARTTLS"
|
|
}
|
|
if err := writeUntagged(s.writer, caps); err != nil {
|
|
return false
|
|
}
|
|
return s.writeErr(cmd.Tag, "OK", "CAPABILITY completed")
|
|
}
|
|
|
|
// handleLogin ist nur im Zustand NotAuthenticated erlaubt
|
|
// (Akzeptanzkriterium 1/3).
|
|
func (s *Session) handleLogin(ctx context.Context, cmd command) bool {
|
|
if s.state != NotAuthenticated {
|
|
return s.writeErr(cmd.Tag, "BAD", "LOGIN not allowed in "+s.state.String()+" state")
|
|
}
|
|
if len(cmd.Args) != 2 {
|
|
return s.writeErr(cmd.Tag, "BAD", "LOGIN requires username and password")
|
|
}
|
|
if s.tlsConfig != nil && !s.tlsActive {
|
|
// Akzeptanzkriterium 1 (ING-06): Anmeldedaten werden
|
|
// ausschließlich über TLS oder nach erfolgreichem STARTTLS
|
|
// akzeptiert, sobald der Server TLS überhaupt anbietet.
|
|
return s.writeErr(cmd.Tag, "NO", "LOGIN disabled without TLS, use STARTTLS")
|
|
}
|
|
if s.loginLimiter != nil {
|
|
if ok, retryAfter := s.loginLimiter.Allow(s.tenantID, s.sourceAddr()); !ok {
|
|
// Akzeptanzkriterium 1/3 (ING-09): definierte Ablehnung MIT
|
|
// Wartezeit-Hinweis statt Verbindungsabbruch ohne Erklärung.
|
|
return s.writeErr(cmd.Tag, "NO", fmt.Sprintf("rate limit exceeded, retry in %.1fs", retryAfter.Seconds()))
|
|
}
|
|
}
|
|
if s.auth == nil {
|
|
return s.writeErr(cmd.Tag, "NO", "LOGIN not available")
|
|
}
|
|
|
|
ok, err := s.auth.Authenticate(ctx, cmd.Args[0], cmd.Args[1])
|
|
if err != nil || !ok {
|
|
// Backoff statt Dauerschleife bei wiederholten Fehlversuchen
|
|
// (Akzeptanzkriterium 3, ING-07).
|
|
backoff, disconnect := s.guard.RecordAuthFailure()
|
|
s.guard.Wait(ctx, backoff)
|
|
if !s.writeErr(cmd.Tag, "NO", "LOGIN failed") {
|
|
return false
|
|
}
|
|
return !disconnect
|
|
}
|
|
s.guard.ResetAuthFailures()
|
|
s.state = Authenticated
|
|
return s.writeErr(cmd.Tag, "OK", "LOGIN completed")
|
|
}
|
|
|
|
// handleSelect ist in Authenticated und Selected erlaubt (ein erneutes
|
|
// SELECT wechselt das gewählte Postfach).
|
|
func (s *Session) handleSelect(ctx context.Context, cmd command) bool {
|
|
if s.state == NotAuthenticated {
|
|
return s.writeErr(cmd.Tag, "BAD", "SELECT not allowed in "+s.state.String()+" state")
|
|
}
|
|
if len(cmd.Args) != 1 {
|
|
return s.writeErr(cmd.Tag, "BAD", "SELECT requires a mailbox name")
|
|
}
|
|
if s.store == nil {
|
|
return s.writeErr(cmd.Tag, "NO", "SELECT not available")
|
|
}
|
|
|
|
mailboxName := cmd.Args[0]
|
|
exists, uidvalidity, ok, err := s.store.Select(ctx, mailboxName)
|
|
if err != nil || !ok {
|
|
// Fehlgeschlagenes SELECT lässt den Zustand laut RFC 3501 §6.3.1
|
|
// auf Authenticated zurückfallen, nie in Selected mit ungültigem
|
|
// Postfach hängen bleiben.
|
|
s.state = Authenticated
|
|
return s.writeErr(cmd.Tag, "NO", "SELECT failed: no such mailbox")
|
|
}
|
|
|
|
if err := writeUntagged(s.writer, fmt.Sprintf("%d EXISTS", exists)); err != nil {
|
|
return false
|
|
}
|
|
// RFC 3501 §2.3.1.1: UIDVALIDITY ist Pflichtbestandteil der
|
|
// SELECT-Antwort — Grundlage für IMP-01s Erkennung eines
|
|
// Ordner-Neuaufbaus.
|
|
if err := writeUntagged(s.writer, fmt.Sprintf("OK [UIDVALIDITY %d] UIDs valid", uidvalidity)); err != nil {
|
|
return false
|
|
}
|
|
s.state = Selected
|
|
s.mailbox = mailboxName
|
|
s.mailboxSize = uint32(exists)
|
|
return s.writeErr(cmd.Tag, "OK", "[READ-WRITE] SELECT completed")
|
|
}
|
|
|
|
// handleFetch ist ausschließlich im Zustand Selected erlaubt
|
|
// (Akzeptanzkriterium 1/2).
|
|
func (s *Session) handleFetch(ctx context.Context, cmd command) bool {
|
|
if s.state != Selected {
|
|
return s.writeErr(cmd.Tag, "BAD", "FETCH not allowed in "+s.state.String()+" state")
|
|
}
|
|
if len(cmd.Args) < 1 {
|
|
return s.writeErr(cmd.Tag, "BAD", "FETCH requires a sequence set")
|
|
}
|
|
|
|
seqNumbers, err := parseSequenceSet(cmd.Args[0], s.mailboxSize)
|
|
if err != nil {
|
|
return s.writeErr(cmd.Tag, "BAD", "FETCH: invalid sequence set")
|
|
}
|
|
|
|
messages, err := s.store.Fetch(ctx, s.mailbox, seqNumbers)
|
|
if err != nil {
|
|
return s.writeErr(cmd.Tag, "NO", "FETCH failed")
|
|
}
|
|
return s.writeFetchResults(cmd.Tag, "FETCH", messages)
|
|
}
|
|
|
|
// handleUIDFetch implementiert "UID FETCH" (RFC 3501 §6.4.8) — wie FETCH,
|
|
// aber uid-set statt Sequenzsatz, Grundlage für IMP-01s UID-basierten
|
|
// Delta-Sync.
|
|
func (s *Session) handleUIDFetch(ctx context.Context, cmd command) bool {
|
|
if s.state != Selected {
|
|
return s.writeErr(cmd.Tag, "BAD", "UID FETCH not allowed in "+s.state.String()+" state")
|
|
}
|
|
if len(cmd.Args) < 2 {
|
|
return s.writeErr(cmd.Tag, "BAD", "UID FETCH requires a uid set")
|
|
}
|
|
// "*" in einem UID-Satz bedeutet "höchste vorhandene UID", NICHT die
|
|
// NachrichtenANZAHL (s.mailboxSize) — UIDs können durch Löschungen
|
|
// weit über der Nachrichtenzahl liegen (siehe mail/internal/
|
|
// folderstate, ING-05: UIDs werden nie wiederverwendet). Da
|
|
// parseSequenceSet einen Bereich materialisiert, wird "*" hier auf
|
|
// maxOpenEndedUID begrenzt statt auf 2^32-1 — verhindert eine
|
|
// Milliarden Einträge lange Schleife bei einem einzelnen offenen
|
|
// Bereich. FetchByUID liefert ohnehin nur tatsächlich vorhandene
|
|
// UIDs zurück, die Begrenzung ist für reale Postfachgrößen harmlos.
|
|
uidSet, err := parseSequenceSet(cmd.Args[1], maxOpenEndedUID)
|
|
if err != nil {
|
|
return s.writeErr(cmd.Tag, "BAD", "UID FETCH: invalid uid set")
|
|
}
|
|
|
|
messages, err := s.store.FetchByUID(ctx, s.mailbox, uidSet)
|
|
if err != nil {
|
|
return s.writeErr(cmd.Tag, "NO", "UID FETCH failed")
|
|
}
|
|
return s.writeFetchResults(cmd.Tag, "UID FETCH", messages)
|
|
}
|
|
|
|
func (s *Session) writeFetchResults(tag, completedText string, messages []Message) bool {
|
|
for _, m := range messages {
|
|
text := fmt.Sprintf("%d FETCH (UID %d FLAGS (%s))", m.SequenceNumber, m.UID, strings.Join(m.Flags, " "))
|
|
if err := writeUntagged(s.writer, text); err != nil {
|
|
return false
|
|
}
|
|
}
|
|
return s.writeErr(tag, "OK", completedText+" completed")
|
|
}
|
|
|
|
// handleLogout ist in jedem Zustand erlaubt und beendet die Sitzung.
|
|
func (s *Session) handleLogout(cmd command) bool {
|
|
if err := writeUntagged(s.writer, "BYE IMAP4rev1 Server logging out"); err != nil {
|
|
return false
|
|
}
|
|
_ = s.writeErr(cmd.Tag, "OK", "LOGOUT completed")
|
|
return false
|
|
}
|
|
|
|
// handleStartTLS implementiert STARTTLS (RFC 3501 §6.2.1): TLS-Aufwertung
|
|
// der bestehenden Klartextverbindung, nur vor der Anmeldung erlaubt.
|
|
func (s *Session) handleStartTLS(ctx context.Context, cmd command) bool {
|
|
if s.tlsConfig == nil {
|
|
return s.writeErr(cmd.Tag, "NO", "STARTTLS not available")
|
|
}
|
|
if s.tlsActive {
|
|
return s.writeErr(cmd.Tag, "BAD", "TLS already active")
|
|
}
|
|
if s.state != NotAuthenticated {
|
|
return s.writeErr(cmd.Tag, "BAD", "STARTTLS not allowed in "+s.state.String()+" state")
|
|
}
|
|
if !s.writeErr(cmd.Tag, "OK", "Begin TLS negotiation now") {
|
|
return false
|
|
}
|
|
|
|
tlsConn, err := tlscert.UpgradeServer(ctx, s.conn, s.tlsConfig)
|
|
if err != nil {
|
|
// Handshake fehlgeschlagen -> Verbindung beenden, kein Rückfall
|
|
// auf Klartext.
|
|
return false
|
|
}
|
|
s.conn = tlsConn
|
|
// Reader/Writer NEU aufsetzen: verhindert, dass vor dem Handshake
|
|
// gepufferte Klartextdaten nach dem Wechsel noch als Kommandos
|
|
// verarbeitet werden (Command-Injection-Schutz).
|
|
s.reader = bufio.NewReaderSize(tlsConn, maxCommandLineBytes)
|
|
s.writer = bufio.NewWriter(tlsConn)
|
|
s.tlsActive = true
|
|
return true
|
|
}
|
|
|
|
// parseSequenceSet unterstützt die für FETCH gebräuchlichsten Formen:
|
|
// eine einzelne Zahl ("1"), eine kommagetrennte Liste ("1,3,5") und einen
|
|
// Bereich mit "*" als offenem Ende ("1:*"), aufgelöst gegen maxSeq (die
|
|
// tatsächliche Nachrichtenzahl des gewählten Postfachs, von SELECT
|
|
// gemeldet). Volle RFC-3501-Sequenzsatz-Grammatik (verschachtelte
|
|
// Bereiche etc.) ist bewusst nicht Bestandteil dieser kleinsten Lösung.
|
|
// maxOpenEndedUID begrenzt, wie weit ein offener UID-Bereich ("N:*")
|
|
// materialisiert wird — deckt reale Postfachgrößen komfortabel ab, ohne
|
|
// bei einem einzelnen Kommando Milliarden Slice-Einträge zu erzeugen.
|
|
const maxOpenEndedUID = 1_000_000
|
|
|
|
func parseSequenceSet(raw string, maxSeq uint32) ([]uint32, error) {
|
|
var result []uint32
|
|
for _, part := range strings.Split(raw, ",") {
|
|
if strings.Contains(part, ":") {
|
|
bounds := strings.SplitN(part, ":", 2)
|
|
if len(bounds) != 2 {
|
|
return nil, fmt.Errorf("imap: ungültiger bereich %q", part)
|
|
}
|
|
from, err := strconv.ParseUint(bounds[0], 10, 32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
to := uint64(maxSeq)
|
|
if bounds[1] != "*" {
|
|
to, err = strconv.ParseUint(bounds[1], 10, 32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for i := from; i <= to; i++ {
|
|
result = append(result, uint32(i))
|
|
}
|
|
continue
|
|
}
|
|
n, err := strconv.ParseUint(part, 10, 32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, uint32(n))
|
|
}
|
|
return result, nil
|
|
}
|