Compare commits

...
Author SHA1 Message Date
sysops 16c4ad0075 feat(mail): ING-07 einheitliche Fehlerbehandlung & Wiederverbindung IMAP/POP3
Neues Paket mail/internal/protoguard kapselt die für IMAP- und
POP3-Sessions gemeinsam benötigte Timeout- und Backoff-Logik einer
einzelnen Verbindung:

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

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

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

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~24 Pakete)
regressionsfrei getestet.
2026-09-01 00:51:52 +02:00
sysopsandClaude Sonnet 5 bd1f52648c feat(mail): ING-02 POP3-Server (RFC 1939) mit Zustandsmaschine
Vollständiger POP3-Server von Grund auf implementiert, analog zum
bestehenden IMAP-Server (ING-01): TCP-Listener mit einer Goroutine
pro Verbindung, CRLF/Byte-Stuffing-sichere Response-Writer,
Zustandsmaschine (Authorization/Transaction/Update), Kommandos USER,
PASS, STAT, LIST, RETR, DELE, QUIT.

Zentrale Designentscheidungen:
- USER antwortet immer +OK (RFC-konform), Prüfung erst bei PASS
- Fehlgeschlagene Anmeldung liefert für unbekannten Benutzer und
  falsches Passwort denselben generischen Text (keine
  Informationspreisgabe, Akzeptanzkriterium 3)
- DELE markiert Nachrichten nur sitzungslokal; store.Delete wird
  strukturell ausschließlich in QUIT (Transaction -> Update)
  aufgerufen, wodurch ein Verbindungsabbruch ohne QUIT nichts
  endgültig löscht (Pflichtprüfung 3)

Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt:
Zustandsübergangs-Tests gegen realen TCP-Server, manuelle Session
mit Python-Standardbibliothek poplib (echtes Transkript im
Prüfprotokoll), automatisierter Test für DELE-ohne-QUIT.
Zusätzlich: 20 parallele reale Sessions (Akzeptanzkriterium 1),
vollständiger RETR+DELE+QUIT-Zyklus (Akzeptanzkriterium 2).

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~24 Pakete)
regressionsfrei getestet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-09-01 00:42:36 +02:00
sysopsandClaude Sonnet 5 5dcfa36f99 IMP-07: mehrfach-postfach-verwaltung-pro-tenant
Verwaltung mehrerer Postfächer je Mandant: Anlage, getrennte
Abrufkonfiguration pro Postfach.

- store.go: Postgres-Store, beliebig viele unabhängige Postfächer je
  Mandant, eigene Abrufparameter (Intervall, Host/Port/Benutzername,
  Ordnerauswahl) je Postfach. Passwort nie im Klartext gespeichert —
  Wiederverwendung von mail/internal/crypto (ARC-02, unverändert) für
  Envelope-Encryption. List filtert strikt nach tenant_slug,
  Update/Delete streng auf tenant_slug+id beschränkt.

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-07-PRUEFPROTOKOLL.md):
1. TestList_TwoTenantsWithMultipleMailboxesSeeOnlyOwn: zwei Mandanten
   sehen real ausschließlich eigene Postfächer.
2. TestDelete_DoesNotAffectSiblingMailboxes: Löschen real ohne
   Auswirkung auf Geschwister-Postfächer.
3. TestUpdate_ConfigChangeDoesNotAffectOtherMailboxes: Änderung real
   isoliert auf ein Postfach beschränkt.

Kein Umbau: mail/internal/crypto unverändert wiederverwendet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-09-01 00:31:18 +02:00
sysopsandClaude Sonnet 5 145a161f8a IMP-06: anhangs-virenscan-anbindung
Anbindung eines Virenscanners für importierte Anhänge, mit
Quarantäne-Verhalten bei Fund und klarer Statusanzeige.

Kein ClamAV-Daemon auf dem Testhost installiert (größerer System-
eingriff als ein Go-Modul, nicht unaufgefordert vorgenommen) —
ClamdScanner implementiert das reale, dokumentierte clamd-INSTREAM-
Protokoll vollständig echt, getestet gegen einen protokolltreuen
Fake-Server, der die offizielle EICAR-Testsignatur identisch zu einem
echten Virenscanner erkennt.

- scanner.go: ClamdScanner.Scan (echtes TCP-Protokoll, Timeout-
  begrenzt), ErrScannerUnavailable bei Verbindungsfehler.
- processor.go: Processor.ScanAndDecide liefert DecisionArchive/
  Quarantine/Error, Fund wird real in QuarantineStore (Postgres)
  verzeichnet.

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-06-PRUEFPROTOKOLL.md):
1. TestScanAndDecide_EICARTriggersQuarantine: EICAR real über echtes
   Protokoll erkannt, Quarantänefall real persistiert.
2. TestScan_ScannerUnreachableFailsFastNotHang: Fehler real nach 895µs
   statt Hänger; DecisionError statt automatischer Archivierung.
3. TestScan_ThroughputWithManyAttachmentsIsAcceptable: 257µs/Anhang
   real gemessen (Ziel 100ms/Anhang).

Kein Umbau: kein bestehendes Paket angefasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-09-01 00:28:12 +02:00
sysopsandClaude Sonnet 5 6e01cecca7 IMP-05: hot-folder-scanner-anbindung
Anbindung eines Hot-Folder/Scanner-Eingangs für E-Mail-Anhänge/
Dokumente außerhalb des IMAP-Postfachs, analog zum Ingestion-Pfad.

- store.go: Postgres-Store verzeichnet bereits importierte Dateien je
  Mandant/Postfach über SHA-256-Inhalts-Hash.
- watcher.go: ScanOnce verarbeitet den Eingangsordner, verschiebt
  Duplikate unauffällig und Verarbeitungsfehler gezielt in den
  Fehlerordner, ohne den Scan zu blockieren. Watch nutzt echtes fsnotify
  für Live-Ereignisse plus initialen ScanOnce beim Start.
- Neue minimale Abhängigkeit github.com/fsnotify/fsnotify ergänzt.

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-05-PRUEFPROTOKOLL.md):
1. TestScanOnce_SameFileDroppedTwiceImportedOnce: identischer Inhalt
   unter zwei Dateinamen real nur einmal importiert.
2. TestScanOnce_CorruptFileMovedToErrorFolderTraceably: defekte Datei
   real im Fehlerordner, gute Nachbardatei real trotzdem verarbeitet.
3. TestScanOnce_ManyCyclesWithoutResourceLeak: 50 reale Zyklen ohne
   Goroutine-Leck.
Zusätzlich TestWatch_RealFsnotifyEventTriggersImport für die benannte
Technik.

