feat(PROJ-45): IMAP Per-Folder UID-Tracking + UIDVALIDITY-Check
- FolderState: GetFolderState, UpsertFolderState, ListFolderStates, DecideResync - syncFolder nutzt per-folder UID-Tracking statt globalem highest_uid - UIDVALIDITY-Check loest automatisch Full-Resync aus - imap_folder_state Tabelle in initSchema (CREATE TABLE IF NOT EXISTS) - SetAuditLogger in main.go verdrahtet Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user