// 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()) }