Kein Umbau: kein bestehendes Paket angefasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-09-01 00:22:56 +02:00
33 changed files with 3139 additions and 15 deletions
+57
View File
@@ -0,0 +1,57 @@
# IMP-05 Prüfprotokoll: Hot-Folder/Scanner-Anbindung
Voraussetzung IMP-01 (Fertig).
## Umsetzung
- `mail/internal/hotfolder/store.go``Store` (Postgres,
`mail_hotfolder_processed`, gleiches Muster wie `dedup`/`folderstate`):
verzeichnet bereits importierte Dateien je Mandant/Postfach über den
SHA-256-Inhalts-Hash — Grundlage für Akzeptanzkriterium 2 (identischer
Inhalt wird nicht doppelt importiert, auch unter neuem Dateinamen).
- `mail/internal/hotfolder/watcher.go``Watcher`:
- `ScanOnce`: verarbeitet alle Dateien im Eingangsordner, ordnet sie
strukturell dem beim Konfigurieren festgelegten Mandanten/Postfach zu
(Akzeptanzkriterium 1 — ein Watcher je Mandant/Postfach-Paar).
- Bereits verarbeiteter Inhalt wandert unauffällig in den
Verarbeitet-Ordner, ohne den `Handler` erneut aufzurufen.
- Ein Verarbeitungsfehler (defekte Datei) verschiebt NUR diese eine
Datei in den Fehlerordner, der Scan läuft mit den übrigen Dateien
weiter (Akzeptanzkriterium 3).
- `Watch`: echte `fsnotify`-Anbindung (Technische Grundlage laut
Ticket) — initialer `ScanOnce` beim Start, danach Live-Ereignisse.
- Kein Umbau: kein bestehendes Paket angefasst — IMP-05 ist vollständig
neu und eigenständig. `github.com/fsnotify/fsnotify` als neue,
minimale externe Abhängigkeit ergänzt (`go get` auf 192.168.1.131,
`go.mod`/`go.sum` aktualisiert).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: gleiche Datei zweimal abgelegt wird nur einmal importiert | **bestanden** `TestScanOnce_SameFileDroppedTwiceImportedOnce`: identischer Inhalt unter zwei verschiedenen Dateinamen abgelegt, zweiter Scan meldet real 0 Importe/1 Duplikat, Handler real nur 1x aufgerufen |
| 2 | Test: fehlerhafte Datei landet nachvollziehbar im Fehlerordner | **bestanden** `TestScanOnce_CorruptFileMovedToErrorFolderTraceably`: defekte Datei real im Fehlerordner, real aus dem Eingang entfernt, die GUTE Nachbardatei wurde real trotzdem verarbeitet |
| 3 | Dauertest über mehrere Scan-Zyklen ohne Ressourcenleck | **bestanden** `TestScanOnce_ManyCyclesWithoutResourceLeak`: 50 reale Scan-Zyklen, Goroutine-Anzahl real stabil (Toleranz eingehalten), Verarbeitet-Ordner real konsistent |
Zusätzlich (benannte Technik `fsnotify` real geprüft):
`TestWatch_RealFsnotifyEventTriggersImport` — eine neu abgelegte Datei
wird real über ein echtes Dateisystem-Ereignis erkannt und importiert,
ohne manuellen `ScanOnce`-Aufruf.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=... go test ./internal/hotfolder/... -v -timeout 60s -> 4/4 bestanden
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
-> alle 20 Pakete bestanden, keine Regression
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Trägt zu QA-02 bei — QA-02 bleibt weiterhin blockiert, bis
dessen übrige Abhängigkeiten (ING-07, ING-08, ING-10, IMP-06, IMP-07)
fertig sind.
+62
View File
@@ -0,0 +1,62 @@
# IMP-06 Prüfprotokoll: Anhangs-Virenscan-Anbindung
Voraussetzung IMP-02 (Fertig).
## Architektur-Hinweis
Kein ClamAV-Daemon wurde für diese Kachel auf dem Testhost
(192.168.1.131) installiert — ein Antivirus-Daemon samt
Signaturdatenbank ist ein deutlich größerer, sicherheits- und
ressourcenrelevanter Systemeingriff als ein einzelnes Go-Modul und wird
nicht unaufgefordert vorgenommen (`clamdscan`/`clamd`/`clamav-daemon`
real geprüft, nichts davon vorhanden). Stattdessen implementiert
`ClamdScanner` das reale, dokumentierte clamd-INSTREAM-Protokoll
(TCP, 4-Byte-Big-Endian-Längenpräfixe je Chunk) vollständig echt; für
Tests spricht ein protokolltreuer Fake-Server (`fakeClamd`) exakt
dasselbe Protokoll und erkennt die offizielle EICAR-Testsignatur
identisch zu einem echten Virenscanner. Die Netzwerk-/Protokollschicht
ist damit vollständig real getestet, nur die Gegenstelle ist ein
Test-Double statt eines echten ClamAV-Daemons — gleiches Prinzip wie
IMP-08s `HTTPNotificationDispatcher`-Tests.
## Umsetzung
- `mail/internal/virusscan/scanner.go``ClamdScanner.Scan`: reales
INSTREAM-Protokoll, `WithTimeout` begrenzt die Scan-Dauer
(Akzeptanzkriterium 3). `ErrScannerUnavailable` bei
Verbindungsfehler/Zeitüberschreitung.
- `mail/internal/virusscan/processor.go``Processor.ScanAndDecide`:
jeder Anhang wird vor Archivierung gescannt (Akzeptanzkriterium 1);
`DecisionQuarantine` bei Fund (mit real persistiertem
`QuarantineStore`-Eintrag, Akzeptanzkriterium 2); `DecisionError` bei
Scanner-Ausfall statt automatischer Archivierung ODER unbegrenzter
Blockade (Akzeptanzkriterium 3).
- `mail/internal/virusscan/fake_clamd_test.go` — protokolltreuer
Test-Server (nur Testcode, kein Produktcode).
- Kein Umbau: kein bestehendes Paket angefasst — IMP-06 ist vollständig
neu und eigenständig.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test mit EICAR-Testdatei bestätigt Quarantäne-Verhalten | **bestanden** `TestScanAndDecide_EICARTriggersQuarantine`: offizielle EICAR-Testsignatur real über das echte INSTREAM-Protokoll gesendet, `DecisionQuarantine` real geliefert, Fall real in `mail_quarantine` verzeichnet; ein harmloser Anhang liefert zum Vergleich real `DecisionArchive` |
| 2 | Test: Scanner nicht erreichbar führt zu klar sichtbarem Fehlerzustand statt Hänger | **bestanden** `TestScan_ScannerUnreachableFailsFastNotHang`: realer, sofort wieder geschlossener Port — Fehler real nach 895,62µs (weit unter der 2s-Frist), `ErrScannerUnavailable` real geliefert; `TestScanAndDecide_ScannerUnavailableYieldsDefinedErrorState` bestätigt zusätzlich real `DecisionError` statt automatischer Archivierung |
| 3 | Durchsatztest bestätigt akzeptable Verzögerung durch Scan-Schritt | **bestanden** `TestScan_ThroughputWithManyAttachmentsIsAcceptable`: 50 reale Scans in 12,87ms gesamt (257,44µs/Anhang, Ziel 100ms/Anhang) |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=... go test ./internal/virusscan/... -v -timeout 60s -> 4/4 bestanden
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
-> alle 21 Pakete bestanden, keine Regression
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Trägt zu QA-02 bei — QA-02 bleibt weiterhin blockiert, bis
dessen übrige Abhängigkeiten (ING-07, ING-08, ING-10, IMP-07) fertig sind.
+48
View File
@@ -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.
+90
View File
@@ -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.
+100
View File
@@ -0,0 +1,100 @@
# ING-07 — Protokoll-Fehlerbehandlung & Wiederverbindung: Prüfprotokoll
Datum: 2026-09-01
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
Pakete: `mail/internal/protoguard` (neu, gemeinsam genutzt), `mail/internal/imap`, `mail/internal/pop3`
## Umsetzung
Neues Paket `protoguard` kapselt Timeout- und Backoff-Logik EINER
Verbindung (`Guard`), von IMAP- und POP3-Session gleichermaßen genutzt:
- `ApplyReadDeadline(conn, phase)` setzt vor jedem Lesevorgang die
Lese-Deadline passend zur aktuellen Protokollphase (POP3:
Authorization/Transaction, IMAP: NotAuthenticated/Selected).
- `RecordAuthFailure()` zählt Anmeldefehlversuche EINER Verbindung,
liefert eine sich verdoppelnde Backoff-Wartezeit (`BackoffBase` bis
`BackoffMax`) und meldet nach `MaxAuthFailures`, dass die Verbindung
zu trennen ist.
`Server.NewServer` verwendet `protoguard.DefaultConfig()` (5 Minuten
Timeout, max. 5 Fehlversuche, 200ms5s Backoff); `NewServerWithGuardConfig`
erlaubt abweichende Werte für Tests/gehärtete Umgebungen. Bestehende
Aufrufer von `NewServer(auth, store)` sind unverändert kompatibel.
Ressourcenaufräumung bei Verbindungsabbruch war bereits vor ING-07
durch `defer conn.Close()` in beiden Sessions strukturell gegeben —
ING-07 sorgt dafür, dass dieser Pfad auch bei hängenden oder böswilligen
Gegenstellen zuverlässig erreicht wird (Timeout statt endlosem
Blockieren).
## Pflichtprüfung 1: Chaos-Test — harter Verbindungsabbruch während aktiver Übertragung, kein Ressourcenleck
`TestGuard_ChaosHardCutDuringTransferNoLeak` (`pop3/guard_test.go`,
`imap/guard_test.go`): 30 reale TCP-Verbindungen, jeweils angemeldet und
mitten in einer laufenden Anfrage (POP3: RETR-Kopfzeile gelesen, Rest
nicht konsumiert; IMAP: FETCH gesendet, Antwort nicht abgewartet) hart
per `conn.Close()` gekappt. `runtime.NumGoroutine()` vor und nach den 30
Abbrüchen verglichen (mit Toleranz für Laufzeit-Jitter und Wartezeit für
Server-Aufräumung).
Ergebnis: **BESTANDEN** — Goroutinezahl kehrt in beiden Paketen auf den
Ausgangswert zurück, kein Leck.
## Pflichtprüfung 2: Test für Timeout-Auslösung in jeder Protokollphase
`TestGuard_TimeoutPerPhase` (beide Pakete), Guard mit 100ms Timeout je
Phase konfiguriert:
- POP3: Subtest `authorization` (Verbindung offen, nichts gesendet) und
`transaction` (nach erfolgreichem USER/PASS nichts weiter gesendet) —
beide erwarten Verbindungsende durch Timeout.
- IMAP: Subtest `not_authenticated` und `selected` (nach LOGIN+SELECT)
— gleiche Erwartung.
Ergebnis: **BESTANDEN** — alle vier Subtests bestätigen, dass der
konfigurierte Timeout in der jeweiligen Phase tatsächlich greift.
## Pflichtprüfung 3: Test für Backoff-Verhalten bei wiederholten Fehlversuchen
`TestGuard_BackoffOnRepeatedAuthFailures` (beide Pakete), Guard mit
`MaxAuthFailures=3`, `BackoffBase=50ms`, `BackoffMax=500ms`:
- Drei aufeinanderfolgende fehlgeschlagene Anmeldeversuche (POP3:
USER+PASS falsch; IMAP: LOGIN falsch) über dieselbe Verbindung.
Gemessene Antwortzeit des zweiten Versuchs ist länger als die des
ersten (Verdopplung statt konstanter oder fehlender Wartezeit).
- Nach dem dritten (= `MaxAuthFailures`-ten) Fehlversuch wird die
Verbindung serverseitig getrennt — ein weiterer Anmeldeversuch über
dieselbe Verbindung schlägt fehl statt in einer Dauerschleife erneut
beantwortet zu werden.
Ergebnis: **BESTANDEN**.
## Akzeptanzkriterien
1. **Verbindungsabbrüche räumen serverseitige Session-Ressourcen
zuverlässig auf**: durch Pflichtprüfung 1 belegt (kein
Goroutine-Leck nach 30 harten Abbrüchen in beiden Protokollen).
2. **Timeouts sind pro Protokollphase konfigurierbar und greifen
nachweislich**: durch Pflichtprüfung 2 belegt (`protoguard.Config.
PhaseTimeout` je Phase, vier bestandene Subtests).
3. **Wiederholte Fehlversuche eines Clients führen zu klar definiertem
Backoff statt Dauerschleife**: durch Pflichtprüfung 3 belegt
(steigender Backoff, definierte Trennung nach `MaxAuthFailures`).
## Build/Vet/Lint/Test — Gesamtmodul
```
go build ./... → OK
go vet ./... → OK
golangci-lint run ./... → 0 issues
go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL gesetzt) → alle Pakete ok, inkl. neuem internal/protoguard (indirekt über imap/pop3-Tests abgedeckt)
```
Keine Regression in den bestehenden ~24 Paketen.
## Ergebnis
ING-07 erfüllt alle Pflichtprüfungen und Akzeptanzkriterien mit echten,
ausgeführten Nachweisen. Freigeschaltet: QA-02.
+2
View File
@@ -28,9 +28,11 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.15.0 // indirect
)
+4
View File
@@ -37,6 +37,8 @@ github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqx
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@@ -56,6 +58,8 @@ golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS mail_hotfolder_processed (
tenant_slug TEXT NOT NULL,
mailbox_name TEXT NOT NULL,
content_hash TEXT NOT NULL,
filename TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, mailbox_name, content_hash)
)
+60
View File
@@ -0,0 +1,60 @@
// Package hotfolder implementiert IMP-05: Anbindung eines Hot-Folder/
// Scanner-Eingangs für E-Mail-Anhänge/Dokumente außerhalb des
// IMAP-Postfachs, analog zum Ingestion-Pfad. Kein Vorbild in archivmail
// für diesen Zuschnitt — Neubau.
package hotfolder
import (
"context"
_ "embed"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/0001_mail_hotfolder_processed.sql
var schemaMigration string
// Store verzeichnet bereits verarbeitete Dateien je Mandant/Postfach
// über deren Inhalts-Hash — Grundlage für Akzeptanzkriterium 2 (kein
// Doppelimport bei identischem Inhalt, auch unter neuem Dateinamen).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// 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("hotfolder: schema anlegen: %w", err)
}
return nil
}
// IsProcessed prüft, ob contentHash für tenantSlug/mailboxName bereits
// erfolgreich importiert wurde.
func (s *Store) IsProcessed(ctx context.Context, tenantSlug, mailboxName, contentHash string) (bool, error) {
var exists bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM mail_hotfolder_processed WHERE tenant_slug = $1 AND mailbox_name = $2 AND content_hash = $3)
`, tenantSlug, mailboxName, contentHash).Scan(&exists)
if err != nil {
return false, fmt.Errorf("hotfolder: verarbeitungsstatus prüfen: %w", err)
}
return exists, nil
}
// MarkProcessed verzeichnet contentHash als erfolgreich importiert.
func (s *Store) MarkProcessed(ctx context.Context, tenantSlug, mailboxName, contentHash, filename string) error {
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_hotfolder_processed (tenant_slug, mailbox_name, content_hash, filename)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_slug, mailbox_name, content_hash) DO NOTHING
`, tenantSlug, mailboxName, contentHash, filename); err != nil {
return fmt.Errorf("hotfolder: als verarbeitet markieren: %w", err)
}
return nil
}
+197
View File
@@ -0,0 +1,197 @@
package hotfolder
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"github.com/fsnotify/fsnotify"
)
// Handler verarbeitet eine erkannte, noch nicht importierte Datei.
// Echte Ablage/Indexierung ist Sache späterer Kacheln — dieses Paket
// bereitet nur die Schnittstelle vor.
type Handler interface {
ProcessFile(ctx context.Context, tenantSlug, mailboxName, filename string, content []byte) error
}
// Watcher überwacht EIN Hot-Folder-Verzeichnis für EINEN Mandanten/EIN
// Postfach (Akzeptanzkriterium 1: Zuordnung ist strukturell — welches
// Verzeichnis zu welchem Mandanten/Postfach gehört, entscheidet der
// Aufrufer beim Konfigurieren des Watchers, nicht dieses Paket anhand
// von Dateiinhalten).
type Watcher struct {
tenantSlug string
mailboxName string
watchDir string
processedDir string
errorDir string
store *Store
handler Handler
}
// NewWatcher legt processedDir/errorDir an, falls sie noch nicht
// existieren.
func NewWatcher(tenantSlug, mailboxName, watchDir, processedDir, errorDir string, store *Store, handler Handler) (*Watcher, error) {
for _, dir := range []string{watchDir, processedDir, errorDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("hotfolder: verzeichnis %s anlegen: %w", dir, err)
}
}
return &Watcher{
tenantSlug: tenantSlug,
mailboxName: mailboxName,
watchDir: watchDir,
processedDir: processedDir,
errorDir: errorDir,
store: store,
handler: handler,
}, nil
}
// ScanResult fasst einen abgeschlossenen Scan-Durchlauf zusammen.
type ScanResult struct {
Imported int
Duplicate int
Failed int
}
// ScanOnce verarbeitet alle regulären Dateien, die aktuell direkt in
// watchDir liegen (nicht rekursiv, processedDir/errorDir liegen
// außerhalb von watchDir und werden dadurch nie mit gescannt). Eine
// einzelne fehlerhafte Datei blockiert NICHT die übrigen
// (Akzeptanzkriterium 3) — sie landet im Fehlerordner, der Scan läuft
// mit der nächsten Datei weiter.
func (w *Watcher) ScanOnce(ctx context.Context) (ScanResult, error) {
entries, err := os.ReadDir(w.watchDir)
if err != nil {
return ScanResult{}, fmt.Errorf("hotfolder: verzeichnis lesen: %w", err)
}
var result ScanResult
for _, e := range entries {
if e.IsDir() {
continue
}
if err := ctx.Err(); err != nil {
return result, err
}
outcome := w.processOne(ctx, e.Name())
switch outcome {
case outcomeImported:
result.Imported++
case outcomeDuplicate:
result.Duplicate++
case outcomeFailed:
result.Failed++
}
}
return result, nil
}
type outcome int
const (
outcomeImported outcome = iota
outcomeDuplicate
outcomeFailed
)
// processOne verarbeitet GENAU EINE Datei — Fehler auf Dateiebene werden
// hier abgefangen (Fehlerordner statt Abbruch), niemals nach oben
// durchgereicht.
func (w *Watcher) processOne(ctx context.Context, filename string) outcome {
fullPath := filepath.Join(w.watchDir, filename)
content, err := os.ReadFile(fullPath)
if err != nil {
// Datei zwischen ReadDir und ReadFile verschwunden (z. B. vom
// Scanner noch nicht vollständig geschrieben) — kein Fehlerordner-
// Umzug möglich, einfach überspringen, nächster Scan versucht es
// erneut.
return outcomeFailed
}
hash := sha256.Sum256(content)
contentHash := hex.EncodeToString(hash[:])
alreadyDone, err := w.store.IsProcessed(ctx, w.tenantSlug, w.mailboxName, contentHash)
if err != nil {
w.moveTo(fullPath, w.errorDir, filename)
return outcomeFailed
}
if alreadyDone {
// Akzeptanzkriterium 2: identischer Inhalt wird nicht doppelt
// importiert — die redundante Kopie wandert unauffällig in den
// Verarbeitet-Ordner, ohne den Handler erneut aufzurufen.
w.moveTo(fullPath, w.processedDir, filename)
return outcomeDuplicate
}
if err := w.handler.ProcessFile(ctx, w.tenantSlug, w.mailboxName, filename, content); err != nil {
w.moveTo(fullPath, w.errorDir, filename)
return outcomeFailed
}
if err := w.store.MarkProcessed(ctx, w.tenantSlug, w.mailboxName, contentHash, filename); err != nil {
w.moveTo(fullPath, w.errorDir, filename)
return outcomeFailed
}
w.moveTo(fullPath, w.processedDir, filename)
return outcomeImported
}
// moveTo verschiebt eine Datei in ein Zielverzeichnis (Akzeptanzkriterium
// 3: Fehlerordner statt Blockade). Ein Fehlschlag beim Verschieben selbst
// wird bewusst nur best-effort behandelt — die Datei bleibt dann im
// Quellverzeichnis stehen und würde beim nächsten Scan erneut
// verarbeitet, was für bereits verarbeitete/fehlerhafte Dateien
// unschädlich ist (Store verhindert Doppelimport, ein wiederholter
// Fehlschlag landet wieder im Fehlerordner).
func (w *Watcher) moveTo(sourcePath, targetDir, filename string) {
_ = os.Rename(sourcePath, filepath.Join(targetDir, filename))
}
// Watch beobachtet watchDir live über fsnotify UND führt zu Beginn einen
// initialen ScanOnce aus (bereits vorhandene Dateien beim Start).
// Blockiert, bis ctx beendet wird.
func (w *Watcher) Watch(ctx context.Context) error {
if _, err := w.ScanOnce(ctx); err != nil {
return err
}
fsWatcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("hotfolder: fsnotify-watcher erstellen: %w", err)
}
defer func() { _ = fsWatcher.Close() }()
if err := fsWatcher.Add(w.watchDir); err != nil {
return fmt.Errorf("hotfolder: verzeichnis beobachten: %w", err)
}
for {
select {
case <-ctx.Done():
return nil
case event, ok := <-fsWatcher.Events:
if !ok {
return nil
}
if event.Op&(fsnotify.Create|fsnotify.Write) == 0 {
continue
}
if _, err := w.ScanOnce(ctx); err != nil {
return err
}
case err, ok := <-fsWatcher.Errors:
if !ok {
return nil
}
return fmt.Errorf("hotfolder: fsnotify-fehler: %w", err)
}
}
}
+214
View File
@@ -0,0 +1,214 @@
// Integrationstest (IMP-05): echte Postgres-Instanz UND echtes
// Dateisystem, folgt derselben Testhost-Konvention wie
// mail/internal/dedup/folderstate — TEST_TENANT_DSN.
package hotfolder
import (
"context"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// recordingHandler zeichnet verarbeitete Dateien auf, kann gezielt für
// bestimmte Dateinamen fehlschlagen (simuliert eine defekte Datei).
type recordingHandler struct {
processed []string
failNames map[string]bool
}
func (h *recordingHandler) ProcessFile(_ context.Context, _, _, filename string, _ []byte) error {
if h.failNames[filename] {
return errFakeCorrupt
}
h.processed = append(h.processed, filename)
return nil
}
var errFakeCorrupt = &corruptFileError{}
type corruptFileError struct{}
func (*corruptFileError) Error() string { return "hotfolder: simuliert defekte datei" }
func setupWatcher(t *testing.T, handler Handler) (*Watcher, string) {
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)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
tenant := "mandant-imp05-hotfolder"
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_hotfolder_processed WHERE tenant_slug LIKE 'mandant-%'`)
})
root := t.TempDir()
watchDir := filepath.Join(root, "eingang")
processedDir := filepath.Join(root, "verarbeitet")
errorDir := filepath.Join(root, "fehler")
watcher, err := NewWatcher(tenant, "INBOX", watchDir, processedDir, errorDir, store, handler)
if err != nil {
t.Fatalf("newwatcher: %v", err)
}
return watcher, watchDir
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatalf("datei schreiben: %v", err)
}
}
// TestScanOnce_SameFileDroppedTwiceImportedOnce ist die geforderte
// Pflichtprüfung 1: gleiche Datei zweimal abgelegt wird nur einmal
// importiert.
func TestScanOnce_SameFileDroppedTwiceImportedOnce(t *testing.T) {
handler := &recordingHandler{failNames: map[string]bool{}}
watcher, watchDir := setupWatcher(t, handler)
ctx := context.Background()
writeFile(t, watchDir, "rechnung.pdf", "identischer inhalt")
result1, err := watcher.ScanOnce(ctx)
if err != nil {
t.Fatalf("erster scan: %v", err)
}
if result1.Imported != 1 {
t.Fatalf("erwartete 1 import im ersten scan, habe %d", result1.Imported)
}
// "Zweimal abgelegt": derselbe Inhalt landet unter NEUEM Dateinamen
// erneut im Eingang (z. B. Scanner mit Zeitstempel-Dateinamen).
writeFile(t, watchDir, "rechnung_kopie.pdf", "identischer inhalt")
result2, err := watcher.ScanOnce(ctx)
if err != nil {
t.Fatalf("zweiter scan: %v", err)
}
if result2.Imported != 0 {
t.Fatalf("erwartete 0 importe im zweiten scan (identischer inhalt bereits verarbeitet), habe %d", result2.Imported)
}
if result2.Duplicate != 1 {
t.Fatalf("erwartete 1 erkanntes duplikat, habe %d", result2.Duplicate)
}
if len(handler.processed) != 1 {
t.Fatalf("handler wurde erwartet genau 1x aufgerufen, habe %d: %v", len(handler.processed), handler.processed)
}
}
// TestScanOnce_CorruptFileMovedToErrorFolderTraceably ist die geforderte
// Pflichtprüfung 2: fehlerhafte Datei landet nachvollziehbar im
// Fehlerordner.
func TestScanOnce_CorruptFileMovedToErrorFolderTraceably(t *testing.T) {
handler := &recordingHandler{failNames: map[string]bool{"defekt.pdf": true}}
watcher, watchDir := setupWatcher(t, handler)
ctx := context.Background()
writeFile(t, watchDir, "defekt.pdf", "kaputter inhalt")
writeFile(t, watchDir, "gut.pdf", "guter inhalt")
result, err := watcher.ScanOnce(ctx)
if err != nil {
t.Fatalf("scan: %v", err)
}
if result.Failed != 1 || result.Imported != 1 {
t.Fatalf("erwartete 1 fehler + 1 import, habe: %+v", result)
}
if _, err := os.Stat(filepath.Join(watcher.errorDir, "defekt.pdf")); err != nil {
t.Fatalf("defekte datei liegt nicht nachvollziehbar im fehlerordner: %v", err)
}
if _, err := os.Stat(filepath.Join(watchDir, "defekt.pdf")); !os.IsNotExist(err) {
t.Fatal("defekte datei liegt noch im eingangsordner — hätte verschoben werden müssen")
}
if _, err := os.Stat(filepath.Join(watcher.processedDir, "gut.pdf")); err != nil {
t.Fatalf("die GUTE datei sollte trotz des defekten nachbarn real verarbeitet worden sein: %v", err)
}
}
// TestScanOnce_ManyCyclesWithoutResourceLeak ist die geforderte
// Pflichtprüfung 3: Dauertest über mehrere Scan-Zyklen ohne
// Ressourcenleck.
func TestScanOnce_ManyCyclesWithoutResourceLeak(t *testing.T) {
handler := &recordingHandler{failNames: map[string]bool{}}
watcher, watchDir := setupWatcher(t, handler)
ctx := context.Background()
before := runtime.NumGoroutine()
const cycles = 50
for i := 0; i < cycles; i++ {
writeFile(t, watchDir, "datei.txt", "inhalt-zyklus")
if _, err := watcher.ScanOnce(ctx); err != nil {
t.Fatalf("scan-zyklus %d: %v", i, err)
}
// Jeder Zyklus legt DIESELBE Datei erneut ab (identischer Inhalt,
// gleicher Dateiname) — nach dem ersten Mal muss jeder weitere
// Zyklus real als Duplikat erkannt werden, kein Ressourcenverbrauch
// pro Zyklus, der sich unbegrenzt aufbaut.
}
after := runtime.NumGoroutine()
// Großzügige Toleranz (Test-Runtime/GC-Hintergrundaktivität) — es
// geht um "kein unbegrenztes Wachstum", nicht um exakte Gleichheit.
if after > before+10 {
t.Fatalf("möglicher goroutine-leck über %d zyklen: vorher=%d nachher=%d", cycles, before, after)
}
entries, err := os.ReadDir(watcher.processedDir)
if err != nil {
t.Fatalf("verarbeitet-ordner lesen: %v", err)
}
if len(entries) != 1 {
t.Fatalf("erwartete genau 1 datei im verarbeitet-ordner nach %d zyklen (immer dieselbe verschoben/dedupliziert), habe %d", cycles, len(entries))
}
}
// TestWatch_RealFsnotifyEventTriggersImport belegt real die im Ticket
// benannte Technik (fsnotify): eine neu abgelegte Datei wird über ein
// echtes Dateisystem-Ereignis erkannt und importiert, ohne dass ein
// manueller ScanOnce-Aufruf nötig ist.
func TestWatch_RealFsnotifyEventTriggersImport(t *testing.T) {
handler := &recordingHandler{failNames: map[string]bool{}}
watcher, watchDir := setupWatcher(t, handler)
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
_ = watcher.Watch(ctx)
}()
t.Cleanup(func() {
cancel()
wg.Wait()
})
time.Sleep(100 * time.Millisecond) // Watcher real gestartet und lauscht
writeFile(t, watchDir, "live-ereignis.txt", "per fsnotify erkannt")
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(filepath.Join(watcher.processedDir, "live-ereignis.txt")); err == nil {
return // real per fsnotify erkannt und verarbeitet
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("datei wurde nicht innerhalb der frist per echtem fsnotify-ereignis importiert")
}
+10 -5
View File
@@ -29,12 +29,17 @@ func (s *Session) handleLogin(ctx context.Context, cmd command) bool {
}
ok, err := s.auth.Authenticate(ctx, cmd.Args[0], cmd.Args[1])
if err != nil {
return s.writeErr(cmd.Tag, "NO", "LOGIN failed")
}
if !ok {
return s.writeErr(cmd.Tag, "NO", "LOGIN failed")
if err != nil || !ok {
// Backoff statt Dauerschleife bei wiederholten Fehlversuchen
// (Akzeptanzkriterium 3, ING-07).
backoff, disconnect := s.guard.RecordAuthFailure()
s.guard.Wait(ctx, backoff)
if !s.writeErr(cmd.Tag, "NO", "LOGIN failed") {
return false
}
return !disconnect
}
s.guard.ResetAuthFailures()
s.state = Authenticated
return s.writeErr(cmd.Tag, "OK", "LOGIN completed")
}
+157
View File
@@ -0,0 +1,157 @@
package imap
import (
"context"
"net"
"runtime"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
)
func startTestServerWithGuardConfig(t *testing.T, guardCfg protoguard.Config) (addr string, stop func()) {
t.Helper()
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
store := fakeMailboxStore{mailboxes: map[string][]Message{
"INBOX": {
{SequenceNumber: 1, UID: 101, Flags: []string{"\\Seen"}},
{SequenceNumber: 2, UID: 102, Flags: []string{}},
},
}}
srv := NewServerWithGuardConfig(auth, store, guardCfg)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
_ = srv.Serve(ctx, listener)
close(done)
}()
return listener.Addr().String(), func() {
cancel()
<-done
}
}
// TestGuard_ChaosHardCutDuringTransferNoLeak ist die geforderte
// Pflichtprüfung 1 (ING-07): Verbindung wird während aktiver
// Übertragung hart gekappt, danach kein Ressourcenleck.
func TestGuard_ChaosHardCutDuringTransferNoLeak(t *testing.T) {
addr, stop := startTestServerWithGuardConfig(t, protoguard.DefaultConfig())
defer stop()
runtime.GC()
baseline := runtime.NumGoroutine()
const rounds = 30
for i := 0; i < rounds; i++ {
c := dial(t, addr)
c.sendTagged(t, `LOGIN alice geheim123`)
c.sendTagged(t, `SELECT INBOX`)
// Mitten in einer laufenden Anfrage hart abbrechen: Kommando
// senden, aber die vollständige Antwort NICHT abwarten.
_, err := c.conn.Write([]byte("A99 FETCH 1:2 (FLAGS)\r\n"))
if err != nil {
t.Fatalf("kommando senden: %v", err)
}
_ = c.conn.Close()
}
deadline := time.Now().Add(3 * time.Second)
for {
runtime.GC()
current := runtime.NumGoroutine()
if current <= baseline+2 {
return
}
if time.Now().After(deadline) {
t.Fatalf("goroutine-leck nach hartem Verbindungsabbruch: baseline=%d, aktuell=%d", baseline, current)
}
time.Sleep(50 * time.Millisecond)
}
}
// TestGuard_TimeoutPerPhase ist die geforderte Pflichtprüfung 2
// (ING-07): Timeout-Auslösung in jeder Protokollphase.
func TestGuard_TimeoutPerPhase(t *testing.T) {
cfg := protoguard.Config{
PhaseTimeout: map[protoguard.Phase]time.Duration{
phaseNotAuthenticated: 100 * time.Millisecond,
phaseSelected: 100 * time.Millisecond,
},
DefaultTimeout: 5 * time.Second,
}
t.Run("not_authenticated", func(t *testing.T) {
addr, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
c := dial(t, addr)
defer c.close()
_ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := c.reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungsende durch NotAuthenticated-Timeout")
}
})
t.Run("selected", func(t *testing.T) {
addr, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
c := dial(t, addr)
defer c.close()
c.sendTagged(t, `LOGIN alice geheim123`)
c.sendTagged(t, `SELECT INBOX`) // jetzt Selected, nichts weiter senden
_ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err := c.reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungsende durch Selected-Timeout")
}
})
}
// TestGuard_BackoffOnRepeatedAuthFailures ist die geforderte
// Pflichtprüfung 3 (ING-07): Backoff-Verhalten bei wiederholten
// Fehlversuchen statt Dauerschleife.
func TestGuard_BackoffOnRepeatedAuthFailures(t *testing.T) {
cfg := protoguard.Config{
DefaultTimeout: 5 * time.Second,
MaxAuthFailures: 3,
BackoffBase: 50 * time.Millisecond,
BackoffMax: 500 * time.Millisecond,
}
addr, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
c := dial(t, addr)
defer c.close()
var attemptDurations []time.Duration
for i := 0; i < 3; i++ {
start := time.Now()
_, lines := c.sendTagged(t, `LOGIN alice falsch`)
last := lines[len(lines)-1]
if !strings.Contains(last, "NO") {
t.Fatalf("fehlversuch %d: erwartete NO, habe: %q", i+1, last)
}
attemptDurations = append(attemptDurations, time.Since(start))
}
if attemptDurations[1] <= attemptDurations[0] {
t.Fatalf("erwartete steigenden Backoff, habe Dauern: %v", attemptDurations)
}
// Nach MaxAuthFailures muss die Verbindung getrennt sein.
_ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := c.conn.Write([]byte("A99 LOGIN alice geheim123\r\n")); err == nil {
_, err = c.reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungstrennung nach %d Fehlversuchen", cfg.MaxAuthFailures)
}
}
}
+14 -4
View File
@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"net"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
)
// 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
// Schnittstelle dafür vor").
type Server struct {
auth Authenticator
store MailboxStore
auth Authenticator
store MailboxStore
guardCfg protoguard.Config
}
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
@@ -41,7 +51,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
}
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)
}
}
+38 -6
View File
@@ -7,6 +7,18 @@ import (
"io"
"net"
"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
@@ -18,27 +30,37 @@ const maxCommandLineBytes = 8192
// Session ist eine einzelne IMAP-Verbindung mit eigener
// Zustandsmaschine (Akzeptanzkriterium 1).
type Session struct {
conn net.Conn
reader *bufio.Reader
writer *bufio.Writer
auth Authenticator
store MailboxStore
conn net.Conn
reader *bufio.Reader
writer *bufio.Writer
auth Authenticator
store MailboxStore
guard *protoguard.Guard
state State
mailbox string // gewähltes Postfach im Zustand Selected
mailboxSize uint32 // Nachrichtenzahl aus dem letzten erfolgreichen SELECT
}
func newSession(conn net.Conn, auth Authenticator, store MailboxStore) *Session {
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: 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).
func (s *Session) State() State { return s.state }
@@ -51,8 +73,18 @@ func (s *Session) Serve(ctx context.Context) {
}
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 {
// Verbindungsende (Timeout, Netzwerkabbruch oder harter
// Abbruch) — Session-Ressourcen werden über das defer
// conn.Close() oben zuverlässig freigegeben
// (Akzeptanzkriterium 1).
return
}
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)
)
+211
View File
@@ -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
}
+172
View File
@@ -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)
}
}
+210
View File
@@ -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")
}
+189
View File
@@ -0,0 +1,189 @@
package pop3
import (
"bufio"
"context"
"net"
"runtime"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/protoguard"
)
func startTestServerWithGuardConfig(t *testing.T, guardCfg protoguard.Config) (addr string, store *fakeMailboxStore, stop func()) {
t.Helper()
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
store = newFakeMailboxStore()
srv := NewServerWithGuardConfig(auth, store, guardCfg)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
_ = srv.Serve(ctx, listener)
close(done)
}()
return listener.Addr().String(), store, func() {
cancel()
<-done
}
}
// TestGuard_ChaosHardCutDuringTransferNoLeak ist die geforderte
// Pflichtprüfung 1 (ING-07): Verbindung wird während aktiver
// Übertragung hart gekappt, danach kein Ressourcenleck.
func TestGuard_ChaosHardCutDuringTransferNoLeak(t *testing.T) {
addr, _, stop := startTestServerWithGuardConfig(t, protoguard.DefaultConfig())
defer stop()
runtime.GC()
baseline := runtime.NumGoroutine()
const rounds = 30
for i := 0; i < rounds; i++ {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
reader := bufio.NewReader(conn)
_, _ = reader.ReadString('\n') // Begrüßung
_, _ = conn.Write([]byte("USER alice\r\n"))
_, _ = reader.ReadString('\n')
_, _ = conn.Write([]byte("PASS geheim123\r\n"))
_, _ = reader.ReadString('\n')
// Mitten in der Multiline-Übertragung (RETR-Antwortkopf gelesen,
// Datenzeilen NICHT vollständig konsumiert) hart abbrechen.
_, _ = conn.Write([]byte("RETR 1\r\n"))
_, _ = reader.ReadString('\n') // nur die "+OK ... octets"-Kopfzeile
_ = conn.Close()
}
// Server braucht kurz, um die abgebrochenen Sessions abzuräumen.
deadline := time.Now().Add(3 * time.Second)
for {
runtime.GC()
current := runtime.NumGoroutine()
if current <= baseline+2 { // kleine Toleranz für Laufzeit-Jitter
return
}
if time.Now().After(deadline) {
t.Fatalf("goroutine-leck nach hartem Verbindungsabbruch: baseline=%d, aktuell=%d", baseline, current)
}
time.Sleep(50 * time.Millisecond)
}
}
// TestGuard_TimeoutPerPhase ist die geforderte Pflichtprüfung 2
// (ING-07): Timeout-Auslösung in jeder Protokollphase.
func TestGuard_TimeoutPerPhase(t *testing.T) {
cfg := protoguard.Config{
PhaseTimeout: map[protoguard.Phase]time.Duration{
phaseAuthorization: 100 * time.Millisecond,
phaseTransaction: 100 * time.Millisecond,
},
DefaultTimeout: 5 * time.Second,
}
t.Run("authorization", func(t *testing.T) {
addr, _, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
reader := bufio.NewReader(conn)
_, _ = reader.ReadString('\n') // Begrüßung, aber nichts weiter senden
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err = reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungsende durch Authorization-Timeout")
}
})
t.Run("transaction", func(t *testing.T) {
addr, _, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
reader := bufio.NewReader(conn)
_, _ = reader.ReadString('\n')
_, _ = conn.Write([]byte("USER alice\r\n"))
_, _ = reader.ReadString('\n')
_, _ = conn.Write([]byte("PASS geheim123\r\n"))
_, _ = reader.ReadString('\n') // jetzt in Transaction, nichts weiter senden
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err = reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungsende durch Transaction-Timeout")
}
})
}
// TestGuard_BackoffOnRepeatedAuthFailures ist die geforderte
// Pflichtprüfung 3 (ING-07): Backoff-Verhalten bei wiederholten
// Fehlversuchen statt Dauerschleife.
func TestGuard_BackoffOnRepeatedAuthFailures(t *testing.T) {
cfg := protoguard.Config{
DefaultTimeout: 5 * time.Second,
MaxAuthFailures: 3,
BackoffBase: 50 * time.Millisecond,
BackoffMax: 500 * time.Millisecond,
}
addr, _, stop := startTestServerWithGuardConfig(t, cfg)
defer stop()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
reader := bufio.NewReader(conn)
_, _ = reader.ReadString('\n')
var attemptDurations []time.Duration
for i := 0; i < 3; i++ {
_, _ = conn.Write([]byte("USER alice\r\n"))
_, _ = reader.ReadString('\n')
start := time.Now()
_, _ = conn.Write([]byte("PASS falsch\r\n"))
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
resp, err := reader.ReadString('\n')
if err != nil {
if i < 2 {
t.Fatalf("fehlversuch %d: unerwarteter Verbindungsabbruch: %v", i+1, err)
}
// dritter Fehlversuch: Trennung nach der Antwort ist erlaubt.
} else if !strings.Contains(resp, "-ERR") {
t.Fatalf("fehlversuch %d: erwartete -ERR, habe: %q", i+1, resp)
}
attemptDurations = append(attemptDurations, time.Since(start))
}
// Backoff steigt: der zweite Fehlversuch muss spürbar länger dauern
// als der erste (Verdopplung statt konstanter/keiner Wartezeit).
if attemptDurations[1] <= attemptDurations[0] {
t.Fatalf("erwartete steigenden Backoff, habe Dauern: %v", attemptDurations)
}
// Nach MaxAuthFailures muss die Verbindung getrennt werden (kein
// endloses erneutes USER/PASS erlaubt) statt in Dauerschleife.
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
if _, err := conn.Write([]byte("USER alice\r\n")); err == nil {
_, err = reader.ReadString('\n')
if err == nil {
t.Fatalf("erwartete Verbindungstrennung nach %d Fehlversuchen", cfg.MaxAuthFailures)
}
}
}
+38
View File
@@ -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
}
+26
View File
@@ -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:],
}
}
+301
View File
@@ -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()
}
+58
View File
@@ -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()
}
+55
View File
@@ -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)
}
}
+145
View File
@@ -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
}
}
+24
View File
@@ -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"
}
}
+119
View File
@@ -0,0 +1,119 @@
// Package protoguard bündelt die Fehlerbehandlungs- und
// Wiederverbindungslogik, die IMAP- und POP3-Sessions gemeinsam
// brauchen (ING-07): pro Protokollphase konfigurierbare Idle-Timeouts
// und Backoff statt Dauerschleife bei wiederholten Anmeldefehlern.
// Ressourcenaufräumung selbst passiert bereits strukturell durch
// defer conn.Close() in den Sessions — Guard sorgt dafür, dass dieser
// Pfad auch bei hängenden oder böswilligen Gegenstellen zuverlässig
// erreicht wird.
package protoguard
import (
"context"
"net"
"time"
)
// Phase identifiziert eine Protokollphase, für die ein eigener
// Idle-Timeout gilt.
type Phase string
// Config steuert Timeout- und Backoff-Verhalten einer Verbindung.
type Config struct {
// PhaseTimeout liefert den Idle-Timeout je Phase. Fehlt ein Eintrag,
// gilt DefaultTimeout.
PhaseTimeout map[Phase]time.Duration
// DefaultTimeout gilt, wenn für die aktuelle Phase kein eigener Wert
// gesetzt ist. 0 bedeutet: kein Timeout.
DefaultTimeout time.Duration
// MaxAuthFailures ist die Anzahl fehlgeschlagener Anmeldeversuche,
// nach der eine Verbindung getrennt wird. 0 bedeutet: unbegrenzt
// (kein Trennen, nur Backoff).
MaxAuthFailures int
// BackoffBase ist die Wartezeit vor der Antwort nach dem ersten
// Fehlversuch, verdoppelt sich je weiterem Fehlversuch bis
// BackoffMax.
BackoffBase time.Duration
BackoffMax time.Duration
}
// DefaultConfig liefert praxistaugliche Werte für Produktionsbetrieb.
func DefaultConfig() Config {
return Config{
DefaultTimeout: 5 * time.Minute,
MaxAuthFailures: 5,
BackoffBase: 200 * time.Millisecond,
BackoffMax: 5 * time.Second,
}
}
// Guard kapselt den Fehlerbehandlungszustand EINER Verbindung: aktuell
// angewandte Phase-Timeouts und Zahl der Anmeldefehlversuche.
type Guard struct {
cfg Config
authFailures int
}
// New erstellt einen Guard für eine einzelne Session.
func New(cfg Config) *Guard {
return &Guard{cfg: cfg}
}
// ApplyReadDeadline setzt die Lese-Deadline von conn passend zur
// angegebenen Protokollphase (Akzeptanzkriterium 2).
func (g *Guard) ApplyReadDeadline(conn net.Conn, phase Phase) error {
d := g.cfg.DefaultTimeout
if pd, ok := g.cfg.PhaseTimeout[phase]; ok {
d = pd
}
if d <= 0 {
return conn.SetReadDeadline(time.Time{})
}
return conn.SetReadDeadline(time.Now().Add(d))
}
// RecordAuthFailure zählt einen fehlgeschlagenen Anmeldeversuch dieser
// Verbindung und liefert die Backoff-Wartezeit vor der Fehlerantwort
// sowie ob die Verbindung danach getrennt werden muss (Akzeptanzkriterium
// 3: klar definierter Backoff statt Dauerschleife).
func (g *Guard) RecordAuthFailure() (backoff time.Duration, disconnect bool) {
g.authFailures++
backoff = g.backoffFor(g.authFailures)
disconnect = g.cfg.MaxAuthFailures > 0 && g.authFailures >= g.cfg.MaxAuthFailures
return backoff, disconnect
}
// ResetAuthFailures setzt den Fehlversuchszähler nach erfolgreicher
// Anmeldung zurück.
func (g *Guard) ResetAuthFailures() { g.authFailures = 0 }
func (g *Guard) backoffFor(failures int) time.Duration {
if g.cfg.BackoffBase <= 0 {
return 0
}
d := g.cfg.BackoffBase
for i := 1; i < failures; i++ {
d *= 2
if g.cfg.BackoffMax > 0 && d >= g.cfg.BackoffMax {
return g.cfg.BackoffMax
}
}
if g.cfg.BackoffMax > 0 && d > g.cfg.BackoffMax {
return g.cfg.BackoffMax
}
return d
}
// Wait wartet d, bricht aber bei ctx-Abbruch sofort ab, damit ein
// Server-Shutdown nicht auf eine laufende Backoff-Pause warten muss.
func (g *Guard) Wait(ctx context.Context, d time.Duration) {
if d <= 0 {
return
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
@@ -0,0 +1,70 @@
// fakeClamd implementiert das reale clamd-INSTREAM-Protokoll
// protokolltreu (kein echter ClamAV-Daemon auf dem Testhost installiert
// — siehe Paket-Dokumentation in scanner.go). Erkennt die offizielle
// EICAR-Testsignatur exakt wie ein echter Virenscanner es täte.
package virusscan
import (
"encoding/binary"
"io"
"net"
"strings"
"testing"
)
// eicarTestString ist die offizielle, von allen Antivirus-Herstellern
// gemeinsam definierte, VOLLKOMMEN UNGEFÄHRLICHE Testsignatur (EICAR
// Institute) — kein echter Schadcode, universeller Standardtest für
// Virenscanner-Integrationen.
const eicarTestString = `X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*`
func startFakeClamd(t *testing.T) (addr string) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go handleFakeClamdConn(conn)
}
}()
t.Cleanup(func() { _ = listener.Close() })
return listener.Addr().String()
}
func handleFakeClamdConn(conn net.Conn) {
defer func() { _ = conn.Close() }()
header := make([]byte, len("zINSTREAM\x00"))
if _, err := io.ReadFull(conn, header); err != nil {
return
}
var content []byte
for {
var lenBuf [4]byte
if _, err := io.ReadFull(conn, lenBuf[:]); err != nil {
return
}
chunkLen := binary.BigEndian.Uint32(lenBuf[:])
if chunkLen == 0 {
break
}
chunk := make([]byte, chunkLen)
if _, err := io.ReadFull(conn, chunk); err != nil {
return
}
content = append(content, chunk...)
}
if strings.Contains(string(content), "EICAR-STANDARD-ANTIVIRUS-TEST-FILE") {
_, _ = conn.Write([]byte("stream: Eicar-Test-Signature FOUND\x00"))
return
}
_, _ = conn.Write([]byte("stream: OK\x00"))
}
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS mail_quarantine (
id BIGSERIAL PRIMARY KEY,
tenant_slug TEXT NOT NULL,
filename TEXT NOT NULL,
content_hash TEXT NOT NULL,
signature_name TEXT NOT NULL,
quarantined_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
+127
View File
@@ -0,0 +1,127 @@
package virusscan
import (
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/0001_mail_quarantine.sql
var schemaMigration string
// Decision ist das Ergebnis der Scan-Entscheidung für einen Anhang
// (Akzeptanzkriterium 1/2/3).
type Decision int
const (
// DecisionArchive: sauber, darf archiviert werden.
DecisionArchive Decision = iota
// DecisionQuarantine: Fund, Archivierung unterbleibt, Anhang
// gequarantänt (Akzeptanzkriterium 2).
DecisionQuarantine
// DecisionError: Scanner nicht erreichbar/Fehler — definierter
// Fehlerzustand statt automatischer Archivierung ODER unbegrenzter
// Blockade (Akzeptanzkriterium 3).
DecisionError
)
// QuarantineStore persistiert Quarantänefälle je Mandant.
type QuarantineStore struct {
pool *pgxpool.Pool
}
func NewQuarantineStore(pool *pgxpool.Pool) *QuarantineStore {
return &QuarantineStore{pool: pool}
}
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert.
func (s *QuarantineStore) EnsureSchema(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, schemaMigration); err != nil {
return fmt.Errorf("virusscan: schema anlegen: %w", err)
}
return nil
}
func (s *QuarantineStore) record(ctx context.Context, tenantSlug, filename, contentHash, signatureName string) error {
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_quarantine (tenant_slug, filename, content_hash, signature_name)
VALUES ($1, $2, $3, $4)
`, tenantSlug, filename, contentHash, signatureName); err != nil {
return fmt.Errorf("virusscan: quarantänefall speichern: %w", err)
}
return nil
}
// List liefert alle Quarantänefälle eines Mandanten — Nachvollziehbarkeit
// (klare Statusanzeige, Akzeptanzkriterium 1).
func (s *QuarantineStore) List(ctx context.Context, tenantSlug string) ([]QuarantineEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT filename, content_hash, signature_name, quarantined_at
FROM mail_quarantine WHERE tenant_slug = $1 ORDER BY quarantined_at DESC
`, tenantSlug)
if err != nil {
return nil, fmt.Errorf("virusscan: quarantänefälle lesen: %w", err)
}
defer rows.Close()
var entries []QuarantineEntry
for rows.Next() {
var e QuarantineEntry
if err := rows.Scan(&e.Filename, &e.ContentHash, &e.SignatureName, &e.QuarantinedAt); err != nil {
return nil, fmt.Errorf("virusscan: quarantänezeile lesen: %w", err)
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("virusscan: quarantänefälle iterieren: %w", err)
}
return entries, nil
}
// QuarantineEntry ist ein einzelner Quarantänefall.
type QuarantineEntry struct {
Filename string
ContentHash string
SignatureName string
QuarantinedAt time.Time
}
// Processor verbindet Scanner mit QuarantineStore
// (Akzeptanzkriterium 1: jeder Anhang wird vor Archivierung geprüft).
type Processor struct {
scanner Scanner
quarantine *QuarantineStore
}
func NewProcessor(scanner Scanner, quarantine *QuarantineStore) *Processor {
return &Processor{scanner: scanner, quarantine: quarantine}
}
// ScanAndDecide prüft content und liefert die Archivierungsentscheidung.
// Bei DecisionQuarantine wurde der Fall bereits real in QuarantineStore
// verzeichnet, bevor ScanAndDecide zurückkehrt.
func (p *Processor) ScanAndDecide(ctx context.Context, tenantSlug, filename string, content []byte) (Decision, Result, error) {
result, err := p.scanner.Scan(ctx, content)
if err != nil {
if errors.Is(err, ErrScannerUnavailable) {
return DecisionError, Result{}, err
}
return DecisionError, Result{}, fmt.Errorf("virusscan: scan fehlgeschlagen: %w", err)
}
if result.Clean {
return DecisionArchive, result, nil
}
hash := sha256.Sum256(content)
if err := p.quarantine.record(ctx, tenantSlug, filename, hex.EncodeToString(hash[:]), result.SignatureName); err != nil {
return DecisionError, result, err
}
return DecisionQuarantine, result, nil
}
+164
View File
@@ -0,0 +1,164 @@
// Integrationstest (IMP-06): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/dedup/folderstate —
// TEST_TENANT_DSN. Der Virenscanner selbst ist der protokolltreue
// fakeClamd (siehe fake_clamd_test.go), die Netzwerk-/Protokollschicht
// (ClamdScanner) ist vollständig real.
package virusscan
import (
"context"
"errors"
"fmt"
"net"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupProcessor(t *testing.T, scanner Scanner) (*Processor, *QuarantineStore, string) {
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() })
quarantine := NewQuarantineStore(pool)
if err := quarantine.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
tenant := "mandant-imp06-virenscan"
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_quarantine WHERE tenant_slug LIKE 'mandant-%'`)
})
return NewProcessor(scanner, quarantine), quarantine, tenant
}
// TestScanAndDecide_EICARTriggersQuarantine ist die geforderte
// Pflichtprüfung 1: Test mit EICAR-Testdatei bestätigt
// Quarantäne-Verhalten.
func TestScanAndDecide_EICARTriggersQuarantine(t *testing.T) {
addr := startFakeClamd(t)
scanner := NewClamdScanner(addr)
processor, quarantine, tenant := setupProcessor(t, scanner)
ctx := context.Background()
decision, result, err := processor.ScanAndDecide(ctx, tenant, "eicar.txt", []byte(eicarTestString))
if err != nil {
t.Fatalf("scanandDecide: %v", err)
}
if decision != DecisionQuarantine {
t.Fatalf("erwartete DecisionQuarantine für EICAR, habe %v", decision)
}
if result.SignatureName == "" {
t.Fatal("erwartete gemeldeten signaturnamen bei fund")
}
entries, err := quarantine.List(ctx, tenant)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(entries) != 1 || entries[0].Filename != "eicar.txt" {
t.Fatalf("erwartete real verzeichneten quarantänefall für eicar.txt, habe: %+v", entries)
}
// Saubere Datei zum Vergleich: DARF archiviert werden.
decision2, _, err := processor.ScanAndDecide(ctx, tenant, "harmlos.txt", []byte("ganz normaler anhangsinhalt"))
if err != nil {
t.Fatalf("scanandDecide (harmlos): %v", err)
}
if decision2 != DecisionArchive {
t.Fatalf("erwartete DecisionArchive für harmlosen inhalt, habe %v", decision2)
}
}
// TestScan_ScannerUnreachableFailsFastNotHang ist die geforderte
// Pflichtprüfung 2: Scanner nicht erreichbar führt zu klar sichtbarem
// Fehlerzustand statt Hänger.
func TestScan_ScannerUnreachableFailsFastNotHang(t *testing.T) {
// Ein real geschlossener Port (nichts lauscht) — kein Hänger, sofortige
// Verbindungsablehnung durch das Betriebssystem.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
unreachableAddr := listener.Addr().String()
_ = listener.Close() // sofort wieder geschlossen -> Verbindung wird real abgelehnt
scanner := NewClamdScanner(unreachableAddr).WithTimeout(2 * time.Second)
start := time.Now()
_, err = scanner.Scan(context.Background(), []byte("beliebiger inhalt"))
elapsed := time.Since(start)
if err == nil {
t.Fatal("erwartete fehler bei nicht erreichbarem scanner, habe nil")
}
if !errors.Is(err, ErrScannerUnavailable) {
t.Fatalf("erwartete ErrScannerUnavailable, habe: %v", err)
}
if elapsed > 2*time.Second {
t.Fatalf("scan hing über die konfigurierte frist hinaus: %s", elapsed)
}
t.Logf("nicht erreichbarer scanner meldete real nach %s: %v", elapsed, err)
}
// TestScanAndDecide_ScannerUnavailableYieldsDefinedErrorState ergänzt
// Pflichtprüfung 2 auf Processor-Ebene: ScanAndDecide liefert
// DecisionError statt automatischer Archivierung.
func TestScanAndDecide_ScannerUnavailableYieldsDefinedErrorState(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listener: %v", err)
}
unreachableAddr := listener.Addr().String()
_ = listener.Close()
scanner := NewClamdScanner(unreachableAddr).WithTimeout(1 * time.Second)
processor, _, tenant := setupProcessor(t, scanner)
decision, _, err := processor.ScanAndDecide(context.Background(), tenant, "irgendwas.pdf", []byte("inhalt"))
if err == nil {
t.Fatal("erwartete fehler, habe nil")
}
if decision != DecisionError {
t.Fatalf("erwartete DecisionError (NICHT automatische archivierung) bei nicht erreichbarem scanner, habe %v", decision)
}
}
// TestScan_ThroughputWithManyAttachmentsIsAcceptable ist die geforderte
// Pflichtprüfung 3: Durchsatztest bestätigt akzeptable Verzögerung durch
// den Scan-Schritt.
func TestScan_ThroughputWithManyAttachmentsIsAcceptable(t *testing.T) {
addr := startFakeClamd(t)
scanner := NewClamdScanner(addr)
const attachments = 50
const targetPerScan = 100 * time.Millisecond
start := time.Now()
for i := 0; i < attachments; i++ {
content := []byte(fmt.Sprintf("anhangsinhalt nummer %d, harmlos", i))
result, err := scanner.Scan(context.Background(), content)
if err != nil {
t.Fatalf("scan %d: %v", i, err)
}
if !result.Clean {
t.Fatalf("scan %d: erwartete sauberes ergebnis, habe fund %q", i, result.SignatureName)
}
}
elapsed := time.Since(start)
perScan := elapsed / attachments
t.Logf("Durchsatz: %d Anhänge in %s (%s/Anhang, Ziel %s/Anhang)", attachments, elapsed, perScan, targetPerScan)
if perScan > targetPerScan {
t.Fatalf("scan zu langsam: %s/anhang, ziel %s/anhang", perScan, targetPerScan)
}
}
+146
View File
@@ -0,0 +1,146 @@
// Package virusscan implementiert IMP-06: Anbindung eines Virenscanners
// für importierte Anhänge, mit Quarantäne-Verhalten bei Fund und klarer
// Statusanzeige. Kein Vorbild in archivmail für diesen Zuschnitt — Neubau.
//
// ClamdScanner spricht das reale, dokumentierte clamd-INSTREAM-Protokoll
// (TCP, Längen-präfixierte Chunks) — kein ClamAV-Daemon wurde für diese
// Kachel auf dem Testhost installiert (ein Antivirus-Daemon samt
// Signaturdatenbank ist ein deutlich größerer, sicherheitsrelevanter
// Eingriff als ein einzelnes Go-Modul und wird nicht unaufgefordert
// vorgenommen). Stattdessen wird ein protokolltreuer Fake-Server für
// Tests verwendet (gleiches Prinzip wie IMP-08s
// HTTPNotificationDispatcher-Tests) — der reale Netzwerkpfad
// (ClamdScanner) ist vollständig echt und real getestet, nur die
// Gegenstelle ist ein Test-Double statt eines echten ClamAV-Daemons.
package virusscan
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"net"
"strings"
"time"
)
// Result ist das Ergebnis eines Scans (Akzeptanzkriterium 1).
type Result struct {
Clean bool
SignatureName string
}
// ErrScannerUnavailable wird geliefert, wenn der Virenscanner nicht
// erreichbar ist oder innerhalb der Frist nicht antwortet
// (Akzeptanzkriterium 3: definierter Fehlerzustand statt unbegrenzter
// Blockade).
var ErrScannerUnavailable = errors.New("virusscan: scanner nicht erreichbar")
// Scanner prüft Anhangsinhalte auf Schadsoftware.
type Scanner interface {
Scan(ctx context.Context, content []byte) (Result, error)
}
// ClamdScanner spricht das clamd-INSTREAM-Protokoll über TCP.
type ClamdScanner struct {
addr string
dialer net.Dialer
timeout time.Duration
}
// DefaultScanTimeout begrenzt einen einzelnen Scan-Vorgang
// (Akzeptanzkriterium 3).
const DefaultScanTimeout = 10 * time.Second
func NewClamdScanner(addr string) *ClamdScanner {
return &ClamdScanner{addr: addr, timeout: DefaultScanTimeout}
}
// WithTimeout überschreibt die Standard-Scan-Zeitüberschreitung (Tests
// nutzen eine kürzere Frist, um Nicht-Erreichbarkeit real zügig zu
// beweisen).
func (c *ClamdScanner) WithTimeout(d time.Duration) *ClamdScanner {
c.timeout = d
return c
}
const clamdChunkSize = 4096
// Scan überträgt content per INSTREAM (RFC-artiges, dokumentiertes
// clamd-Protokoll: "zINSTREAM\0" gefolgt von 4-Byte-Big-Endian-
// Längenpräfixen je Chunk, abgeschlossen durch ein Null-Längen-Chunk) und
// interpretiert die Antwortzeile.
func (c *ClamdScanner) Scan(ctx context.Context, content []byte) (Result, error) {
scanCtx := ctx
var cancel context.CancelFunc
if c.timeout > 0 {
scanCtx, cancel = context.WithTimeout(ctx, c.timeout)
defer cancel()
}
conn, err := c.dialer.DialContext(scanCtx, "tcp", c.addr)
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
defer func() { _ = conn.Close() }()
if deadline, ok := scanCtx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
if _, err := conn.Write([]byte("zINSTREAM\x00")); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
for offset := 0; offset < len(content); offset += clamdChunkSize {
end := offset + clamdChunkSize
if end > len(content) {
end = len(content)
}
chunk := content[offset:end]
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(chunk)))
if _, err := conn.Write(lenBuf[:]); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
if _, err := conn.Write(chunk); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
}
// Null-Längen-Chunk signalisiert Ende des Streams.
var zero [4]byte
if _, err := conn.Write(zero[:]); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\x00')
if err != nil {
return Result{}, fmt.Errorf("%w: antwort lesen: %v", ErrScannerUnavailable, err)
}
line = strings.TrimRight(line, "\x00\r\n")
return parseClamdResponse(line)
}
// parseClamdResponse interpretiert eine clamd-Antwortzeile, z. B.
// "stream: OK" oder "stream: Eicar-Test-Signature FOUND".
func parseClamdResponse(line string) (Result, error) {
switch {
case strings.HasSuffix(line, "OK"):
return Result{Clean: true}, nil
case strings.HasSuffix(line, "FOUND"):
// Format: "stream: <Signaturname> FOUND"
trimmed := strings.TrimSuffix(line, "FOUND")
trimmed = strings.TrimSpace(trimmed)
signature := trimmed
if idx := strings.LastIndex(trimmed, ":"); idx != -1 {
signature = strings.TrimSpace(trimmed[idx+1:])
}
return Result{Clean: false, SignatureName: signature}, nil
default:
return Result{}, fmt.Errorf("virusscan: unerwartete scanner-antwort: %q", line)
}
}