Compare commits

...
Author SHA1 Message Date
sysops 6631bcbbdd feat(mail): ING-03 SMTP-Server & Mailer (RFC 5321)
Neues Paket mail/internal/smtp: SMTP-Server für eingehende Mails, von
Grund auf implementiert, analog zu mail/internal/imap und
mail/internal/pop3 — TCP-Listener mit einer Goroutine pro Verbindung,
Session-Zustandsmaschine (Greeting/Ready/MailFromSet/RcptToSet),
Kommandos HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT.
Envelope wird schrittweise aufgebaut und validiert (503 bei
übersprungenen Schritten, 553 bei ungültiger Absender-/Empfängeradresse),
Nachrichtengröße wird während DATA laufend gegen eine konfigurierbare
Höchstgröße geprüft (552 bei Überschreitung, Sink bekommt die Nachricht
nicht). Dot-Stuffing beim Empfang korrekt rückgängig gemacht.

Neues Paket mail/internal/mailer: Mailer-Komponente für ausgehende
Nachrichten. headerWriter ist die einzige Stelle, an der Header
geschrieben werden — jeder Feldwert wird hart gegen CR/LF/Steuerzeichen
geprüft, bevor er in die Nachricht geschrieben wird. Behebt den
bekannten archivmail-Fehler (Header-Injection durch Stringkonkatenation
ohne CRLF-Prüfung, siehe known-issues-archivmail.md #1). Sender.Send
überträgt per echtem net/smtp-Client (Standardbibliothek) — keine
Zugangsdaten im Code, Zieladresse kommt vom Aufrufer.

Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt:
CRLF-/Steuerzeichen-Injection in Betreff und Anzeigenamen schlägt fehl
(vier Testfälle), Ende-zu-Ende-Header-Integritätstest über echten
SMTP-Dialog (Mailpit/MailHog nicht installierbar auf diesem Rechner —
Ersatz durch den in dieser Kachel gebauten echten SMTP-Server, kein
Mock, im Prüfprotokoll begründet), Lasttest mit 50 gleichzeitigen
Verbindungen ohne Goroutine-/Verbindungsleck.

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~26 Pakete)
regressionsfrei getestet.
2026-09-01 00:59:56 +02:00
sysops 16c4ad0075 feat(mail): ING-07 einheitliche Fehlerbehandlung & Wiederverbindung IMAP/POP3
Neues Paket mail/internal/protoguard kapselt die für IMAP- und
POP3-Sessions gemeinsam benötigte Timeout- und Backoff-Logik einer
einzelnen Verbindung:

- Pro Protokollphase konfigurierbarer Idle-Read-Timeout (POP3:
  Authorization/Transaction, IMAP: NotAuthenticated/Selected), vor
  jedem Lesevorgang neu gesetzt.
- Sich verdoppelnder Backoff bei wiederholten Anmeldefehlversuchen
  einer Verbindung (BackoffBase bis BackoffMax), Verbindungstrennung
  nach konfigurierbarer Höchstzahl statt Dauerschleife.

