diff --git a/mail/docs/ING-05-PRUEFPROTOKOLL.md b/mail/docs/ING-05-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..fc81004 --- /dev/null +++ b/mail/docs/ING-05-PRUEFPROTOKOLL.md @@ -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. diff --git a/mail/internal/folderstate/migrations/0001_mail_folder_state.sql b/mail/internal/folderstate/migrations/0001_mail_folder_state.sql new file mode 100644 index 0000000..278b884 --- /dev/null +++ b/mail/internal/folderstate/migrations/0001_mail_folder_state.sql @@ -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) +) diff --git a/mail/internal/folderstate/migrations/0002_mail_folder_state_events.sql b/mail/internal/folderstate/migrations/0002_mail_folder_state_events.sql new file mode 100644 index 0000000..b978b48 --- /dev/null +++ b/mail/internal/folderstate/migrations/0002_mail_folder_state_events.sql @@ -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() +) diff --git a/mail/internal/folderstate/store.go b/mail/internal/folderstate/store.go new file mode 100644 index 0000000..a6a6d6e --- /dev/null +++ b/mail/internal/folderstate/store.go @@ -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()) +} diff --git a/mail/internal/folderstate/store_test.go b/mail/internal/folderstate/store_test.go new file mode 100644 index 0000000..43e2724 --- /dev/null +++ b/mail/internal/folderstate/store_test.go @@ -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) + } +}