package imap import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" ) // FolderState represents per-folder UID tracking for an IMAP account (PROJ-45). // Each IMAP folder has its own independent UID sequence — tracking a single // account-global last_uid (the legacy behaviour) silently skips folders whose // highest UID falls below the global value and misses UIDVALIDITY resets. type FolderState struct { AccountID int64 Folder string LastUID uint32 UIDValidity uint32 } // GetFolderState returns the saved per-folder UID tracking record. // If no record exists yet (first sync of this folder), an empty // FolderState{LastUID: 0, UIDValidity: 0} is returned and the caller // MUST treat it as a request for a full folder resync. func (s *Store) GetFolderState(ctx context.Context, accountID int64, folder string) (FolderState, error) { var st FolderState st.AccountID = accountID st.Folder = folder err := s.pool.QueryRow(ctx, ` SELECT last_uid, uid_validity FROM imap_folder_state WHERE account_id = $1 AND folder = $2`, accountID, folder, ).Scan(&st.LastUID, &st.UIDValidity) if errors.Is(err, pgx.ErrNoRows) { return st, nil } if err != nil { return st, fmt.Errorf("imap store: get folder state: %w", err) } return st, nil } // UpsertFolderState writes (or overwrites) the per-folder UID tracking record. // Used after a successful folder sync to persist the new high-water mark. func (s *Store) UpsertFolderState(ctx context.Context, st FolderState) error { _, err := s.pool.Exec(ctx, ` INSERT INTO imap_folder_state (account_id, folder, last_uid, uid_validity, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (account_id, folder) DO UPDATE SET last_uid = EXCLUDED.last_uid, uid_validity = EXCLUDED.uid_validity, updated_at = now()`, st.AccountID, st.Folder, st.LastUID, st.UIDValidity, ) if err != nil { return fmt.Errorf("imap store: upsert folder state: %w", err) } return nil } // ResyncDecision describes what the scheduler should do for a folder given // the stored state and the UIDVALIDITY reported by the server right after SELECT. // Extracted as a pure function so the branching logic can be unit-tested // without a live IMAP server. type ResyncDecision struct { FullResync bool // true → ignore stored last_uid, fetch all UIDs UIDValidReset bool // true → write an audit-log "uidvalidity_reset" entry EffectiveStart uint32 // UID to filter on (0 means "all messages") } // DecideResync implements the per-folder sync gate (PROJ-45). // // - server UIDVALIDITY == 0 → treat as "unknown", continue incrementally // with the stored last_uid (defensive; some servers misbehave). // - stored UIDVALIDITY == 0 → first ever sync of this folder → full sync, // but no "reset" event (nothing to reset from). // - stored != server (both != 0) → mailbox rebuilt server-side → full resync // AND log a UIDVALIDITY-reset audit event. // - otherwise → incremental from stored last_uid. func DecideResync(storedLastUID, storedUIDValidity, serverUIDValidity uint32) ResyncDecision { if serverUIDValidity == 0 { return ResyncDecision{ FullResync: storedLastUID == 0, EffectiveStart: storedLastUID, } } if storedUIDValidity == 0 { return ResyncDecision{FullResync: storedLastUID == 0, EffectiveStart: storedLastUID} } if storedUIDValidity != serverUIDValidity { return ResyncDecision{FullResync: true, UIDValidReset: true, EffectiveStart: 0} } return ResyncDecision{FullResync: storedLastUID == 0, EffectiveStart: storedLastUID} } // ListFolderStates returns all per-folder UID tracking records for an account. // Currently used for diagnostics and potential admin UI. func (s *Store) ListFolderStates(ctx context.Context, accountID int64) ([]FolderState, error) { rows, err := s.pool.Query(ctx, ` SELECT account_id, folder, last_uid, uid_validity FROM imap_folder_state WHERE account_id = $1 ORDER BY folder`, accountID) if err != nil { return nil, fmt.Errorf("imap store: list folder states: %w", err) } defer rows.Close() var states []FolderState for rows.Next() { var st FolderState if err := rows.Scan(&st.AccountID, &st.Folder, &st.LastUID, &st.UIDValidity); err != nil { return nil, fmt.Errorf("imap store: scan folder state: %w", err) } states = append(states, st) } return states, rows.Err() }