Server.NewServer bleibt unverändert (Standardkonfiguration);
NewServerWithGuardConfig erlaubt abweichende Werte. Ressourcenaufräumung
bei Verbindungsabbruch war bereits durch defer conn.Close() strukturell
gegeben — der Timeout sorgt dafür, dass dieser Pfad auch bei hängenden
oder böswilligen Gegenstellen zuverlässig erreicht wird.

Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt:
Chaos-Test mit 30 hart gekappten Verbindungen während aktiver
Übertragung (kein Goroutine-Leck), Timeout-Auslösung in jeder
Protokollphase beider Server, steigender Backoff mit definierter
Verbindungstrennung nach Höchstzahl an Fehlversuchen.

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~24 Pakete)
regressionsfrei getestet.
2026-09-01 00:51:52 +02:00
22 changed files with 1934 additions and 28 deletions
+116
View File
@@ -0,0 +1,116 @@
# ING-03 — SMTP-Server & Mailer: Prüfprotokoll
Datum: 2026-09-01
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
Pakete: `mail/internal/smtp` (SMTP-Server, neu), `mail/internal/mailer` (Mailer-Komponente, neu)
## Umsetzung
**`mail/internal/smtp`** — SMTP-Server (RFC 5321) für eingehende Mails,
von Grund auf implementiert, architektonisch analog zu
`mail/internal/imap`/`pop3`: TCP-Listener mit einer Goroutine pro
Verbindung, Session-Zustandsmaschine (Greeting → Ready → MailFromSet →
RcptToSet), Kommandos HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, NOOP,
QUIT. Envelope-Aufbau ist strikt schrittweise: MAIL FROM ohne HELO,
RCPT TO ohne MAIL FROM und DATA ohne mindestens ein gültiges RCPT TO
werden jeweils mit `503` zurückgewiesen. Absender-/Empfängeradressen
werden vor Annahme validiert (`503`/`553` bei ungültiger Syntax bzw.
Steuerzeichen). Die Nachrichtengröße wird während des DATA-Empfangs
laufend geprüft; eine Überschreitung führt zu `552` und verworfener
Nachricht, ohne den Sink zu erreichen. Dot-(Byte-)Stuffing wird beim
Empfang korrekt rückgängig gemacht (RFC 5321 §4.5.2).
**`mail/internal/mailer`** — Mailer-Komponente für ausgehende
Nachrichten. `headerWriter` (`header.go`) ist die EINZIGE Stelle, an der
Header geschrieben werden: jeder Feldwert wird vor dem Schreiben hart
gegen CR/LF/Steuerzeichen geprüft, `Message.Build()` nutzt
ausschließlich diese API — keine freie Stringkonkatenation von
From/To/Subject (behebt den bekannten archivmail-Fehler #1,
Header-Injection durch ungeprüfte Konkatenation). `Sender.Send`
überträgt die gebaute Nachricht per echtem `net/smtp`-Client
(Standardbibliothek, reale TCP-Verbindung) über HELO/MAIL FROM/RCPT
TO/DATA. Keine Zugangsdaten im Code — die Zieladresse wird als
Parameter/Umgebungsvariable vom Aufrufer bereitgestellt.
## Pflichtprüfung 1: Steuerzeichen/CRLF in Betreff und Anzeigenamen — kein Header-Bruch möglich
`TestHeaderWriter_RejectsControlCharsAndCRLFInSubjectAndDisplayName`
(`mailer/mailer_test.go`), vier Fälle: CRLF im Betreff (versuchte
Bcc-Injection), CRLF im Anzeigenamen des Absenders, nackter LF ohne CR,
Steuerzeichen NUL im Betreff — `Message.Build()` liefert in allen vier
Fällen einen Fehler, KEINE gebaute Nachricht. Ergänzend
`TestHeaderWriter_AcceptsCleanValues`: normale Werte (inkl. Umlaute)
werden nicht fälschlich abgelehnt.
Ergebnis: **BESTANDEN**.
## Pflichtprüfung 2: automatisierter Test sendet Testmail über Mailpit/MailHog, prüft Header-Integrität
**Abweichung von der wörtlichen Ticketvorgabe, dokumentiert:** Mailpit
und MailHog sind auf diesem Rechner NICHT installiert — Projektregel
verbietet das Nachinstallieren zusätzlicher Toolchains/Dienste
(kein Docker verfügbar, keine Systempaketinstallation). Als echter
Ersatz — kein Mock, kein fabriziertes Transkript, dieselbe Konvention
wie die manuellen Client-Tests aus ING-01/ING-02 — läuft
`TestSender_SendRealMessageOverSMTP_HeaderIntegrity`
(`mailer/mailer_test.go`) gegen den in dieser Kachel gebauten, echten
`mail/internal/smtp`-Server: realer TCP-Listener, echter
`net/smtp`-Standardbibliotheks-Client, reale HELO/MAIL FROM/RCPT
TO/DATA-Sequenz über das Netzwerk. Geprüft wird:
- Envelope (`From`/`To`) kommt beim Server unverändert an.
- From-, To-, Subject- und ein zusätzlicher Header (`X-NEXARCH-Test`)
kommen byte-identisch als eigene Headerzeilen an.
- Genau eine Leerzeile trennt Header von Body (`\r\n\r\n`), Body-Text
vollständig und unverändert.
Ergebnis: **BESTANDEN** — Header-Integrität über einen echten
Ende-zu-Ende-SMTP-Dialog bestätigt.
## Pflichtprüfung 3: Lasttest mit gleichzeitigen Verbindungen ohne Verbindungsleck
`TestServer_ConcurrentConnectionsNoLeak` (`smtp/smtp_test.go`): 50
parallele reale TCP-Verbindungen, jede vollständige
EHLO/MAIL/RCPT/DATA/QUIT-Sequenz. Alle 50 Nachrichten kommen beim Sink
an. `runtime.NumGoroutine()` vor und nach dem Lasttest verglichen (mit
Toleranz für Laufzeit-Jitter und Aufräumzeit).
Ergebnis: **BESTANDEN** — Goroutinezahl kehrt auf den Ausgangswert
zurück, kein Verbindungs-/Ressourcenleck.
## Akzeptanzkriterien
1. **SMTP-Annahme validiert Envelope und Nachrichtengröße vor der
Annahme**: `TestSession_EnvelopeMustBeBuiltBeforeData` (schrittweise
Envelope-Prüfung, `503` bei übersprungenen Schritten) und
`TestData_MessageSizeCheckedBeforeAcceptance` (Überschreitung der
konfigurierten Höchstgröße führt zu `552`, Sink bekommt die
Nachricht NICHT, Session danach weiter funktionsfähig).
2. **Mailer erzeugt Header ausschließlich über strukturierte
Writer-API, keine freie Stringkonkatenation**: `header.go`
(`headerWriter.WriteField`) ist der einzige Ort, an dem
`Message.Build()` Header schreibt; durch Pflichtprüfung 1 belegt.
3. **Ungültige Empfängerdaten führen zu sauberer SMTP-Fehlermeldung
statt Absturz**: `TestRcptTo_InvalidRecipientCleanError` und
`TestMailFrom_InvalidSenderCleanError``553` bei ungültiger
Adresse, Verbindung bleibt danach nutzbar.
## Build/Vet/Lint/Test — Gesamtmodul
```
go build ./... → OK
go vet ./... → OK
golangci-lint run ./... → 0 issues
go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL gesetzt) → alle Pakete ok, inkl. neuen internal/smtp und internal/mailer
```
Keine Regression in den bestehenden ~26 Paketen.
## Ergebnis
ING-03 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten
Nachweisen. Pflichtprüfung 2 wurde mangels installierbarem
Mailpit/MailHog gegen den eigenen, in dieser Kachel gebauten
SMTP-Server durchgeführt (funktional gleichwertig: echter SMTP-Dialog,
kein Mock) — siehe Abschnitt oben. Freigeschaltet: ING-06, ING-08,
ING-09, ING-10, QA-04, QA-07.
+100
View File
@@ -0,0 +1,100 @@
# ING-07 — Protokoll-Fehlerbehandlung & Wiederverbindung: Prüfprotokoll
Datum: 2026-09-01
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
Pakete: `mail/internal/protoguard` (neu, gemeinsam genutzt), `mail/internal/imap`, `mail/internal/pop3`
## Umsetzung
Neues Paket `protoguard` kapselt Timeout- und Backoff-Logik EINER
Verbindung (`Guard`), von IMAP- und POP3-Session gleichermaßen genutzt:
- `ApplyReadDeadline(conn, phase)` setzt vor jedem Lesevorgang die
Lese-Deadline passend zur aktuellen Protokollphase (POP3:
Authorization/Transaction, IMAP: NotAuthenticated/Selected).
- `RecordAuthFailure()` zählt Anmeldefehlversuche EINER Verbindung,
liefert eine sich verdoppelnde Backoff-Wartezeit (`BackoffBase` bis
`BackoffMax`) und meldet nach `MaxAuthFailures`, dass die Verbindung
zu trennen ist.
`Server.NewServer` verwendet `protoguard.DefaultConfig()` (5 Minuten
Timeout, max. 5 Fehlversuche, 200ms5s Backoff); `NewServerWithGuardConfig`
erlaubt abweichende Werte für Tests/gehärtete Umgebungen. Bestehende
Aufrufer von `NewServer(auth, store)` sind unverändert kompatibel.
Ressourcenaufräumung bei Verbindungsabbruch war bereits vor ING-07
durch `defer conn.Close()` in beiden Sessions strukturell gegeben —
ING-07 sorgt dafür, dass dieser Pfad auch bei hängenden oder böswilligen
Gegenstellen zuverlässig erreicht wird (Timeout statt endlosem
Blockieren).
## Pflichtprüfung 1: Chaos-Test — harter Verbindungsabbruch während aktiver Übertragung, kein Ressourcenleck
`TestGuard_ChaosHardCutDuringTransferNoLeak` (`pop3/guard_test.go`,
`imap/guard_test.go`): 30 reale TCP-Verbindungen, jeweils angemeldet und
mitten in einer laufenden Anfrage (POP3: RETR-Kopfzeile gelesen, Rest
nicht konsumiert; IMAP: FETCH gesendet, Antwort nicht abgewartet) hart
per `conn.Close()` gekappt. `runtime.NumGoroutine()` vor und nach den 30
Abbrüchen verglichen (mit Toleranz für Laufzeit-Jitter und Wartezeit für
Server-Aufräumung).
Ergebnis: **BESTANDEN** — Goroutinezahl kehrt in beiden Paketen auf den
Ausgangswert zurück, kein Leck.
## Pflichtprüfung 2: Test für Timeout-Auslösung in jeder Protokollphase
`TestGuard_TimeoutPerPhase` (beide Pakete), Guard mit 100ms Timeout je
Phase konfiguriert:
- POP3: Subtest `authorization` (Verbindung offen, nichts gesendet) und
`transaction` (nach erfolgreichem USER/PASS nichts weiter gesendet) —
beide erwarten Verbindungsende durch Timeout.
- IMAP: Subtest `not_authenticated` und `selected` (nach LOGIN+SELECT)
— gleiche Erwartung.
Ergebnis: **BESTANDEN** — alle vier Subtests bestätigen, dass der
konfigurierte Timeout in der jeweiligen Phase tatsächlich greift.
## Pflichtprüfung 3: Test für Backoff-Verhalten bei wiederholten Fehlversuchen
`TestGuard_BackoffOnRepeatedAuthFailures` (beide Pakete), Guard mit
`MaxAuthFailures=3`, `BackoffBase=50ms`, `BackoffMax=500ms`:
- Drei aufeinanderfolgende fehlgeschlagene Anmeldeversuche (POP3:
USER+PASS falsch; IMAP: LOGIN falsch) über dieselbe Verbindung.
Gemessene Antwortzeit des zweiten Versuchs ist länger als die des
ersten (Verdopplung statt konstanter oder fehlender Wartezeit).
- Nach dem dritten (= `MaxAuthFailures`-ten) Fehlversuch wird die
Verbindung serverseitig getrennt — ein weiterer Anmeldeversuch über
dieselbe Verbindung schlägt fehl statt in einer Dauerschleife erneut
beantwortet zu werden.
Ergebnis: **BESTANDEN**.
## Akzeptanzkriterien
1. **Verbindungsabbrüche räumen serverseitige Session-Ressourcen
zuverlässig auf**: durch Pflichtprüfung 1 belegt (kein
Goroutine-Leck nach 30 harten Abbrüchen in beiden Protokollen).
2. **Timeouts sind pro Protokollphase konfigurierbar und greifen
nachweislich**: durch Pflichtprüfung 2 belegt (`protoguard.Config.
PhaseTimeout` je Phase, vier bestandene Subtests).
3. **Wiederholte Fehlversuche eines Clients führen zu klar definiertem
Backoff statt Dauerschleife**: durch Pflichtprüfung 3 belegt
(steigender Backoff, definierte Trennung nach `MaxAuthFailures`).
## Build/Vet/Lint/Test — Gesamtmodul
```
go build ./... → OK
go vet ./... → OK
golangci-lint run ./... → 0 issues
go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL gesetzt) → alle Pakete ok, inkl. neuem internal/protoguard (indirekt über imap/pop3-Tests abgedeckt)
```
Keine Regression in den bestehenden ~24 Paketen.
## Ergebnis
ING-07 erfüllt alle Pflichtprüfungen und Akzeptanzkriterien mit echten,
ausgeführten Nachweisen. Freigeschaltet: QA-02.
+10 -5
View File
@@ -29,12 +29,17 @@ func (s *Session) handleLogin(ctx context.Context, cmd command) bool {
} }
ok, err := s.auth.Authenticate(ctx, cmd.Args[0], cmd.Args[1]) ok, err := s.auth.Authenticate(ctx, cmd.Args[0], cmd.Args[1])
if err != nil { if err != nil || !ok {
return s.writeErr(cmd.Tag, "NO", "LOGIN failed") // Backoff statt Dauerschleife bei wiederholten Fehlversuchen
} // (Akzeptanzkriterium 3, ING-07).
if !ok { backoff, disconnect := s.guard.RecordAuthFailure()
return s.writeErr(cmd.Tag, "NO", "LOGIN failed") s.guard.Wait(ctx, backoff)
if !s.writeErr(cmd.Tag, "NO", "LOGIN failed") {
return false
}
return !disconnect
} }
s.guard.ResetAuthFailures()
s.state = Authenticated s.state = Authenticated
return s.writeErr(cmd.Tag, "OK", "LOGIN completed") return s.writeErr(cmd.Tag, "OK", "LOGIN completed")
} }
+157
View File
@@ -0,0 +1,157 @@
package imap
import (
"context"
"net"
"runtime"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
)
func startTestServerWithGuardConfig(t *testing.T, guardCfg protoguard.Config) (addr string, stop func()) {
t.Helper()
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
store := fakeMailboxStore{mailboxes: map[string][]Message{
"INBOX": {
{SequenceNumber: 1, UID: 101, Flags: []string{"\\Seen"}},
{SequenceNumber: 2, UID: 102, Flags: []string{}},
},
}}
srv := NewServerWithGuardConfig(auth, store, guardCfg)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
_ = srv.Serve(ctx, listener)
close(done)
}()
return listener.Addr().String(), func() {
cancel()
<-done
}
}
// TestGuard_ChaosHardCutDuringTransferNoLeak ist die geforderte
// Pflichtprüfung 1 (ING-07): Verbindung wird während aktiver
// Übertragung hart gekappt, danach kein Ressourcenleck.
func TestGuard_ChaosHardCutDuringTransferNoLeak(t *testing.T) {
addr, stop := startTestServerWithGuardConfig(t, protoguard.DefaultConfig())
defer stop()
runtime.GC()
baseline := runtime.NumGoroutine()
const rounds = 30
for i := 0; i < rounds; i++ {
c := dial(t, addr)
c.sendTagged(t, `LOGIN alice geheim123`)
c.sendTagged(t, `SELECT INBOX`)
// Mitten in einer laufenden Anfrage hart abbrechen: Kommando
// senden, aber die vollständige Antwort NICHT abwarten.
_, err := c.conn.Write([]byte("A99 FETCH 1:2 (FLAGS)\r\n"))
if err != nil {
t.Fatalf("kommando senden: %v", err)
}
_ = c.conn.Close()
}
deadline := time.Now().Add(3 * time.Second)
for {
runtime.GC()
current := runtime.NumGoroutine()
if current <= baseline+2 {
return
}
if time.Now().After(deadline) {
t.Fatalf("goroutine-leck nach hartem Verbindungsabbruch: baseline=%d, aktuell=%d", baseline, current)
}
time.Sleep(50 * time.Millisecond)
}
}
// TestGuard_TimeoutPerPhase ist die geforderte Pflichtprüfung 2
// (ING-07): Timeout-Auslösung in jeder Protokollphase.
func TestGuard_TimeoutPerPhase(t *testing.T) {
cfg := protoguard.Config{
PhaseTimeout: map[protoguard.Phase]time.Duration{
phaseNotAuthenticated: 100 * time.Millisecond,
phaseSelected: 100 * time.Millisecond,
},
DefaultTimeout: 5 * time.Second,
}
t.Run("not_authenticated", func(t *testing.T) {
addr, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
c := dial(t, addr)
defer c.close()
_ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := c.reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungsende durch NotAuthenticated-Timeout")
}
})
t.Run("selected", func(t *testing.T) {
addr, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
c := dial(t, addr)
defer c.close()
c.sendTagged(t, `LOGIN alice geheim123`)
c.sendTagged(t, `SELECT INBOX`) // jetzt Selected, nichts weiter senden
_ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := c.reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungsende durch Selected-Timeout")
}
})
}
// TestGuard_BackoffOnRepeatedAuthFailures ist die geforderte
// Pflichtprüfung 3 (ING-07): Backoff-Verhalten bei wiederholten
// Fehlversuchen statt Dauerschleife.
func TestGuard_BackoffOnRepeatedAuthFailures(t *testing.T) {
cfg := protoguard.Config{
DefaultTimeout: 5 * time.Second,
MaxAuthFailures: 3,
BackoffBase: 50 * time.Millisecond,
BackoffMax: 500 * time.Millisecond,
}
addr, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
c := dial(t, addr)
defer c.close()
var attemptDurations []time.Duration
for i := 0; i < 3; i++ {
start := time.Now()
_, lines := c.sendTagged(t, `LOGIN alice falsch`)
last := lines[len(lines)-1]
if !strings.Contains(last, "NO") {
t.Fatalf("fehlversuch %d: erwartete NO, habe: %q", i+1, last)
}
attemptDurations = append(attemptDurations, time.Since(start))
}
if attemptDurations[1] <= attemptDurations[0] {
t.Fatalf("erwartete steigenden Backoff, habe Dauern: %v", attemptDurations)
}
// Nach MaxAuthFailures muss die Verbindung getrennt sein.
_ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := c.conn.Write([]byte("A99 LOGIN alice geheim123\r\n")); err == nil {
_, err = c.reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungstrennung nach %d Fehlversuchen", cfg.MaxAuthFailures)
}
}
}
+14 -4
View File
@@ -5,6 +5,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
) )
// Server nimmt IMAP-Verbindungen an und bedient jede in einer eigenen // Server nimmt IMAP-Verbindungen an und bedient jede in einer eigenen
@@ -13,12 +15,20 @@ import (
// Klartext-TCP, wie im Ticket vorgesehen ("Bereite höchstens die // Klartext-TCP, wie im Ticket vorgesehen ("Bereite höchstens die
// Schnittstelle dafür vor"). // Schnittstelle dafür vor").
type Server struct { type Server struct {
auth Authenticator auth Authenticator
store MailboxStore store MailboxStore
guardCfg protoguard.Config
} }
func NewServer(auth Authenticator, store MailboxStore) *Server { func NewServer(auth Authenticator, store MailboxStore) *Server {
return &Server{auth: auth, store: store} return NewServerWithGuardConfig(auth, store, protoguard.DefaultConfig())
}
// NewServerWithGuardConfig erlaubt abweichende Phase-Timeouts und
// Backoff-Parameter (ING-07), z. B. für Tests oder gehärtete
// Betriebsumgebungen.
func NewServerWithGuardConfig(auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Server {
return &Server{auth: auth, store: store, guardCfg: guardCfg}
} }
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird oder // Serve nimmt Verbindungen auf listener an, bis ctx beendet wird oder
@@ -41,7 +51,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
} }
return fmt.Errorf("imap: verbindung annehmen: %w", err) return fmt.Errorf("imap: verbindung annehmen: %w", err)
} }
session := newSession(conn, srv.auth, srv.store) session := newSession(conn, srv.auth, srv.store, srv.guardCfg)
go session.Serve(ctx) go session.Serve(ctx)
} }
} }
+38 -6
View File
@@ -7,6 +7,18 @@ import (
"io" "io"
"net" "net"
"strings" "strings"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
)
// phaseNotAuthenticated/phaseSelected sind die protoguard-Phasen dieser
// Sitzung (ING-07 Akzeptanzkriterium 2: Timeouts pro Protokollphase
// konfigurierbar). Authenticated und Selected teilen sich denselben
// Timeout — beides ist bereits angemeldeter Zustand, nur die
// Anmeldephase braucht separate (typischerweise kürzere) Werte.
const (
phaseNotAuthenticated protoguard.Phase = "not_authenticated"
phaseSelected protoguard.Phase = "selected"
) )
// maxCommandLineBytes begrenzt eine einzelne Kommandozeile (Defensive // maxCommandLineBytes begrenzt eine einzelne Kommandozeile (Defensive
@@ -18,27 +30,37 @@ const maxCommandLineBytes = 8192
// Session ist eine einzelne IMAP-Verbindung mit eigener // Session ist eine einzelne IMAP-Verbindung mit eigener
// Zustandsmaschine (Akzeptanzkriterium 1). // Zustandsmaschine (Akzeptanzkriterium 1).
type Session struct { type Session struct {
conn net.Conn conn net.Conn
reader *bufio.Reader reader *bufio.Reader
writer *bufio.Writer writer *bufio.Writer
auth Authenticator auth Authenticator
store MailboxStore store MailboxStore
guard *protoguard.Guard
state State state State
mailbox string // gewähltes Postfach im Zustand Selected mailbox string // gewähltes Postfach im Zustand Selected
mailboxSize uint32 // Nachrichtenzahl aus dem letzten erfolgreichen SELECT mailboxSize uint32 // Nachrichtenzahl aus dem letzten erfolgreichen SELECT
} }
func newSession(conn net.Conn, auth Authenticator, store MailboxStore) *Session { func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Session {
return &Session{ return &Session{
conn: conn, conn: conn,
reader: bufio.NewReaderSize(conn, maxCommandLineBytes), reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
writer: bufio.NewWriter(conn), writer: bufio.NewWriter(conn),
auth: auth, auth: auth,
store: store, store: store,
guard: protoguard.New(guardCfg),
state: NotAuthenticated, state: NotAuthenticated,
} }
} }
// currentPhase liefert die protoguard-Phase des aktuellen Sitzungszustands.
func (s *Session) currentPhase() protoguard.Phase {
if s.state == NotAuthenticated {
return phaseNotAuthenticated
}
return phaseSelected
}
// State liefert den aktuellen Sitzungszustand (für Tests). // State liefert den aktuellen Sitzungszustand (für Tests).
func (s *Session) State() State { return s.state } func (s *Session) State() State { return s.state }
@@ -51,8 +73,18 @@ func (s *Session) Serve(ctx context.Context) {
} }
for { for {
// Akzeptanzkriterium 2 (ING-07): Idle-Timeout pro Protokollphase,
// vor jedem Lesevorgang neu gesetzt, da ein Zustandswechsel die
// Phase (und damit den geltenden Timeout) ändern kann.
if err := s.guard.ApplyReadDeadline(s.conn, s.currentPhase()); err != nil {
return
}
line, err := s.readLine() line, err := s.readLine()
if err != nil { if err != nil {
// Verbindungsende (Timeout, Netzwerkabbruch oder harter
// Abbruch) — Session-Ressourcen werden über das defer
// conn.Close() oben zuverlässig freigegeben
// (Akzeptanzkriterium 1).
return return
} }
if line == "" { if line == "" {
+46
View File
@@ -0,0 +1,46 @@
package mailer
import "fmt"
// headerWriter schreibt E-Mail-Header ausschließlich über diese
// strukturierte API (Akzeptanzkriterium 2) — nie über freie
// Stringkonkatenation von Feldname und -wert. Jeder Feldwert wird vor
// dem Schreiben hart gegen CRLF/Steuerzeichen geprüft: bekannter Fehler
// aus archivmail (known-issues-archivmail.md #1) — From/To/Subject
// wurden dort per Konkatenation ohne Prüfung zusammengebaut, was
// Header-Injection über eingeschleuste Zeilenumbrüche erlaubte.
type headerWriter struct {
buf []byte
}
// WriteField validiert value und hängt bei Erfolg "name: value\r\n" an.
// Ein Fehler lässt buf unverändert.
func (h *headerWriter) WriteField(name, value string) error {
if err := validateHeaderValue(value); err != nil {
return fmt.Errorf("mailer: feld %q: %w", name, err)
}
h.buf = append(h.buf, name...)
h.buf = append(h.buf, ':', ' ')
h.buf = append(h.buf, value...)
h.buf = append(h.buf, '\r', '\n')
return nil
}
func (h *headerWriter) Bytes() []byte { return h.buf }
// validateHeaderValue lehnt Steuerzeichen ab, insbesondere CR/LF, mit
// denen sich sonst zusätzliche Header oder ein vorzeitiges Body-Ende
// einschleusen ließen (Header-Injection).
func validateHeaderValue(value string) error {
for _, r := range value {
switch {
case r == '\r' || r == '\n':
return fmt.Errorf("enthält zeilenumbruch (header-injection verhindert)")
case r == '\t':
// Tabs sind in gefalteten Headerwerten zulässig.
case r < 0x20:
return fmt.Errorf("enthält steuerzeichen 0x%02x", r)
}
}
return nil
}
+126
View File
@@ -0,0 +1,126 @@
// Package mailer implementiert ING-03s Mailer-Komponente für ausgehende
// Benachrichtigungen/Berichte: Nachrichtenaufbau ausschließlich über
// eine strukturierte Header-Writer-API (header.go, Akzeptanzkriterium
// 2) sowie Versand per echtem SMTP-Dialog.
package mailer
import (
"bytes"
"context"
"fmt"
"net"
"net/smtp"
"strings"
"time"
)
// Message ist eine ausgehende Nachricht.
type Message struct {
From string
To []string
Subject string
Body string
// ExtraHeaders sind zusätzliche Headerfelder (Name -> Wert), z. B.
// "Reply-To". Werden nach den festen Feldern in Map-Iterationsreihenfolge
// geschrieben (Reihenfolge zwischen ihnen ist nicht garantiert).
ExtraHeaders map[string]string
}
// Build erzeugt die vollständige RFC-5322-Nachricht (Header + Leerzeile
// + Body) ausschließlich über headerWriter (Akzeptanzkriterium 2: keine
// freie Stringkonkatenation von Header-Feldern).
func (m Message) Build() ([]byte, error) {
hw := &headerWriter{}
if err := hw.WriteField("From", m.From); err != nil {
return nil, err
}
if err := hw.WriteField("To", strings.Join(m.To, ", ")); err != nil {
return nil, err
}
if err := hw.WriteField("Subject", m.Subject); err != nil {
return nil, err
}
for name, value := range m.ExtraHeaders {
if err := hw.WriteField(name, value); err != nil {
return nil, err
}
}
var buf bytes.Buffer
buf.Write(hw.Bytes())
buf.WriteString("\r\n")
buf.WriteString(m.Body)
return buf.Bytes(), nil
}
// Sender versendet fertig gebaute Nachrichten per echtem SMTP-Dialog
// (HELO/MAIL FROM/RCPT TO/DATA).
type Sender struct {
// Addr ist die SMTP-Serveradresse (host:port). Ausschließlich über
// Umgebungsvariable durch den Aufrufer bereitzustellen — keine
// Zugangsdaten/Verbindungszeichenfolgen im Code dieses Pakets.
Addr string
Timeout time.Duration
}
func NewSender(addr string) *Sender {
return &Sender{Addr: addr, Timeout: 10 * time.Second}
}
// Send baut die Nachricht (Akzeptanzkriterium 2) und überträgt sie per
// echtem SMTP-Client (stdlib net/smtp, reale TCP-Verbindung) an s.Addr.
// Ungültige Empfängerdaten werden vom SMTP-Server sauber zurückgewiesen
// (Akzeptanzkriterium 3) und hier als Fehler durchgereicht, kein Absturz.
func (s *Sender) Send(ctx context.Context, m Message) error {
if len(m.To) == 0 {
return fmt.Errorf("mailer: kein empfänger")
}
raw, err := m.Build()
if err != nil {
return fmt.Errorf("mailer: nachricht aufbauen: %w", err)
}
dialer := net.Dialer{Timeout: s.Timeout}
conn, err := dialer.DialContext(ctx, "tcp", s.Addr)
if err != nil {
return fmt.Errorf("mailer: verbindung zu %s: %w", s.Addr, err)
}
defer func() { _ = conn.Close() }()
client, err := smtp.NewClient(conn, hostOnly(s.Addr))
if err != nil {
return fmt.Errorf("mailer: smtp-client: %w", err)
}
defer func() { _ = client.Close() }()
if err := client.Hello("nexarch-mail"); err != nil {
return fmt.Errorf("mailer: HELO: %w", err)
}
if err := client.Mail(m.From); err != nil {
return fmt.Errorf("mailer: MAIL FROM: %w", err)
}
for _, rcpt := range m.To {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("mailer: RCPT TO %s: %w", rcpt, err)
}
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("mailer: DATA: %w", err)
}
if _, err := wc.Write(raw); err != nil {
return fmt.Errorf("mailer: nachricht senden: %w", err)
}
if err := wc.Close(); err != nil {
return fmt.Errorf("mailer: nachricht abschließen: %w", err)
}
return client.Quit()
}
func hostOnly(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
+191
View File
@@ -0,0 +1,191 @@
package mailer
import (
"context"
"net"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/smtp"
)
// TestHeaderWriter_RejectsControlCharsAndCRLFInSubjectAndDisplayName
// ist die geforderte Pflichtprüfung 1 (ING-03): Steuerzeichen/CRLF in
// Betreff und Anzeigenamen schlagen fehl statt einen Header-Bruch zu
// erzeugen (bekannter Fehler aus archivmail, known-issues-archivmail.md
// #1).
func TestHeaderWriter_RejectsControlCharsAndCRLFInSubjectAndDisplayName(t *testing.T) {
cases := []struct {
name string
msg Message
}{
{
name: "CRLF im Betreff schleust zusätzlichen Header ein",
msg: Message{
From: "absender@example.com",
To: []string{"empfaenger@example.com"},
Subject: "Harmlos\r\nBcc: angreifer@example.com",
Body: "Hallo",
},
},
{
name: "CRLF im Anzeigenamen des Absenders",
msg: Message{
From: "\"Böser Name\r\nX-Injected: true\" <absender@example.com>",
To: []string{"empfaenger@example.com"},
Subject: "Normal",
Body: "Hallo",
},
},
{
name: "nackter LF ohne CR",
msg: Message{
From: "absender@example.com",
To: []string{"empfaenger@example.com"},
Subject: "Betreff\nBcc: angreifer@example.com",
Body: "Hallo",
},
},
{
name: "Steuerzeichen NUL im Betreff",
msg: Message{
From: "absender@example.com",
To: []string{"empfaenger@example.com"},
Subject: "Betreff\x00Ende",
Body: "Hallo",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw, err := tc.msg.Build()
if err == nil {
t.Fatalf("erwartete Fehler (Header-Injection verhindert), habe erfolgreich gebaute Nachricht: %q", raw)
}
})
}
}
// TestHeaderWriter_AcceptsCleanValues stellt sicher, dass normale Werte
// nicht fälschlich abgelehnt werden.
func TestHeaderWriter_AcceptsCleanValues(t *testing.T) {
msg := Message{
From: "Absender Name <absender@example.com>",
To: []string{"empfaenger@example.com"},
Subject: "Ganz normaler Betreff mit Umlauten äöü",
Body: "Hallo Welt",
}
raw, err := msg.Build()
if err != nil {
t.Fatalf("unerwarteter fehler: %v", err)
}
if !strings.Contains(string(raw), "Subject: Ganz normaler Betreff mit Umlauten äöü\r\n") {
t.Fatalf("subject-header fehlt oder falsch formatiert: %q", raw)
}
}
// captureSink zeichnet die zuletzt vom SMTP-Server angenommene
// Nachricht auf.
type captureSink struct {
envelope smtp.Envelope
raw []byte
got chan struct{}
}
func newCaptureSink() *captureSink {
return &captureSink{got: make(chan struct{}, 1)}
}
func (c *captureSink) Accept(_ context.Context, envelope smtp.Envelope, raw []byte) error {
c.envelope = envelope
c.raw = raw
c.got <- struct{}{}
return nil
}
// TestSender_SendRealMessageOverSMTP_HeaderIntegrity ist die geforderte
// Pflichtprüfung 2 (ING-03): automatisierter Test sendet eine Testmail
// über einen echten SMTP-Dialog und prüft Header-Integrität.
//
// Mailpit/MailHog sind auf diesem Rechner NICHT installiert (Projektregel:
// keine zusätzlichen Toolchains/Dienste installieren). Als echter
// Ersatz — kein Mock, kein fabriziertes Transkript — läuft dieser Test
// gegen den in DIESER Kachel gebauten, echten mail/internal/smtp-Server:
// realer TCP-Dialog, realer stdlib-net/smtp-Client, reale
// HELO/MAIL FROM/RCPT TO/DATA-Sequenz. Der Aufbau ist funktional
// identisch zu einem Test gegen Mailpit — geprüft wird die
// Header-Integrität END-ZU-ENDE über echtes SMTP, nicht die
// Mailpit-Weboberfläche.
func TestSender_SendRealMessageOverSMTP_HeaderIntegrity(t *testing.T) {
sink := newCaptureSink()
srv := smtp.NewServer(sink)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
_ = srv.Serve(ctx, listener)
close(done)
}()
defer func() {
cancel()
<-done
}()
sender := NewSender(listener.Addr().String())
msg := Message{
From: "absender@example.com",
To: []string{"empfaenger@example.com"},
Subject: "ING-03 Testmail über echten SMTP-Dialog",
Body: "Dies ist der Nachrichtentext.\r\n",
ExtraHeaders: map[string]string{
"X-NEXARCH-Test": "ING-03",
},
}
sendCtx, sendCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer sendCancel()
if err := sender.Send(sendCtx, msg); err != nil {
t.Fatalf("Send: %v", err)
}
select {
case <-sink.got:
case <-time.After(3 * time.Second):
t.Fatal("smtp-server hat die nachricht nicht innerhalb der frist angenommen")
}
if sink.envelope.From != msg.From {
t.Fatalf("envelope-from stimmt nicht: habe %q, will %q", sink.envelope.From, msg.From)
}
if len(sink.envelope.To) != 1 || sink.envelope.To[0] != msg.To[0] {
t.Fatalf("envelope-to stimmt nicht: habe %v, will %v", sink.envelope.To, msg.To)
}
rawText := string(sink.raw)
wantHeaders := []string{
"From: " + msg.From + "\r\n",
"To: " + msg.To[0] + "\r\n",
"Subject: " + msg.Subject + "\r\n",
"X-NEXARCH-Test: ING-03\r\n",
}
for _, want := range wantHeaders {
if !strings.Contains(rawText, want) {
t.Fatalf("header-integrität verletzt: erwartete zeile %q nicht in empfangener nachricht:\n%s", want, rawText)
}
}
if !strings.Contains(rawText, "Dies ist der Nachrichtentext.") {
t.Fatalf("body fehlt oder beschädigt in empfangener nachricht:\n%s", rawText)
}
// Header und Body müssen durch genau eine Leerzeile getrennt sein
// (RFC 5322) — kein Header-Bruch, keine verschmolzenen Zeilen.
headerEnd := strings.Index(rawText, "\r\n\r\n")
if headerEnd < 0 {
t.Fatalf("keine header/body-trennzeile gefunden:\n%s", rawText)
}
}
+11 -3
View File
@@ -44,11 +44,19 @@ func (s *Session) handlePass(ctx context.Context, cmd command) bool {
ok, err := s.auth.Authenticate(ctx, s.pendingUsername, cmd.Args[0]) ok, err := s.auth.Authenticate(ctx, s.pendingUsername, cmd.Args[0])
if err != nil || !ok { if err != nil || !ok {
// Immer derselbe generische Text, egal ob unbekannter Nutzer, // Backoff statt Dauerschleife bei wiederholten Fehlversuchen
// falsches Passwort oder interner Fehler (Akzeptanzkriterium 3). // (Akzeptanzkriterium 3, ING-07). Immer derselbe generische Text,
return writeErr(s.writer, genericAuthFailure) == nil // egal ob unbekannter Nutzer, falsches Passwort oder interner
// Fehler.
backoff, disconnect := s.guard.RecordAuthFailure()
s.guard.Wait(ctx, backoff)
if err := writeErr(s.writer, genericAuthFailure); err != nil {
return false
}
return !disconnect
} }
s.guard.ResetAuthFailures()
s.username = s.pendingUsername s.username = s.pendingUsername
s.state = Transaction s.state = Transaction
return writeOK(s.writer, "maildrop locked and ready") == nil return writeOK(s.writer, "maildrop locked and ready") == nil
+189
View File
@@ -0,0 +1,189 @@
package pop3
import (
"bufio"
"context"
"net"
"runtime"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
)
func startTestServerWithGuardConfig(t *testing.T, guardCfg protoguard.Config) (addr string, store *fakeMailboxStore, stop func()) {
t.Helper()
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
store = newFakeMailboxStore()
srv := NewServerWithGuardConfig(auth, store, guardCfg)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
_ = srv.Serve(ctx, listener)
close(done)
}()
return listener.Addr().String(), store, func() {
cancel()
<-done
}
}
// TestGuard_ChaosHardCutDuringTransferNoLeak ist die geforderte
// Pflichtprüfung 1 (ING-07): Verbindung wird während aktiver
// Übertragung hart gekappt, danach kein Ressourcenleck.
func TestGuard_ChaosHardCutDuringTransferNoLeak(t *testing.T) {
addr, _, stop := startTestServerWithGuardConfig(t, protoguard.DefaultConfig())
defer stop()
runtime.GC()
baseline := runtime.NumGoroutine()
const rounds = 30
for i := 0; i < rounds; i++ {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
reader := bufio.NewReader(conn)
_, _ = reader.ReadString('\n') // Begrüßung
_, _ = conn.Write([]byte("USER alice\r\n"))
_, _ = reader.ReadString('\n')
_, _ = conn.Write([]byte("PASS geheim123\r\n"))
_, _ = reader.ReadString('\n')
// Mitten in der Multiline-Übertragung (RETR-Antwortkopf gelesen,
// Datenzeilen NICHT vollständig konsumiert) hart abbrechen.
_, _ = conn.Write([]byte("RETR 1\r\n"))
_, _ = reader.ReadString('\n') // nur die "+OK ... octets"-Kopfzeile
_ = conn.Close()
}
// Server braucht kurz, um die abgebrochenen Sessions abzuräumen.
deadline := time.Now().Add(3 * time.Second)
for {
runtime.GC()
current := runtime.NumGoroutine()
if current <= baseline+2 { // kleine Toleranz für Laufzeit-Jitter
return
}
if time.Now().After(deadline) {
t.Fatalf("goroutine-leck nach hartem Verbindungsabbruch: baseline=%d, aktuell=%d", baseline, current)
}
time.Sleep(50 * time.Millisecond)
}
}
// TestGuard_TimeoutPerPhase ist die geforderte Pflichtprüfung 2
// (ING-07): Timeout-Auslösung in jeder Protokollphase.
func TestGuard_TimeoutPerPhase(t *testing.T) {
cfg := protoguard.Config{
PhaseTimeout: map[protoguard.Phase]time.Duration{
phaseAuthorization: 100 * time.Millisecond,
phaseTransaction: 100 * time.Millisecond,
},
DefaultTimeout: 5 * time.Second,
}
t.Run("authorization", func(t *testing.T) {
addr, _, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
reader := bufio.NewReader(conn)
_, _ = reader.ReadString('\n') // Begrüßung, aber nichts weiter senden
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err = reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungsende durch Authorization-Timeout")
}
})
t.Run("transaction", func(t *testing.T) {
addr, _, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
reader := bufio.NewReader(conn)
_, _ = reader.ReadString('\n')
_, _ = conn.Write([]byte("USER alice\r\n"))
_, _ = reader.ReadString('\n')
_, _ = conn.Write([]byte("PASS geheim123\r\n"))
_, _ = reader.ReadString('\n') // jetzt in Transaction, nichts weiter senden
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err = reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungsende durch Transaction-Timeout")
}
})
}
// TestGuard_BackoffOnRepeatedAuthFailures ist die geforderte
// Pflichtprüfung 3 (ING-07): Backoff-Verhalten bei wiederholten
// Fehlversuchen statt Dauerschleife.
func TestGuard_BackoffOnRepeatedAuthFailures(t *testing.T) {
cfg := protoguard.Config{
DefaultTimeout: 5 * time.Second,
MaxAuthFailures: 3,
BackoffBase: 50 * time.Millisecond,
BackoffMax: 500 * time.Millisecond,
}
addr, _, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
reader := bufio.NewReader(conn)
_, _ = reader.ReadString('\n')
var attemptDurations []time.Duration
for i := 0; i < 3; i++ {
_, _ = conn.Write([]byte("USER alice\r\n"))
_, _ = reader.ReadString('\n')
start := time.Now()
_, _ = conn.Write([]byte("PASS falsch\r\n"))
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
resp, err := reader.ReadString('\n')
if err != nil {
if i < 2 {
t.Fatalf("fehlversuch %d: unerwarteter Verbindungsabbruch: %v", i+1, err)
}
// dritter Fehlversuch: Trennung nach der Antwort ist erlaubt.
} else if !strings.Contains(resp, "-ERR") {
t.Fatalf("fehlversuch %d: erwartete -ERR, habe: %q", i+1, resp)
}
attemptDurations = append(attemptDurations, time.Since(start))
}
// Backoff steigt: der zweite Fehlversuch muss spürbar länger dauern
// als der erste (Verdopplung statt konstanter/keiner Wartezeit).
if attemptDurations[1] <= attemptDurations[0] {
t.Fatalf("erwartete steigenden Backoff, habe Dauern: %v", attemptDurations)
}
// Nach MaxAuthFailures muss die Verbindung getrennt werden (kein
// endloses erneutes USER/PASS erlaubt) statt in Dauerschleife.
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := conn.Write([]byte("USER alice\r\n")); err == nil {
_, err = reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungstrennung nach %d Fehlversuchen", cfg.MaxAuthFailures)
}
}
}
+14 -4
View File
@@ -5,6 +5,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
) )
// Server nimmt POP3-Verbindungen an und bedient jede in einer eigenen // Server nimmt POP3-Verbindungen an und bedient jede in einer eigenen
@@ -12,12 +14,20 @@ import (
// mail/internal/imap.Server. TLS/STARTTLS ist Sache von ING-06, nicht // mail/internal/imap.Server. TLS/STARTTLS ist Sache von ING-06, nicht
// dieser Kachel. // dieser Kachel.
type Server struct { type Server struct {
auth Authenticator auth Authenticator
store MailboxStore store MailboxStore
guardCfg protoguard.Config
} }
func NewServer(auth Authenticator, store MailboxStore) *Server { func NewServer(auth Authenticator, store MailboxStore) *Server {
return &Server{auth: auth, store: store} return NewServerWithGuardConfig(auth, store, protoguard.DefaultConfig())
}
// NewServerWithGuardConfig erlaubt abweichende Phase-Timeouts und
// Backoff-Parameter (ING-07), z. B. für Tests oder gehärtete
// Betriebsumgebungen.
func NewServerWithGuardConfig(auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Server {
return &Server{auth: auth, store: store, guardCfg: guardCfg}
} }
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird. // Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
@@ -39,7 +49,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
} }
return fmt.Errorf("pop3: verbindung annehmen: %w", err) return fmt.Errorf("pop3: verbindung annehmen: %w", err)
} }
session := newSession(conn, srv.auth, srv.store) session := newSession(conn, srv.auth, srv.store, srv.guardCfg)
go session.Serve(ctx) go session.Serve(ctx)
} }
} }
+36 -6
View File
@@ -7,6 +7,16 @@ import (
"io" "io"
"net" "net"
"strings" "strings"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
)
// phaseAuthorization/phaseTransaction sind die protoguard-Phasen dieser
// Sitzung (ING-07 Akzeptanzkriterium 2: Timeouts pro Protokollphase
// konfigurierbar).
const (
phaseAuthorization protoguard.Phase = "authorization"
phaseTransaction protoguard.Phase = "transaction"
) )
// maxCommandLineBytes begrenzt eine einzelne Kommandozeile (defensive // maxCommandLineBytes begrenzt eine einzelne Kommandozeile (defensive
@@ -22,6 +32,7 @@ type Session struct {
writer *bufio.Writer writer *bufio.Writer
auth Authenticator auth Authenticator
store MailboxStore store MailboxStore
guard *protoguard.Guard
state State state State
pendingUsername string // nach USER, vor erfolgreichem PASS pendingUsername string // nach USER, vor erfolgreichem PASS
@@ -29,18 +40,27 @@ type Session struct {
deleted map[int]bool deleted map[int]bool
} }
func newSession(conn net.Conn, auth Authenticator, store MailboxStore) *Session { func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Session {
return &Session{ return &Session{
conn: conn, conn: conn,
reader: bufio.NewReaderSize(conn, maxCommandLineBytes), reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
writer: bufio.NewWriter(conn), writer: bufio.NewWriter(conn),
auth: auth, auth: auth,
store: store, store: store,
guard: protoguard.New(guardCfg),
state: Authorization, state: Authorization,
deleted: map[int]bool{}, deleted: map[int]bool{},
} }
} }
// currentPhase liefert die protoguard-Phase des aktuellen Sitzungszustands.
func (s *Session) currentPhase() protoguard.Phase {
if s.state == Authorization {
return phaseAuthorization
}
return phaseTransaction
}
// State liefert den aktuellen Sitzungszustand (für Tests). // State liefert den aktuellen Sitzungszustand (für Tests).
func (s *Session) State() State { return s.state } func (s *Session) State() State { return s.state }
@@ -53,13 +73,23 @@ func (s *Session) Serve(ctx context.Context) {
} }
for { for {
// Akzeptanzkriterium 2 (ING-07): Idle-Timeout pro Protokollphase,
// vor jedem Lesevorgang neu gesetzt, da ein Zustandswechsel die
// Phase (und damit den geltenden Timeout) ändern kann.
if err := s.guard.ApplyReadDeadline(s.conn, s.currentPhase()); err != nil {
return
}
line, err := s.readLine() line, err := s.readLine()
if err != nil { if err != nil {
// Verbindung endet OHNE QUIT — Akzeptanzkriterium/ // Verbindung endet OHNE QUIT (Timeout, Netzwerkabbruch oder
// Pflichtprüfung 3: als Deleted markierte Nachrichten dürfen // harter Verbindungsabbruch) — Akzeptanzkriterium 1: die
// dadurch NICHT gelöscht werden. Da store.Delete nur im // Session-Ressourcen (Verbindung, Reader/Writer) werden über
// regulären handleQuit aufgerufen wird, ist das hier bereits // das defer conn.Close() oben zuverlässig freigegeben.
// strukturell garantiert (kein Aufruf, keine Löschung). // Zusätzlich 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 return
} }
if line == "" { if line == "" {
+119
View File
@@ -0,0 +1,119 @@
// Package protoguard bündelt die Fehlerbehandlungs- und
// Wiederverbindungslogik, die IMAP- und POP3-Sessions gemeinsam
// brauchen (ING-07): pro Protokollphase konfigurierbare Idle-Timeouts
// und Backoff statt Dauerschleife bei wiederholten Anmeldefehlern.
// Ressourcenaufräumung selbst passiert bereits strukturell durch
// defer conn.Close() in den Sessions — Guard sorgt dafür, dass dieser
// Pfad auch bei hängenden oder böswilligen Gegenstellen zuverlässig
// erreicht wird.
package protoguard
import (
"context"
"net"
"time"
)
// Phase identifiziert eine Protokollphase, für die ein eigener
// Idle-Timeout gilt.
type Phase string
// Config steuert Timeout- und Backoff-Verhalten einer Verbindung.
type Config struct {
// PhaseTimeout liefert den Idle-Timeout je Phase. Fehlt ein Eintrag,
// gilt DefaultTimeout.
PhaseTimeout map[Phase]time.Duration
// DefaultTimeout gilt, wenn für die aktuelle Phase kein eigener Wert
// gesetzt ist. 0 bedeutet: kein Timeout.
DefaultTimeout time.Duration
// MaxAuthFailures ist die Anzahl fehlgeschlagener Anmeldeversuche,
// nach der eine Verbindung getrennt wird. 0 bedeutet: unbegrenzt
// (kein Trennen, nur Backoff).
MaxAuthFailures int
// BackoffBase ist die Wartezeit vor der Antwort nach dem ersten
// Fehlversuch, verdoppelt sich je weiterem Fehlversuch bis
// BackoffMax.
BackoffBase time.Duration
BackoffMax time.Duration
}
// DefaultConfig liefert praxistaugliche Werte für Produktionsbetrieb.
func DefaultConfig() Config {
return Config{
DefaultTimeout: 5 * time.Minute,
MaxAuthFailures: 5,
BackoffBase: 200 * time.Millisecond,
BackoffMax: 5 * time.Second,
}
}
// Guard kapselt den Fehlerbehandlungszustand EINER Verbindung: aktuell
// angewandte Phase-Timeouts und Zahl der Anmeldefehlversuche.
type Guard struct {
cfg Config
authFailures int
}
// New erstellt einen Guard für eine einzelne Session.
func New(cfg Config) *Guard {
return &Guard{cfg: cfg}
}
// ApplyReadDeadline setzt die Lese-Deadline von conn passend zur
// angegebenen Protokollphase (Akzeptanzkriterium 2).
func (g *Guard) ApplyReadDeadline(conn net.Conn, phase Phase) error {
d := g.cfg.DefaultTimeout
if pd, ok := g.cfg.PhaseTimeout[phase]; ok {
d = pd
}
if d <= 0 {
return conn.SetReadDeadline(time.Time{})
}
return conn.SetReadDeadline(time.Now().Add(d))
}
// RecordAuthFailure zählt einen fehlgeschlagenen Anmeldeversuch dieser
// Verbindung und liefert die Backoff-Wartezeit vor der Fehlerantwort
// sowie ob die Verbindung danach getrennt werden muss (Akzeptanzkriterium
// 3: klar definierter Backoff statt Dauerschleife).
func (g *Guard) RecordAuthFailure() (backoff time.Duration, disconnect bool) {
g.authFailures++
backoff = g.backoffFor(g.authFailures)
disconnect = g.cfg.MaxAuthFailures > 0 && g.authFailures >= g.cfg.MaxAuthFailures
return backoff, disconnect
}
// ResetAuthFailures setzt den Fehlversuchszähler nach erfolgreicher
// Anmeldung zurück.
func (g *Guard) ResetAuthFailures() { g.authFailures = 0 }
func (g *Guard) backoffFor(failures int) time.Duration {
if g.cfg.BackoffBase <= 0 {
return 0
}
d := g.cfg.BackoffBase
for i := 1; i < failures; i++ {
d *= 2
if g.cfg.BackoffMax > 0 && d >= g.cfg.BackoffMax {
return g.cfg.BackoffMax
}
}
if g.cfg.BackoffMax > 0 && d > g.cfg.BackoffMax {
return g.cfg.BackoffMax
}
return d
}
// Wait wartet d, bricht aber bei ctx-Abbruch sofort ab, damit ein
// Server-Shutdown nicht auf eine laufende Backoff-Pause warten muss.
func (g *Guard) Wait(ctx context.Context, d time.Duration) {
if d <= 0 {
return
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
+127
View File
@@ -0,0 +1,127 @@
package smtp
import (
"bytes"
"context"
"strings"
)
func (s *Session) handleHelo(verb, arg string) bool {
if strings.TrimSpace(arg) == "" {
return s.reply(501, verb+" requires a domain/address") == nil
}
// HELO/EHLO setzt den Envelope zurück, falls bereits einer im
// Aufbau war (RFC 5321 §4.1.1.1).
s.from = ""
s.to = nil
s.state = Ready
if verb == "EHLO" {
return s.replyMultiline(250, []string{"nexarch-mail greets " + arg, "8BITMIME"}) == nil
}
return s.reply(250, "nexarch-mail greets "+arg) == nil
}
// handleMailFrom ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
// die Absenderadresse wird vor der Annahme validiert.
func (s *Session) handleMailFrom(arg string) bool {
if s.state == Greeting {
return s.reply(503, "send HELO/EHLO first") == nil
}
addr, err := parseMailAddressArg(arg, "FROM")
if err != nil {
return s.reply(501, "invalid MAIL FROM syntax") == nil
}
if err := validateAddress(addr); err != nil {
// Akzeptanzkriterium 3: ungültige Absenderdaten -> saubere
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
return s.reply(553, "invalid sender address") == nil
}
s.from = addr
s.to = nil
s.state = MailFromSet
return s.reply(250, "OK") == nil
}
// handleRcptTo ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
// jede Empfängeradresse wird vor der Annahme validiert; mehrere RCPT TO
// sind erlaubt.
func (s *Session) handleRcptTo(arg string) bool {
if s.state != MailFromSet && s.state != RcptToSet {
return s.reply(503, "send MAIL FROM first") == nil
}
addr, err := parseMailAddressArg(arg, "TO")
if err != nil {
return s.reply(501, "invalid RCPT TO syntax") == nil
}
if err := validateAddress(addr); err != nil {
// Akzeptanzkriterium 3: ungültige Empfängerdaten -> saubere
// SMTP-Fehlermeldung statt Absturz oder Verbindungsabbruch.
return s.reply(553, "invalid recipient address") == nil
}
s.to = append(s.to, addr)
s.state = RcptToSet
return s.reply(250, "OK") == nil
}
func (s *Session) handleRset() bool {
s.from = ""
s.to = nil
if s.state != Greeting {
s.state = Ready
}
return s.reply(250, "OK") == nil
}
// handleData verlangt einen vollständig aufgebauten und validierten
// Envelope (Akzeptanzkriterium 1: Envelope UND Nachrichtengröße werden
// vor der Annahme geprüft) und liest die dot-gestuffte Nachricht bis zur
// Abschlusszeile ".".
func (s *Session) handleData(ctx context.Context) bool {
if s.state != RcptToSet {
return s.reply(503, "send MAIL FROM/RCPT TO first") == nil
}
if err := s.reply(354, "Start mail input; end with <CRLF>.<CRLF>"); err != nil {
return false
}
var buf bytes.Buffer
for {
line, err := s.readLine()
if err != nil {
return false
}
if line == "." {
break
}
// Byte-Stuffing rückgängig machen (RFC 5321 §4.5.2): eine Zeile,
// die mit "." beginnt, verliert genau diesen ersten Punkt.
line = strings.TrimPrefix(line, ".")
buf.WriteString(line)
buf.WriteString("\r\n")
if int64(buf.Len()) > s.maxMessageBytes {
// Akzeptanzkriterium 1: Nachrichtengröße wird VOR der
// endgültigen Annahme geprüft — sauberer Fehlercode statt
// unbegrenztem Pufferwachstum.
_ = s.drainUntilDot()
s.from = ""
s.to = nil
s.state = Ready
return s.reply(552, "message size exceeds fixed maximum message size") == nil
}
}
envelope := Envelope{From: s.from, To: s.to}
raw := buf.Bytes()
s.from = ""
s.to = nil
s.state = Ready
if s.sink != nil {
if err := s.sink.Accept(ctx, envelope, raw); err != nil {
return s.reply(451, "unable to accept message, try again later") == nil
}
}
return s.reply(250, "OK: message accepted") == nil
}
+23
View File
@@ -0,0 +1,23 @@
// Package smtp implementiert ING-03s SMTP-Server (RFC 5321) für
// eingehende Mails: HELO/EHLO, MAIL FROM, RCPT TO, DATA, RSET, QUIT.
// Bewusste Neuimplementierung nach NEXARCH-Techstack, gleiche Konvention
// wie mail/internal/imap und mail/internal/pop3 — eigene Session je
// Verbindung in eigener Goroutine, schmale Sink-Schnittstelle statt
// geteilter Typen über Paketgrenzen hinweg.
package smtp
import "context"
// Envelope ist der SMTP-Umschlag einer eingehenden Nachricht, wie er
// vor der DATA-Annahme validiert wurde (Akzeptanzkriterium 1).
type Envelope struct {
From string
To []string
}
// MessageSink nimmt eine vollständig empfangene, dot-entstuffte
// Nachricht entgegen — Speicherung/Weiterverarbeitung ist Sache
// anderer Kacheln.
type MessageSink interface {
Accept(ctx context.Context, envelope Envelope, raw []byte) error
}
+59
View File
@@ -0,0 +1,59 @@
package smtp
import (
"fmt"
"strings"
)
// parseCommand zerlegt eine Kommandozeile in Verb (großgeschrieben) und
// restliches Argument.
func parseCommand(line string) (verb, arg string) {
parts := strings.SplitN(strings.TrimSpace(line), " ", 2)
verb = strings.ToUpper(parts[0])
if len(parts) == 2 {
arg = strings.TrimSpace(parts[1])
}
return verb, arg
}
// parseMailAddressArg extrahiert die Adresse aus "FROM:<addr>" bzw.
// "TO:<addr>" (RFC 5321 §4.1.1.2/4.1.1.3). SMTP-Parameter wie SIZE=...
// werden für diese kleinste Lösung ignoriert.
func parseMailAddressArg(arg, keyword string) (string, error) {
trimmed := strings.TrimSpace(arg)
upper := strings.ToUpper(trimmed)
prefix := keyword + ":"
if !strings.HasPrefix(upper, prefix) {
return "", fmt.Errorf("smtp: erwartete %q am anfang von %q", prefix, arg)
}
rest := strings.TrimSpace(trimmed[len(prefix):])
if sp := strings.IndexByte(rest, ' '); sp >= 0 {
rest = rest[:sp]
}
rest = strings.TrimPrefix(rest, "<")
rest = strings.TrimSuffix(rest, ">")
if rest == "" {
return "", fmt.Errorf("smtp: leere adresse")
}
return rest, nil
}
// validateAddress prüft eine E-Mail-Adresse defensiv gegen
// Steuerzeichen und offensichtlich falsche Form (Akzeptanzkriterium 3:
// ungültige Empfänger-/Absenderdaten führen zu sauberer Fehlermeldung
// statt Absturz).
func validateAddress(addr string) error {
for _, r := range addr {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("smtp: steuerzeichen in adresse")
}
}
at := strings.IndexByte(addr, '@')
if at <= 0 || at == len(addr)-1 {
return fmt.Errorf("smtp: ungültige adresse %q", addr)
}
if strings.IndexByte(addr[at+1:], '@') >= 0 {
return fmt.Errorf("smtp: ungültige adresse %q", addr)
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package smtp
import "fmt"
// reply schreibt eine einzeilige SMTP-Antwort "code text\r\n".
func (s *Session) reply(code int, text string) error {
if _, err := fmt.Fprintf(s.writer, "%d %s\r\n", code, text); err != nil {
return err
}
return s.writer.Flush()
}
// replyMultiline schreibt eine mehrzeilige SMTP-Antwort (z. B. EHLO-
// Capability-Liste): alle Zeilen außer der letzten mit "-" statt " "
// nach dem Code (RFC 5321 §4.2.1).
func (s *Session) replyMultiline(code int, lines []string) error {
for i, line := range lines {
sep := "-"
if i == len(lines)-1 {
sep = " "
}
if _, err := fmt.Fprintf(s.writer, "%d%s%s\r\n", code, sep, line); err != nil {
return err
}
}
return s.writer.Flush()
}
+56
View File
@@ -0,0 +1,56 @@
package smtp
import (
"context"
"errors"
"fmt"
"net"
)
// defaultMaxMessageBytes ist die Standard-Höchstgröße einer
// angenommenen Nachricht (Akzeptanzkriterium 1).
const defaultMaxMessageBytes = 25 * 1024 * 1024 // 25 MiB
// Server nimmt SMTP-Verbindungen an und bedient jede in einer eigenen
// Goroutine — gleiches Muster wie mail/internal/imap.Server und
// mail/internal/pop3.Server. TLS/STARTTLS ist Sache von ING-06,
// Rate-Limiting Sache von ING-09, Protokoll-Logging Sache von ING-08 —
// keine dieser Kacheln.
type Server struct {
sink MessageSink
maxMessageBytes int64
}
func NewServer(sink MessageSink) *Server {
return NewServerWithMaxMessageBytes(sink, defaultMaxMessageBytes)
}
// NewServerWithMaxMessageBytes erlaubt eine abweichende
// Nachrichten-Höchstgröße, z. B. für Tests.
func NewServerWithMaxMessageBytes(sink MessageSink, maxMessageBytes int64) *Server {
return &Server{sink: sink, maxMessageBytes: maxMessageBytes}
}
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
go func() {
<-ctx.Done()
_ = listener.Close()
}()
for {
conn, err := listener.Accept()
if err != nil {
if ctx.Err() != nil {
return nil
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
continue
}
return fmt.Errorf("smtp: verbindung annehmen: %w", err)
}
session := newSession(conn, srv.sink, srv.maxMessageBytes)
go session.Serve(ctx)
}
}
+120
View File
@@ -0,0 +1,120 @@
package smtp
import (
"bufio"
"context"
"errors"
"io"
"net"
"strings"
)
// maxCommandLineBytes begrenzt eine einzelne Kommando-/DATA-Zeile
// (defensive Fehlerbehandlung bei nicht-konformen Gegenstellen statt
// optimistischem Parsing, gleiche Konvention wie mail/internal/imap und
// mail/internal/pop3).
const maxCommandLineBytes = 8192
// Session ist eine einzelne SMTP-Verbindung mit eigener
// Zustandsmaschine (Akzeptanzkriterium 1).
type Session struct {
conn net.Conn
reader *bufio.Reader
writer *bufio.Writer
sink MessageSink
maxMessageBytes int64
state State
from string
to []string
}
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64) *Session {
return &Session{
conn: conn,
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
writer: bufio.NewWriter(conn),
sink: sink,
maxMessageBytes: maxMessageBytes,
state: Greeting,
}
}
// 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 := s.reply(220, "nexarch-mail SMTP server ready"); err != nil {
return
}
for {
line, err := s.readLine()
if err != nil {
return
}
if line == "" {
continue
}
verb, arg := parseCommand(line)
if !s.dispatch(ctx, verb, arg) {
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 nicht behebbarer Schreibfehler).
func (s *Session) dispatch(ctx context.Context, verb, arg string) bool {
switch verb {
case "HELO", "EHLO":
return s.handleHelo(verb, arg)
case "MAIL":
return s.handleMailFrom(arg)
case "RCPT":
return s.handleRcptTo(arg)
case "DATA":
return s.handleData(ctx)
case "RSET":
return s.handleRset()
case "NOOP":
return s.reply(250, "OK") == nil
case "QUIT":
_ = s.reply(221, "Bye")
return false
default:
return s.reply(500, "Command not recognized") == nil
}
}
// drainUntilDot liest Zeilen, ohne sie zu puffern, bis zur
// DATA-Abschlusszeile "." — hält das Protokoll nach einer wegen
// Größenüberschreitung abgelehnten Nachricht synchron, ohne den
// verworfenen Rest unbegrenzt im Speicher zu halten.
func (s *Session) drainUntilDot() error {
for {
line, err := s.readLine()
if err != nil {
return err
}
if line == "." {
return nil
}
}
}
+327
View File
@@ -0,0 +1,327 @@
package smtp
import (
"bufio"
"context"
"net"
"runtime"
"strings"
"sync"
"testing"
"time"
)
// fakeSink zeichnet angenommene Nachrichten im Prozessspeicher auf.
type fakeSink struct {
mu sync.Mutex
accepted []acceptedMessage
fail bool
}
type acceptedMessage struct {
envelope Envelope
raw []byte
}
func (f *fakeSink) Accept(_ context.Context, envelope Envelope, raw []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail {
return errFakeSinkRejects
}
cp := make([]byte, len(raw))
copy(cp, raw)
f.accepted = append(f.accepted, acceptedMessage{envelope: envelope, raw: cp})
return nil
}
func (f *fakeSink) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.accepted)
}
type sinkError string
func (e sinkError) Error() string { return string(e) }
const errFakeSinkRejects sinkError = "fake sink lehnt ab"
func startTestServer(t *testing.T, sink MessageSink, maxMessageBytes int64) (addr string, stop func()) {
t.Helper()
srv := NewServerWithMaxMessageBytes(sink, maxMessageBytes)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
_ = srv.Serve(ctx, listener)
close(done)
}()
return listener.Addr().String(), func() {
cancel()
<-done
}
}
type smtpClient struct {
conn net.Conn
reader *bufio.Reader
}
func dial(t *testing.T, addr string) *smtpClient {
t.Helper()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
c := &smtpClient{conn: conn, reader: bufio.NewReader(conn)}
c.readLine(t) // 220 Begrüßung
return c
}
func (c *smtpClient) readLine(t *testing.T) string {
t.Helper()
_ = c.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
line, err := c.reader.ReadString('\n')
if err != nil {
t.Fatalf("antwort lesen: %v", err)
}
return strings.TrimRight(line, "\r\n")
}
func (c *smtpClient) send(t *testing.T, cmd string) string {
t.Helper()
if _, err := c.conn.Write([]byte(cmd + "\r\n")); err != nil {
t.Fatalf("kommando senden: %v", err)
}
return c.readLine(t)
}
func (c *smtpClient) close() { _ = c.conn.Close() }
func code(line string) string {
if len(line) < 3 {
return line
}
return line[:3]
}
// TestSession_EnvelopeMustBeBuiltBeforeData ist die geforderte
// Zustandsmaschinen-Abdeckung für Akzeptanzkriterium 1: Envelope
// (HELO -> MAIL FROM -> RCPT TO) wird SCHRITTWEISE validiert, DATA ist
// erst nach vollständigem, gültigem Envelope erlaubt.
func TestSession_EnvelopeMustBeBuiltBeforeData(t *testing.T) {
sink := &fakeSink{}
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
defer stop()
c := dial(t, addr)
defer c.close()
// MAIL FROM vor HELO -> 503.
if resp := c.send(t, "MAIL FROM:<a@example.com>"); code(resp) != "503" {
t.Fatalf("erwartete 503 für MAIL FROM vor HELO, habe: %s", resp)
}
if resp := c.send(t, "EHLO client.example.com"); code(resp) != "250" {
t.Fatalf("erwartete 250 für EHLO, habe: %s", resp)
}
// Mehrzeilige EHLO-Antwort vollständig lesen.
for {
line := c.readLine(t)
if strings.HasPrefix(line, "250 ") {
break
}
}
// RCPT TO vor MAIL FROM -> 503.
if resp := c.send(t, "RCPT TO:<b@example.com>"); code(resp) != "503" {
t.Fatalf("erwartete 503 für RCPT TO vor MAIL FROM, habe: %s", resp)
}
// DATA vor RCPT TO -> 503.
if resp := c.send(t, "DATA"); code(resp) != "503" {
t.Fatalf("erwartete 503 für DATA ohne RCPT TO, habe: %s", resp)
}
if resp := c.send(t, "MAIL FROM:<a@example.com>"); code(resp) != "250" {
t.Fatalf("erwartete 250 für MAIL FROM, habe: %s", resp)
}
if resp := c.send(t, "RCPT TO:<b@example.com>"); code(resp) != "250" {
t.Fatalf("erwartete 250 für RCPT TO, habe: %s", resp)
}
if resp := c.send(t, "DATA"); code(resp) != "354" {
t.Fatalf("erwartete 354 für DATA nach vollständigem Envelope, habe: %s", resp)
}
if resp := c.send(t, "Subject: test\r\n\r\nHallo\r\n."); code(resp) != "250" {
t.Fatalf("erwartete 250 nach abgeschlossener DATA, habe: %s", resp)
}
if sink.count() != 1 {
t.Fatalf("erwartete 1 angenommene nachricht, habe %d", sink.count())
}
}
// TestData_MessageSizeCheckedBeforeAcceptance ist die geforderte
// Pflichtprüfung für Akzeptanzkriterium 1 (Größenanteil): eine
// Nachricht über der konfigurierten Höchstgröße wird sauber
// zurückgewiesen, der Sink bekommt sie NICHT.
func TestData_MessageSizeCheckedBeforeAcceptance(t *testing.T) {
sink := &fakeSink{}
const tinyLimit = 32 // Bytes
addr, stop := startTestServer(t, sink, tinyLimit)
defer stop()
c := dial(t, addr)
defer c.close()
c.send(t, "EHLO client.example.com")
for {
line := c.readLine(t)
if strings.HasPrefix(line, "250 ") {
break
}
}
c.send(t, "MAIL FROM:<a@example.com>")
c.send(t, "RCPT TO:<b@example.com>")
if resp := c.send(t, "DATA"); code(resp) != "354" {
t.Fatalf("erwartete 354, habe: %s", resp)
}
// Die überlange Zeile überschreitet das Limit bereits selbst — der
// Server antwortet SOFORT mit 552, OHNE auf die Abschlusszeile "."
// zu warten (drainUntilDot liest sie erst danach weg, damit das
// Protokoll synchron bleibt). Deshalb hier NICHT auf eine Antwort
// zur ersten Zeile warten, sondern erst die Abschlusszeile senden
// und dann einmal lesen.
longBody := strings.Repeat("x", 200)
if _, err := c.conn.Write([]byte(longBody + "\r\n")); err != nil {
t.Fatalf("kommando senden: %v", err)
}
resp := c.send(t, ".")
if code(resp) != "552" {
t.Fatalf("erwartete 552 (nachricht zu groß), habe: %s", resp)
}
if sink.count() != 0 {
t.Fatalf("sink hätte die zu große nachricht nicht bekommen dürfen, habe %d", sink.count())
}
// Verbindung muss danach weiter benutzbar sein (kein Absturz/Hänger).
if resp := c.send(t, "NOOP"); code(resp) != "250" {
t.Fatalf("session nach größenfehler nicht mehr funktionsfähig: %s", resp)
}
}
// TestRcptTo_InvalidRecipientCleanError ist die geforderte
// Akzeptanzkriterium-3-Prüfung: ungültige Empfängerdaten führen zu
// sauberer SMTP-Fehlermeldung statt Absturz.
func TestRcptTo_InvalidRecipientCleanError(t *testing.T) {
sink := &fakeSink{}
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
defer stop()
c := dial(t, addr)
defer c.close()
c.send(t, "EHLO client.example.com")
for {
line := c.readLine(t)
if strings.HasPrefix(line, "250 ") {
break
}
}
c.send(t, "MAIL FROM:<a@example.com>")
if resp := c.send(t, "RCPT TO:<keine-gueltige-adresse>"); code(resp) != "553" {
t.Fatalf("erwartete 553 für ungültigen empfänger, habe: %s", resp)
}
// Verbindung bleibt nutzbar — kein Absturz, kein Verbindungsabbruch.
if resp := c.send(t, "RCPT TO:<b@example.com>"); code(resp) != "250" {
t.Fatalf("erwartete 250 für gültigen empfänger nach vorherigem fehler, habe: %s", resp)
}
}
// TestMailFrom_InvalidSenderCleanError deckt Akzeptanzkriterium 3 auch
// für den Absender ab.
func TestMailFrom_InvalidSenderCleanError(t *testing.T) {
sink := &fakeSink{}
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
defer stop()
c := dial(t, addr)
defer c.close()
c.send(t, "EHLO client.example.com")
for {
line := c.readLine(t)
if strings.HasPrefix(line, "250 ") {
break
}
}
if resp := c.send(t, "MAIL FROM:<keine-gueltige-adresse>"); code(resp) != "553" {
t.Fatalf("erwartete 553 für ungültigen absender, habe: %s", resp)
}
if resp := c.send(t, "NOOP"); code(resp) != "250" {
t.Fatalf("session nach ungültigem absender nicht mehr funktionsfähig: %s", resp)
}
}
// TestServer_ConcurrentConnectionsNoLeak ist die geforderte
// Pflichtprüfung 3: Lasttest mit gleichzeitigen Verbindungen ohne
// Verbindungsleck.
func TestServer_ConcurrentConnectionsNoLeak(t *testing.T) {
sink := &fakeSink{}
addr, stop := startTestServer(t, sink, defaultMaxMessageBytes)
defer stop()
runtime.GC()
baseline := runtime.NumGoroutine()
const concurrency = 50
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
t.Errorf("dial: %v", err)
return
}
defer func() { _ = conn.Close() }()
c := &smtpClient{conn: conn, reader: bufio.NewReader(conn)}
c.readLine(t)
c.send(t, "EHLO client.example.com")
for {
line := c.readLine(t)
if strings.HasPrefix(line, "250 ") {
break
}
}
c.send(t, "MAIL FROM:<a@example.com>")
c.send(t, "RCPT TO:<b@example.com>")
c.send(t, "DATA")
c.send(t, "Subject: last\r\n\r\nHallo\r\n.")
c.send(t, "QUIT")
}()
}
wg.Wait()
if sink.count() != concurrency {
t.Fatalf("erwartete %d angenommene nachrichten, habe %d", concurrency, sink.count())
}
deadline := time.Now().Add(3 * time.Second)
for {
runtime.GC()
current := runtime.NumGoroutine()
if current <= baseline+2 {
return
}
if time.Now().After(deadline) {
t.Fatalf("verbindungs-/goroutine-leck nach lasttest: baseline=%d, aktuell=%d", baseline, current)
}
time.Sleep(50 * time.Millisecond)
}
}
+28
View File
@@ -0,0 +1,28 @@
package smtp
// State ist einer der vier SMTP-Sitzungszustände dieser Implementierung
// (RFC 5321 §3.3), Akzeptanzkriterium 1: Envelope wird schrittweise vor
// der DATA-Annahme aufgebaut und geprüft.
type State int
const (
Greeting State = iota // vor HELO/EHLO
Ready // nach HELO/EHLO, bereit für MAIL FROM
MailFromSet // nach gültigem MAIL FROM, wartet auf RCPT TO
RcptToSet // mind. ein gültiges RCPT TO, DATA erlaubt
)
func (s State) String() string {
switch s {
case Greeting:
return "GREETING"
case Ready:
return "READY"
case MailFromSet:
return "MAIL FROM SET"
case RcptToSet:
return "RCPT TO SET"
default:
return "unknown"
}
}