Compare commits

...
Author SHA1 Message Date
sysopsandClaude Sonnet 5 03c47d98f4 IMP-04: fehlerbehandlung-nicht-konformer-server
Defensive Fehlerbehandlung für nicht-RFC-konforme Mailserver beim
Import, mit dokumentierten Fallback-Pfaden statt Abbruch.

- client_real.go: resolveUIDValidity behandelt UIDVALIDITY=0 (bekannte
  archivmail-Abweichung, known-issues #5) und fehlende UIDVALIDITY-Angabe
  als definierten Fallback statt Sync-Abbruch — deterministisch aus dem
  Postfachnamen abgeleitet (FNV-1a), stabil bei wiederholten Läufen.
  parseFetchLines überspringt kaputte/unerwartete FETCH-Zeilen einzeln
  und protokolliert sie, statt den gesamten Lauf zu stoppen. Neuer
  Logger/WithLogger für nachvollziehbares Support-Logging.
- Echten Bug behoben: die getaggte Abschlusszeile enthält ebenfalls
  "FETCH " und wurde zunächst fälschlich als unerwartete Antwort
  geloggt — jetzt nur echte Untagged-Zeilen (Präfix "* ") betrachtet.

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-04-PRUEFPROTOKOLL.md):
1. TestResolveUIDValidity_ZeroTriggersDefinedFallbackNotAbort: Server
   meldet real UIDVALIDITY=0, Sync liefert real Fallback statt Fehler.
2. TestParseFetchLines_UnexpectedResponseSkippedRestContinue: 2 kaputte
   Zeilen real übersprungen+protokolliert, übrige Nachrichten kommen an.
3. TestResolveUIDValidity_RegressionGuardAgainstZeroAbort: direkter
   Regressionsschutz gegen den ursprünglichen UIDVALIDITY-Bug.

Kein Umbau: imap/folderstate/scheduler.go unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:55:38 +02:00
sysopsandClaude Sonnet 5 e9947b1e28 IMP-01: imap-postfach-abruf-scheduler
Scheduler für periodischen IMAP-Postfach-Abruf mit UID-basiertem
Delta-Sync: neue Nachrichten erkennen, Zustandsänderungen abgleichen.

- imap (ING-01) minimal erweitert: Message.UID, MailboxStore.FetchByUID
  (UID FETCH), SELECT meldet jetzt UIDVALIDITY (RFC-Pflichtbestandteil).
  Echten Bug behoben: UID FETCH n:* löste "*" fälschlich gegen die
  Nachrichtenanzahl statt die höchste UID auf.
- imapimport/state.go: Store persistiert last_uidvalidity,
  last_synced_uid, interval_seconds je Mandant/Postfach (übersteht
  Neustarts).
- imapimport/scheduler.go: RunOnce klassifiziert Nachrichten per
  UID-Vergleich, persistiert Fortschritt nach JEDER einzelnen neuen
  Nachricht (nicht erst am Ende), UIDVALIDITY-Änderung löst
  vollständigen Resync aus (archivmail-Fehler UIDVALIDITY=0 vermieden).
- imapimport/client_real.go: echtes IMAP4rev1 über TCP
  (LOGIN/SELECT/UID FETCH/LOGOUT).

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-01-PRUEFPROTOKOLL.md):
1. TestRunOnce_TwoConsecutiveRunsNoDuplicateImport: zweiter Lauf real
   0 neue Nachrichten.
2. TestRunOnce_SimulatedRestartMidSyncConsistentEndState: Absturz nach 2
   von 5 Nachrichten, Neustart verarbeitet real genau die restlichen 3,
   konsistenter Endzustand.
3. TestRunOnce_AgainstRealTestMailboxWithRealisticVolume: echter
   End-zu-Ende-IMAP-Lauf mit 30 Nachrichten gegen den echten
   ING-01-Server, alle real importiert.

Kein Umbau: mail/internal/folderstate (ING-05) unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:45:08 +02:00
sysopsandClaude Sonnet 5 0d3779d03e ING-05: folder-state-uidvalidity-handling
Folder-State-Verwaltung inklusive UIDVALIDITY/UIDNEXT-Handling (RFC 3501
§2.3.1.1), damit Clients und Importvorgänge konsistente Sichten
erhalten. Direkte Vorbedingung für IMP-01.

- store.go: GetOrCreate/CurrentState konsistent bei parallelem Zugriff
  (INSERT ON CONFLICT + Rücklese). NextUID vergibt UIDs atomar über
  UPDATE...RETURNING unter Zeilensperre, protokolliert jede Vergabe.
  Rebuild garantiert über GREATEST(uidvalidity+1, jetzt) eine strikt neue
  UIDVALIDITY auch bei Neuaufbauten innerhalb derselben Nanosekunde,
  setzt UIDNEXT zurück auf 1. RecordDeletion ändert UIDNEXT nicht (UIDs
  werden nie wiederverwendet).
- Bekannten Fehler vermieden (archivmail: UIDVALIDITY=0 bricht Resync):
  UIDVALIDITY wird selbst erzeugt (Unix-Nanosekunden), nie von außen
  übernommen.
- Kein Umbau: mail/internal/imap (ING-01) unverändert, folderstate ist
  eigenständig und kann künftig (IMP-01) als MailboxStore-Implementierung
  dienen.

Prüfungen (alle real durchgeführt, siehe mail/docs/ING-05-PRUEFPROTOKOLL.md):
1. TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild: UIDVALIDITY
   real geändert, UIDNEXT real zurückgesetzt, Ereignis real protokolliert.
2. TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency: 20 reale
   gleichzeitige Vergaben, 0 Dopplungen.
3. TestNextUID_MonotonicAcrossManyInsertDeleteCycles: 200 Zyklen real
   strikt monoton, Löschungen ohne Einfluss auf UIDNEXT.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:37:21 +02:00
sysopsandClaude Sonnet 5 54c5f74778 ING-01: imap-server-grundgeruest
IMAP-Server-Grundgerüst: TCP-Listener, Command-Parser, Session-
Zustandsmaschine (Not Authenticated/Authenticated/Selected), Grundbefehle
CAPABILITY/LOGIN/SELECT/FETCH/LOGOUT.

- parser.go: Tag+Kommando+Argumente (Atome, zitierte Zeichenketten),
  keine IMAP-Literalsyntax (kleinste Lösung).
- response.go: sanitizeResponseText entfernt eingebettete CR/LF vor jeder
  Antwortzeile — bekannten archivmail-Fehler (Header-/Zeilen-Injection
  durch Stringkonkatenation ohne CRLF-Prüfung) strukturell vermieden.
- session.go/commands.go: strikte Zustandsprüfung je Kommando, verbotene
  Übergänge und fehlerhafte Zeilen liefern BAD/NO statt
  Verbindungsabbruch. maxCommandLineBytes begrenzt Pufferwachstum
  defensiv.
- server.go: TCP-Accept-Schleife, eine Goroutine je Verbindung.
- Authenticator/MailboxStore als schmale Schnittstellen — echte
  Benutzerverwaltungs-/Postfach-Anbindung ist Sache von IMP-01 u. a.

Prüfungen (alle real durchgeführt, siehe mail/docs/ING-01-PRUEFPROTOKOLL.md):
1. Manuelle Session mit Pythons imaplib gegen den echten laufenden
   Server: alle Grundbefehle real beantwortet, ungültiges SELECT liefert
   real NO ohne Verbindungsabbruch.
2. TestSession_StateTransitionsAndForbiddenTransitions: alle drei
   Zustandsübergänge und deren verbotene Übergänge real über TCP geprüft.
3. TestServer_50ParallelSessionsNoLeak: 50 reale parallele Sessions,
   0 Fehler.

