Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6631bcbbdd | ||
|
|
16c4ad0075 | ||
|
|
bd1f52648c | ||
|
|
5dcfa36f99 |
@@ -0,0 +1,48 @@
|
|||||||
|
# IMP-07 – Prüfprotokoll: Mehrfach-Postfach-Verwaltung pro Tenant
|
||||||
|
|
||||||
|
Voraussetzung IMP-01 (Fertig), Core TEN-01/TEN-02 (Fertig,
|
||||||
|
Tenant-Datenmodell & Onboarding).
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `mail/internal/mailboxconfig/store.go` — `Store` (Postgres,
|
||||||
|
`mail_mailboxes`): `Create` legt beliebig viele, voneinander
|
||||||
|
unabhängige Postfächer je Mandant an (Akzeptanzkriterium 1). Jedes
|
||||||
|
Postfach hat eigene Abrufparameter — Intervall, IMAP-Host/Port/
|
||||||
|
Benutzername, Ordnerauswahl (Akzeptanzkriterium 2).
|
||||||
|
- Passwort wird NIE im Klartext gespeichert — Wiederverwendung von
|
||||||
|
`mail/internal/crypto` (ARC-02, unverändert): `Create` verschlüsselt
|
||||||
|
über `crypto.Service.Seal`, `GetDecryptedPassword` entschlüsselt bei
|
||||||
|
Bedarf über `crypto.Service.Open`, als separater, bewusster Aufruf
|
||||||
|
(nicht Bestandteil von `List`, damit Zugangsdaten nicht beiläufig
|
||||||
|
mitgeliefert werden).
|
||||||
|
- `List` filtert strikt nach `tenant_slug` (Akzeptanzkriterium 3).
|
||||||
|
`Update`/`Delete` sind streng auf `tenant_slug` + `id` beschränkt.
|
||||||
|
- Kein Umbau: `mail/internal/crypto` unverändert wiederverwendet, kein
|
||||||
|
anderes Paket angefasst.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Test: zwei Mandanten mit je mehreren Postfächern sehen ausschließlich eigene Postfächer | **bestanden** – `TestList_TwoTenantsWithMultipleMailboxesSeeOnlyOwn`: Mandant A mit 2, Mandant B mit 1 Postfach — jeweils real nur die eigenen sichtbar |
|
||||||
|
| 2 | Test: Löschen eines Postfachs beeinträchtigt andere Postfächer desselben Mandanten nicht | **bestanden** – `TestDelete_DoesNotAffectSiblingMailboxes`: Postfach „eins" real gelöscht, Postfach „zwei" bleibt real vollständig funktionsfähig (Zugangsdaten weiterhin real entschlüsselbar) |
|
||||||
|
| 3 | Konfigurationsänderung an einem Postfach wirkt nicht auf andere | **bestanden** – `TestUpdate_ConfigChangeDoesNotAffectOtherMailboxes`: Änderung an Postfach „eins" (Host/Intervall) real übernommen, Postfach „zwei" real unverändert |
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131)
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
golangci-lint run ./... -> 0 issues
|
||||||
|
TEST_TENANT_DSN=... go test ./internal/mailboxconfig/... -v -timeout 60s -> 3/3 bestanden
|
||||||
|
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
|
||||||
|
-> alle 22 Pakete bestanden, keine Regression
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||||
|
real erfüllt. Entsperrt ARC-09, trägt zu QA-02 bei — QA-02 bleibt
|
||||||
|
weiterhin blockiert, bis dessen übrige Abhängigkeiten (ING-07, ING-08,
|
||||||
|
ING-10) fertig sind.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# ING-02 — POP3-Server: Prüfprotokoll
|
||||||
|
|
||||||
|
Datum: 2026-09-01
|
||||||
|
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
|
||||||
|
Paket: `mail/internal/pop3`
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
Vollständiger POP3-Server (RFC 1939) von Grund auf implementiert:
|
||||||
|
TCP-Listener, CRLF/Byte-Stuffing-sichere Response-Writer, Session-Zustandsmaschine
|
||||||
|
(Authorization / Transaction / Update), Kommandos USER, PASS, STAT, LIST, RETR,
|
||||||
|
DELE, QUIT. Architektonisch analog zum bestehenden `mail/internal/imap`-Paket
|
||||||
|
(ING-01).
|
||||||
|
|
||||||
|
## Pflichtprüfung 1: automatisierter Test für jede Zustandsübergangs-Regel
|
||||||
|
|
||||||
|
`TestSession_StateTransitions` (`pop3_test.go`), realer TCP-Client gegen realen
|
||||||
|
Server:
|
||||||
|
|
||||||
|
- STAT/RETR in Authorization → `-ERR` (verboten)
|
||||||
|
- PASS ohne vorheriges USER → `-ERR`
|
||||||
|
- USER + PASS korrekt → Authorization → Transaction
|
||||||
|
- USER erneut in Transaction → `-ERR` (verboten)
|
||||||
|
- STAT in Transaction → `+OK` (erlaubt)
|
||||||
|
- QUIT in Transaction → `+OK`, Verbindungsende
|
||||||
|
|
||||||
|
Ergebnis: **BESTANDEN**.
|
||||||
|
|
||||||
|
## Pflichtprüfung 2: manuelle Session mit Standard-POP3-Client gegen Test-Postfach
|
||||||
|
|
||||||
|
Realer Server (`pop3.NewServer`) auf `127.0.0.1:14400` gestartet (Wegwerf-Programm
|
||||||
|
`mail/cmd/pop3-manual-test`, danach entfernt), Testpostfach mit 2 Nachrichten
|
||||||
|
(fest codiert: `testuser`/`testpass`). Session mit Python-Standardbibliothek
|
||||||
|
`poplib` (kein selbstgeschriebener Client) durchgeführt, reales Transkript:
|
||||||
|
|
||||||
|
```
|
||||||
|
Begruessung: b'+OK POP3 server ready'
|
||||||
|
USER -> b'+OK send PASS'
|
||||||
|
PASS -> b'+OK maildrop locked and ready'
|
||||||
|
STAT -> (2, 45)
|
||||||
|
LIST -> b'+OK 2 messages (45 octets)' [b'1 25', b'2 20'] 12
|
||||||
|
RETR 1 -> b'+OK 26 octets' [b'Erste Testnachricht Inhalt'] 28
|
||||||
|
DELE 1 -> b'+OK message 1 deleted'
|
||||||
|
QUIT -> b'+OK goodbye'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis: **BESTANDEN** — echter Standard-Client, keine Ausnahme, alle Antworten
|
||||||
|
RFC-1939-konform.
|
||||||
|
|
||||||
|
## Pflichtprüfung 3: DELE ohne QUIT löscht nichts endgültig
|
||||||
|
|
||||||
|
`TestCommands_DeleWithoutQuitDeletesNothing` (`pop3_test.go`): DELE 1 gesendet,
|
||||||
|
Verbindung danach OHNE QUIT hart geschlossen, 100ms gewartet, Store-Zustand
|
||||||
|
geprüft — weiterhin 2 Nachrichten vorhanden (keine endgültige Löschung).
|
||||||
|
|
||||||
|
Strukturell garantiert durch Code-Design: `store.Delete` wird ausschließlich in
|
||||||
|
`handleQuit` im Zustand `Transaction → Update` aufgerufen; `handleDele` mutiert
|
||||||
|
nur `s.deleted` (sitzungslokal).
|
||||||
|
|
||||||
|
Ergebnis: **BESTANDEN**.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
1. **Jede Verbindung eigene Goroutine**: `Server.Serve` startet pro Accept eine
|
||||||
|
neue Goroutine (`server.go`). Zusätzlich belegt: `TestServer_ManyParallelSessions`,
|
||||||
|
20 parallele reale TCP-Sessions, alle erfolgreich.
|
||||||
|
2. **RETR liefert vollständige Nachricht, DELE+QUIT löscht endgültig**:
|
||||||
|
`TestCommands_RetrDeleFullCycle` — RETR liefert mehrzeiligen Inhalt
|
||||||
|
vollständig und byte-identisch; nach DELE+QUIT sinkt die Nachrichtenzahl im
|
||||||
|
Store tatsächlich von 2 auf 1.
|
||||||
|
3. **Fehlerhafte Anmeldeversuche ohne Informationspreisgabe**:
|
||||||
|
`TestPass_RejectsWithoutInformationLeak` — unbekannter Benutzername und
|
||||||
|
falsches Passwort liefern byte-identischen `-ERR`-Text
|
||||||
|
(`genericAuthFailure = "authentication failed"`).
|
||||||
|
|
||||||
|
## 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/pop3 (0.109s, 5/5 Tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keine Regression in den bestehenden ~23 Paketen.
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
ING-02 erfüllt alle Pflichtprüfungen und Akzeptanzkriterien mit echten,
|
||||||
|
ausgeführten Nachweisen. Freigeschaltet: ING-06, ING-07, ING-08, ING-10, QA-07.
|
||||||
@@ -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.
|
||||||
@@ -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, 200ms–5s 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.
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS mail_mailboxes (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_slug TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
imap_host TEXT NOT NULL,
|
||||||
|
imap_port INT NOT NULL DEFAULT 993,
|
||||||
|
imap_username TEXT NOT NULL,
|
||||||
|
wrapped_password_dek BYTEA NOT NULL,
|
||||||
|
encrypted_password BYTEA NOT NULL,
|
||||||
|
folder_selection TEXT NOT NULL DEFAULT 'INBOX',
|
||||||
|
interval_seconds INT NOT NULL DEFAULT 300,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_slug, name)
|
||||||
|
)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Package mailboxconfig implementiert IMP-07: Verwaltung mehrerer
|
||||||
|
// Postfächer je Mandant (Anlage, getrennte Abrufkonfiguration je
|
||||||
|
// Postfach). Setzt NEXARCH-Core TEN-01/TEN-02 (Tenant-Datenmodell,
|
||||||
|
// beide Fertig) voraus — dieses Paket kennt tenant_slug nur als
|
||||||
|
// opaken String, keine eigene Tenant-Verwaltung.
|
||||||
|
//
|
||||||
|
// Postfach-Zugangsdaten (Passwort) werden NIE im Klartext gespeichert —
|
||||||
|
// Wiederverwendung von mail/internal/crypto (ARC-02, bereits fertig,
|
||||||
|
// unverändert) für Envelope-Encryption, gleiches Muster wie
|
||||||
|
// mail/internal/encstorage.
|
||||||
|
package mailboxconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/0001_mail_mailboxes.sql
|
||||||
|
var schemaMigration string
|
||||||
|
|
||||||
|
// ErrNotFound wird geliefert, wenn kein Postfach mit den angegebenen
|
||||||
|
// Bezugsdaten existiert.
|
||||||
|
var ErrNotFound = errors.New("mailboxconfig: postfach nicht gefunden")
|
||||||
|
|
||||||
|
// MailboxConfig ist die Konfiguration EINES Postfachs
|
||||||
|
// (Akzeptanzkriterium 2: eigene Abrufparameter — Intervall, Ordnerauswahl;
|
||||||
|
// Zugangsdaten werden separat über GetDecryptedPassword bezogen, nie
|
||||||
|
// beim Auflisten mitgeliefert).
|
||||||
|
type MailboxConfig struct {
|
||||||
|
ID int64
|
||||||
|
TenantSlug string
|
||||||
|
Name string
|
||||||
|
IMAPHost string
|
||||||
|
IMAPPort int
|
||||||
|
IMAPUsername string
|
||||||
|
FolderSelection []string
|
||||||
|
IntervalSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultIntervalSeconds = 300
|
||||||
|
|
||||||
|
// Store verwaltet Postfachkonfigurationen je Mandant in Postgres.
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
crypto *crypto.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool, cryptoSvc *crypto.Service) *Store {
|
||||||
|
return &Store{pool: pool, crypto: cryptoSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert.
|
||||||
|
func (s *Store) EnsureSchema(ctx context.Context) error {
|
||||||
|
if _, err := s.pool.Exec(ctx, schemaMigration); err != nil {
|
||||||
|
return fmt.Errorf("mailboxconfig: schema anlegen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInput sind die für die Anlage nötigen Angaben.
|
||||||
|
type CreateInput struct {
|
||||||
|
Name string
|
||||||
|
IMAPHost string
|
||||||
|
IMAPPort int
|
||||||
|
IMAPUsername string
|
||||||
|
Password string
|
||||||
|
FolderSelection []string
|
||||||
|
IntervalSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create legt ein neues Postfach für tenantSlug an (Akzeptanzkriterium 1:
|
||||||
|
// ein Mandant kann mehrere Postfächer unabhängig konfigurieren — kein
|
||||||
|
// Limit, keine gegenseitige Abhängigkeit zwischen Postfächern desselben
|
||||||
|
// Mandanten). Das Passwort wird über mail/internal/crypto verschlüsselt,
|
||||||
|
// niemals im Klartext gespeichert.
|
||||||
|
func (s *Store) Create(ctx context.Context, tenantSlug string, in CreateInput) (int64, error) {
|
||||||
|
if in.IntervalSeconds <= 0 {
|
||||||
|
in.IntervalSeconds = defaultIntervalSeconds
|
||||||
|
}
|
||||||
|
if len(in.FolderSelection) == 0 {
|
||||||
|
in.FolderSelection = []string{"INBOX"}
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := s.crypto.Seal(ctx, tenantSlug, strings.NewReader(in.Password))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("mailboxconfig: passwort verschlüsseln: %w", err)
|
||||||
|
}
|
||||||
|
ciphertext, err := io.ReadAll(env.Ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("mailboxconfig: chiffretext lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO mail_mailboxes
|
||||||
|
(tenant_slug, name, imap_host, imap_port, imap_username, wrapped_password_dek, encrypted_password, folder_selection, interval_seconds)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`, tenantSlug, in.Name, in.IMAPHost, in.IMAPPort, in.IMAPUsername, env.WrappedDEK, ciphertext, strings.Join(in.FolderSelection, ","), in.IntervalSeconds).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("mailboxconfig: postfach anlegen: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List liefert alle Postfächer eines Mandanten (Akzeptanzkriterium 3:
|
||||||
|
// strikt nach tenant_slug gefiltert) — OHNE Zugangsdaten.
|
||||||
|
func (s *Store) List(ctx context.Context, tenantSlug string) ([]MailboxConfig, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, name, imap_host, imap_port, imap_username, folder_selection, interval_seconds
|
||||||
|
FROM mail_mailboxes WHERE tenant_slug = $1 ORDER BY name
|
||||||
|
`, tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mailboxconfig: postfächer lesen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var configs []MailboxConfig
|
||||||
|
for rows.Next() {
|
||||||
|
var c MailboxConfig
|
||||||
|
var folders string
|
||||||
|
c.TenantSlug = tenantSlug
|
||||||
|
if err := rows.Scan(&c.ID, &c.Name, &c.IMAPHost, &c.IMAPPort, &c.IMAPUsername, &folders, &c.IntervalSeconds); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailboxconfig: postfachzeile lesen: %w", err)
|
||||||
|
}
|
||||||
|
c.FolderSelection = strings.Split(folders, ",")
|
||||||
|
configs = append(configs, c)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("mailboxconfig: postfächer iterieren: %w", err)
|
||||||
|
}
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInput sind die änderbaren Felder eines Postfachs
|
||||||
|
// (Akzeptanzkriterium 2/3: Konfigurationsänderung betrifft ausschließlich
|
||||||
|
// dieses eine Postfach).
|
||||||
|
type UpdateInput struct {
|
||||||
|
IMAPHost string
|
||||||
|
IMAPPort int
|
||||||
|
FolderSelection []string
|
||||||
|
IntervalSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ändert die Abrufparameter EINES Postfachs, streng auf
|
||||||
|
// tenantSlug+id beschränkt.
|
||||||
|
func (s *Store) Update(ctx context.Context, tenantSlug string, id int64, in UpdateInput) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE mail_mailboxes
|
||||||
|
SET imap_host = $3, imap_port = $4, folder_selection = $5, interval_seconds = $6, updated_at = now()
|
||||||
|
WHERE tenant_slug = $1 AND id = $2
|
||||||
|
`, tenantSlug, id, in.IMAPHost, in.IMAPPort, strings.Join(in.FolderSelection, ","), in.IntervalSeconds)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mailboxconfig: postfach aktualisieren: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete entfernt GENAU EIN Postfach, streng auf tenantSlug+id beschränkt
|
||||||
|
// (Akzeptanzkriterium/Pflichtprüfung 2: andere Postfächer desselben
|
||||||
|
// Mandanten bleiben unberührt).
|
||||||
|
func (s *Store) Delete(ctx context.Context, tenantSlug string, id int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `DELETE FROM mail_mailboxes WHERE tenant_slug = $1 AND id = $2`, tenantSlug, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mailboxconfig: postfach löschen: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDecryptedPassword entschlüsselt das Postfach-Passwort — separater,
|
||||||
|
// bewusster Aufruf statt Bestandteil von List/Get, damit Zugangsdaten
|
||||||
|
// nicht beiläufig mitgeliefert werden.
|
||||||
|
func (s *Store) GetDecryptedPassword(ctx context.Context, tenantSlug string, id int64) (string, error) {
|
||||||
|
var wrappedDEK, ciphertext []byte
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT wrapped_password_dek, encrypted_password FROM mail_mailboxes
|
||||||
|
WHERE tenant_slug = $1 AND id = $2
|
||||||
|
`, tenantSlug, id).Scan(&wrappedDEK, &ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("mailboxconfig: postfach lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintextReader, err := s.crypto.Open(ctx, tenantSlug, wrappedDEK, bytes.NewReader(ciphertext))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("mailboxconfig: passwort entschlüsseln: %w", err)
|
||||||
|
}
|
||||||
|
plaintext, err := io.ReadAll(plaintextReader)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("mailboxconfig: passwort lesen: %w", err)
|
||||||
|
}
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// Integrationstest (IMP-07): echte Postgres-Instanz, folgt derselben
|
||||||
|
// Testhost-Konvention wie mail/internal/dedup/folderstate — TEST_TENANT_DSN.
|
||||||
|
package mailboxconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeKEKProvider liefert einen festen, mandantenspezifischen KEK —
|
||||||
|
// gleiche Testkonvention wie encstorage_test.go (ARC-02).
|
||||||
|
type fakeKEKProvider struct{}
|
||||||
|
|
||||||
|
func (fakeKEKProvider) TenantKEK(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
return bytes.Repeat([]byte{0x42}, crypto.KEKSize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { pool.Close() })
|
||||||
|
|
||||||
|
store := NewStore(pool, crypto.NewService(fakeKEKProvider{}))
|
||||||
|
if err := store.EnsureSchema(ctx); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_mailboxes WHERE tenant_slug LIKE 'mandant-%'`)
|
||||||
|
})
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestMailbox(t *testing.T, store *Store, tenant, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
id, err := store.Create(context.Background(), tenant, CreateInput{
|
||||||
|
Name: name,
|
||||||
|
IMAPHost: "imap." + name + ".example",
|
||||||
|
IMAPPort: 993,
|
||||||
|
IMAPUsername: "user@" + name + ".example",
|
||||||
|
Password: "geheim-" + name,
|
||||||
|
FolderSelection: []string{"INBOX"},
|
||||||
|
IntervalSeconds: 300,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("postfach %s anlegen: %v", name, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestList_TwoTenantsWithMultipleMailboxesSeeOnlyOwn ist die geforderte
|
||||||
|
// Pflichtprüfung 1: zwei Mandanten mit je mehreren Postfächern sehen
|
||||||
|
// ausschließlich eigene Postfächer.
|
||||||
|
func TestList_TwoTenantsWithMultipleMailboxesSeeOnlyOwn(t *testing.T) {
|
||||||
|
store := setupStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantA := "mandant-imp07-a"
|
||||||
|
tenantB := "mandant-imp07-b"
|
||||||
|
|
||||||
|
createTestMailbox(t, store, tenantA, "vertrieb")
|
||||||
|
createTestMailbox(t, store, tenantA, "support")
|
||||||
|
createTestMailbox(t, store, tenantB, "buchhaltung")
|
||||||
|
|
||||||
|
listA, err := store.List(ctx, tenantA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list mandant a: %v", err)
|
||||||
|
}
|
||||||
|
if len(listA) != 2 {
|
||||||
|
t.Fatalf("mandant a: erwartete 2 eigene postfächer, habe %d: %+v", len(listA), listA)
|
||||||
|
}
|
||||||
|
|
||||||
|
listB, err := store.List(ctx, tenantB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list mandant b: %v", err)
|
||||||
|
}
|
||||||
|
if len(listB) != 1 || listB[0].Name != "buchhaltung" {
|
||||||
|
t.Fatalf("mandant b sieht falsche/fremde postfächer: %+v", listB)
|
||||||
|
}
|
||||||
|
for _, mb := range listB {
|
||||||
|
if mb.Name == "vertrieb" || mb.Name == "support" {
|
||||||
|
t.Fatalf("mandant b sieht postfach von mandant a: %+v", mb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_DoesNotAffectSiblingMailboxes ist die geforderte
|
||||||
|
// Pflichtprüfung 2: Löschen eines Postfachs beeinträchtigt andere
|
||||||
|
// Postfächer desselben Mandanten nicht.
|
||||||
|
func TestDelete_DoesNotAffectSiblingMailboxes(t *testing.T) {
|
||||||
|
store := setupStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := "mandant-imp07-loeschen"
|
||||||
|
|
||||||
|
idA := createTestMailbox(t, store, tenant, "eins")
|
||||||
|
idB := createTestMailbox(t, store, tenant, "zwei")
|
||||||
|
|
||||||
|
if err := store.Delete(ctx, tenant, idA); err != nil {
|
||||||
|
t.Fatalf("löschen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := store.List(ctx, tenant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].ID != idB {
|
||||||
|
t.Fatalf("erwartete nur postfach 'zwei' übrig, habe: %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das verbleibende Postfach ist real weiterhin voll funktionsfähig
|
||||||
|
// (Zugangsdaten weiterhin entschlüsselbar).
|
||||||
|
pw, err := store.GetDecryptedPassword(ctx, tenant, idB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verbleibendes postfach nicht mehr funktionsfähig: %v", err)
|
||||||
|
}
|
||||||
|
if pw != "geheim-zwei" {
|
||||||
|
t.Fatalf("erwartetes passwort für verbleibendes postfach, habe %q", pw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdate_ConfigChangeDoesNotAffectOtherMailboxes ist die geforderte
|
||||||
|
// Pflichtprüfung 3: Konfigurationsänderung an einem Postfach wirkt nicht
|
||||||
|
// auf andere.
|
||||||
|
func TestUpdate_ConfigChangeDoesNotAffectOtherMailboxes(t *testing.T) {
|
||||||
|
store := setupStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := "mandant-imp07-update"
|
||||||
|
|
||||||
|
idA := createTestMailbox(t, store, tenant, "eins")
|
||||||
|
idB := createTestMailbox(t, store, tenant, "zwei")
|
||||||
|
|
||||||
|
if err := store.Update(ctx, tenant, idA, UpdateInput{
|
||||||
|
IMAPHost: "neuer-host.example",
|
||||||
|
IMAPPort: 143,
|
||||||
|
FolderSelection: []string{"INBOX", "Archiv"},
|
||||||
|
IntervalSeconds: 900,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := store.List(ctx, tenant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
var mbA, mbB MailboxConfig
|
||||||
|
for _, mb := range list {
|
||||||
|
switch mb.ID {
|
||||||
|
case idA:
|
||||||
|
mbA = mb
|
||||||
|
case idB:
|
||||||
|
mbB = mb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mbA.IMAPHost != "neuer-host.example" || mbA.IntervalSeconds != 900 {
|
||||||
|
t.Fatalf("änderung an postfach 'eins' wurde nicht real übernommen: %+v", mbA)
|
||||||
|
}
|
||||||
|
if mbB.IMAPHost != "imap.zwei.example" || mbB.IntervalSeconds != 300 {
|
||||||
|
t.Fatalf("postfach 'zwei' wurde fälschlich mitverändert: %+v", mbB)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
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 {
|
||||||
|
// Backoff statt Dauerschleife bei wiederholten Fehlversuchen
|
||||||
|
// (Akzeptanzkriterium 3, ING-07). Immer derselbe generische Text,
|
||||||
|
// 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.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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Package pop3 implementiert ING-02: den POP3-Server (RFC 1939) mit den
|
||||||
|
// Zuständen Authorization/Transaction/Update und den Kernbefehlen
|
||||||
|
// USER/PASS/STAT/LIST/RETR/DELE/QUIT. Bewusste Neuimplementierung nach
|
||||||
|
// NEXARCH-Techstack, kein 1:1-Übernehmen von archivmail — gleiche
|
||||||
|
// Konvention wie mail/internal/imap (ING-01): eigene, schmale
|
||||||
|
// Authenticator/MailboxStore-Schnittstellen statt geteilter Typen über
|
||||||
|
// Paketgrenzen hinweg, CRLF-sichere Antworten (response.go).
|
||||||
|
package pop3
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Authenticator prüft Zugangsdaten für PASS.
|
||||||
|
type Authenticator interface {
|
||||||
|
Authenticate(ctx context.Context, username, password string) (ok bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message ist eine Nachricht im Postfach (nur Nummer/Größe für STAT/
|
||||||
|
// LIST — Inhalt kommt separat über MailboxStore.Retrieve, damit LIST
|
||||||
|
// nicht unnötig alle Nachrichteninhalte laden muss).
|
||||||
|
type Message struct {
|
||||||
|
Number int
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxStore liefert Postfachzustand für STAT/LIST/RETR/DELE.
|
||||||
|
type MailboxStore interface {
|
||||||
|
// List liefert alle (noch nicht gelöschten) Nachrichten des Postfachs
|
||||||
|
// username.
|
||||||
|
List(ctx context.Context, username string) ([]Message, error)
|
||||||
|
// Retrieve liefert den vollständigen Inhalt einer Nachricht
|
||||||
|
// (Akzeptanzkriterium 2: RETR liefert vollständige Nachrichten).
|
||||||
|
Retrieve(ctx context.Context, username string, number int) ([]byte, error)
|
||||||
|
// Delete löscht die angegebenen Nachrichtennummern ENDGÜLTIG — wird
|
||||||
|
// AUSSCHLIESSLICH im Update-Zustand nach einem regulären QUIT
|
||||||
|
// aufgerufen (Akzeptanzkriterium 2/Pflichtprüfung 3: DELE markiert
|
||||||
|
// nur innerhalb der Sitzung, committet wird erst hier).
|
||||||
|
Delete(ctx context.Context, username string, numbers []int) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// command ist eine geparste POP3-Kommandozeile — POP3 hat (anders als
|
||||||
|
// IMAP) keine Tags, nur "KOMMANDO [Argumente]".
|
||||||
|
type command struct {
|
||||||
|
Name string // groß geschrieben (z. B. "USER")
|
||||||
|
Args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCommandLine zerlegt eine Kommandozeile (bereits ohne CRLF) in
|
||||||
|
// Kommandoname und Leerzeichen-getrennte Argumente. POP3-Argumente
|
||||||
|
// (Benutzername/Passwort/Nachrichtennummern) enthalten in der Praxis
|
||||||
|
// keine Anführungszeichen-Syntax wie IMAP — ein einfacher Split genügt
|
||||||
|
// für die kleinste Lösung.
|
||||||
|
func parseCommandLine(line string) command {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return command{}
|
||||||
|
}
|
||||||
|
return command{
|
||||||
|
Name: strings.ToUpper(fields[0]),
|
||||||
|
Args: fields[1:],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeAuthenticator struct {
|
||||||
|
users map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeAuthenticator) Authenticate(_ context.Context, username, password string) (bool, error) {
|
||||||
|
want, ok := f.users[username]
|
||||||
|
return ok && want == password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeMailboxStore hält Nachrichten im Prozessspeicher — Delete entfernt
|
||||||
|
// sie erst bei tatsächlichem Aufruf (durch handleQuit im Update-Zustand).
|
||||||
|
type fakeMailboxStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
messages map[string]map[int]string // username -> nummer -> inhalt
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeMailboxStore() *fakeMailboxStore {
|
||||||
|
return &fakeMailboxStore{messages: map[string]map[int]string{
|
||||||
|
"alice": {1: "Erste Testnachricht\nmit zwei Zeilen", 2: "Zweite Testnachricht"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMailboxStore) List(_ context.Context, username string) ([]Message, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
msgs := f.messages[username]
|
||||||
|
result := make([]Message, 0, len(msgs))
|
||||||
|
for n, content := range msgs {
|
||||||
|
result = append(result, Message{Number: n, Size: int64(len(content))})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMailboxStore) Retrieve(_ context.Context, username string, number int) ([]byte, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
content, ok := f.messages[username][number]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("keine solche nachricht")
|
||||||
|
}
|
||||||
|
return []byte(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMailboxStore) Delete(_ context.Context, username string, numbers []int) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, n := range numbers {
|
||||||
|
delete(f.messages[username], n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMailboxStore) count(username string) int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return len(f.messages[username])
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTestServer(t *testing.T) (addr string, store *fakeMailboxStore, stop func()) {
|
||||||
|
t.Helper()
|
||||||
|
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
|
||||||
|
store = newFakeMailboxStore()
|
||||||
|
srv := NewServer(auth, store)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pop3Client struct {
|
||||||
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func dial(t *testing.T, addr string) *pop3Client {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
c := &pop3Client{conn: conn, reader: bufio.NewReader(conn)}
|
||||||
|
c.readLine(t) // Begrüßung
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *pop3Client) 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// send sendet EIN Kommando und liest EINE Antwortzeile (Statuszeile).
|
||||||
|
func (c *pop3Client) 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMultiline sendet ein Kommando und liest bis zur "."-Abschlusszeile.
|
||||||
|
func (c *pop3Client) sendMultiline(t *testing.T, cmd string) (status string, dataLines []string) {
|
||||||
|
t.Helper()
|
||||||
|
status = c.send(t, cmd)
|
||||||
|
if !strings.HasPrefix(status, "+OK") {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
line := c.readLine(t)
|
||||||
|
if line == "." {
|
||||||
|
return status, dataLines
|
||||||
|
}
|
||||||
|
dataLines = append(dataLines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *pop3Client) close() { _ = c.conn.Close() }
|
||||||
|
|
||||||
|
func loginAsAlice(t *testing.T, c *pop3Client) {
|
||||||
|
t.Helper()
|
||||||
|
if resp := c.send(t, "USER alice"); !strings.HasPrefix(resp, "+OK") {
|
||||||
|
t.Fatalf("USER: %s", resp)
|
||||||
|
}
|
||||||
|
if resp := c.send(t, "PASS geheim123"); !strings.HasPrefix(resp, "+OK") {
|
||||||
|
t.Fatalf("PASS: %s", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSession_StateTransitions ist die geforderte Pflichtprüfung 1:
|
||||||
|
// automatisierter Test für jede Zustandsübergangs-Regel.
|
||||||
|
func TestSession_StateTransitions(t *testing.T) {
|
||||||
|
addr, _, stop := startTestServer(t)
|
||||||
|
defer stop()
|
||||||
|
c := dial(t, addr)
|
||||||
|
defer c.close()
|
||||||
|
|
||||||
|
// Verbotener Übergang: STAT/RETR/DELE in Authorization.
|
||||||
|
if resp := c.send(t, "STAT"); !strings.HasPrefix(resp, "-ERR") {
|
||||||
|
t.Fatalf("erwartete -ERR für STAT in Authorization, habe: %s", resp)
|
||||||
|
}
|
||||||
|
if resp := c.send(t, "RETR 1"); !strings.HasPrefix(resp, "-ERR") {
|
||||||
|
t.Fatalf("erwartete -ERR für RETR in Authorization, habe: %s", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS ohne vorheriges USER.
|
||||||
|
if resp := c.send(t, "PASS irgendwas"); !strings.HasPrefix(resp, "-ERR") {
|
||||||
|
t.Fatalf("erwartete -ERR für PASS ohne USER, habe: %s", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization -> Transaction.
|
||||||
|
loginAsAlice(t, c)
|
||||||
|
|
||||||
|
// Verbotener Übergang: USER/PASS erneut in Transaction.
|
||||||
|
if resp := c.send(t, "USER alice"); !strings.HasPrefix(resp, "-ERR") {
|
||||||
|
t.Fatalf("erwartete -ERR für USER in Transaction, habe: %s", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Transaction erlaubt: STAT.
|
||||||
|
if resp := c.send(t, "STAT"); !strings.HasPrefix(resp, "+OK") {
|
||||||
|
t.Fatalf("erwartete +OK für STAT in Transaction, habe: %s", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction -> (Update, real durchlaufen) -> Verbindungsende.
|
||||||
|
if resp := c.send(t, "QUIT"); !strings.HasPrefix(resp, "+OK") {
|
||||||
|
t.Fatalf("erwartete +OK für QUIT, habe: %s", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommands_RetrDeleFullCycle deckt Akzeptanzkriterium 2 ab: RETR
|
||||||
|
// liefert vollständige Nachrichten, DELE + QUIT löscht endgültig.
|
||||||
|
func TestCommands_RetrDeleFullCycle(t *testing.T) {
|
||||||
|
addr, store, stop := startTestServer(t)
|
||||||
|
defer stop()
|
||||||
|
c := dial(t, addr)
|
||||||
|
defer c.close()
|
||||||
|
loginAsAlice(t, c)
|
||||||
|
|
||||||
|
status, lines := c.sendMultiline(t, "RETR 1")
|
||||||
|
if !strings.HasPrefix(status, "+OK") {
|
||||||
|
t.Fatalf("RETR: %s", status)
|
||||||
|
}
|
||||||
|
full := strings.Join(lines, "\n")
|
||||||
|
if full != "Erste Testnachricht\nmit zwei Zeilen" {
|
||||||
|
t.Fatalf("RETR lieferte keine vollständige nachricht, habe: %q", full)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp := c.send(t, "DELE 1"); !strings.HasPrefix(resp, "+OK") {
|
||||||
|
t.Fatalf("DELE: %s", resp)
|
||||||
|
}
|
||||||
|
if resp := c.send(t, "QUIT"); !strings.HasPrefix(resp, "+OK") {
|
||||||
|
t.Fatalf("QUIT: %s", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if store.count("alice") != 1 {
|
||||||
|
t.Fatalf("erwartete 1 verbleibende nachricht nach DELE+QUIT, habe %d", store.count("alice"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommands_DeleWithoutQuitDeletesNothing ist die geforderte
|
||||||
|
// Pflichtprüfung 3: DELE ohne anschließendes QUIT löscht nichts
|
||||||
|
// endgültig.
|
||||||
|
func TestCommands_DeleWithoutQuitDeletesNothing(t *testing.T) {
|
||||||
|
addr, store, stop := startTestServer(t)
|
||||||
|
defer stop()
|
||||||
|
c := dial(t, addr)
|
||||||
|
loginAsAlice(t, c)
|
||||||
|
|
||||||
|
if resp := c.send(t, "DELE 1"); !strings.HasPrefix(resp, "+OK") {
|
||||||
|
t.Fatalf("DELE: %s", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbindung OHNE QUIT abrupt schließen.
|
||||||
|
c.close()
|
||||||
|
time.Sleep(100 * time.Millisecond) // server real verarbeiten lassen
|
||||||
|
|
||||||
|
if store.count("alice") != 2 {
|
||||||
|
t.Fatalf("erwartete weiterhin 2 nachrichten (kein QUIT, keine endgültige löschung), habe %d", store.count("alice"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPass_RejectsWithoutInformationLeak ist die geforderte
|
||||||
|
// Akzeptanzkriterium-3-Prüfung: fehlerhafte Anmeldeversuche ohne
|
||||||
|
// Informationspreisgabe.
|
||||||
|
func TestPass_RejectsWithoutInformationLeak(t *testing.T) {
|
||||||
|
addr, _, stop := startTestServer(t)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
c1 := dial(t, addr)
|
||||||
|
defer c1.close()
|
||||||
|
c1.send(t, "USER unbekannter_nutzer")
|
||||||
|
respUnknownUser := c1.send(t, "PASS irgendwas")
|
||||||
|
|
||||||
|
c2 := dial(t, addr)
|
||||||
|
defer c2.close()
|
||||||
|
c2.send(t, "USER alice")
|
||||||
|
respWrongPassword := c2.send(t, "PASS falschespasswort")
|
||||||
|
|
||||||
|
if respUnknownUser != respWrongPassword {
|
||||||
|
t.Fatalf("unterschiedliche fehlermeldungen verraten, ob der nutzer existiert: %q vs %q", respUnknownUser, respWrongPassword)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(respUnknownUser, "-ERR") {
|
||||||
|
t.Fatalf("erwartete -ERR, habe: %s", respUnknownUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServer_ManyParallelSessions belegt Robustheit unter Last (Vorbild
|
||||||
|
// ING-01) — kein expliziter Lasttest im Ticket gefordert, aber sinnvolle
|
||||||
|
// Ergänzung zur Zustandsmaschinen-Testabdeckung.
|
||||||
|
func TestServer_ManyParallelSessions(t *testing.T) {
|
||||||
|
addr, _, stop := startTestServer(t)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
const sessions = 20
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < sessions; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("dial %d: %v", n, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
c := &pop3Client{conn: conn, reader: bufio.NewReader(conn)}
|
||||||
|
c.readLine(t)
|
||||||
|
loginAsAlice(t, c)
|
||||||
|
c.send(t, "STAT")
|
||||||
|
c.send(t, "QUIT")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sanitizeResponseText entfernt eingebettete CR/LF aus text, BEVOR er in
|
||||||
|
// eine Antwortzeile eingebettet wird (Bekannter Fehler vermeiden — gleiche
|
||||||
|
// Konvention wie mail/internal/imap/response.go: archivmail erlaubte
|
||||||
|
// Header-/Zeilen-Injection durch Stringkonkatenation ohne CRLF-Prüfung).
|
||||||
|
func sanitizeResponseText(text string) string {
|
||||||
|
text = strings.ReplaceAll(text, "\r", "")
|
||||||
|
text = strings.ReplaceAll(text, "\n", "")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOK(w *bufio.Writer, text string) error {
|
||||||
|
_, err := w.WriteString("+OK " + sanitizeResponseText(text) + "\r\n")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w *bufio.Writer, text string) error {
|
||||||
|
_, err := w.WriteString("-ERR " + sanitizeResponseText(text) + "\r\n")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMultiline schreibt eine mehrzeilige POP3-Antwort (LIST/RETR):
|
||||||
|
// "+OK ...\r\n" gefolgt von den Datenzeilen und einer abschließenden
|
||||||
|
// "." -Zeile (RFC 1939 §3). content wird an "\n" in Zeilen zerlegt; jede
|
||||||
|
// Zeile, die selbst mit "." beginnt, wird per "Byte-Stuffing" verdoppelt
|
||||||
|
// (RFC-Pflicht UND zusätzlicher Schutz gegen eine vorzeitig wirkende
|
||||||
|
// Terminierungszeile durch Nachrichteninhalt).
|
||||||
|
func writeMultiline(w *bufio.Writer, okText, content string) error {
|
||||||
|
if _, err := w.WriteString("+OK " + sanitizeResponseText(okText) + "\r\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
|
for _, line := range strings.Split(normalized, "\n") {
|
||||||
|
line = strings.TrimSuffix(line, "\r")
|
||||||
|
if strings.HasPrefix(line, ".") {
|
||||||
|
line = "." + line
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString(line + "\r\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString(".\r\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server nimmt POP3-Verbindungen an und bedient jede in einer eigenen
|
||||||
|
// Goroutine (Akzeptanzkriterium 1) — gleiches Muster wie
|
||||||
|
// mail/internal/imap.Server. TLS/STARTTLS ist Sache von ING-06, nicht
|
||||||
|
// dieser Kachel.
|
||||||
|
type Server struct {
|
||||||
|
auth Authenticator
|
||||||
|
store MailboxStore
|
||||||
|
guardCfg protoguard.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(auth Authenticator, store MailboxStore) *Server {
|
||||||
|
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.
|
||||||
|
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("pop3: verbindung annehmen: %w", err)
|
||||||
|
}
|
||||||
|
session := newSession(conn, srv.auth, srv.store, srv.guardCfg)
|
||||||
|
go session.Serve(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"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
|
||||||
|
// Fehlerbehandlung bei nicht-konformen Gegenstellen, gleiche Konvention
|
||||||
|
// wie mail/internal/imap).
|
||||||
|
const maxCommandLineBytes = 8192
|
||||||
|
|
||||||
|
// Session ist eine einzelne POP3-Verbindung mit eigener Zustandsmaschine
|
||||||
|
// (Akzeptanzkriterium 1).
|
||||||
|
type Session struct {
|
||||||
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
writer *bufio.Writer
|
||||||
|
auth Authenticator
|
||||||
|
store MailboxStore
|
||||||
|
guard *protoguard.Guard
|
||||||
|
|
||||||
|
state State
|
||||||
|
pendingUsername string // nach USER, vor erfolgreichem PASS
|
||||||
|
username string // nach erfolgreichem PASS
|
||||||
|
deleted map[int]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSession(conn net.Conn, auth Authenticator, store MailboxStore, guardCfg protoguard.Config) *Session {
|
||||||
|
return &Session{
|
||||||
|
conn: conn,
|
||||||
|
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||||
|
writer: bufio.NewWriter(conn),
|
||||||
|
auth: auth,
|
||||||
|
store: store,
|
||||||
|
guard: protoguard.New(guardCfg),
|
||||||
|
state: Authorization,
|
||||||
|
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).
|
||||||
|
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 := writeOK(s.writer, "POP3 server ready"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
if err != nil {
|
||||||
|
// Verbindung endet OHNE QUIT (Timeout, Netzwerkabbruch oder
|
||||||
|
// harter Verbindungsabbruch) — Akzeptanzkriterium 1: die
|
||||||
|
// Session-Ressourcen (Verbindung, Reader/Writer) werden über
|
||||||
|
// das defer conn.Close() oben zuverlässig freigegeben.
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := parseCommandLine(line)
|
||||||
|
if cmd.Name == "" {
|
||||||
|
if err := writeErr(s.writer, "unrecognized command"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.dispatch(ctx, cmd) {
|
||||||
|
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 Schreibfehler).
|
||||||
|
func (s *Session) dispatch(ctx context.Context, cmd command) bool {
|
||||||
|
switch cmd.Name {
|
||||||
|
case "USER":
|
||||||
|
return s.handleUser(cmd)
|
||||||
|
case "PASS":
|
||||||
|
return s.handlePass(ctx, cmd)
|
||||||
|
case "STAT":
|
||||||
|
return s.handleStat(ctx)
|
||||||
|
case "LIST":
|
||||||
|
return s.handleList(ctx, cmd)
|
||||||
|
case "RETR":
|
||||||
|
return s.handleRetr(ctx, cmd)
|
||||||
|
case "DELE":
|
||||||
|
return s.handleDele(cmd)
|
||||||
|
case "QUIT":
|
||||||
|
return s.handleQuit(ctx)
|
||||||
|
default:
|
||||||
|
return writeErr(s.writer, "unknown command") == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
// State ist einer der drei POP3-Sitzungszustände (RFC 1939 §3),
|
||||||
|
// Akzeptanzkriterium 1.
|
||||||
|
type State int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Authorization State = iota
|
||||||
|
Transaction
|
||||||
|
Update
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s State) String() string {
|
||||||
|
switch s {
|
||||||
|
case Authorization:
|
||||||
|
return "AUTHORIZATION"
|
||||||
|
case Transaction:
|
||||||
|
return "TRANSACTION"
|
||||||
|
case Update:
|
||||||
|
return "UPDATE"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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():
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user