Kein Umbau: alle bestehenden Pakete unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:33:00 +02:00
24 changed files with 2581 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
# IMP-01 Prüfprotokoll: IMAP-Postfach-Abruf & Scheduler
Voraussetzung ING-01, ING-05 (beide Fertig).
## Umsetzung
- `mail/internal/imap` (ING-01) minimal erweitert: `Message.UID`,
`MailboxStore.FetchByUID` (RFC 3501 §6.4.8, `UID FETCH`), `SELECT`
meldet jetzt `UIDVALIDITY` (RFC-Pflichtbestandteil, war zuvor nicht
Bestandteil der Antwort). Dabei einen echten Bug im selben Zug
gefunden und behoben: `UID FETCH n:*` löste `*` fälschlich gegen die
Nachrichten**anzahl** statt die höchste UID auf — mit
`maxOpenEndedUID`-Begrenzung (statt eines naiven 2³²-1-Sentinels, der
eine milliardenfache Schleife ausgelöst hätte) korrigiert.
- `mail/internal/imapimport/state.go``Store` (Postgres,
`mail_import_state`): persistiert `last_uidvalidity`,
`last_synced_uid`, `interval_seconds` je Mandant/Postfach
(Akzeptanzkriterium 3, übersteht Neustarts, da nie im
Prozessspeicher).
- `mail/internal/imapimport/scheduler.go``Scheduler.RunOnce`:
UID-Vergleich klassifiziert Nachrichten als neu vs. bestehend
(Akzeptanzkriterium 1), Fortschritt wird NACH JEDER einzelnen neuen
Nachricht persistiert (nicht erst am Ende), UIDVALIDITY-Änderung löst
vollständigen Resync aus (Akzeptanzkriterium 2, bekannten
archivmail-Fehler UIDVALIDITY=0 vermieden).
- `mail/internal/imapimport/client_real.go``RealClient`: echtes
IMAP4rev1 über TCP (LOGIN/SELECT/UID FETCH/LOGOUT), für den
realistischen Testpostfach-Nachweis UND als produktive Anbindung an
jeden RFC-3501-konformen Server nutzbar.
- Kein Umbau: `mail/internal/folderstate` (ING-05) unverändert — die
UIDVALIDITY-Erzeugung bei echtem Ordner-Neuaufbau bleibt dort, IMP-01
reagiert nur auf eine geänderte UIDVALIDITY, erzeugt selbst keine.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: zwei aufeinanderfolgende Läufe importieren keine Nachricht doppelt | **bestanden** `TestRunOnce_TwoConsecutiveRunsNoDuplicateImport`: 3 Nachrichten im ersten Lauf real importiert, zweiter Lauf gegen unverändertes Postfach liefert real 0 neue, 3 bestehende |
| 2 | Test: simulierter Dienst-Neustart mitten im Abgleich führt zu konsistentem Endzustand | **bestanden** `TestRunOnce_SimulatedRestartMidSyncConsistentEndState`: Handler schlägt real nach 2 von 5 Nachrichten fehl, neuer Scheduler auf demselben persistenten Store verarbeitet real GENAU die verbleibenden 3, keine der ersten 2 erneut, `last_synced_uid` real konsistent bei 5 |
| 3 | Test gegen Testpostfach mit realistischem Nachrichtenaufkommen | **bestanden** `TestRunOnce_AgainstRealTestMailboxWithRealisticVolume`: echter End-zu-Ende-IMAP4rev1-Lauf (`RealClient` gegen echten laufenden ING-01-Server) mit 30 Nachrichten — alle 30 real importiert, zweiter Lauf real 0 neue/30 bestehende |
Zusätzlich (Akzeptanzkriterium 3, Intervallkonfiguration):
`TestSetInterval_ConfigurableAndSurvivesRestart` — konfiguriertes
Intervall bleibt nach simuliertem Neustart (neue Store-Instanz auf
demselben Postgres-Zustand) real erhalten.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=... go test ./internal/imapimport/... -v -timeout 60s -> 4/4 bestanden
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
-> alle 15 Pakete bestanden, keine Regression (inkl. ING-01: 6/6 weiterhin grün
nach UID-FETCH-Erweiterung)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt IMP-02, IMP-03, IMP-04, IMP-05, IMP-07, IMP-08,
IMP-09, INT-05, UX-01.
+58
View File
@@ -0,0 +1,58 @@
# IMP-04 Prüfprotokoll: Fehlerbehandlung nicht-konformer Server
Voraussetzung IMP-01 (Fertig).
## Umsetzung
- `mail/internal/imapimport/client_real.go` erweitert:
- `resolveUIDValidity`: eine gemeldete `UIDVALIDITY=0` (bekannte
Abweichung nicht-konformer Server, known-issues-archivmail.md #5)
oder eine ganz fehlende UIDVALIDITY-Angabe löst KEINEN Abbruch mehr
aus, sondern einen definierten Fallback (Akzeptanzkriterium 1):
`fallbackUIDValidity` leitet deterministisch (FNV-1a, gleiche Technik
wie `search.DocumentID`) einen von 0 verschiedenen Ersatzwert aus dem
Postfachnamen ab — bei wiederholten Läufen gegen denselben
nicht-konformen Server bleibt der Fallback STABIL, kein unnötiger
Voll-Resync bei jedem einzelnen Lauf.
- `parseFetchLines`/`parseSingleFetchLine`: eine einzelne unerwartete
oder kaputte `FETCH`-Zeile wird protokolliert und übersprungen, alle
übrigen, korrekt lesbaren Nachrichten werden trotzdem geliefert
(Akzeptanzkriterium 2) — der gesamte Lauf bricht dafür nicht ab.
- `Logger`/`RealClient.WithLogger`: jede erkannte Abweichung läuft über
ein protokollierbares, austauschbares Logging-Ziel mit festem,
durchsuchbarem Präfix (Akzeptanzkriterium 3: für Support
nachvollziehbar) — Standard ist `log.Printf`.
- Dabei einen echten, durch die neue Logging-Logik selbst eingeführten
Bug gefunden und behoben: die getaggte Kommando-Abschlusszeile (z. B.
`"C3 OK UID FETCH completed"`) enthält ebenfalls die Zeichenfolge
`"FETCH "` und wurde beim ersten Anlauf fälschlich als "unerwartete
Serverantwort" geloggt — behoben, indem nur echte Untagged-Zeilen
(Präfix `"* "`) überhaupt als FETCH-Zeile in Betracht gezogen werden.
- Kein Umbau: `mail/internal/imap` (ING-01)/`folderstate` (ING-05)/
`imapimport/scheduler.go` (IMP-01) unverändert — IMP-04 erweitert
ausschließlich `client_real.go`.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test simuliert Server mit UIDVALIDITY=0 und bestätigt greifenden Fallback | **bestanden** `TestResolveUIDValidity_ZeroTriggersDefinedFallbackNotAbort`: hand-gesteuerter Fake-Server meldet real `UIDVALIDITY=0`, `Sync` schlägt real NICHT fehl, liefert real einen von 0 verschiedenen, deterministischen Fallback-Wert und alle 3 Nachrichten, Fallback-Hinweis real protokolliert |
| 2 | Test mit unerwarteter/kaputter Serverantwort bestätigt Weiterlauf für übrige Nachrichten | **bestanden** `TestParseFetchLines_UnexpectedResponseSkippedRestContinue`: 2 bewusst kaputte Zeilen zwischen 2 korrekten real gesendet — `Sync` liefert real trotzdem beide korrekt lesbaren Nachrichten, beide kaputten Zeilen real protokolliert und übersprungen, kein Abbruch |
| 3 | Regressionstest verhindert Wiederauftreten des UIDVALIDITY-Bugs | **bestanden** `TestResolveUIDValidity_RegressionGuardAgainstZeroAbort`: direkter, vom Netzwerkpfad unabhängiger Test von `resolveUIDValidity` mit `UIDVALIDITY=0` UND mit gänzlich fehlender Angabe — beide liefern real keinen Fehler und einen Fallback-Wert != 0 |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=... go test ./internal/imapimport/... -v -timeout 60s -> 7/7 bestanden
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
-> alle 15 Pakete bestanden, keine Regression
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt IMP-08 (gemeinsam mit QA-02, bleibt weiterhin
blockiert bis dessen übrige Abhängigkeiten fertig sind).
+78
View File
@@ -0,0 +1,78 @@
# ING-01 Prüfprotokoll: IMAP-Server-Grundgerüst
Keine Vorbedingungen im Mail-Board (sofort startbar).
## Umsetzung
- `mail/internal/imap/state.go``State` (`NotAuthenticated`,
`Authenticated`, `Selected`, RFC 3501 §3).
- `mail/internal/imap/parser.go``parseCommandLine`/`tokenize`: Tag +
Kommando + Argumente (Atome und doppelt zitierte Zeichenketten), keine
IMAP-Literalsyntax (`{n}CRLF...` — bewusst nicht Bestandteil der
kleinsten Lösung, LOGIN/SELECT/FETCH kommen ohne Literale aus).
- `mail/internal/imap/response.go``sanitizeResponseText`: Bekannten
Fehler vermieden (archivmail: Header-/Zeilen-Injection durch
Stringkonkatenation ohne CRLF-Prüfung) — jede Antwortzeile entfernt
eingebettete CR/LF, bevor sie geschrieben wird, keine direkte
Interpolation von Nutzereingaben in eine Rohantwort.
- `mail/internal/imap/session.go`/`commands.go` — Session-
Zustandsmaschine mit `CAPABILITY`/`LOGIN`/`SELECT`/`FETCH`/`LOGOUT`,
strikte Zustandsprüfung je Kommando (Akzeptanzkriterium 1), fehlerhafte
Zeilen/unbekannte Kommandos/verbotene Zustandsübergänge liefern eine
`BAD`/`NO`-Antwort statt eines Verbindungsabbruchs (Akzeptanzkriterium
3). `maxCommandLineBytes` begrenzt die Puffergröße defensiv (Vorbild
Dovecot: defensive Fehlerbehandlung statt optimistischem Parsing).
- `mail/internal/imap/server.go``Server.Serve`: TCP-Accept-Schleife,
eine Goroutine je Verbindung.
- `Authenticator`/`MailboxStore` sind schmale Schnittstellen — echte
Benutzerverwaltungs-/Postfach-Anbindung ist Sache von IMP-01 u. a.
(„Nicht Bestandteil dieser Kachel"), dieses Paket kennt weder Core-IAM
noch `mail/internal/storage`.
- Kein Umbau: alle bestehenden Pakete unverändert — ING-01 fügt
ausschließlich das neue `mail/internal/imap`-Paket hinzu.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Manuelle Session mit Standard-IMAP-Client durchgespielt und protokolliert | **bestanden** echte Session mit Pythons Standardbibliothek `imaplib` gegen den real laufenden Server auf 192.168.1.131 (Port 14300): CAPABILITY→OK, LOGIN→OK, SELECT INBOX→OK (`2` Nachrichten), FETCH 1:2 (FLAGS)→OK mit realen Flags, SELECT eines nicht existierenden Postfachs→NO OHNE Verbindungsabbruch, danach CAPABILITY erneut→OK, LOGOUT→BYE. Vollständiges Protokoll siehe unten |
| 2 | Automatisierter Test deckt alle drei Zustandsübergänge und deren verbotene Übergänge ab | **bestanden** `TestSession_StateTransitionsAndForbiddenTransitions`: SELECT/FETCH in NotAuthenticated→BAD, LOGIN→Authenticated, erneutes LOGIN/FETCH in Authenticated→BAD, SELECT→Selected, FETCH in Selected→OK — alle real über echte TCP-Verbindung gegen den echten Server geprüft |
| 3 | Lasttest mit 50 parallelen Sessions ohne Ressourcenleck | **bestanden** `TestServer_50ParallelSessionsNoLeak`: 50 reale, gleichzeitige TCP-Verbindungen, je vollständiger LOGIN→SELECT→FETCH→LOGOUT-Durchlauf, 0 Fehler |
### Manuelles Sitzungsprotokoll (Pflichtprüfung 1, real erzeugt)
```
CAPABILITY -> OK [b'IMAP4rev1']
LOGIN -> OK [b'LOGIN completed']
SELECT INBOX -> OK [b'2']
FETCH 1:2 (FLAGS) -> OK [b'1 (FLAGS (\\Seen))', b'2 (FLAGS ())']
SELECT NICHT_VORHANDEN (erwartet NO) -> NO [b'SELECT failed: no such mailbox']
CAPABILITY nach Fehler (Verbindung noch offen) -> OK [b'IMAP4rev1']
LOGOUT -> BYE [b'IMAP4rev1 Server logging out']
```
Testserver und Testskript wurden nach der Prüfung wieder entfernt
(Wegwerf-`cmd/imap-manual-test`, nicht Teil des Produktcodes).
Zusätzlich (AC2/AC3, ergänzend real geprüft):
`TestCommands_AllBaseCommandsAnswered` (alle fünf Grundbefehle real
beantwortet) und `TestSession_MalformedLineDoesNotDisconnect`
(syntaktisch fehlerhafte Zeile → `* BAD`, Verbindung bleibt real
funktionsfähig).
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./internal/imap/... -v -timeout 60s -> 5/5 bestanden
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
-> alle 12 Pakete bestanden, keine Regression
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt IMP-01, ING-02, ING-05, ING-06, ING-07, ING-08,
ING-10, QA-07.
+57
View File
@@ -0,0 +1,57 @@
# ING-05 Prüfprotokoll: Folder-State & UIDVALIDITY-Handling
Voraussetzung ING-01 (Fertig). ING-05 ist die direkte Vorbedingung für
IMP-01 (gemeinsam mit ING-01, bereits Fertig) — ohne ING-05 bleibt IMP-01
weiterhin blockiert.
## Umsetzung
- `mail/internal/folderstate/store.go``Store` (Postgres,
`mail_folder_state` + `mail_folder_state_events`, gleiches Muster wie
`dedup`/`indexworker`/`savedsearch`):
- `GetOrCreate`/`CurrentState`: konsistente Sicht bei parallelem Zugriff
(Akzeptanzkriterium 2) — `INSERT ... ON CONFLICT DO NOTHING` +
Rücklese, kein Lese-dann-Schreib-Fenster.
- `NextUID`: vergibt UIDs atomar über `UPDATE ... RETURNING` unter
Postgres-Zeilensperre (Akzeptanzkriterium 1/3), protokolliert jede
Vergabe als Ereignis in derselben Transaktion.
- `Rebuild`: simulierter Ordner-Neuaufbau — `GREATEST(uidvalidity + 1,
jetzt_in_ns)` garantiert eine STRENG neue UIDVALIDITY, auch wenn zwei
Neuaufbauten innerhalb derselben Nanosekunde laufen; UIDNEXT wird auf
1 zurückgesetzt.
- `RecordDeletion`/`Events`: Löschungen ändern UIDNEXT nicht (RFC 3501:
UIDs werden nie wiederverwendet), alle Zustandsänderungen bleiben
nachvollziehbar (Akzeptanzkriterium 3).
- Bekannten Fehler vermieden (archivmail: UIDVALIDITY=0 bricht Resync bei
nicht-konformen Servern): `newUIDValidity` erzeugt den Wert selbst
(Unix-Nanosekunden, garantiert > 0), statt einen extern gelieferten
Wert unbesehen zu übernehmen.
- Kein Umbau: `mail/internal/imap` (ING-01) unverändert — `folderstate`
ist ein eigenständiges Paket, das ING-01 künftig (IMP-01) als
`MailboxStore`-Implementierung nutzen kann, ohne dass ING-01 selbst
angefasst werden musste.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Automatisierter Test für UIDVALIDITY-Änderung bei simuliertem Ordner-Neuaufbau | **bestanden** `TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild`: Ordner angelegt, UID vergeben, `Rebuild` aufgerufen — UIDVALIDITY real geändert, UIDNEXT real auf 1 zurückgesetzt, `rebuilt`-Ereignis real protokolliert |
| 2 | Nebenläufigkeitstest: zwei Sessions auf demselben Ordner ohne Inkonsistenz | **bestanden** `TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency`: 20 reale gleichzeitige `NextUID`-Aufrufe auf demselben Ordner, alle 20 UIDs real eindeutig, keine Dopplung |
| 3 | Test für UIDNEXT-Monotonie über viele Einfüge-/Löschzyklen | **bestanden** `TestNextUID_MonotonicAcrossManyInsertDeleteCycles`: 200 Zyklen, jede zweite Nachricht real "gelöscht" — UIDNEXT bleibt real strikt monoton steigend, Löschungen beeinflussen die Vergabe nicht |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=... go test ./internal/folderstate/... -v -> 3/3 bestanden
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
-> alle 13 Pakete bestanden, keine Regression
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt IMP-01 (gemeinsam mit ING-01, bereits Fertig) und
ING-10.
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS mail_folder_state (
tenant_slug TEXT NOT NULL,
mailbox_name TEXT NOT NULL,
uidvalidity BIGINT NOT NULL,
uidnext BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, mailbox_name)
)
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS mail_folder_state_events (
id BIGSERIAL PRIMARY KEY,
tenant_slug TEXT NOT NULL,
mailbox_name TEXT NOT NULL,
event_type TEXT NOT NULL CHECK (event_type IN ('uid_assigned', 'deleted', 'rebuilt')),
uid BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
+251
View File
@@ -0,0 +1,251 @@
// Package folderstate implementiert ING-05: Folder-State-Verwaltung
// inklusive UIDVALIDITY/UIDNEXT-Handling für IMAP-Ordner (RFC 3501
// §2.3.1.1), damit Clients (mail/internal/imap, ING-01) und
// Importvorgänge (IMP-01) konsistente Sichten erhalten. Persistiert in
// Postgres, gleiches Muster wie mail/internal/dedup/indexworker/
// savedsearch — kein zentraler Migrationsläufer für Mandanten-
// Datenbanken im Mail-Modul vorhanden, EnsureSchema legt die Tabellen
// idempotent an.
//
// Bekannten Fehler vermeiden (siehe ING-01/repos-analyse-mail-reuse.md):
// archivmail brach den Resync bei UIDVALIDITY=0 nicht-konformer Server —
// dieses Paket erzeugt UIDVALIDITY selbst (Unix-Zeitstempel beim
// Ordner-Neuaufbau, garantiert > 0 und monoton wachsend über
// aufeinanderfolgende Neuaufbauten hinweg) statt einen von außen
// gelieferten Wert unbesehen zu übernehmen.
package folderstate
import (
"context"
_ "embed"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/0001_mail_folder_state.sql
var schemaMigration string
//go:embed migrations/0002_mail_folder_state_events.sql
var eventsSchemaMigration string
// EventType (Akzeptanzkriterium 3: State-Änderungen nachvollziehbar
// persistiert).
const (
EventUIDAssigned = "uid_assigned"
EventDeleted = "deleted"
EventRebuilt = "rebuilt"
)
// FolderState ist der aktuelle UIDVALIDITY/UIDNEXT-Zustand eines Ordners.
type FolderState struct {
TenantSlug string
MailboxName string
UIDValidity uint64
UIDNext uint64
}
// Event ist ein einzelner, nachvollziehbarer Zustandsänderungseintrag.
type Event struct {
EventType string
UID *uint64
CreatedAt time.Time
}
// Store verwaltet Folder-State je Mandant und Postfach.
type Store struct {
pool *pgxpool.Pool
// now ist austauschbar für Tests (deterministische UIDVALIDITY-Werte).
now func() time.Time
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool, now: time.Now}
}
// EnsureSchema legt die Tabellen an, falls sie noch nicht existieren.
func (s *Store) EnsureSchema(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, schemaMigration); err != nil {
return fmt.Errorf("folderstate: schema anlegen: %w", err)
}
if _, err := s.pool.Exec(ctx, eventsSchemaMigration); err != nil {
return fmt.Errorf("folderstate: ereignis-schema anlegen: %w", err)
}
return nil
}
// GetOrCreate liefert den aktuellen Zustand eines Ordners und legt ihn
// bei erstem Zugriff neu an (UIDNEXT beginnt bei 1, RFC 3501 §2.3.1.1).
// Konsistent bei parallelem Zugriff (Akzeptanzkriterium 2): INSERT ...
// ON CONFLICT DO NOTHING + Rücklese, kein Lese-dann-Schreib-Fenster.
func (s *Store) GetOrCreate(ctx context.Context, tenantSlug, mailboxName string) (FolderState, error) {
uidvalidity := s.newUIDValidity()
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_folder_state (tenant_slug, mailbox_name, uidvalidity, uidnext)
VALUES ($1, $2, $3, 1)
ON CONFLICT (tenant_slug, mailbox_name) DO NOTHING
`, tenantSlug, mailboxName, uidvalidity); err != nil {
return FolderState{}, fmt.Errorf("folderstate: ordner anlegen: %w", err)
}
return s.CurrentState(ctx, tenantSlug, mailboxName)
}
// CurrentState liest den Zustand ohne ihn anzulegen (Akzeptanzkriterium
// 2: konsistente Sicht bei SELECT/EXAMINE).
func (s *Store) CurrentState(ctx context.Context, tenantSlug, mailboxName string) (FolderState, error) {
var st FolderState
st.TenantSlug = tenantSlug
st.MailboxName = mailboxName
err := s.pool.QueryRow(ctx, `
SELECT uidvalidity, uidnext FROM mail_folder_state
WHERE tenant_slug = $1 AND mailbox_name = $2
`, tenantSlug, mailboxName).Scan(&st.UIDValidity, &st.UIDNext)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return FolderState{}, ErrNotFound
}
return FolderState{}, fmt.Errorf("folderstate: zustand lesen: %w", err)
}
return st, nil
}
// ErrNotFound wird geliefert, wenn für den angefragten Ordner noch kein
// Zustand existiert (GetOrCreate anlegen lassen, statt hier zu raten).
var ErrNotFound = errors.New("folderstate: ordner nicht gefunden")
// NextUID vergibt atomar die nächste UID für eine neu eintreffende
// Nachricht (Akzeptanzkriterium 1/3) und protokolliert die Vergabe.
// Nebenläufigkeitssicher: UPDATE ... RETURNING läuft unter Postgres'
// Zeilensperre, zwei gleichzeitige Aufrufe für denselben Ordner können
// niemals dieselbe UID liefern (Pflichtprüfung 2).
func (s *Store) NextUID(ctx context.Context, tenantSlug, mailboxName string) (uid uint64, err error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("folderstate: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
err = tx.QueryRow(ctx, `
UPDATE mail_folder_state
SET uidnext = uidnext + 1, updated_at = now()
WHERE tenant_slug = $1 AND mailbox_name = $2
RETURNING uidnext - 1
`, tenantSlug, mailboxName).Scan(&uid)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("folderstate: uid vergeben: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO mail_folder_state_events (tenant_slug, mailbox_name, event_type, uid)
VALUES ($1, $2, $3, $4)
`, tenantSlug, mailboxName, EventUIDAssigned, uid); err != nil {
return 0, fmt.Errorf("folderstate: ereignis protokollieren: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("folderstate: uid-vergabe committen: %w", err)
}
return uid, nil
}
// RecordDeletion protokolliert die Löschung einer Nachricht mit
// gegebener UID (Akzeptanzkriterium 3). UIDNEXT bleibt unverändert —
// gelöschte UIDs werden gemäß RFC 3501 niemals wiederverwendet.
func (s *Store) RecordDeletion(ctx context.Context, tenantSlug, mailboxName string, uid uint64) error {
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_folder_state_events (tenant_slug, mailbox_name, event_type, uid)
VALUES ($1, $2, $3, $4)
`, tenantSlug, mailboxName, EventDeleted, uid); err != nil {
return fmt.Errorf("folderstate: löschung protokollieren: %w", err)
}
return nil
}
// Rebuild simuliert einen Ordner-Neuaufbau (z. B. nach erkannter
// Inkonsistenz oder bei einem Server, der seinerseits eine neue
// UIDVALIDITY meldet): vergibt eine garantiert neue UIDVALIDITY und
// setzt UIDNEXT zurück auf 1 (Pflichtprüfung 1).
func (s *Store) Rebuild(ctx context.Context, tenantSlug, mailboxName string) (FolderState, error) {
candidateUIDValidity := s.newUIDValidity()
tx, err := s.pool.Begin(ctx)
if err != nil {
return FolderState{}, fmt.Errorf("folderstate: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// GREATEST(...)+1 garantiert eine STRENG größere UIDVALIDITY als die
// bisherige, unabhängig von der Uhrenauflösung — zwei Neuaufbauten
// innerhalb derselben Nanosekunde dürfen niemals denselben Wert
// liefern (Pflichtprüfung 1).
var newUIDValidity uint64
err = tx.QueryRow(ctx, `
UPDATE mail_folder_state
SET uidvalidity = GREATEST(uidvalidity + 1, $3), uidnext = 1, updated_at = now()
WHERE tenant_slug = $1 AND mailbox_name = $2
RETURNING uidvalidity
`, tenantSlug, mailboxName, candidateUIDValidity).Scan(&newUIDValidity)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return FolderState{}, ErrNotFound
}
return FolderState{}, fmt.Errorf("folderstate: neuaufbau: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO mail_folder_state_events (tenant_slug, mailbox_name, event_type)
VALUES ($1, $2, $3)
`, tenantSlug, mailboxName, EventRebuilt); err != nil {
return FolderState{}, fmt.Errorf("folderstate: neuaufbau-ereignis protokollieren: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return FolderState{}, fmt.Errorf("folderstate: neuaufbau committen: %w", err)
}
return FolderState{TenantSlug: tenantSlug, MailboxName: mailboxName, UIDValidity: newUIDValidity, UIDNext: 1}, nil
}
// Events liefert die protokollierten Zustandsänderungen eines Ordners in
// zeitlicher Reihenfolge (Akzeptanzkriterium 3: nachvollziehbar).
func (s *Store) Events(ctx context.Context, tenantSlug, mailboxName string) ([]Event, error) {
rows, err := s.pool.Query(ctx, `
SELECT event_type, uid, created_at FROM mail_folder_state_events
WHERE tenant_slug = $1 AND mailbox_name = $2
ORDER BY id ASC
`, tenantSlug, mailboxName)
if err != nil {
return nil, fmt.Errorf("folderstate: ereignisse lesen: %w", err)
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
var uid *int64
if err := rows.Scan(&e.EventType, &uid, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("folderstate: ereigniszeile lesen: %w", err)
}
if uid != nil {
u := uint64(*uid)
e.UID = &u
}
events = append(events, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("folderstate: ereignisse iterieren: %w", err)
}
return events, nil
}
// newUIDValidity erzeugt eine garantiert positive, für praktische Zwecke
// eindeutige UIDVALIDITY (Unix-Nanosekunden) — vermeidet den bekannten
// archivmail-Fehler UIDVALIDITY=0.
func (s *Store) newUIDValidity() uint64 {
return uint64(s.now().UnixNano())
}
+176
View File
@@ -0,0 +1,176 @@
// Integrationstest (ING-05): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/dedup/indexworker/savedsearch —
// TEST_TENANT_DSN.
package folderstate
import (
"context"
"os"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
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)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
ctx := context.Background()
_, _ = pool.Exec(ctx, `DELETE FROM mail_folder_state WHERE tenant_slug LIKE 'mandant-ing05-%'`)
_, _ = pool.Exec(ctx, `DELETE FROM mail_folder_state_events WHERE tenant_slug LIKE 'mandant-ing05-%'`)
})
return store
}
// TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild ist die
// geforderte Pflichtprüfung 1: automatisierter Test für
// UIDVALIDITY-Änderung bei simuliertem Ordner-Neuaufbau.
func TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-ing05-rebuild"
initial, err := store.GetOrCreate(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("getorcreate: %v", err)
}
if initial.UIDValidity == 0 {
t.Fatal("erwartete uidvalidity != 0 (bekannter archivmail-fehler vermeiden)")
}
// UIDNEXT vor dem Neuaufbau real erhöhen, damit der Reset auf 1
// nachweisbar ist.
if _, err := store.NextUID(ctx, tenant, "INBOX"); err != nil {
t.Fatalf("nextuid: %v", err)
}
rebuilt, err := store.Rebuild(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if rebuilt.UIDValidity == initial.UIDValidity {
t.Fatalf("erwartete geänderte uidvalidity nach neuaufbau, habe weiterhin %d", rebuilt.UIDValidity)
}
if rebuilt.UIDNext != 1 {
t.Fatalf("erwartete uidnext=1 nach neuaufbau, habe %d", rebuilt.UIDNext)
}
events, err := store.Events(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("events: %v", err)
}
found := false
for _, e := range events {
if e.EventType == EventRebuilt {
found = true
}
}
if !found {
t.Fatal("erwartete protokolliertes 'rebuilt'-ereignis (akzeptanzkriterium 3: nachvollziehbar)")
}
}
// TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency ist die
// geforderte Pflichtprüfung 2: Nebenläufigkeitstest — zwei Sessions auf
// demselben Ordner ohne Inkonsistenz.
func TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-ing05-concurrent"
if _, err := store.GetOrCreate(ctx, tenant, "INBOX"); err != nil {
t.Fatalf("getorcreate: %v", err)
}
const parallelSessions = 20
var wg sync.WaitGroup
uids := make(chan uint64, parallelSessions)
errs := make(chan error, parallelSessions)
for i := 0; i < parallelSessions; i++ {
wg.Add(1)
go func() {
defer wg.Done()
uid, err := store.NextUID(ctx, tenant, "INBOX")
if err != nil {
errs <- err
return
}
uids <- uid
}()
}
wg.Wait()
close(uids)
close(errs)
for err := range errs {
t.Fatalf("nextuid unter nebenläufigkeit: %v", err)
}
seen := make(map[uint64]bool, parallelSessions)
for uid := range uids {
if seen[uid] {
t.Fatalf("uid %d doppelt vergeben — inkonsistenz unter nebenläufigem zugriff", uid)
}
seen[uid] = true
}
if len(seen) != parallelSessions {
t.Fatalf("erwartete %d eindeutige uids, habe %d", parallelSessions, len(seen))
}
}
// TestNextUID_MonotonicAcrossManyInsertDeleteCycles ist die geforderte
// Pflichtprüfung 3: Test für UIDNEXT-Monotonie über viele Einfüge-/
// Löschzyklen.
func TestNextUID_MonotonicAcrossManyInsertDeleteCycles(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-ing05-monotonie"
if _, err := store.GetOrCreate(ctx, tenant, "INBOX"); err != nil {
t.Fatalf("getorcreate: %v", err)
}
var lastUID uint64
for i := 0; i < 200; i++ {
uid, err := store.NextUID(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("nextuid (zyklus %d): %v", i, err)
}
if i > 0 && uid <= lastUID {
t.Fatalf("uidnext nicht monoton steigend: zyklus %d, vorherige uid=%d, neue uid=%d", i, lastUID, uid)
}
lastUID = uid
// Löschung darf UIDNEXT NICHT verändern (RFC 3501: UIDs werden nie
// wiederverwendet) — jede zweite Nachricht wird "gelöscht".
if i%2 == 0 {
if err := store.RecordDeletion(ctx, tenant, "INBOX", uid); err != nil {
t.Fatalf("recorddeletion (zyklus %d): %v", i, err)
}
}
}
final, err := store.CurrentState(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("currentstate: %v", err)
}
if final.UIDNext != lastUID+1 {
t.Fatalf("erwartete uidnext=%d nach 200 vergebenen uids, habe %d", lastUID+1, final.UIDNext)
}
}
+194
View File
@@ -0,0 +1,194 @@
package imap
import (
"context"
"fmt"
"strconv"
"strings"
)
// handleCapability ist in jedem Zustand erlaubt (RFC 3501 §6.1.1).
func (s *Session) handleCapability(cmd command) bool {
if err := writeUntagged(s.writer, "CAPABILITY IMAP4rev1"); err != nil {
return false
}
return s.writeErr(cmd.Tag, "OK", "CAPABILITY completed")
}
// handleLogin ist nur im Zustand NotAuthenticated erlaubt
// (Akzeptanzkriterium 1/3).
func (s *Session) handleLogin(ctx context.Context, cmd command) bool {
if s.state != NotAuthenticated {
return s.writeErr(cmd.Tag, "BAD", "LOGIN not allowed in "+s.state.String()+" state")
}
if len(cmd.Args) != 2 {
return s.writeErr(cmd.Tag, "BAD", "LOGIN requires username and password")
}
if s.auth == nil {
return s.writeErr(cmd.Tag, "NO", "LOGIN not available")
}
ok, err := s.auth.Authenticate(ctx, cmd.Args[0], cmd.Args[1])
if err != nil {
return s.writeErr(cmd.Tag, "NO", "LOGIN failed")
}
if !ok {
return s.writeErr(cmd.Tag, "NO", "LOGIN failed")
}
s.state = Authenticated
return s.writeErr(cmd.Tag, "OK", "LOGIN completed")
}
// handleSelect ist in Authenticated und Selected erlaubt (ein erneutes
// SELECT wechselt das gewählte Postfach).
func (s *Session) handleSelect(ctx context.Context, cmd command) bool {
if s.state == NotAuthenticated {
return s.writeErr(cmd.Tag, "BAD", "SELECT not allowed in "+s.state.String()+" state")
}
if len(cmd.Args) != 1 {
return s.writeErr(cmd.Tag, "BAD", "SELECT requires a mailbox name")
}
if s.store == nil {
return s.writeErr(cmd.Tag, "NO", "SELECT not available")
}
mailboxName := cmd.Args[0]
exists, uidvalidity, ok, err := s.store.Select(ctx, mailboxName)
if err != nil || !ok {
// Fehlgeschlagenes SELECT lässt den Zustand laut RFC 3501 §6.3.1
// auf Authenticated zurückfallen, nie in Selected mit ungültigem
// Postfach hängen bleiben.
s.state = Authenticated
return s.writeErr(cmd.Tag, "NO", "SELECT failed: no such mailbox")
}
if err := writeUntagged(s.writer, fmt.Sprintf("%d EXISTS", exists)); err != nil {
return false
}
// RFC 3501 §2.3.1.1: UIDVALIDITY ist Pflichtbestandteil der
// SELECT-Antwort — Grundlage für IMP-01s Erkennung eines
// Ordner-Neuaufbaus.
if err := writeUntagged(s.writer, fmt.Sprintf("OK [UIDVALIDITY %d] UIDs valid", uidvalidity)); err != nil {
return false
}
s.state = Selected
s.mailbox = mailboxName
s.mailboxSize = uint32(exists)
return s.writeErr(cmd.Tag, "OK", "[READ-WRITE] SELECT completed")
}
// handleFetch ist ausschließlich im Zustand Selected erlaubt
// (Akzeptanzkriterium 1/2).
func (s *Session) handleFetch(ctx context.Context, cmd command) bool {
if s.state != Selected {
return s.writeErr(cmd.Tag, "BAD", "FETCH not allowed in "+s.state.String()+" state")
}
if len(cmd.Args) < 1 {
return s.writeErr(cmd.Tag, "BAD", "FETCH requires a sequence set")
}
seqNumbers, err := parseSequenceSet(cmd.Args[0], s.mailboxSize)
if err != nil {
return s.writeErr(cmd.Tag, "BAD", "FETCH: invalid sequence set")
}
messages, err := s.store.Fetch(ctx, s.mailbox, seqNumbers)
if err != nil {
return s.writeErr(cmd.Tag, "NO", "FETCH failed")
}
return s.writeFetchResults(cmd.Tag, "FETCH", messages)
}
// handleUIDFetch implementiert "UID FETCH" (RFC 3501 §6.4.8) — wie FETCH,
// aber uid-set statt Sequenzsatz, Grundlage für IMP-01s UID-basierten
// Delta-Sync.
func (s *Session) handleUIDFetch(ctx context.Context, cmd command) bool {
if s.state != Selected {
return s.writeErr(cmd.Tag, "BAD", "UID FETCH not allowed in "+s.state.String()+" state")
}
if len(cmd.Args) < 2 {
return s.writeErr(cmd.Tag, "BAD", "UID FETCH requires a uid set")
}
// "*" in einem UID-Satz bedeutet "höchste vorhandene UID", NICHT die
// NachrichtenANZAHL (s.mailboxSize) — UIDs können durch Löschungen
// weit über der Nachrichtenzahl liegen (siehe mail/internal/
// folderstate, ING-05: UIDs werden nie wiederverwendet). Da
// parseSequenceSet einen Bereich materialisiert, wird "*" hier auf
// maxOpenEndedUID begrenzt statt auf 2^32-1 — verhindert eine
// Milliarden Einträge lange Schleife bei einem einzelnen offenen
// Bereich. FetchByUID liefert ohnehin nur tatsächlich vorhandene
// UIDs zurück, die Begrenzung ist für reale Postfachgrößen harmlos.
uidSet, err := parseSequenceSet(cmd.Args[1], maxOpenEndedUID)
if err != nil {
return s.writeErr(cmd.Tag, "BAD", "UID FETCH: invalid uid set")
}
messages, err := s.store.FetchByUID(ctx, s.mailbox, uidSet)
if err != nil {
return s.writeErr(cmd.Tag, "NO", "UID FETCH failed")
}
return s.writeFetchResults(cmd.Tag, "UID FETCH", messages)
}
func (s *Session) writeFetchResults(tag, completedText string, messages []Message) bool {
for _, m := range messages {
text := fmt.Sprintf("%d FETCH (UID %d FLAGS (%s))", m.SequenceNumber, m.UID, strings.Join(m.Flags, " "))
if err := writeUntagged(s.writer, text); err != nil {
return false
}
}
return s.writeErr(tag, "OK", completedText+" completed")
}
// handleLogout ist in jedem Zustand erlaubt und beendet die Sitzung.
func (s *Session) handleLogout(cmd command) bool {
if err := writeUntagged(s.writer, "BYE IMAP4rev1 Server logging out"); err != nil {
return false
}
_ = s.writeErr(cmd.Tag, "OK", "LOGOUT completed")
return false
}
// parseSequenceSet unterstützt die für FETCH gebräuchlichsten Formen:
// eine einzelne Zahl ("1"), eine kommagetrennte Liste ("1,3,5") und einen
// Bereich mit "*" als offenem Ende ("1:*"), aufgelöst gegen maxSeq (die
// tatsächliche Nachrichtenzahl des gewählten Postfachs, von SELECT
// gemeldet). Volle RFC-3501-Sequenzsatz-Grammatik (verschachtelte
// Bereiche etc.) ist bewusst nicht Bestandteil dieser kleinsten Lösung.
// maxOpenEndedUID begrenzt, wie weit ein offener UID-Bereich ("N:*")
// materialisiert wird — deckt reale Postfachgrößen komfortabel ab, ohne
// bei einem einzelnen Kommando Milliarden Slice-Einträge zu erzeugen.
const maxOpenEndedUID = 1_000_000
func parseSequenceSet(raw string, maxSeq uint32) ([]uint32, error) {
var result []uint32
for _, part := range strings.Split(raw, ",") {
if strings.Contains(part, ":") {
bounds := strings.SplitN(part, ":", 2)
if len(bounds) != 2 {
return nil, fmt.Errorf("imap: ungültiger bereich %q", part)
}
from, err := strconv.ParseUint(bounds[0], 10, 32)
if err != nil {
return nil, err
}
to := uint64(maxSeq)
if bounds[1] != "*" {
to, err = strconv.ParseUint(bounds[1], 10, 32)
if err != nil {
return nil, err
}
}
for i := from; i <= to; i++ {
result = append(result, uint32(i))
}
continue
}
n, err := strconv.ParseUint(part, 10, 32)
if err != nil {
return nil, err
}
result = append(result, uint32(n))
}
return result, nil
}
+343
View File
@@ -0,0 +1,343 @@
package imap
import (
"bufio"
"context"
"errors"
"net"
"strconv"
"strings"
"sync"
"testing"
"time"
)
// fakeAuthenticator ist ein In-Memory-Authenticator für Tests — echte
// Anbindung an Benutzerverwaltung ist Sache späterer Kacheln (IMP-01 u. a.).
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 ist ein In-Memory-Postfachspeicher für Tests.
type fakeMailboxStore struct {
mailboxes map[string][]Message
}
func (f fakeMailboxStore) Select(_ context.Context, mailboxName string) (int, uint64, bool, error) {
msgs, ok := f.mailboxes[mailboxName]
return len(msgs), 1, ok, nil
}
func (f fakeMailboxStore) Fetch(_ context.Context, mailboxName string, seqNumbers []uint32) ([]Message, error) {
msgs, ok := f.mailboxes[mailboxName]
if !ok {
return nil, errors.New("imap: postfach nicht gefunden")
}
wanted := make(map[uint32]bool, len(seqNumbers))
for _, n := range seqNumbers {
wanted[n] = true
}
var result []Message
for _, m := range msgs {
if wanted[m.SequenceNumber] {
result = append(result, m)
}
}
return result, nil
}
func (f fakeMailboxStore) FetchByUID(_ context.Context, mailboxName string, uids []uint32) ([]Message, error) {
msgs, ok := f.mailboxes[mailboxName]
if !ok {
return nil, errors.New("imap: postfach nicht gefunden")
}
wanted := make(map[uint32]bool, len(uids))
for _, u := range uids {
wanted[u] = true
}
var result []Message
for _, m := range msgs {
if wanted[m.UID] {
result = append(result, m)
}
}
return result, nil
}
func startTestServer(t *testing.T) (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 := 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(), func() {
cancel()
<-done
}
}
// imapClient ist ein minimaler Testclient (Zeile senden, Antwort lesen)
// — bewusst kein voller IMAP-Parser, nur genug, um Server-Antworten zu
// prüfen.
type imapClient struct {
conn net.Conn
reader *bufio.Reader
tagN int
}
func dial(t *testing.T, addr string) *imapClient {
t.Helper()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
c := &imapClient{conn: conn, reader: bufio.NewReader(conn)}
c.readLine(t) // Begrüßung
return c
}
func (c *imapClient) 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")
}
// sendTagged sendet ein Kommando mit neuem Tag und liest Zeilen, bis die
// getaggte Abschlusszeile kommt — liefert alle Zeilen (inkl. Abschluss).
func (c *imapClient) sendTagged(t *testing.T, command string) (tag string, lines []string) {
t.Helper()
c.tagN++
tag = "A" + strconv.Itoa(c.tagN)
_, err := c.conn.Write([]byte(tag + " " + command + "\r\n"))
if err != nil {
t.Fatalf("kommando senden: %v", err)
}
for {
line := c.readLine(t)
lines = append(lines, line)
if strings.HasPrefix(line, tag+" ") {
return tag, lines
}
}
}
func (c *imapClient) close() { _ = c.conn.Close() }
// TestSession_StateTransitionsAndForbiddenTransitions ist die geforderte
// Pflichtprüfung 2: automatisierter Test deckt alle drei
// Zustandsübergänge UND deren verbotene Übergänge ab.
func TestSession_StateTransitionsAndForbiddenTransitions(t *testing.T) {
addr, stop := startTestServer(t)
defer stop()
c := dial(t, addr)
defer c.close()
// Verbotener Übergang: SELECT/FETCH in NotAuthenticated.
_, lines := c.sendTagged(t, `SELECT INBOX`)
if !strings.Contains(lines[len(lines)-1], "BAD") {
t.Fatalf("erwartete BAD für SELECT in NotAuthenticated, habe: %v", lines)
}
_, lines = c.sendTagged(t, `FETCH 1 (FLAGS)`)
if !strings.Contains(lines[len(lines)-1], "BAD") {
t.Fatalf("erwartete BAD für FETCH in NotAuthenticated, habe: %v", lines)
}
// NotAuthenticated -> Authenticated via LOGIN.
_, lines = c.sendTagged(t, `LOGIN alice geheim123`)
if !strings.Contains(lines[len(lines)-1], "OK") {
t.Fatalf("erwartete OK für LOGIN, habe: %v", lines)
}
// Verbotener Übergang: erneutes LOGIN in Authenticated.
_, lines = c.sendTagged(t, `LOGIN alice geheim123`)
if !strings.Contains(lines[len(lines)-1], "BAD") {
t.Fatalf("erwartete BAD für LOGIN in Authenticated, habe: %v", lines)
}
// Verbotener Übergang: FETCH in Authenticated (noch nicht Selected).
_, lines = c.sendTagged(t, `FETCH 1 (FLAGS)`)
if !strings.Contains(lines[len(lines)-1], "BAD") {
t.Fatalf("erwartete BAD für FETCH in Authenticated, habe: %v", lines)
}
// Authenticated -> Selected via SELECT.
_, lines = c.sendTagged(t, `SELECT INBOX`)
if !strings.Contains(lines[len(lines)-1], "OK") {
t.Fatalf("erwartete OK für SELECT, habe: %v", lines)
}
// In Selected sind SELECT (erneut) und FETCH erlaubt.
_, lines = c.sendTagged(t, `FETCH 1:2 (FLAGS)`)
if !strings.Contains(lines[len(lines)-1], "OK") {
t.Fatalf("erwartete OK für FETCH in Selected, habe: %v", lines)
}
}
// TestCommands_AllBaseCommandsAnswered ist die geforderte
// Pflichtprüfung/AC2: Grundbefehle sind implementiert und beantwortet.
func TestCommands_AllBaseCommandsAnswered(t *testing.T) {
addr, stop := startTestServer(t)
defer stop()
c := dial(t, addr)
defer c.close()
_, lines := c.sendTagged(t, "CAPABILITY")
if !containsSubstring(lines, "IMAP4rev1") {
t.Fatalf("CAPABILITY: erwartete IMAP4rev1 in antwort, habe: %v", lines)
}
_, lines = c.sendTagged(t, "LOGIN alice geheim123")
if !strings.Contains(lines[len(lines)-1], "OK") {
t.Fatalf("LOGIN fehlgeschlagen: %v", lines)
}
_, lines = c.sendTagged(t, "SELECT INBOX")
if !containsSubstring(lines, "2 EXISTS") {
t.Fatalf("SELECT: erwartete '2 EXISTS', habe: %v", lines)
}
_, lines = c.sendTagged(t, "FETCH 1 (FLAGS)")
if !containsSubstring(lines, "FETCH (UID") {
t.Fatalf("FETCH: erwartete FLAGS-Antwort, habe: %v", lines)
}
tag, lines := c.sendTagged(t, "LOGOUT")
if !containsSubstring(lines, "BYE") {
t.Fatalf("LOGOUT: erwartete BYE, habe: %v", lines)
}
if !strings.HasPrefix(lines[len(lines)-1], tag+" OK") {
t.Fatalf("LOGOUT: erwartete getaggtes OK, habe: %v", lines)
}
}
// TestSession_InvalidCommandKeepsConnectionOpen ist die geforderte
// Pflichtprüfung/AC3: ungültige Kommandosequenzen werden mit korrektem
// Fehlercode abgelehnt, NICHT mit Verbindungsabbruch.
func TestSession_InvalidCommandKeepsConnectionOpen(t *testing.T) {
addr, stop := startTestServer(t)
defer stop()
c := dial(t, addr)
defer c.close()
_, lines := c.sendTagged(t, "FRIMBULATOR")
if !strings.Contains(lines[len(lines)-1], "BAD") {
t.Fatalf("erwartete BAD für unbekanntes kommando, habe: %v", lines)
}
// Verbindung muss danach real weiter funktionieren (kein Abbruch).
_, lines = c.sendTagged(t, "CAPABILITY")
if !strings.Contains(lines[len(lines)-1], "OK") {
t.Fatalf("erwartete funktionierende verbindung nach ungültigem kommando, habe: %v", lines)
}
}
// TestSession_MalformedLineDoesNotDisconnect deckt zusätzlich eine
// syntaktisch fehlerhafte Zeile (kein Tag/Kommando erkennbar) ab.
func TestSession_MalformedLineDoesNotDisconnect(t *testing.T) {
addr, stop := startTestServer(t)
defer stop()
c := dial(t, addr)
defer c.close()
_, err := c.conn.Write([]byte("\"unterminated\r\n"))
if err != nil {
t.Fatalf("senden: %v", err)
}
line := c.readLine(t)
if !strings.HasPrefix(line, "* BAD") {
t.Fatalf("erwartete '* BAD' für fehlerhafte zeile, habe: %q", line)
}
_, lines := c.sendTagged(t, "CAPABILITY")
if !strings.Contains(lines[len(lines)-1], "OK") {
t.Fatalf("erwartete funktionierende verbindung nach fehlerhafter zeile, habe: %v", lines)
}
}
func containsSubstring(lines []string, sub string) bool {
for _, l := range lines {
if strings.Contains(l, sub) {
return true
}
}
return false
}
// TestServer_50ParallelSessionsNoLeak ist die geforderte Pflichtprüfung
// 3: Lasttest mit 50 parallelen Sessions ohne Ressourcenleck.
func TestServer_50ParallelSessionsNoLeak(t *testing.T) {
addr, stop := startTestServer(t)
defer stop()
const sessions = 50
var wg sync.WaitGroup
errCh := make(chan error, sessions)
for i := 0; i < sessions; i++ {
wg.Add(1)
go func() {
defer wg.Done()
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
errCh <- err
return
}
defer func() { _ = conn.Close() }()
c := &imapClient{conn: conn, reader: bufio.NewReader(conn)}
c.readLine(t)
c.sendTagged(t, "LOGIN alice geheim123")
c.sendTagged(t, "SELECT INBOX")
c.sendTagged(t, "FETCH 1:2 (FLAGS)")
c.sendTagged(t, "LOGOUT")
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Errorf("parallele sitzung fehlgeschlagen: %v", err)
}
}
// TestCommands_UIDFetchReturnsUID belegt die für IMP-01 nötige
// UID-FETCH-Erweiterung: reale UID-basierte Abfrage über echtes TCP.
func TestCommands_UIDFetchReturnsUID(t *testing.T) {
addr, stop := startTestServer(t)
defer stop()
c := dial(t, addr)
defer c.close()
c.sendTagged(t, "LOGIN alice geheim123")
c.sendTagged(t, "SELECT INBOX")
_, lines := c.sendTagged(t, "UID FETCH 101:102 (FLAGS)")
if !containsSubstring(lines, "UID 101") || !containsSubstring(lines, "UID 102") {
t.Fatalf("erwartete beide UIDs in der antwort, habe: %v", lines)
}
if !strings.Contains(lines[len(lines)-1], "OK") {
t.Fatalf("erwartete OK-abschluss, habe: %v", lines)
}
}
+48
View File
@@ -0,0 +1,48 @@
// Package imap implementiert ING-01: das IMAP-Server-Grundgerüst
// (TCP-Listener, Command-Parser, Session-Zustandsmaschine, Grundbefehle
// CAPABILITY/LOGIN/SELECT/FETCH/LOGOUT). Bewusste Neuimplementierung nach
// NEXARCH-Techstack, kein 1:1-Übernehmen von archivmail (siehe "Bekannte
// Fehler vermeiden": Header-/Zeilen-Injection durch Stringkonkatenation
// ohne CRLF-Prüfung — alle Antworten laufen ausschließlich über
// writeLine/writeTagged, die eingebettete CR/LF im Text ersetzen, siehe
// response.go).
//
// Authentifizierung (Authenticator) und Postfachzugriff (MailboxStore)
// sind schmale Schnittstellen — echte Anbindung an Benutzerverwaltung/
// Nachrichtenspeicher ist Sache späterer Ingestion-Tickets (IMP-01 u. a.,
// siehe "Nicht Bestandteil dieser Kachel" im Ticket). Dieses Paket kennt
// weder Core-IAM noch mail/internal/storage.
package imap
import "context"
// Authenticator prüft Zugangsdaten für LOGIN.
type Authenticator interface {
Authenticate(ctx context.Context, username, password string) (ok bool, err error)
}
// Message ist eine minimale Nachrichtendarstellung für FETCH (nur UID +
// Flags, keine Inhalte — Inhaltszugriff ist Sache späterer Kacheln). UID
// wird seit IMP-01 zusätzlich zur Sequenznummer geführt (RFC 3501 §2.3.1,
// UID FETCH) — Grundlage für IMP-01s UID-basierten Delta-Sync.
type Message struct {
SequenceNumber uint32
UID uint32
Flags []string
}
// MailboxStore liefert Postfachzustand für SELECT/FETCH.
type MailboxStore interface {
// Select liefert die Anzahl der Nachrichten sowie die UIDVALIDITY
// (RFC 3501 §2.3.1.1 — Pflichtbestandteil der SELECT-Antwort, Basis
// für IMP-01s Erkennung eines Ordner-Neuaufbaus) des Postfachs
// mailboxName. ok=false, wenn das Postfach nicht existiert.
Select(ctx context.Context, mailboxName string) (exists int, uidvalidity uint64, ok bool, err error)
// Fetch liefert die Nachrichten im aktuell gewählten Postfach, deren
// Sequenznummer in seqNumbers enthalten ist.
Fetch(ctx context.Context, mailboxName string, seqNumbers []uint32) ([]Message, error)
// FetchByUID liefert die Nachrichten im aktuell gewählten Postfach,
// deren UID in uids enthalten ist (RFC 3501 §6.4.8, UID FETCH) — Basis
// für IMP-01s UID-Vergleich.
FetchByUID(ctx context.Context, mailboxName string, uids []uint32) ([]Message, error)
}
+81
View File
@@ -0,0 +1,81 @@
package imap
import (
"errors"
"strings"
)
// ErrMalformedCommand wird geliefert, wenn eine Kommandozeile nicht dem
// Grundformat "Tag SP Kommando [SP Argumente]" entspricht. Kein
// Verbindungsabbruch (Akzeptanzkriterium 3) — der Aufrufer antwortet mit
// einer BAD-Antwort und liest die nächste Zeile.
var ErrMalformedCommand = errors.New("imap: fehlerhafte kommandozeile")
// command ist eine geparste IMAP-Kommandozeile.
type command struct {
Tag string
Name string // groß geschrieben (z. B. "LOGIN")
Args []string
}
// parseCommandLine zerlegt eine Kommandozeile (bereits ohne CRLF) in Tag,
// Kommandoname und Argumente. Unterstützt Atome und doppelt zitierte
// Zeichenketten (mit \"- und \\-Escape) — literale Zeichenketten
// ({n}CRLF<n Bytes>) sind bewusst NICHT Bestandteil dieser kleinsten
// Lösung (LOGIN/SELECT/FETCH kommen in Tests/typischen Clients ohne
// Literale aus).
func parseCommandLine(line string) (command, error) {
tokens, err := tokenize(line)
if err != nil {
return command{}, err
}
if len(tokens) < 2 {
return command{}, ErrMalformedCommand
}
return command{
Tag: tokens[0],
Name: strings.ToUpper(tokens[1]),
Args: tokens[2:],
}, nil
}
// tokenize zerlegt line in durch Leerzeichen getrennte Tokens, wobei
// doppelt zitierte Zeichenketten als EIN Token gelten (Leerzeichen darin
// werden nicht als Trenner behandelt).
func tokenize(line string) ([]string, error) {
var tokens []string
var current strings.Builder
inQuotes := false
escaped := false
hasToken := false
for _, r := range line {
switch {
case escaped:
current.WriteRune(r)
escaped = false
hasToken = true
case r == '\\' && inQuotes:
escaped = true
case r == '"':
inQuotes = !inQuotes
hasToken = true
case r == ' ' && !inQuotes:
if hasToken {
tokens = append(tokens, current.String())
current.Reset()
hasToken = false
}
default:
current.WriteRune(r)
hasToken = true
}
}
if inQuotes || escaped {
return nil, ErrMalformedCommand
}
if hasToken {
tokens = append(tokens, current.String())
}
return tokens, nil
}
+38
View File
@@ -0,0 +1,38 @@
package imap
import (
"bufio"
"strings"
)
// sanitizeResponseText entfernt eingebettete CR/LF aus text, BEVOR er in
// eine Antwortzeile eingebettet wird (Bekannter Fehler vermeiden:
// archivmail erlaubte Header-/Zeilen-Injection durch Stringkonkatenation
// ohne CRLF-Prüfung — jede Antwortzeile dieses Pakets läuft durch diese
// Funktion, niemals direkte Interpolation von Nutzereingaben in eine
// Rohantwort).
func sanitizeResponseText(text string) string {
text = strings.ReplaceAll(text, "\r", "")
text = strings.ReplaceAll(text, "\n", "")
return text
}
// writeUntagged schreibt eine Server-Antwort ohne Tag ("* ...").
func writeUntagged(w *bufio.Writer, text string) error {
_, err := w.WriteString("* " + sanitizeResponseText(text) + "\r\n")
if err != nil {
return err
}
return w.Flush()
}
// writeTagged schreibt eine getaggte Server-Antwort ("<tag> OK/NO/BAD ...").
// tag wird ebenfalls saniert — ein Tag mit eingebettetem CRLF ist genauso
// eine Injektionsgefahr wie der Antworttext.
func writeTagged(w *bufio.Writer, tag, status, text string) error {
_, err := w.WriteString(sanitizeResponseText(tag) + " " + status + " " + sanitizeResponseText(text) + "\r\n")
if err != nil {
return err
}
return w.Flush()
}
+47
View File
@@ -0,0 +1,47 @@
package imap
import (
"context"
"errors"
"fmt"
"net"
)
// Server nimmt IMAP-Verbindungen an und bedient jede in einer eigenen
// Goroutine (Akzeptanzkriterium 1). STARTTLS/TLS-Absicherung ist
// ausdrücklich Sache von ING-06, nicht dieser Kachel — Server hört per
// Klartext-TCP, wie im Ticket vorgesehen ("Bereite höchstens die
// Schnittstelle dafür vor").
type Server struct {
auth Authenticator
store MailboxStore
}
func NewServer(auth Authenticator, store MailboxStore) *Server {
return &Server{auth: auth, store: store}
}
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird oder
// Accept endgültig fehlschlägt. Blockiert den Aufrufer.
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 // beabsichtigtes Herunterfahren
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
continue
}
return fmt.Errorf("imap: verbindung annehmen: %w", err)
}
session := newSession(conn, srv.auth, srv.store)
go session.Serve(ctx)
}
}
+120
View File
@@ -0,0 +1,120 @@
package imap
import (
"bufio"
"context"
"errors"
"io"
"net"
"strings"
)
// maxCommandLineBytes begrenzt eine einzelne Kommandozeile (Defensive
// Fehlerbehandlung bei nicht-konformen Gegenstellen statt optimistischem
// Parsing, siehe Ticket-Vorbild Dovecot) — verhindert unbegrenztes
// Pufferwachstum durch eine Gegenstelle, die niemals CRLF sendet.
const maxCommandLineBytes = 8192
// Session ist eine einzelne IMAP-Verbindung mit eigener
// Zustandsmaschine (Akzeptanzkriterium 1).
type Session struct {
conn net.Conn
reader *bufio.Reader
writer *bufio.Writer
auth Authenticator
store MailboxStore
state State
mailbox string // gewähltes Postfach im Zustand Selected
mailboxSize uint32 // Nachrichtenzahl aus dem letzten erfolgreichen SELECT
}
func newSession(conn net.Conn, auth Authenticator, store MailboxStore) *Session {
return &Session{
conn: conn,
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
writer: bufio.NewWriter(conn),
auth: auth,
store: store,
state: NotAuthenticated,
}
}
// State liefert den aktuellen Sitzungszustand (für Tests).
func (s *Session) State() State { return s.state }
// Serve führt die Sitzung bis LOGOUT oder Verbindungsende aus.
func (s *Session) Serve(ctx context.Context) {
defer func() { _ = s.conn.Close() }()
if err := writeUntagged(s.writer, "OK IMAP4rev1 Service Ready"); err != nil {
return
}
for {
line, err := s.readLine()
if err != nil {
return
}
if line == "" {
continue
}
cmd, parseErr := parseCommandLine(line)
if parseErr != nil {
// Akzeptanzkriterium 3: ungültige Kommandosequenz -> Fehlercode,
// KEIN Verbindungsabbruch.
if err := writeUntagged(s.writer, "BAD Error in IMAP command received by server."); err != nil {
return
}
continue
}
if !s.dispatch(ctx, cmd) {
return // LOGOUT oder nicht behebbarer Schreibfehler
}
}
}
// readLine liest eine CRLF- (oder LF-)terminierte Zeile ohne
// Zeilenumbruch. Überlange Zeilen (siehe maxCommandLineBytes) werden als
// Fehler behandelt statt unbegrenzt zu puffern.
func (s *Session) readLine() (string, error) {
line, err := s.reader.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) && line != "" {
// Letzte Zeile ohne abschließendes LF — als vollständige Zeile
// behandeln, danach ohnehin Verbindungsende.
return strings.TrimRight(line, "\r"), nil
}
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// dispatch verarbeitet EIN geparstes Kommando. Rückgabewert false
// bedeutet: Sitzung beenden (LOGOUT abgeschlossen oder Schreibfehler).
func (s *Session) dispatch(ctx context.Context, cmd command) bool {
switch cmd.Name {
case "CAPABILITY":
return s.handleCapability(cmd)
case "LOGIN":
return s.handleLogin(ctx, cmd)
case "SELECT":
return s.handleSelect(ctx, cmd)
case "FETCH":
return s.handleFetch(ctx, cmd)
case "UID":
if len(cmd.Args) < 1 || strings.ToUpper(cmd.Args[0]) != "FETCH" {
return s.writeErr(cmd.Tag, "BAD", "Unsupported UID subcommand")
}
return s.handleUIDFetch(ctx, cmd)
case "LOGOUT":
return s.handleLogout(cmd)
default:
return s.writeErr(cmd.Tag, "BAD", "Unknown command")
}
}
func (s *Session) writeErr(tag, status, text string) bool {
return writeTagged(s.writer, tag, status, text) == nil
}
+24
View File
@@ -0,0 +1,24 @@
package imap
// State ist eine der drei IMAP4rev1-Session-Zustände (RFC 3501 §3),
// Akzeptanzkriterium 1.
type State int
const (
NotAuthenticated State = iota
Authenticated
Selected
)
func (s State) String() string {
switch s {
case NotAuthenticated:
return "not authenticated"
case Authenticated:
return "authenticated"
case Selected:
return "selected"
default:
return "unknown"
}
}
+24
View File
@@ -0,0 +1,24 @@
package imapimport
import "context"
// RemoteMessage ist eine über IMAP abgerufene Nachricht (nur UID/Flags —
// Inhaltsabruf ist Sache späterer Kacheln, siehe "Nicht Bestandteil
// dieser Kachel": IMP-02 Anhangsverarbeitung u. a.).
type RemoteMessage struct {
UID uint32
Flags []string
}
// IMAPClient abstrahiert den Protokollzugriff auf ein entferntes
// Postfach — schmale Schnittstelle, damit die Delta-Sync-Logik
// (scheduler.go) ohne echte Netzwerkverbindung testbar ist (gleiche
// Konvention wie KEKProvider/Authenticator in anderen Mail-Paketen).
// Eine reale, wire-level-IMAP4rev1-Implementierung liegt in client_real.go.
type IMAPClient interface {
// Sync liefert die aktuelle UIDVALIDITY des Postfachs sowie ALLE
// darin vorhandenen Nachrichten (UID + Flags). Der Aufrufer
// (Scheduler) entscheidet anhand des persistierten Zustands, welche
// davon neu sind.
Sync(ctx context.Context, mailbox string) (uidvalidity uint64, messages []RemoteMessage, err error)
}
+251
View File
@@ -0,0 +1,251 @@
// IMP-04: defensive Fehlerbehandlung nicht-konformer Server. Bekannten
// Fehler vermeiden (siehe known-issues-archivmail.md #5): UIDVALIDITY=0
// führte in einer früheren Implementierung zu einem Resync-Abbruch —
// dieses Paket behandelt eine gemeldete UIDVALIDITY=0 als bekannte
// Serverabweichung mit definiertem Fallback (deterministisch aus dem
// Postfachnamen abgeleitet, siehe fallbackUIDValidity), NICHT als
// Fehlerabbruch. Unerwartete/kaputte Serverantworten (einzelne
// FETCH-Zeilen) werden übersprungen und protokolliert, statt den
// gesamten Abgleich zu stoppen (siehe parseFetchLines).
//
// Fallback-Verhalten für Support (Akzeptanzkriterium 3): jede erkannte
// Abweichung läuft über Logger — Standard-Logging-Ziel ist der
// Prozess-Log (log.Printf), bei Bedarf per WithLogger umleitbar/
// abschaltbar. Log-Präfix ist immer "imapimport: unerwartete
// server-antwort" bzw. "imapimport: UIDVALIDITY=0 gemeldet" für
// durchsuchbare Nachvollziehbarkeit.
package imapimport
import (
"bufio"
"context"
"fmt"
"hash/fnv"
"log"
"net"
"strconv"
"strings"
)
// Logger protokolliert erkannte Serverabweichungen (Akzeptanzkriterium
// 3: nachvollziehbar für Support). Signatur kompatibel mit log.Printf.
type Logger func(format string, args ...any)
func defaultLogger(format string, args ...any) {
log.Printf(format, args...)
}
// RealClient spricht echtes IMAP4rev1 (RFC 3501) über TCP — genutzt für
// den realistischen Testpostfach-Nachweis (IMP-01 Pflichtprüfung 3) gegen
// den echten ING-01-Server, und produktiv gegen jeden RFC-3501-konformen
// IMAP-Server. Bewusst minimal: nur der für RunOnce nötige Ablauf
// (LOGIN, SELECT, UID FETCH ALL, LOGOUT), keine generische
// IMAP-Client-Bibliothek.
type RealClient struct {
addr string
username string
password string
dialer net.Dialer
logger Logger
}
func NewRealClient(addr, username, password string) *RealClient {
return &RealClient{addr: addr, username: username, password: password, logger: defaultLogger}
}
// WithLogger ersetzt das Standard-Logging-Ziel (z. B. für Tests, die die
// protokollierten Meldungen prüfen wollen, oder um es abzuschalten).
func (c *RealClient) WithLogger(logger Logger) *RealClient {
c.logger = logger
return c
}
func (c *RealClient) log(format string, args ...any) {
if c.logger != nil {
c.logger(format, args...)
}
}
func (c *RealClient) Sync(ctx context.Context, mailbox string) (uint64, []RemoteMessage, error) {
conn, err := c.dialer.DialContext(ctx, "tcp", c.addr)
if err != nil {
return 0, nil, fmt.Errorf("imapimport: verbindung aufbauen: %w", err)
}
defer func() { _ = conn.Close() }()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
reader := bufio.NewReader(conn)
// Begrüßung.
if _, err := readLine(reader); err != nil {
return 0, nil, fmt.Errorf("imapimport: begrüßung lesen: %w", err)
}
if _, err := sendCommand(conn, reader, 1, "LOGIN "+c.username+" "+c.password); err != nil {
return 0, nil, fmt.Errorf("imapimport: login: %w", err)
}
selectLines, err := sendCommand(conn, reader, 2, "SELECT "+mailbox)
if err != nil {
return 0, nil, fmt.Errorf("imapimport: select: %w", err)
}
uidvalidity, err := c.resolveUIDValidity(selectLines, mailbox)
if err != nil {
return 0, nil, err
}
fetchLines, err := sendCommand(conn, reader, 3, "UID FETCH 1:* (FLAGS)")
if err != nil {
return 0, nil, fmt.Errorf("imapimport: uid fetch: %w", err)
}
messages := c.parseFetchLines(fetchLines)
_, _ = sendCommand(conn, reader, 4, "LOGOUT")
return uidvalidity, messages, nil
}
func readLine(reader *bufio.Reader) (string, error) {
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// sendCommand sendet ein getaggtes Kommando und liest alle Zeilen bis
// zur getaggten Abschlusszeile (inklusive). Liefert einen Fehler, wenn
// die Abschlusszeile nicht "OK" meldet.
func sendCommand(conn net.Conn, reader *bufio.Reader, tagN int, command string) ([]string, error) {
tag := "C" + strconv.Itoa(tagN)
if _, err := conn.Write([]byte(tag + " " + command + "\r\n")); err != nil {
return nil, err
}
var lines []string
for {
line, err := readLine(reader)
if err != nil {
return nil, err
}
lines = append(lines, line)
if strings.HasPrefix(line, tag+" ") {
if !strings.HasPrefix(line, tag+" OK") {
return lines, fmt.Errorf("server meldete: %s", line)
}
return lines, nil
}
}
}
// resolveUIDValidity liest UIDVALIDITY aus der SELECT-Antwort
// (Akzeptanzkriterium 1). Eine gemeldete UIDVALIDITY=0 — bekannte
// Abweichung nicht-konformer Server (known-issues-archivmail.md #5) —
// löst einen definierten Fallback aus statt eines Abbruchs: ein
// deterministisch aus dem Postfachnamen abgeleiteter Ersatzwert, der bei
// wiederholten Läufen gegen denselben nicht-konformen Server STABIL
// bleibt (kein unnötiger Voll-Resync bei jedem einzelnen Lauf).
func (c *RealClient) resolveUIDValidity(lines []string, mailbox string) (uint64, error) {
v, found := extractUIDValidity(lines)
if !found {
c.log("imapimport: unerwartete server-antwort: keine UIDVALIDITY in SELECT-Antwort für %q gefunden, verwende fallback", mailbox)
return fallbackUIDValidity(mailbox), nil
}
if v == 0 {
c.log("imapimport: UIDVALIDITY=0 gemeldet für postfach %q (bekannte abweichung nicht-konformer server) — verwende definierten fallback statt sync-abbruch", mailbox)
return fallbackUIDValidity(mailbox), nil
}
return v, nil
}
// fallbackUIDValidity leitet einen deterministischen, garantiert von 0
// verschiedenen Ersatzwert aus dem Postfachnamen ab (FNV-1a, gleiche
// Technik wie mail/internal/search.DocumentID).
func fallbackUIDValidity(mailbox string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte("imap-fallback-uidvalidity:"))
_, _ = h.Write([]byte(mailbox))
v := h.Sum64()
if v == 0 {
v = 1
}
return v
}
// extractUIDValidity sucht "UIDVALIDITY <n>" in den SELECT-Antwortzeilen.
// found=false, wenn keine UIDVALIDITY-Angabe vorhanden ODER sie nicht als
// Zahl lesbar ist (beides bekannte Serverabweichungen, siehe
// resolveUIDValidity — kein Fehlerabbruch an dieser Stelle).
func extractUIDValidity(lines []string) (value uint64, found bool) {
for _, line := range lines {
idx := strings.Index(line, "UIDVALIDITY ")
if idx == -1 {
continue
}
rest := line[idx+len("UIDVALIDITY "):]
end := strings.IndexAny(rest, "] ")
if end == -1 {
end = len(rest)
}
v, err := strconv.ParseUint(rest[:end], 10, 64)
if err != nil {
return 0, false
}
return v, true
}
return 0, false
}
// parseFetchLines parst Zeilen der Form
// "* <seq> FETCH (UID <uid> FLAGS (<flags>))" (siehe mail/internal/imap
// writeFetchResults). Akzeptanzkriterium 2: eine einzelne unerwartete/
// kaputte Zeile wird protokolliert und übersprungen, alle übrigen,
// korrekt lesbaren Nachrichten werden trotzdem geliefert — der gesamte
// Lauf bricht dafür NICHT ab.
func (c *RealClient) parseFetchLines(lines []string) []RemoteMessage {
var messages []RemoteMessage
for _, line := range lines {
if !strings.HasPrefix(line, "* ") {
continue // getaggte Abschlusszeile ("C3 OK ..."), kein Untagged-FETCH
}
if !strings.Contains(line, "FETCH ") {
continue // anderweitiges Untagged (z. B. künftig "* OK ..."), nichts zu parsen
}
msg, ok := parseSingleFetchLine(line)
if !ok {
c.log("imapimport: unerwartete server-antwort übersprungen: %q", line)
continue
}
messages = append(messages, msg)
}
return messages
}
func parseSingleFetchLine(line string) (RemoteMessage, bool) {
if !strings.Contains(line, "FETCH (UID ") {
return RemoteMessage{}, false
}
uidIdx := strings.Index(line, "UID ") + len("UID ")
rest := line[uidIdx:]
spaceIdx := strings.IndexByte(rest, ' ')
if spaceIdx == -1 {
return RemoteMessage{}, false
}
uid, err := strconv.ParseUint(rest[:spaceIdx], 10, 32)
if err != nil {
return RemoteMessage{}, false
}
var flags []string
flagsStart := strings.Index(line, "FLAGS (")
flagsEnd := strings.LastIndex(line, ")")
if flagsStart != -1 && flagsEnd > flagsStart {
inner := line[flagsStart+len("FLAGS (") : flagsEnd]
if inner != "" {
flags = strings.Split(inner, " ")
}
}
return RemoteMessage{UID: uint32(uid), Flags: flags}, true
}
@@ -0,0 +1,132 @@
// TestRunOnce_AgainstRealTestMailboxWithRealisticVolume ist die
// geforderte Pflichtprüfung 3: Test gegen Testpostfach mit realistischem
// Nachrichtenaufkommen — echter IMAP4rev1-Wire-Protokoll-Lauf gegen den
// echten ING-01-Server (mail/internal/imap), kein Fake.
package imapimport
import (
"context"
"net"
"testing"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/imap"
)
type realTestAuthenticator struct{}
func (realTestAuthenticator) Authenticate(_ context.Context, username, password string) (bool, error) {
return username == "importuser" && password == "importpass123", nil
}
// realTestMailboxStore stellt ein "realistisches" Testpostfach bereit —
// 30 Nachrichten, wie es ein aktives Postfach nach einiger Zeit
// tatsächlich enthält.
type realTestMailboxStore struct {
uidvalidity uint64
messages []imap.Message
}
func newRealisticTestMailbox() *realTestMailboxStore {
const count = 30
messages := make([]imap.Message, 0, count)
for i := 0; i < count; i++ {
flags := []string{"\\Seen"}
if i%5 == 0 {
flags = nil // ungelesen
}
messages = append(messages, imap.Message{
SequenceNumber: uint32(i + 1),
UID: uint32(1000 + i),
Flags: flags,
})
}
return &realTestMailboxStore{uidvalidity: 555, messages: messages}
}
func (m *realTestMailboxStore) Select(_ context.Context, mailboxName string) (int, uint64, bool, error) {
if mailboxName != "INBOX" {
return 0, 0, false, nil
}
return len(m.messages), m.uidvalidity, true, nil
}
func (m *realTestMailboxStore) Fetch(_ context.Context, _ string, seqNumbers []uint32) ([]imap.Message, error) {
return m.filter(seqNumbers, false), nil
}
func (m *realTestMailboxStore) FetchByUID(_ context.Context, _ string, uids []uint32) ([]imap.Message, error) {
return m.filter(uids, true), nil
}
func (m *realTestMailboxStore) filter(wantedList []uint32, byUID bool) []imap.Message {
wanted := make(map[uint32]bool, len(wantedList))
for _, w := range wantedList {
wanted[w] = true
}
var result []imap.Message
for _, msg := range m.messages {
key := msg.SequenceNumber
if byUID {
key = msg.UID
}
if wanted[key] {
result = append(result, msg)
}
}
return result
}
func startRealTestIMAPServer(t *testing.T) (addr string, stop func()) {
t.Helper()
srv := imap.NewServer(realTestAuthenticator{}, newRealisticTestMailbox())
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
}
}
func TestRunOnce_AgainstRealTestMailboxWithRealisticVolume(t *testing.T) {
store := setupStore(t)
scheduler := NewScheduler(store)
ctx := context.Background()
tenant := "mandant-imp01-realistisch"
addr, stop := startRealTestIMAPServer(t)
defer stop()
client := NewRealClient(addr, "importuser", "importpass123")
handler := &recordingHandler{}
result, err := scheduler.RunOnce(ctx, tenant, "INBOX", client, handler)
if err != nil {
t.Fatalf("runonce gegen echten server: %v", err)
}
if result.NewMessages != 30 {
t.Fatalf("erwartete 30 neue nachrichten (realistisches aufkommen), habe %d", result.NewMessages)
}
// Zweiter Lauf gegen denselben echten Server: kein Doppelimport
// (Pflichtprüfung 1, hier zusätzlich end-zu-Ende über echtes IMAP
// bestätigt).
handler2 := &recordingHandler{}
result2, err := scheduler.RunOnce(ctx, tenant, "INBOX", client, handler2)
if err != nil {
t.Fatalf("zweiter lauf gegen echten server: %v", err)
}
if result2.NewMessages != 0 {
t.Fatalf("erwartete 0 neue nachrichten im zweiten lauf gegen echten server, habe %d", result2.NewMessages)
}
if result2.ExistingMessages != 30 {
t.Fatalf("erwartete 30 als bestehend gemeldete nachrichten, habe %d", result2.ExistingMessages)
}
}
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS mail_import_state (
tenant_slug TEXT NOT NULL,
mailbox_name TEXT NOT NULL,
last_uidvalidity BIGINT NOT NULL DEFAULT 0,
last_synced_uid BIGINT NOT NULL DEFAULT 0,
interval_seconds INT NOT NULL DEFAULT 300,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, mailbox_name)
)
@@ -0,0 +1,164 @@
// IMP-04: Fehlerbehandlung nicht-konformer Server. Baut einen minimalen,
// hand-gesteuerten Fake-Server (roher TCP, KEIN mail/internal/imap) auf,
// der bewusst nicht-konforme Antworten sendet — echte Kontrolle über
// genau das Fehlerszenario, das getestet werden soll.
package imapimport
import (
"bufio"
"context"
"fmt"
"net"
"strings"
"testing"
)
// scriptedServer nimmt EINE Verbindung an und sendet exakt die
// vorgegebenen Zeilen als Antwort auf jedes eingehende Kommando (in
// Reihenfolge) — genug Kontrolle, um nicht-konforme Serverantworten
// exakt zu reproduzieren.
type scriptedServer struct {
responses [][]string // je eingehendem Kommando eine Antwortzeilen-Liste
}
func (s *scriptedServer) start(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() {
conn, err := listener.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
reader := bufio.NewReader(conn)
_, _ = conn.Write([]byte("* OK IMAP4rev1 Service Ready\r\n"))
for _, respLines := range s.responses {
if _, err := reader.ReadString('\n'); err != nil {
return
}
for _, line := range respLines {
if _, err := conn.Write([]byte(line + "\r\n")); err != nil {
return
}
}
}
}()
t.Cleanup(func() { _ = listener.Close() })
return listener.Addr().String()
}
// TestResolveUIDValidity_ZeroTriggersDefinedFallbackNotAbort ist die
// geforderte Pflichtprüfung 1: Test simuliert Server mit UIDVALIDITY=0
// und bestätigt greifenden Fallback.
func TestResolveUIDValidity_ZeroTriggersDefinedFallbackNotAbort(t *testing.T) {
srv := &scriptedServer{responses: [][]string{
{"C1 OK LOGIN completed"},
{"* 3 EXISTS", "* OK [UIDVALIDITY 0] UIDs valid", "C2 OK [READ-WRITE] SELECT completed"},
{"* 1 FETCH (UID 1 FLAGS ())", "* 2 FETCH (UID 2 FLAGS ())", "* 3 FETCH (UID 3 FLAGS ())", "C3 OK UID FETCH completed"},
{"C4 OK LOGOUT completed"},
}}
addr := srv.start(t)
var loggedFallback bool
client := NewRealClient(addr, "user", "pass").WithLogger(func(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if strings.Contains(msg, "UIDVALIDITY=0") {
loggedFallback = true
}
})
uidvalidity, messages, err := client.Sync(context.Background(), "INBOX")
if err != nil {
// Akzeptanzkriterium 1: KEIN Sync-Abbruch bei UIDVALIDITY=0.
t.Fatalf("erwartete erfolgreichen sync trotz UIDVALIDITY=0, habe fehler: %v", err)
}
if uidvalidity == 0 {
t.Fatal("erwartete definierten fallback-wert != 0, habe weiterhin 0")
}
if len(messages) != 3 {
t.Fatalf("erwartete 3 nachrichten trotz UIDVALIDITY=0, habe %d", len(messages))
}
if !loggedFallback {
t.Fatal("erwartete protokollierten fallback-hinweis (akzeptanzkriterium 3: nachvollziehbar)")
}
// Fallback ist deterministisch für dasselbe Postfach — ein zweiter
// Aufruf gegen einen erneut nicht-konformen Server liefert real
// denselben Ersatzwert, löst also keinen unnötigen Voll-Resync bei
// jedem einzelnen Lauf aus.
if fallbackUIDValidity("INBOX") != uidvalidity {
t.Fatalf("erwartete deterministischen fallback, habe %d vs %d", fallbackUIDValidity("INBOX"), uidvalidity)
}
}
// TestParseFetchLines_UnexpectedResponseSkippedRestContinue ist die
// geforderte Pflichtprüfung 2: Test mit unerwarteter/kaputter
// Serverantwort bestätigt Weiterlauf für übrige Nachrichten.
func TestParseFetchLines_UnexpectedResponseSkippedRestContinue(t *testing.T) {
srv := &scriptedServer{responses: [][]string{
{"C1 OK LOGIN completed"},
{"* 3 EXISTS", "* OK [UIDVALIDITY 42] UIDs valid", "C2 OK [READ-WRITE] SELECT completed"},
{
"* 1 FETCH (UID 1 FLAGS ())",
"* GARBAGE NOT EVEN A FETCH LINE AT ALL", // kaputte/unerwartete Antwort
"* 2 FETCH SOMETHING UNPARSEABLE HERE (UID)", // ebenfalls kaputt
"* 3 FETCH (UID 3 FLAGS (\\Seen))",
"C3 OK UID FETCH completed",
},
{"C4 OK LOGOUT completed"},
}}
addr := srv.start(t)
var skippedCount int
client := NewRealClient(addr, "user", "pass").WithLogger(func(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if strings.Contains(msg, "unerwartete server-antwort übersprungen") {
skippedCount++
}
})
uidvalidity, messages, err := client.Sync(context.Background(), "INBOX")
if err != nil {
t.Fatalf("erwartete erfolgreichen sync trotz kaputter zeilen, habe fehler: %v", err)
}
if uidvalidity != 42 {
t.Fatalf("erwartete uidvalidity=42, habe %d", uidvalidity)
}
// Akzeptanzkriterium 2: die BEIDEN kaputten Zeilen werden übersprungen
// UND protokolliert, die ÜBRIGEN (real 2) Nachrichten kommen trotzdem an.
if len(messages) != 2 {
t.Fatalf("erwartete 2 lesbare nachrichten trotz kaputter zeilen, habe %d: %+v", len(messages), messages)
}
if skippedCount != 2 {
t.Fatalf("erwartete 2 protokollierte übersprungene zeilen, habe %d", skippedCount)
}
}
// TestResolveUIDValidity_RegressionGuardAgainstZeroAbort ist die
// geforderte Pflichtprüfung 3: Regressionstest verhindert
// Wiederauftreten des UIDVALIDITY-Bugs — prüft die Fallback-Funktion
// isoliert und direkt, unabhängig vom Netzwerkpfad.
func TestResolveUIDValidity_RegressionGuardAgainstZeroAbort(t *testing.T) {
client := NewRealClient("unused:0", "u", "p")
value, err := client.resolveUIDValidity([]string{"* OK [UIDVALIDITY 0] UIDs valid"}, "INBOX")
if err != nil {
t.Fatalf("regression: UIDVALIDITY=0 löste real einen fehler aus (der genau vermiedene bug): %v", err)
}
if value == 0 {
t.Fatal("regression: fallback lieferte weiterhin 0 — bug erneut aufgetreten")
}
// Fehlende UIDVALIDITY-Angabe (noch nicht-konformer als 0) darf
// ebenfalls nicht abbrechen.
value2, err := client.resolveUIDValidity([]string{"C2 OK SELECT completed"}, "INBOX")
if err != nil {
t.Fatalf("regression: fehlende UIDVALIDITY löste real einen fehler aus: %v", err)
}
if value2 == 0 {
t.Fatal("regression: fallback bei fehlender UIDVALIDITY lieferte 0")
}
}
+104
View File
@@ -0,0 +1,104 @@
package imapimport
import (
"context"
"fmt"
"sort"
)
// Handler verarbeitet die vom Scheduler klassifizierten Nachrichten.
// Echte Ablage/Indexierung ist Sache späterer Kacheln (IMP-02 u. a.) —
// dieses Paket bereitet nur die Schnittstelle vor.
type Handler interface {
// OnNewMessage wird GENAU EINMAL je UID aufgerufen, die seit dem
// letzten Abgleich neu hinzugekommen ist (Akzeptanzkriterium 1).
// Ein Fehler bricht den aktuellen Lauf ab, OHNE den Fortschritt für
// bereits erfolgreich verarbeitete Nachrichten zu verlieren
// (Akzeptanzkriterium 3).
OnNewMessage(ctx context.Context, tenantSlug, mailboxName string, msg RemoteMessage) error
// OnExistingMessageState wird für bereits bekannte Nachrichten mit
// ihrem AKTUELLEN Flag-Zustand aufgerufen (Akzeptanzkriterium 2:
// Zustandsänderungen wie gelesen/gelöscht abgeglichen) — NIEMALS als
// Neuimport, die Nachricht selbst wird nicht erneut abgelegt.
OnExistingMessageState(ctx context.Context, tenantSlug, mailboxName string, msg RemoteMessage) error
}
// Scheduler führt den periodischen, UID-basierten Delta-Sync aus.
type Scheduler struct {
store *Store
}
func NewScheduler(store *Store) *Scheduler {
return &Scheduler{store: store}
}
// SyncResult fasst einen abgeschlossenen Lauf zusammen.
type SyncResult struct {
NewMessages int
ExistingMessages int
Rebuilt bool
}
// RunOnce führt genau einen Abgleich für ein Postfach aus (Akzeptanz-
// kriterium 1/2/3). Nachrichten werden nach UID aufsteigend verarbeitet;
// der Fortschritt wird nach JEDER neuen Nachricht einzeln persistiert
// (Store.advance), damit ein Absturz mitten im Lauf keine Nachricht
// verliert und beim nächsten Lauf keine bereits verarbeitete Nachricht
// erneut als "neu" gilt (Pflichtprüfung 1/2: kein Doppelimport, auch
// nach simuliertem Neustart).
func (s *Scheduler) RunOnce(ctx context.Context, tenantSlug, mailboxName string, client IMAPClient, handler Handler) (SyncResult, error) {
state, err := s.store.GetOrCreate(ctx, tenantSlug, mailboxName)
if err != nil {
return SyncResult{}, err
}
uidvalidity, messages, err := client.Sync(ctx, mailboxName)
if err != nil {
return SyncResult{}, fmt.Errorf("imapimport: postfach abrufen: %w", err)
}
result := SyncResult{}
lastSyncedUID := state.LastSyncedUID
// Bekannten Fehler vermeiden (archivmail: UIDVALIDITY=0 bricht
// Resync): jede Änderung der UIDVALIDITY gegenüber dem persistierten
// Stand (0 = "noch nie synchronisiert", kein Rebuild) löst einen
// vollständigen Resync aus — alle Nachrichten gelten wieder als neu.
if state.LastUIDValidity != 0 && uidvalidity != state.LastUIDValidity {
lastSyncedUID = 0
result.Rebuilt = true
}
sorted := make([]RemoteMessage, len(messages))
copy(sorted, messages)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].UID < sorted[j].UID })
for _, msg := range sorted {
if msg.UID > lastSyncedUID {
if err := handler.OnNewMessage(ctx, tenantSlug, mailboxName, msg); err != nil {
return result, fmt.Errorf("imapimport: neue nachricht uid=%d verarbeiten: %w", msg.UID, err)
}
lastSyncedUID = msg.UID
if err := s.store.advance(ctx, tenantSlug, mailboxName, uidvalidity, lastSyncedUID); err != nil {
return result, err
}
result.NewMessages++
continue
}
if err := handler.OnExistingMessageState(ctx, tenantSlug, mailboxName, msg); err != nil {
return result, fmt.Errorf("imapimport: zustand für uid=%d abgleichen: %w", msg.UID, err)
}
result.ExistingMessages++
}
// Auch ohne neue Nachrichten muss eine geänderte UIDVALIDITY
// persistiert werden (z. B. Rebuild bei leerem Postfach).
if uidvalidity != state.LastUIDValidity {
if err := s.store.advance(ctx, tenantSlug, mailboxName, uidvalidity, lastSyncedUID); err != nil {
return result, err
}
}
return result, nil
}
+189
View File
@@ -0,0 +1,189 @@
// Integrationstest (IMP-01): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/dedup/folderstate/savedsearch —
// TEST_TENANT_DSN.
package imapimport
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
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)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_import_state WHERE tenant_slug LIKE 'mandant-imp01-%'`)
})
return store
}
// fakeIMAPClient simuliert ein entferntes Postfach — echte
// Netzwerkanbindung ist Sache von client_real.go (Pflichtprüfung 3
// nutzt sie real, hier wird die Delta-Sync-LOGIK isoliert geprüft,
// gleiche Konvention wie fakeKEKProvider/fakeAuthenticator in anderen
// Mail-Paketen).
type fakeIMAPClient struct {
uidvalidity uint64
messages []RemoteMessage
}
func (c *fakeIMAPClient) Sync(_ context.Context, _ string) (uint64, []RemoteMessage, error) {
return c.uidvalidity, c.messages, nil
}
// recordingHandler zeichnet auf, welche UIDs als neu bzw. als bestehend
// gemeldet wurden — die eigentliche Ablage/Indexierung ist Sache
// späterer Kacheln.
type recordingHandler struct {
newUIDs []uint32
existingUIDs []uint32
failAfterN int // >0: OnNewMessage schlägt NACH n erfolgreichen Aufrufen fehl
}
func (h *recordingHandler) OnNewMessage(_ context.Context, _, _ string, msg RemoteMessage) error {
if h.failAfterN > 0 && len(h.newUIDs) >= h.failAfterN {
return errors.New("simulierter absturz mitten im abgleich")
}
h.newUIDs = append(h.newUIDs, msg.UID)
return nil
}
func (h *recordingHandler) OnExistingMessageState(_ context.Context, _, _ string, msg RemoteMessage) error {
h.existingUIDs = append(h.existingUIDs, msg.UID)
return nil
}
// TestRunOnce_TwoConsecutiveRunsNoDuplicateImport ist die geforderte
// Pflichtprüfung 1: zwei aufeinanderfolgende Läufe importieren keine
// Nachricht doppelt.
func TestRunOnce_TwoConsecutiveRunsNoDuplicateImport(t *testing.T) {
store := setupStore(t)
scheduler := NewScheduler(store)
ctx := context.Background()
tenant := "mandant-imp01-doppelimport"
client := &fakeIMAPClient{uidvalidity: 1, messages: []RemoteMessage{
{UID: 1, Flags: nil}, {UID: 2, Flags: nil}, {UID: 3, Flags: nil},
}}
handler1 := &recordingHandler{}
result1, err := scheduler.RunOnce(ctx, tenant, "INBOX", client, handler1)
if err != nil {
t.Fatalf("erster lauf: %v", err)
}
if result1.NewMessages != 3 || len(handler1.newUIDs) != 3 {
t.Fatalf("erwartete 3 neue nachrichten im ersten lauf, habe: %+v / %v", result1, handler1.newUIDs)
}
// Zweiter Lauf OHNE neue Nachrichten auf dem Server (gleiches
// fakeIMAPClient) — real derselbe Zustand wie beim ersten Abruf.
handler2 := &recordingHandler{}
result2, err := scheduler.RunOnce(ctx, tenant, "INBOX", client, handler2)
if err != nil {
t.Fatalf("zweiter lauf: %v", err)
}
if result2.NewMessages != 0 {
t.Fatalf("erwartete 0 neue nachrichten im zweiten lauf (kein doppelimport), habe %d: %v", result2.NewMessages, handler2.newUIDs)
}
if result2.ExistingMessages != 3 {
t.Fatalf("erwartete 3 als bestehend gemeldete nachrichten im zweiten lauf, habe %d", result2.ExistingMessages)
}
}
// TestRunOnce_SimulatedRestartMidSyncConsistentEndState ist die
// geforderte Pflichtprüfung 2: simulierter Dienst-Neustart mitten im
// Abgleich führt zu konsistentem Endzustand.
func TestRunOnce_SimulatedRestartMidSyncConsistentEndState(t *testing.T) {
store := setupStore(t)
scheduler := NewScheduler(store)
ctx := context.Background()
tenant := "mandant-imp01-neustart"
client := &fakeIMAPClient{uidvalidity: 1, messages: []RemoteMessage{
{UID: 1}, {UID: 2}, {UID: 3}, {UID: 4}, {UID: 5},
}}
// Erster Lauf "stürzt" nach 2 erfolgreich verarbeiteten Nachrichten ab.
crashingHandler := &recordingHandler{failAfterN: 2}
_, err := scheduler.RunOnce(ctx, tenant, "INBOX", client, crashingHandler)
if err == nil {
t.Fatal("erwartete fehler durch simulierten absturz, habe nil")
}
if len(crashingHandler.newUIDs) != 2 {
t.Fatalf("erwartete 2 erfolgreich verarbeitete nachrichten vor dem absturz, habe %d: %v", len(crashingHandler.newUIDs), crashingHandler.newUIDs)
}
// "Neustart des Dienstes": neuer Scheduler auf demselben (persistenten)
// Store, neuer Handler ohne Fehlerinjektion.
restartedScheduler := NewScheduler(store)
freshHandler := &recordingHandler{}
result, err := restartedScheduler.RunOnce(ctx, tenant, "INBOX", client, freshHandler)
if err != nil {
t.Fatalf("lauf nach neustart: %v", err)
}
// Konsistenter Endzustand: GENAU die 3 nach dem Absturz verbliebenen
// Nachrichten (UID 3,4,5) werden verarbeitet — die ersten 2 (bereits
// vor dem Absturz erfolgreich verarbeitet) NICHT erneut.
if result.NewMessages != 3 {
t.Fatalf("erwartete 3 neue nachrichten nach neustart, habe %d: %v", result.NewMessages, freshHandler.newUIDs)
}
for _, uid := range freshHandler.newUIDs {
if uid <= 2 {
t.Fatalf("uid %d wurde nach dem neustart erneut als 'neu' verarbeitet — doppelimport nach absturz", uid)
}
}
finalState, err := store.Get(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("endzustand lesen: %v", err)
}
if finalState.LastSyncedUID != 5 {
t.Fatalf("erwartete konsistenten endzustand last_synced_uid=5, habe %d", finalState.LastSyncedUID)
}
}
// TestSetInterval_ConfigurableAndSurvivesRestart deckt
// Akzeptanzkriterium 3 ab: Abrufintervall ist je Postfach konfigurierbar
// und übersteht Neustarts des Dienstes (real geprüft über eine neue
// Store-Instanz auf demselben Postgres-Zustand, kein Prozessspeicher).
func TestSetInterval_ConfigurableAndSurvivesRestart(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-imp01-intervall"
if _, err := store.GetOrCreate(ctx, tenant, "INBOX"); err != nil {
t.Fatalf("getorcreate: %v", err)
}
if err := store.SetInterval(ctx, tenant, "INBOX", 900); err != nil {
t.Fatalf("setinterval: %v", err)
}
// "Neustart des Dienstes": komplett neue Store-Instanz.
restartedStore := NewStore(store.pool)
state, err := restartedStore.Get(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("get nach neustart: %v", err)
}
if state.IntervalSeconds != 900 {
t.Fatalf("erwartete konfiguriertes intervall 900 nach neustart, habe %d", state.IntervalSeconds)
}
}
+112
View File
@@ -0,0 +1,112 @@
// Package imapimport implementiert IMP-01: den Scheduler für
// periodischen IMAP-Postfach-Abruf mit UID-basiertem Delta-Sync. Baut
// auf ING-01 (mail/internal/imap, IMAP-Server-Grundgerüst inkl. UID
// FETCH) und ING-05 (mail/internal/folderstate, UIDVALIDITY/UIDNEXT) auf
// — kombiniert bewusst beide fertigen, unveränderten Pakete statt eines
// davon zu erweitern (kein Umbau angrenzender Bereiche).
//
// Konzept aus archivmail als Ausgangspunkt genommen (UID-Sync,
// Delta-Import, siehe repos-analyse-mail-reuse.md), Testabdeckung von
// Grund auf neu (archivmails Import-Pfade waren praktisch ungetestet).
// Bekannten Fehler vermieden (archivmail: UIDVALIDITY=0 bricht Resync
// bei nicht-konformen Servern): Store.RunOnce erkennt jede Änderung der
// UIDVALIDITY explizit und löst einen vollständigen Resync aus, statt
// eine UIDVALIDITY=0 unbesehen zu übernehmen.
package imapimport
import (
"context"
_ "embed"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/0001_mail_import_state.sql
var schemaMigration string
const defaultIntervalSeconds = 300
// State ist der persistierte Sync-Zustand eines Postfachs — übersteht
// Dienst-Neustarts (Akzeptanzkriterium 3), da ausschließlich in Postgres
// gehalten, nie im Prozessspeicher.
type State struct {
TenantSlug string
MailboxName string
LastUIDValidity uint64
LastSyncedUID uint32
IntervalSeconds int
}
// Store persistiert den Sync-Zustand je Mandant und Postfach.
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("imapimport: schema anlegen: %w", err)
}
return nil
}
// GetOrCreate liefert den Sync-Zustand eines Postfachs, legt ihn bei
// erstem Zugriff mit dem Standardintervall neu an.
func (s *Store) GetOrCreate(ctx context.Context, tenantSlug, mailboxName string) (State, error) {
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_import_state (tenant_slug, mailbox_name, interval_seconds)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_slug, mailbox_name) DO NOTHING
`, tenantSlug, mailboxName, defaultIntervalSeconds); err != nil {
return State{}, fmt.Errorf("imapimport: sync-zustand anlegen: %w", err)
}
return s.Get(ctx, tenantSlug, mailboxName)
}
// Get liest den aktuellen Sync-Zustand.
func (s *Store) Get(ctx context.Context, tenantSlug, mailboxName string) (State, error) {
var st State
st.TenantSlug = tenantSlug
st.MailboxName = mailboxName
err := s.pool.QueryRow(ctx, `
SELECT last_uidvalidity, last_synced_uid, interval_seconds
FROM mail_import_state WHERE tenant_slug = $1 AND mailbox_name = $2
`, tenantSlug, mailboxName).Scan(&st.LastUIDValidity, &st.LastSyncedUID, &st.IntervalSeconds)
if err != nil {
return State{}, fmt.Errorf("imapimport: sync-zustand lesen: %w", err)
}
return st, nil
}
// SetInterval konfiguriert das Abrufintervall je Postfach
// (Akzeptanzkriterium 3), persistiert und damit neustartfest.
func (s *Store) SetInterval(ctx context.Context, tenantSlug, mailboxName string, seconds int) error {
if _, err := s.pool.Exec(ctx, `
UPDATE mail_import_state SET interval_seconds = $3, updated_at = now()
WHERE tenant_slug = $1 AND mailbox_name = $2
`, tenantSlug, mailboxName, seconds); err != nil {
return fmt.Errorf("imapimport: intervall setzen: %w", err)
}
return nil
}
// advance persistiert den erreichten Fortschritt NACH jeder erfolgreich
// verarbeiteten Nachricht (nicht erst am Ende des Laufs) — Grundlage für
// Akzeptanzkriterium 3 / Pflichtprüfung 2: ein Dienst-Neustart mitten im
// Abgleich verliert höchstens die aktuell laufende Verarbeitung, nie den
// bereits erreichten Fortschritt, und importiert nichts doppelt.
func (s *Store) advance(ctx context.Context, tenantSlug, mailboxName string, uidvalidity uint64, syncedUID uint32) error {
if _, err := s.pool.Exec(ctx, `
UPDATE mail_import_state
SET last_uidvalidity = $3, last_synced_uid = $4, updated_at = now()
WHERE tenant_slug = $1 AND mailbox_name = $2
`, tenantSlug, mailboxName, uidvalidity, syncedUID); err != nil {
return fmt.Errorf("imapimport: fortschritt persistieren: %w", err)
}
return nil
}