diff --git a/internal/adapters/nylas/demo_policy.go b/internal/adapters/nylas/demo_policy.go
index a1043f7..4a59f0f 100644
--- a/internal/adapters/nylas/demo_policy.go
+++ b/internal/adapters/nylas/demo_policy.go
@@ -13,6 +13,7 @@ func (d *DemoClient) ListPolicies(ctx context.Context) ([]domain.Policy, error)
Name: "Demo Policy",
ApplicationID: "app-demo",
OrganizationID: "org-demo",
+ Rules: []string{"rule-demo-1"},
},
}, nil
}
@@ -23,6 +24,7 @@ func (d *DemoClient) GetPolicy(ctx context.Context, policyID string) (*domain.Po
Name: "Demo Policy",
ApplicationID: "app-demo",
OrganizationID: "org-demo",
+ Rules: []string{"rule-demo-1"},
}, nil
}
@@ -33,6 +35,7 @@ func (d *DemoClient) CreatePolicy(ctx context.Context, payload map[string]any) (
Name: name,
ApplicationID: "app-demo",
OrganizationID: "org-demo",
+ Rules: []string{"rule-demo-1"},
}, nil
}
@@ -43,6 +46,7 @@ func (d *DemoClient) UpdatePolicy(ctx context.Context, policyID string, payload
Name: name,
ApplicationID: "app-demo",
OrganizationID: "org-demo",
+ Rules: []string{"rule-demo-1"},
}, nil
}
diff --git a/internal/adapters/nylas/mock_policy.go b/internal/adapters/nylas/mock_policy.go
index 1e2e628..09f27ad 100644
--- a/internal/adapters/nylas/mock_policy.go
+++ b/internal/adapters/nylas/mock_policy.go
@@ -13,6 +13,7 @@ func (m *MockClient) ListPolicies(ctx context.Context) ([]domain.Policy, error)
Name: "Default Policy",
ApplicationID: "app-123",
OrganizationID: "org-123",
+ Rules: []string{"rule-1"},
},
}, nil
}
@@ -23,6 +24,7 @@ func (m *MockClient) GetPolicy(ctx context.Context, policyID string) (*domain.Po
Name: "Default Policy",
ApplicationID: "app-123",
OrganizationID: "org-123",
+ Rules: []string{"rule-1"},
}, nil
}
@@ -33,6 +35,7 @@ func (m *MockClient) CreatePolicy(ctx context.Context, payload map[string]any) (
Name: name,
ApplicationID: "app-123",
OrganizationID: "org-123",
+ Rules: []string{"rule-1"},
}, nil
}
@@ -43,6 +46,7 @@ func (m *MockClient) UpdatePolicy(ctx context.Context, policyID string, payload
Name: name,
ApplicationID: "app-123",
OrganizationID: "org-123",
+ Rules: []string{"rule-1"},
}, nil
}
diff --git a/internal/air/cache/cache.go b/internal/air/cache/cache.go
index 9fce3ba..4e05e49 100644
--- a/internal/air/cache/cache.go
+++ b/internal/air/cache/cache.go
@@ -92,6 +92,15 @@ func sanitizeEmail(email string) string {
return safe + ".db"
}
+func isAccountDBFile(name string) bool {
+ if !strings.HasSuffix(name, ".db") || strings.HasSuffix(name, "-wal") || strings.HasSuffix(name, "-shm") {
+ return false
+ }
+
+ // Shared databases are not per-account caches.
+ return name != "photos.db"
+}
+
// DBPath returns the database path for an email.
func (m *Manager) DBPath(email string) string {
return filepath.Join(m.basePath, sanitizeEmail(email))
@@ -240,7 +249,7 @@ func (m *Manager) ListCachedAccounts() ([]string, error) {
var emails []string
for _, entry := range entries {
name := entry.Name()
- if strings.HasSuffix(name, ".db") && !strings.HasSuffix(name, "-wal") && !strings.HasSuffix(name, "-shm") {
+ if isAccountDBFile(name) {
email := strings.TrimSuffix(name, ".db")
emails = append(emails, email)
}
diff --git a/internal/air/cache/emails.go b/internal/air/cache/emails.go
index 912e490..c834d03 100644
--- a/internal/air/cache/emails.go
+++ b/internal/air/cache/emails.go
@@ -273,6 +273,15 @@ func (s *EmailStore) UpdateFlags(id string, unread, starred *bool) error {
return nil
}
+ return s.UpdateMessage(id, unread, starred, nil)
+}
+
+// UpdateMessage updates cached message flags and folder placement.
+func (s *EmailStore) UpdateMessage(id string, unread, starred *bool, folders []string) error {
+ if unread == nil && starred == nil && folders == nil {
+ return nil
+ }
+
// Use strings.Builder to avoid string concatenation in loop
var query strings.Builder
query.WriteString("UPDATE emails SET")
@@ -290,6 +299,18 @@ func (s *EmailStore) UpdateFlags(id string, unread, starred *bool) error {
}
query.WriteString(" starred = ?")
args = append(args, boolToInt(*starred))
+ needComma = true
+ }
+ if folders != nil {
+ if needComma {
+ query.WriteString(",")
+ }
+ folderID := ""
+ if len(folders) > 0 {
+ folderID = folders[0]
+ }
+ query.WriteString(" folder_id = ?")
+ args = append(args, folderID)
}
query.WriteString(" WHERE id = ?")
diff --git a/internal/air/cache/encryption.go b/internal/air/cache/encryption.go
index e18d45a..a3ea695 100644
--- a/internal/air/cache/encryption.go
+++ b/internal/air/cache/encryption.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"os"
+ "time"
// Import for side effects - registers sqlite3 driver and adiantum VFS
_ "github.com/ncruces/go-sqlite3/driver"
@@ -29,14 +30,21 @@ const (
// allowedTables is a whitelist of table names that can be used in SQL queries.
// This prevents SQL injection by ensuring only known table names are used.
var allowedTables = map[string]bool{
- "emails": true,
- "events": true,
- "contacts": true,
- "folders": true,
- "calendars": true,
- "sync_state": true,
+ "emails": true,
+ "events": true,
+ "contacts": true,
+ "folders": true,
+ "calendars": true,
+ "sync_state": true,
+ "attachments": true,
+ "offline_queue": true,
}
+var (
+ getOrCreateKeyFunc = getOrCreateKey
+ deleteKeyFunc = deleteKey
+)
+
// tableNames returns the list of allowed table names for migration operations.
func tableNames() []string {
names := make([]string, 0, len(allowedTables))
@@ -108,8 +116,9 @@ func openEncryptedDB(dbPath string, key []byte) (*sql.DB, error) {
return nil, fmt.Errorf("open encrypted database: %w", err)
}
- // Verify the key works by running a simple query
- if _, err := db.Exec("SELECT 1"); err != nil {
+ // Verify the key works by reading the schema, which fails with the wrong key.
+ var schemaObjects int
+ if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master").Scan(&schemaObjects); err != nil {
_ = db.Close()
return nil, fmt.Errorf("verify encryption key: %w", err)
}
@@ -161,7 +170,7 @@ func (m *EncryptedManager) GetDB(email string) (*sql.DB, error) {
}
// Get or create encryption key
- key, err := getOrCreateKey(email)
+ key, err := getOrCreateKeyFunc(email)
if err != nil {
return nil, fmt.Errorf("get encryption key for %s: %w", email, err)
}
@@ -207,7 +216,7 @@ func (m *EncryptedManager) ClearCache(email string) error {
// Remove encryption key if encryption is enabled
if m.encryption.Enabled {
delete(m.keys, email)
- if err := deleteKey(email); err != nil {
+ if err := deleteKeyFunc(email); err != nil {
// Log but don't fail - key might not exist
fmt.Fprintf(os.Stderr, "warning: failed to delete encryption key: %v\n", err)
}
@@ -233,7 +242,7 @@ func (m *EncryptedManager) MigrateToEncrypted(email string) error {
defer func() { _ = unencryptedDB.Close() }()
// Get or create encryption key
- key, err := getOrCreateKey(email)
+ key, err := getOrCreateKeyFunc(email)
if err != nil {
return fmt.Errorf("get encryption key: %w", err)
}
@@ -298,7 +307,7 @@ func (m *EncryptedManager) MigrateToUnencrypted(email string) error {
key, ok := m.keys[email]
if !ok {
var err error
- key, err = getOrCreateKey(email)
+ key, err = getOrCreateKeyFunc(email)
if err != nil {
return fmt.Errorf("get encryption key: %w", err)
}
@@ -352,12 +361,33 @@ func (m *EncryptedManager) MigrateToUnencrypted(email string) error {
_ = os.Remove(backupPath)
_ = os.Remove(backupPath + "-wal")
_ = os.Remove(backupPath + "-shm")
- _ = deleteKey(email)
+ _ = deleteKeyFunc(email)
delete(m.keys, email)
return nil
}
+// ClearAllCaches removes all encrypted cache databases and associated keys.
+func (m *EncryptedManager) ClearAllCaches() error {
+ accounts, err := m.ListCachedAccounts()
+ if err != nil {
+ return err
+ }
+
+ if err := m.Manager.ClearAllCaches(); err != nil {
+ return err
+ }
+
+ for _, email := range accounts {
+ delete(m.keys, email)
+ if err := deleteKeyFunc(email); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: failed to delete encryption key: %v\n", err)
+ }
+ }
+
+ return nil
+}
+
// copyTable copies all rows from one table to another.
func copyTable(src, dst *sql.DB, table string) error {
// Validate table name against whitelist to prevent SQL injection
@@ -437,8 +467,9 @@ func IsEncrypted(dbPath string) (bool, error) {
}
defer func() { _ = db.Close() }()
- // Try a simple query - will fail if encrypted
- _, err = db.Exec("SELECT 1")
+ // Read the schema - this fails when the database is encrypted and opened without a key.
+ var schemaObjects int
+ err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master").Scan(&schemaObjects)
if err != nil {
// Database exists but can't be read - likely encrypted
return true, nil
@@ -446,3 +477,35 @@ func IsEncrypted(dbPath string) (bool, error) {
return false, nil
}
+
+// GetStats returns statistics for an encrypted cache database.
+func (m *EncryptedManager) GetStats(email string) (*CacheStats, error) {
+ db, err := m.GetDB(email)
+ if err != nil {
+ return nil, err
+ }
+
+ stats := &CacheStats{Email: email}
+
+ info, err := os.Stat(m.DBPath(email))
+ if err == nil {
+ stats.SizeBytes = info.Size()
+ }
+
+ row := db.QueryRow("SELECT COUNT(*) FROM emails")
+ _ = row.Scan(&stats.EmailCount)
+
+ row = db.QueryRow("SELECT COUNT(*) FROM events")
+ _ = row.Scan(&stats.EventCount)
+
+ row = db.QueryRow("SELECT COUNT(*) FROM contacts")
+ _ = row.Scan(&stats.ContactCount)
+
+ var lastSync int64
+ row = db.QueryRow("SELECT MAX(last_sync) FROM sync_state")
+ if err := row.Scan(&lastSync); err == nil && lastSync > 0 {
+ stats.LastSync = time.Unix(lastSync, 0)
+ }
+
+ return stats, nil
+}
diff --git a/internal/air/cache/encryption_migration_roundtrip_test.go b/internal/air/cache/encryption_migration_roundtrip_test.go
new file mode 100644
index 0000000..3d7e8c3
--- /dev/null
+++ b/internal/air/cache/encryption_migration_roundtrip_test.go
@@ -0,0 +1,121 @@
+package cache
+
+import (
+ "database/sql"
+ "testing"
+)
+
+func TestMigrateEncryptionPreservesAttachmentsAndOfflineQueue(t *testing.T) {
+ originalGetKey := getOrCreateKeyFunc
+ originalDeleteKey := deleteKeyFunc
+ defer func() {
+ getOrCreateKeyFunc = originalGetKey
+ deleteKeyFunc = originalDeleteKey
+ }()
+
+ key, err := generateKey()
+ if err != nil {
+ t.Fatalf("generate encryption key: %v", err)
+ }
+ getOrCreateKeyFunc = func(string) ([]byte, error) { return key, nil }
+ deleteKeyFunc = func(string) error { return nil }
+
+ tmpDir := t.TempDir()
+ cfg := Config{BasePath: tmpDir}
+ email := "encrypted@example.com"
+
+ plainMgr, err := NewManager(cfg)
+ if err != nil {
+ t.Fatalf("new plain manager: %v", err)
+ }
+ defer func() { _ = plainMgr.Close() }()
+
+ db, err := plainMgr.GetDB(email)
+ if err != nil {
+ t.Fatalf("get plain db: %v", err)
+ }
+
+ if _, err := db.Exec(`
+ INSERT INTO attachments (id, email_id, filename, content_type, size, hash, local_path, cached_at, accessed_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, "att-1", "email-1", "invoice.pdf", "application/pdf", 42, "hash-1", "/tmp/att-1", 1, 1); err != nil {
+ t.Fatalf("insert attachment: %v", err)
+ }
+ if _, err := db.Exec(`
+ INSERT INTO offline_queue (type, resource_id, payload, created_at)
+ VALUES (?, ?, ?, ?)
+ `, string(ActionDelete), "email-1", `{"email_id":"email-1"}`, 1); err != nil {
+ t.Fatalf("insert offline queue action: %v", err)
+ }
+ if err := plainMgr.CloseDB(email); err != nil {
+ t.Fatalf("close plain db before migration: %v", err)
+ }
+
+ encMgr, err := NewEncryptedManager(cfg, EncryptionConfig{Enabled: true})
+ if err != nil {
+ t.Fatalf("new encrypted manager: %v", err)
+ }
+ defer func() { _ = encMgr.Close() }()
+
+ if err := encMgr.MigrateToEncrypted(email); err != nil {
+ t.Fatalf("migrate to encrypted: %v", err)
+ }
+
+ isEncrypted, err := IsEncrypted(encMgr.DBPath(email))
+ if err != nil {
+ t.Fatalf("detect encrypted db: %v", err)
+ }
+ if !isEncrypted {
+ t.Fatal("expected database to be encrypted after migration")
+ }
+
+ rawDB, err := sql.Open(driverName, encMgr.DBPath(email))
+ if err != nil {
+ t.Fatalf("open encrypted db without key: %v", err)
+ }
+ defer func() { _ = rawDB.Close() }()
+
+ var rawCount int
+ if err := rawDB.QueryRow("SELECT COUNT(*) FROM attachments").Scan(&rawCount); err == nil {
+ t.Fatal("expected unencrypted reader to fail on encrypted database")
+ }
+
+ encryptedDB, err := encMgr.GetDB(email)
+ if err != nil {
+ t.Fatalf("open encrypted db with key: %v", err)
+ }
+
+ var attachmentCount, queueCount int
+ if err := encryptedDB.QueryRow("SELECT COUNT(*) FROM attachments").Scan(&attachmentCount); err != nil {
+ t.Fatalf("count attachments after encrypt migration: %v", err)
+ }
+ if err := encryptedDB.QueryRow("SELECT COUNT(*) FROM offline_queue").Scan(&queueCount); err != nil {
+ t.Fatalf("count offline queue after encrypt migration: %v", err)
+ }
+ if attachmentCount != 1 || queueCount != 1 {
+ t.Fatalf("expected migrated attachment/offline queue rows to be preserved, got attachments=%d queue=%d", attachmentCount, queueCount)
+ }
+
+ if err := encMgr.CloseDB(email); err != nil {
+ t.Fatalf("close encrypted db: %v", err)
+ }
+ if err := encMgr.MigrateToUnencrypted(email); err != nil {
+ t.Fatalf("migrate to unencrypted: %v", err)
+ }
+
+ plainDB, err := sql.Open(driverName, encMgr.DBPath(email))
+ if err != nil {
+ t.Fatalf("open unencrypted db: %v", err)
+ }
+ defer func() { _ = plainDB.Close() }()
+
+ if err := plainDB.QueryRow("SELECT COUNT(*) FROM attachments").Scan(&attachmentCount); err != nil {
+ t.Fatalf("count attachments after decrypt migration: %v", err)
+ }
+ if err := plainDB.QueryRow("SELECT COUNT(*) FROM offline_queue").Scan(&queueCount); err != nil {
+ t.Fatalf("count offline queue after decrypt migration: %v", err)
+ }
+ if attachmentCount != 1 || queueCount != 1 {
+ t.Fatalf("expected round-trip migration to preserve attachment/offline queue rows, got attachments=%d queue=%d", attachmentCount, queueCount)
+ }
+}
diff --git a/internal/air/cache/offline.go b/internal/air/cache/offline.go
index 128b174..15836e7 100644
--- a/internal/air/cache/offline.go
+++ b/internal/air/cache/offline.go
@@ -13,6 +13,7 @@ type ActionType string
const (
ActionMarkRead ActionType = "mark_read"
ActionMarkUnread ActionType = "mark_unread"
+ ActionUpdateMessage ActionType = "update_message"
ActionStar ActionType = "star"
ActionUnstar ActionType = "unstar"
ActionArchive ActionType = "archive"
@@ -256,22 +257,40 @@ func (a *QueuedAction) GetActionData(v any) error {
// MarkReadPayload is the payload for mark read/unread actions.
type MarkReadPayload struct {
+ GrantID string `json:"grant_id,omitempty"`
EmailID string `json:"email_id"`
Unread bool `json:"unread"`
}
+// UpdateMessagePayload is the payload for a generic message update.
+type UpdateMessagePayload struct {
+ GrantID string `json:"grant_id,omitempty"`
+ EmailID string `json:"email_id"`
+ Unread *bool `json:"unread,omitempty"`
+ Starred *bool `json:"starred,omitempty"`
+ Folders []string `json:"folders,omitempty"`
+}
+
// StarPayload is the payload for star/unstar actions.
type StarPayload struct {
+ GrantID string `json:"grant_id,omitempty"`
EmailID string `json:"email_id"`
Starred bool `json:"starred"`
}
// MovePayload is the payload for move actions.
type MovePayload struct {
+ GrantID string `json:"grant_id,omitempty"`
EmailID string `json:"email_id"`
FolderID string `json:"folder_id"`
}
+// DeleteMessagePayload is the payload for delete actions.
+type DeleteMessagePayload struct {
+ GrantID string `json:"grant_id,omitempty"`
+ EmailID string `json:"email_id"`
+}
+
// SendEmailPayload is the payload for send email actions.
type SendEmailPayload struct {
To []string `json:"to"`
diff --git a/internal/air/cache/photos.go b/internal/air/cache/photos.go
index 547627e..b80f385 100644
--- a/internal/air/cache/photos.go
+++ b/internal/air/cache/photos.go
@@ -207,6 +207,14 @@ func (s *PhotoStore) TotalSize() (int64, error) {
return size, err
}
+// Close releases the underlying photo metadata database handle.
+func (s *PhotoStore) Close() error {
+ if s == nil || s.db == nil {
+ return nil
+ }
+ return s.db.Close()
+}
+
// RemoveOrphaned removes photo files not referenced in database.
func (s *PhotoStore) RemoveOrphaned() (int, error) {
// Get all known contact IDs
diff --git a/internal/air/cache/schema.go b/internal/air/cache/schema.go
index 8fe4321..ac38855 100644
--- a/internal/air/cache/schema.go
+++ b/internal/air/cache/schema.go
@@ -268,6 +268,40 @@ func initSchema(db *sql.DB) error {
return fmt.Errorf("create sync_state table: %w", err)
}
+ // Create attachments metadata table
+ _, err = tx.Exec(`
+ CREATE TABLE IF NOT EXISTS attachments (
+ id TEXT PRIMARY KEY,
+ email_id TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ content_type TEXT,
+ size INTEGER NOT NULL,
+ hash TEXT NOT NULL,
+ local_path TEXT NOT NULL,
+ cached_at INTEGER NOT NULL,
+ accessed_at INTEGER NOT NULL
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("create attachments table: %w", err)
+ }
+
+ // Create offline action queue table
+ _, err = tx.Exec(`
+ CREATE TABLE IF NOT EXISTS offline_queue (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ type TEXT NOT NULL,
+ resource_id TEXT NOT NULL,
+ payload TEXT,
+ created_at INTEGER NOT NULL,
+ attempts INTEGER DEFAULT 0,
+ last_error TEXT
+ )
+ `)
+ if err != nil {
+ return fmt.Errorf("create offline_queue table: %w", err)
+ }
+
// Create indexes for common queries
indexes := []string{
"CREATE INDEX IF NOT EXISTS idx_emails_folder ON emails(folder_id)",
@@ -278,6 +312,10 @@ func initSchema(db *sql.DB) error {
"CREATE INDEX IF NOT EXISTS idx_events_calendar ON events(calendar_id)",
"CREATE INDEX IF NOT EXISTS idx_events_time ON events(start_time, end_time)",
"CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email)",
+ "CREATE INDEX IF NOT EXISTS idx_attachments_email ON attachments(email_id)",
+ "CREATE INDEX IF NOT EXISTS idx_attachments_hash ON attachments(hash)",
+ "CREATE INDEX IF NOT EXISTS idx_attachments_accessed ON attachments(accessed_at)",
+ "CREATE INDEX IF NOT EXISTS idx_offline_queue_created ON offline_queue(created_at)",
}
for _, idx := range indexes {
if _, err = tx.Exec(idx); err != nil {
diff --git a/internal/air/cache/settings.go b/internal/air/cache/settings.go
index 49f1a09..ef59258 100644
--- a/internal/air/cache/settings.go
+++ b/internal/air/cache/settings.go
@@ -234,6 +234,14 @@ func (s *Settings) ToEncryptionConfig() EncryptionConfig {
}
}
+// BasePath returns the directory containing the settings file.
+func (s *Settings) BasePath() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ return filepath.Dir(s.filePath)
+}
+
// Validate checks if settings are valid.
func (s *Settings) Validate() error {
s.mu.RLock()
diff --git a/internal/air/cache_runtime.go b/internal/air/cache_runtime.go
new file mode 100644
index 0000000..c6dc2f1
--- /dev/null
+++ b/internal/air/cache_runtime.go
@@ -0,0 +1,170 @@
+package air
+
+import (
+ "database/sql"
+ "fmt"
+ "os"
+
+ "github.com/nylas/cli/internal/air/cache"
+)
+
+type cacheRuntimeManager interface {
+ GetDB(email string) (*sql.DB, error)
+ Close() error
+ ClearCache(email string) error
+ ClearAllCaches() error
+ ListCachedAccounts() ([]string, error)
+ GetStats(email string) (*cache.CacheStats, error)
+ DBPath(email string) string
+}
+
+func newCacheRuntimeManager(cfg cache.Config, encCfg cache.EncryptionConfig) (cacheRuntimeManager, error) {
+ if encCfg.Enabled {
+ return cache.NewEncryptedManager(cfg, encCfg)
+ }
+ return cache.NewManager(cfg)
+}
+
+func migrateCacheEncryption(basePath string, enabled bool) error {
+ cfg := cache.DefaultConfig()
+ cfg.BasePath = basePath
+
+ plainMgr, err := cache.NewManager(cfg)
+ if err != nil {
+ return fmt.Errorf("create cache manager for migration: %w", err)
+ }
+ defer func() { _ = plainMgr.Close() }()
+
+ encryptedMgr, err := cache.NewEncryptedManager(cfg, cache.EncryptionConfig{Enabled: true})
+ if err != nil {
+ return fmt.Errorf("create encrypted cache manager for migration: %w", err)
+ }
+ defer func() { _ = encryptedMgr.Close() }()
+
+ accounts, err := plainMgr.ListCachedAccounts()
+ if err != nil {
+ return fmt.Errorf("list cached accounts for migration: %w", err)
+ }
+
+ for _, email := range accounts {
+ dbPath := plainMgr.DBPath(email)
+ isEncrypted, err := cache.IsEncrypted(dbPath)
+ if err != nil {
+ return fmt.Errorf("detect cache encryption for %s: %w", email, err)
+ }
+
+ switch {
+ case enabled && !isEncrypted:
+ if err := encryptedMgr.MigrateToEncrypted(email); err != nil {
+ return fmt.Errorf("migrate cache to encrypted for %s: %w", email, err)
+ }
+ case !enabled && isEncrypted:
+ if err := encryptedMgr.MigrateToUnencrypted(email); err != nil {
+ return fmt.Errorf("migrate cache to unencrypted for %s: %w", email, err)
+ }
+ }
+ }
+
+ return nil
+}
+
+func (s *Server) reconfigureCacheRuntime() error {
+ if s.demoMode || s.cacheSettings == nil {
+ return nil
+ }
+
+ s.stopBackgroundSync()
+
+ cacheCfg := cache.DefaultConfig()
+ if basePath := s.cacheSettings.BasePath(); basePath != "" {
+ cacheCfg.BasePath = basePath
+ }
+ cacheCfg = s.cacheSettings.ToConfig(cacheCfg.BasePath)
+ encCfg := s.cacheSettings.ToEncryptionConfig()
+
+ var photoStore *cache.PhotoStore
+ err := func() error {
+ s.runtimeMu.Lock()
+ defer s.runtimeMu.Unlock()
+
+ if s.photoStore != nil {
+ if err := s.photoStore.Close(); err != nil {
+ return fmt.Errorf("close existing photo store: %w", err)
+ }
+ s.photoStore = nil
+ }
+
+ if s.cacheManager != nil {
+ if err := s.cacheManager.Close(); err != nil {
+ return fmt.Errorf("close existing cache manager: %w", err)
+ }
+ s.cacheManager = nil
+ }
+ s.clearOfflineQueues()
+
+ if !s.cacheSettings.IsCacheEnabled() {
+ return nil
+ }
+
+ if err := migrateCacheEncryption(cacheCfg.BasePath, encCfg.Enabled); err != nil {
+ return err
+ }
+
+ cacheManager, err := newCacheRuntimeManager(cacheCfg, encCfg)
+ if err != nil {
+ return fmt.Errorf("initialize cache manager: %w", err)
+ }
+ s.cacheManager = cacheManager
+
+ photoStore, err = openPhotoStore(cacheCfg.BasePath)
+ if err != nil {
+ _ = cacheManager.Close()
+ s.cacheManager = nil
+ return err
+ }
+ s.photoStore = photoStore
+
+ if s.cacheSettings.Get().OfflineQueueEnabled {
+ if err := s.initializeOfflineQueuesLocked(); err != nil {
+ _ = s.photoStore.Close()
+ s.photoStore = nil
+ _ = cacheManager.Close()
+ s.cacheManager = nil
+ return err
+ }
+ }
+
+ return nil
+ }()
+ if err != nil {
+ return err
+ }
+
+ if photoStore != nil {
+ go prunePhotoStore(photoStore)
+ }
+ s.startBackgroundSync()
+
+ return nil
+}
+
+func openPhotoStore(basePath string) (*cache.PhotoStore, error) {
+ photoDB, err := cache.OpenSharedDB(basePath, "photos.db")
+ if err != nil {
+ return nil, fmt.Errorf("open photo database: %w", err)
+ }
+
+ photoStore, err := cache.NewPhotoStore(photoDB, basePath, cache.DefaultPhotoTTL)
+ if err != nil {
+ _ = photoDB.Close()
+ return nil, fmt.Errorf("initialize photo store: %w", err)
+ }
+
+ return photoStore, nil
+}
+
+func prunePhotoStore(photoStore *cache.PhotoStore) {
+ if pruned, err := photoStore.Prune(); err == nil && pruned > 0 {
+ fmt.Fprintf(os.Stderr, "Pruned %d expired photos from cache\n", pruned)
+ }
+}
diff --git a/internal/air/handlers_cache.go b/internal/air/handlers_cache.go
index 1d6589d..a665ca3 100644
--- a/internal/air/handlers_cache.go
+++ b/internal/air/handlers_cache.go
@@ -1,6 +1,8 @@
package air
import (
+ "database/sql"
+ "fmt"
"net/http"
"time"
@@ -103,10 +105,13 @@ func (s *Server) handleCacheStatus(w http.ResponseWriter, r *http.Request) {
}
// Get stats for each account
- if s.cacheManager != nil {
- accounts, _ := s.cacheManager.ListCachedAccounts()
+ _ = s.withCacheManager(func(manager cacheRuntimeManager) error {
+ accounts, err := manager.ListCachedAccounts()
+ if err != nil {
+ return err
+ }
for _, email := range accounts {
- stats, err := s.cacheManager.GetStats(email)
+ stats, err := manager.GetStats(email)
if err != nil {
continue
}
@@ -130,12 +135,16 @@ func (s *Server) handleCacheStatus(w http.ResponseWriter, r *http.Request) {
response.LastSync = info.LastSync
}
}
- }
+ return nil
+ })
// Count pending actions across all queues
- for _, queue := range s.offlineQueues {
- count, _ := queue.Count()
- response.PendingActions += count
+ for _, email := range s.offlineQueueEmails() {
+ _ = s.withOfflineQueue(email, func(queue *cache.OfflineQueue) error {
+ count, _ := queue.Count()
+ response.PendingActions += count
+ return nil
+ })
}
writeJSON(w, http.StatusOK, response)
@@ -150,7 +159,7 @@ func (s *Server) handleCacheSync(w http.ResponseWriter, r *http.Request) {
return
}
- if s.cacheManager == nil {
+ if !s.hasCacheRuntime() {
writeJSON(w, http.StatusOK, CacheSyncResponse{
Success: false,
Error: "Cache not initialized",
@@ -195,7 +204,7 @@ func (s *Server) handleCacheSync(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, CacheSyncResponse{
Success: true,
- Message: "Synced " + string(rune('0'+synced)) + " account(s)",
+ Message: fmt.Sprintf("Synced %d account(s)", synced),
})
}
@@ -208,7 +217,7 @@ func (s *Server) handleCacheClear(w http.ResponseWriter, r *http.Request) {
return
}
- if s.cacheManager == nil {
+ if !s.hasCacheRuntime() {
writeJSON(w, http.StatusOK, CacheSyncResponse{
Success: false,
Error: "Cache not initialized",
@@ -221,24 +230,30 @@ func (s *Server) handleCacheClear(w http.ResponseWriter, r *http.Request) {
if email != "" {
// Clear single account
- if err := s.cacheManager.ClearCache(email); err != nil {
+ if err := s.withCacheManager(func(manager cacheRuntimeManager) error {
+ return manager.ClearCache(email)
+ }); err != nil {
writeJSON(w, http.StatusOK, CacheSyncResponse{
Success: false,
Error: "Failed to clear cache: " + err.Error(),
})
return
}
+ s.offlineQueuesMu.Lock()
delete(s.offlineQueues, email)
+ s.offlineQueuesMu.Unlock()
} else {
// Clear all accounts
- if err := s.cacheManager.ClearAllCaches(); err != nil {
+ if err := s.withCacheManager(func(manager cacheRuntimeManager) error {
+ return manager.ClearAllCaches()
+ }); err != nil {
writeJSON(w, http.StatusOK, CacheSyncResponse{
Success: false,
Error: "Failed to clear cache: " + err.Error(),
})
return
}
- s.offlineQueues = make(map[string]*cache.OfflineQueue)
+ s.clearOfflineQueues()
}
writeJSON(w, http.StatusOK, CacheSyncResponse{
@@ -275,7 +290,7 @@ func (s *Server) handleCacheSearch(w http.ResponseWriter, r *http.Request) {
return
}
- if s.cacheManager == nil {
+ if !s.hasCacheRuntime() {
writeJSON(w, http.StatusOK, CacheSearchResponse{
Results: []CacheSearchResult{},
Query: query,
@@ -295,19 +310,12 @@ func (s *Server) handleCacheSearch(w http.ResponseWriter, r *http.Request) {
return
}
- db, err := s.cacheManager.GetDB(email)
- if err != nil {
- writeJSON(w, http.StatusOK, CacheSearchResponse{
- Results: []CacheSearchResult{},
- Query: query,
- Total: 0,
- })
- return
- }
-
- // Perform unified search
- results, err := cache.UnifiedSearch(db, query, 20)
- if err != nil {
+ var results []*cache.UnifiedSearchResult
+ if err := s.withAccountDB(email, func(db *sql.DB) error {
+ var err error
+ results, err = cache.UnifiedSearch(db, query, 20)
+ return err
+ }); err != nil {
writeJSON(w, http.StatusOK, CacheSearchResponse{
Results: []CacheSearchResult{},
Query: query,
@@ -400,6 +408,8 @@ func (s *Server) updateCacheSettings(w http.ResponseWriter, r *http.Request) {
return
}
+ current := s.cacheSettings.Get()
+
// Update settings
err := s.cacheSettings.Update(func(s *cache.Settings) {
s.Enabled = req.Enabled
@@ -422,6 +432,38 @@ func (s *Server) updateCacheSettings(w http.ResponseWriter, r *http.Request) {
return
}
+ if current.Enabled != req.Enabled ||
+ current.OfflineQueueEnabled != req.OfflineQueueEnabled ||
+ current.EncryptionEnabled != req.EncryptionEnabled {
+ if err := s.reconfigureCacheRuntime(); err != nil {
+ _ = s.cacheSettings.Update(func(settings *cache.Settings) {
+ settings.Enabled = current.Enabled
+ settings.MaxSizeMB = current.MaxSizeMB
+ settings.TTLDays = current.TTLDays
+ settings.SyncIntervalMinutes = current.SyncIntervalMinutes
+ settings.OfflineQueueEnabled = current.OfflineQueueEnabled
+ settings.EncryptionEnabled = current.EncryptionEnabled
+ settings.Theme = current.Theme
+ settings.DefaultView = current.DefaultView
+ settings.CompactMode = current.CompactMode
+ settings.PreviewPosition = current.PreviewPosition
+ })
+ _ = s.reconfigureCacheRuntime()
+ writeJSON(w, http.StatusOK, map[string]any{
+ "success": false,
+ "error": "Failed to apply runtime settings: " + err.Error(),
+ })
+ return
+ }
+ }
+
+ if current.SyncIntervalMinutes != req.SyncIntervalMinutes &&
+ current.Enabled == req.Enabled &&
+ current.OfflineQueueEnabled == req.OfflineQueueEnabled &&
+ current.EncryptionEnabled == req.EncryptionEnabled {
+ s.restartBackgroundSync()
+ }
+
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"message": "Settings updated successfully",
diff --git a/internal/air/handlers_cache_test.go b/internal/air/handlers_cache_test.go
index 0f51fab..ff4cfc8 100644
--- a/internal/air/handlers_cache_test.go
+++ b/internal/air/handlers_cache_test.go
@@ -1,11 +1,15 @@
package air
import (
+ "bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
+
+ "github.com/nylas/cli/internal/air/cache"
+ "github.com/nylas/cli/internal/domain"
)
// ================================
@@ -314,3 +318,120 @@ func TestHandleCacheClear_WithEmail(t *testing.T) {
t.Errorf("expected status 200, got %d", w.Code)
}
}
+
+func TestUpdateCacheSettings_EnableCacheStartsBackgroundSync(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ settings, err := cache.LoadSettings(tmpDir)
+ if err != nil {
+ t.Fatalf("load settings: %v", err)
+ }
+ if err := settings.Update(func(cfg *cache.Settings) {
+ cfg.Enabled = false
+ cfg.SyncIntervalMinutes = 5
+ }); err != nil {
+ t.Fatalf("seed settings: %v", err)
+ }
+
+ server := &Server{
+ cacheSettings: settings,
+ grantStore: &testGrantStore{
+ grants: []domain.GrantInfo{{
+ ID: "grant-123",
+ Email: "user@example.com",
+ Provider: domain.ProviderGoogle,
+ }},
+ defaultGrant: "grant-123",
+ },
+ offlineQueues: make(map[string]*cache.OfflineQueue),
+ isOnline: false,
+ }
+ t.Cleanup(func() {
+ _ = server.Stop()
+ })
+
+ body, err := json.Marshal(CacheSettingsResponse{
+ Enabled: true,
+ MaxSizeMB: settings.Get().MaxSizeMB,
+ TTLDays: settings.Get().TTLDays,
+ SyncIntervalMinutes: 5,
+ OfflineQueueEnabled: settings.Get().OfflineQueueEnabled,
+ EncryptionEnabled: settings.Get().EncryptionEnabled,
+ Theme: settings.Get().Theme,
+ DefaultView: settings.Get().DefaultView,
+ CompactMode: settings.Get().CompactMode,
+ PreviewPosition: settings.Get().PreviewPosition,
+ })
+ if err != nil {
+ t.Fatalf("marshal request: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodPut, "/api/cache/settings", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.updateCacheSettings(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+ if !server.hasCacheRuntime() {
+ t.Fatal("expected cache runtime to be initialized")
+ }
+ if !server.syncRunning {
+ t.Fatal("expected background sync to start after enabling cache")
+ }
+}
+
+func TestUpdateCacheSettings_SyncIntervalRestartChangesWorkerChannel(t *testing.T) {
+ t.Parallel()
+
+ server, _, _ := newCachedTestServer(t)
+ server.nylasClient = nil
+ server.SetOnline(false)
+ server.startBackgroundSync()
+ t.Cleanup(func() {
+ server.stopBackgroundSync()
+ })
+
+ if !server.syncRunning {
+ t.Fatal("expected background sync to be running")
+ }
+
+ current := server.cacheSettings.Get()
+ body, err := json.Marshal(CacheSettingsResponse{
+ Enabled: current.Enabled,
+ MaxSizeMB: current.MaxSizeMB,
+ TTLDays: current.TTLDays,
+ SyncIntervalMinutes: current.SyncIntervalMinutes + 5,
+ OfflineQueueEnabled: current.OfflineQueueEnabled,
+ EncryptionEnabled: current.EncryptionEnabled,
+ Theme: current.Theme,
+ DefaultView: current.DefaultView,
+ CompactMode: current.CompactMode,
+ PreviewPosition: current.PreviewPosition,
+ })
+ if err != nil {
+ t.Fatalf("marshal request: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodPut, "/api/cache/settings", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.updateCacheSettings(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+ if !server.syncRunning {
+ t.Fatal("expected background sync to remain running")
+ }
+ if server.syncStopCh == nil {
+ t.Fatal("expected restarted background sync to have a stop channel")
+ }
+ if got := server.cacheSettings.Get().SyncIntervalMinutes; got != current.SyncIntervalMinutes+5 {
+ t.Fatalf("expected sync interval to update to %d, got %d", current.SyncIntervalMinutes+5, got)
+ }
+}
diff --git a/internal/air/handlers_config.go b/internal/air/handlers_config.go
index a23aa65..3dbad7f 100644
--- a/internal/air/handlers_config.go
+++ b/internal/air/handlers_config.go
@@ -76,7 +76,7 @@ func (s *Server) handleListGrants(w http.ResponseWriter, r *http.Request) {
return
}
- // Filter to only supported providers (Google, Microsoft)
+ // Filter to only providers supported by Air.
var grantList []Grant
for _, g := range grants {
if g.Provider.IsSupportedByAir() {
diff --git a/internal/air/handlers_config_test.go b/internal/air/handlers_config_test.go
index f17c765..c618f29 100644
--- a/internal/air/handlers_config_test.go
+++ b/internal/air/handlers_config_test.go
@@ -7,6 +7,8 @@ import (
"net/http/httptest"
"strings"
"testing"
+
+ "github.com/nylas/cli/internal/domain"
)
// Helper to create demo server for handler tests
@@ -158,6 +160,58 @@ func TestHandleSetDefaultGrant_InvalidJSON(t *testing.T) {
}
}
+func TestHandleListGrants_IncludesNylasProviders(t *testing.T) {
+ t.Parallel()
+
+ server := &Server{
+ grantStore: &testGrantStore{
+ grants: []domain.GrantInfo{
+ {ID: "grant-google", Email: "google@example.com", Provider: domain.ProviderGoogle},
+ {ID: "grant-nylas", Email: "nylas@example.com", Provider: domain.ProviderNylas},
+ {ID: "grant-imap", Email: "imap@example.com", Provider: domain.ProviderIMAP},
+ },
+ defaultGrant: "grant-nylas",
+ },
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/grants", nil)
+ w := httptest.NewRecorder()
+
+ server.handleListGrants(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+
+ var resp GrantsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+
+ if len(resp.Grants) != 2 {
+ t.Fatalf("expected 2 supported grants, got %d", len(resp.Grants))
+ }
+
+ providers := make(map[string]bool, len(resp.Grants))
+ for _, g := range resp.Grants {
+ providers[g.Provider] = true
+ }
+
+ if !providers[string(domain.ProviderGoogle)] {
+ t.Error("expected google grant to be included")
+ }
+ if !providers[string(domain.ProviderNylas)] {
+ t.Error("expected nylas grant to be included")
+ }
+ if providers[string(domain.ProviderIMAP)] {
+ t.Error("did not expect imap grant to be included")
+ }
+
+ if resp.DefaultGrant != "grant-nylas" {
+ t.Errorf("expected default grant 'grant-nylas', got %s", resp.DefaultGrant)
+ }
+}
+
// ================================
// FOLDERS HANDLER TESTS
// ================================
diff --git a/internal/air/handlers_contacts.go b/internal/air/handlers_contacts.go
index 8774cbd..e81aa72 100644
--- a/internal/air/handlers_contacts.go
+++ b/internal/air/handlers_contacts.go
@@ -52,23 +52,26 @@ func (s *Server) handleListContacts(w http.ResponseWriter, r *http.Request) {
accountEmail := s.getAccountEmail(grantID)
// Try cache first (only for first page without complex filters)
- if cursor == "" && params.Email == "" && params.Source == "" && s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
- if store, err := s.getContactStore(accountEmail); err == nil {
- cacheOpts := cache.ContactListOptions{
- Group: group,
- Limit: params.Limit,
+ if cursor == "" && params.Email == "" && params.Source == "" && s.cacheAvailable() {
+ cacheOpts := cache.ContactListOptions{
+ Group: group,
+ Limit: params.Limit,
+ }
+ var cached []*cache.CachedContact
+ if err := s.withContactStore(accountEmail, func(store *cache.ContactStore) error {
+ var err error
+ cached, err = store.List(cacheOpts)
+ return err
+ }); err == nil && len(cached) > 0 {
+ resp := ContactsResponse{
+ Contacts: make([]ContactResponse, 0, len(cached)),
+ HasMore: len(cached) >= params.Limit,
}
- if cached, err := store.List(cacheOpts); err == nil && len(cached) > 0 {
- resp := ContactsResponse{
- Contacts: make([]ContactResponse, 0, len(cached)),
- HasMore: len(cached) >= params.Limit,
- }
- for _, c := range cached {
- resp.Contacts = append(resp.Contacts, cachedContactToResponse(c))
- }
- writeJSON(w, http.StatusOK, resp)
- return
+ for _, c := range cached {
+ resp.Contacts = append(resp.Contacts, cachedContactToResponse(c))
}
+ writeJSON(w, http.StatusOK, resp)
+ return
}
}
diff --git a/internal/air/handlers_contacts_helpers.go b/internal/air/handlers_contacts_helpers.go
index feb1405..76f2c16 100644
--- a/internal/air/handlers_contacts_helpers.go
+++ b/internal/air/handlers_contacts_helpers.go
@@ -37,8 +37,16 @@ func (s *Server) handleContactPhoto(w http.ResponseWriter, r *http.Request, cont
}
// Try to serve from cache first
- if s.photoStore != nil {
- if imageData, contentType, err := s.photoStore.Get(contactID); err == nil && imageData != nil {
+ if s.hasPhotoStore() {
+ var (
+ imageData []byte
+ contentType string
+ )
+ if err := s.withPhotoStore(func(store *cache.PhotoStore) error {
+ var err error
+ imageData, contentType, err = store.Get(contactID)
+ return err
+ }); err == nil && imageData != nil {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("Content-Length", strconv.Itoa(len(imageData)))
@@ -106,8 +114,10 @@ func (s *Server) handleContactPhoto(w http.ResponseWriter, r *http.Request, cont
}
// Cache the photo for future requests (30 days)
- if s.photoStore != nil {
- _ = s.photoStore.Put(contactID, contentType, imageData)
+ if s.hasPhotoStore() {
+ _ = s.withPhotoStore(func(store *cache.PhotoStore) error {
+ return store.Put(contactID, contentType, imageData)
+ })
}
// Set headers and write image
diff --git a/internal/air/handlers_contacts_search.go b/internal/air/handlers_contacts_search.go
index 34bb001..3e3e55d 100644
--- a/internal/air/handlers_contacts_search.go
+++ b/internal/air/handlers_contacts_search.go
@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
+ "github.com/nylas/cli/internal/air/cache"
"github.com/nylas/cli/internal/domain"
)
@@ -96,20 +97,22 @@ func (s *Server) handleContactSearch(w http.ResponseWriter, r *http.Request) {
accountEmail := s.getAccountEmail(grantID)
// Try cache search first
- if q != "" && s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
- if store, err := s.getContactStore(accountEmail); err == nil {
- cached, err := store.Search(q, params.Limit)
- if err == nil && len(cached) > 0 {
- resp := ContactsResponse{
- Contacts: make([]ContactResponse, 0, len(cached)),
- HasMore: len(cached) >= params.Limit,
- }
- for _, c := range cached {
- resp.Contacts = append(resp.Contacts, cachedContactToResponse(c))
- }
- writeJSON(w, http.StatusOK, resp)
- return
+ if q != "" && s.cacheAvailable() {
+ var cached []*cache.CachedContact
+ if err := s.withContactStore(accountEmail, func(store *cache.ContactStore) error {
+ var err error
+ cached, err = store.Search(q, params.Limit)
+ return err
+ }); err == nil && len(cached) > 0 {
+ resp := ContactsResponse{
+ Contacts: make([]ContactResponse, 0, len(cached)),
+ HasMore: len(cached) >= params.Limit,
+ }
+ for _, c := range cached {
+ resp.Contacts = append(resp.Contacts, cachedContactToResponse(c))
}
+ writeJSON(w, http.StatusOK, resp)
+ return
}
}
diff --git a/internal/air/handlers_email.go b/internal/air/handlers_email.go
index 2e648b4..68a97e6 100644
--- a/internal/air/handlers_email.go
+++ b/internal/air/handlers_email.go
@@ -1,6 +1,9 @@
package air
import (
+ "context"
+ "errors"
+ "net"
"net/http"
"strings"
"time"
@@ -66,25 +69,15 @@ func (s *Server) handleListEmails(w http.ResponseWriter, r *http.Request) {
accountEmail := s.getAccountEmail(grantID)
// Try cache first (only for first page without complex filters)
- if cursor == "" && s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
- if store, err := s.getEmailStore(accountEmail); err == nil {
- cacheOpts := cache.ListOptions{
- Limit: params.Limit,
- FolderID: folderID,
- UnreadOnly: params.Unread != nil && *params.Unread,
- StarredOnly: params.Starred != nil && *params.Starred,
- }
- if cached, err := store.List(cacheOpts); err == nil && len(cached) > 0 {
- resp := EmailsResponse{
- Emails: make([]EmailResponse, 0, len(cached)),
- HasMore: len(cached) >= params.Limit,
- }
- for _, e := range cached {
- resp.Emails = append(resp.Emails, cachedEmailToResponse(e))
- }
- writeJSON(w, http.StatusOK, resp)
- return
- }
+ if cursor == "" && s.cacheAvailable() {
+ var cached []*cache.CachedEmail
+ if err := s.withEmailStore(accountEmail, func(store *cache.EmailStore) error {
+ var err error
+ cached, err = s.queryCachedEmails(store, params, folderID, fromFilter, searchQuery)
+ return err
+ }); err == nil && len(cached) > 0 {
+ writeJSON(w, http.StatusOK, cachedEmailsToResponse(cached, params.Limit))
+ return
}
}
@@ -95,20 +88,17 @@ func (s *Server) handleListEmails(w http.ResponseWriter, r *http.Request) {
result, err := s.nylasClient.GetMessagesWithCursor(ctx, grantID, params)
if err != nil {
// If offline and cache available, try cache as fallback
- if s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
- if store, storeErr := s.getEmailStore(accountEmail); storeErr == nil {
- cacheOpts := cache.ListOptions{Limit: params.Limit, FolderID: folderID}
- if cached, cacheErr := store.List(cacheOpts); cacheErr == nil && len(cached) > 0 {
- resp := EmailsResponse{
- Emails: make([]EmailResponse, 0, len(cached)),
- HasMore: false,
- }
- for _, e := range cached {
- resp.Emails = append(resp.Emails, cachedEmailToResponse(e))
- }
- writeJSON(w, http.StatusOK, resp)
- return
- }
+ if s.cacheAvailable() {
+ var cached []*cache.CachedEmail
+ if storeErr := s.withEmailStore(accountEmail, func(store *cache.EmailStore) error {
+ var cacheErr error
+ cached, cacheErr = s.queryCachedEmails(store, params, folderID, fromFilter, searchQuery)
+ return cacheErr
+ }); storeErr == nil && len(cached) > 0 {
+ resp := cachedEmailsToResponse(cached, params.Limit)
+ resp.HasMore = false
+ writeJSON(w, http.StatusOK, resp)
+ return
}
}
writeJSON(w, http.StatusInternalServerError, map[string]string{
@@ -118,12 +108,13 @@ func (s *Server) handleListEmails(w http.ResponseWriter, r *http.Request) {
}
// Cache the results
- if s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
- if store, err := s.getEmailStore(accountEmail); err == nil {
+ if s.cacheAvailable() {
+ _ = s.withEmailStore(accountEmail, func(store *cache.EmailStore) error {
for i := range result.Data {
_ = store.Put(domainMessageToCached(&result.Data[i]))
}
- }
+ return nil
+ })
}
// Convert to response format
@@ -184,14 +175,17 @@ func (s *Server) handleGetEmail(w http.ResponseWriter, r *http.Request, emailID
accountEmail := s.getAccountEmail(grantID)
// Try cache first
- if s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
- if store, err := s.getEmailStore(accountEmail); err == nil {
- if cached, err := store.Get(emailID); err == nil && cached != nil {
- resp := cachedEmailToResponse(cached)
- resp.Body = cached.BodyHTML // Include full body
- writeJSON(w, http.StatusOK, resp)
- return
- }
+ if s.cacheAvailable() {
+ var cached *cache.CachedEmail
+ if err := s.withEmailStore(accountEmail, func(store *cache.EmailStore) error {
+ var err error
+ cached, err = store.Get(emailID)
+ return err
+ }); err == nil && cached != nil {
+ resp := cachedEmailToResponse(cached)
+ resp.Body = cached.BodyHTML // Include full body
+ writeJSON(w, http.StatusOK, resp)
+ return
}
}
@@ -202,14 +196,17 @@ func (s *Server) handleGetEmail(w http.ResponseWriter, r *http.Request, emailID
msg, err := s.nylasClient.GetMessage(ctx, grantID, emailID)
if err != nil {
// Try cache as fallback on error
- if s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
- if store, storeErr := s.getEmailStore(accountEmail); storeErr == nil {
- if cached, cacheErr := store.Get(emailID); cacheErr == nil && cached != nil {
- resp := cachedEmailToResponse(cached)
- resp.Body = cached.BodyHTML
- writeJSON(w, http.StatusOK, resp)
- return
- }
+ if s.cacheAvailable() {
+ var cached *cache.CachedEmail
+ if storeErr := s.withEmailStore(accountEmail, func(store *cache.EmailStore) error {
+ var cacheErr error
+ cached, cacheErr = store.Get(emailID)
+ return cacheErr
+ }); storeErr == nil && cached != nil {
+ resp := cachedEmailToResponse(cached)
+ resp.Body = cached.BodyHTML
+ writeJSON(w, http.StatusOK, resp)
+ return
}
}
writeJSON(w, http.StatusInternalServerError, map[string]string{
@@ -219,10 +216,10 @@ func (s *Server) handleGetEmail(w http.ResponseWriter, r *http.Request, emailID
}
// Cache the result
- if s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
- if store, err := s.getEmailStore(accountEmail); err == nil {
- _ = store.Put(domainMessageToCached(msg))
- }
+ if s.cacheAvailable() {
+ _ = s.withEmailStore(accountEmail, func(store *cache.EmailStore) error {
+ return store.Put(domainMessageToCached(msg))
+ })
}
writeJSON(w, http.StatusOK, emailToResponse(*msg, true))
@@ -240,6 +237,8 @@ func (s *Server) handleUpdateEmail(w http.ResponseWriter, r *http.Request, email
return
}
+ accountEmail := s.getAccountEmail(grantID)
+
ctx, cancel := s.withTimeout(r)
defer cancel()
@@ -249,8 +248,30 @@ func (s *Server) handleUpdateEmail(w http.ResponseWriter, r *http.Request, email
Folders: req.Folders,
}
+ if !s.IsOnline() {
+ if err := s.enqueueMessageUpdate(grantID, accountEmail, emailID, updateReq); err == nil {
+ s.updateCachedEmail(accountEmail, emailID, req.Unread, req.Starred, req.Folders)
+ writeJSON(w, http.StatusOK, UpdateEmailResponse{
+ Success: true,
+ Message: "Email update queued until connection is restored",
+ })
+ return
+ }
+ }
+
_, err := s.nylasClient.UpdateMessage(ctx, grantID, emailID, updateReq)
if err != nil {
+ if s.shouldQueueEmailAction(err) {
+ if queueErr := s.enqueueMessageUpdate(grantID, accountEmail, emailID, updateReq); queueErr == nil {
+ s.SetOnline(false)
+ s.updateCachedEmail(accountEmail, emailID, req.Unread, req.Starred, req.Folders)
+ writeJSON(w, http.StatusOK, UpdateEmailResponse{
+ Success: true,
+ Message: "Email update queued until connection is restored",
+ })
+ return
+ }
+ }
writeJSON(w, http.StatusInternalServerError, UpdateEmailResponse{
Success: false,
Error: "Failed to update email: " + err.Error(),
@@ -258,6 +279,8 @@ func (s *Server) handleUpdateEmail(w http.ResponseWriter, r *http.Request, email
return
}
+ s.updateCachedEmail(accountEmail, emailID, req.Unread, req.Starred, req.Folders)
+
writeJSON(w, http.StatusOK, UpdateEmailResponse{
Success: true,
Message: "Email updated",
@@ -271,11 +294,35 @@ func (s *Server) handleDeleteEmail(w http.ResponseWriter, r *http.Request, email
return
}
+ accountEmail := s.getAccountEmail(grantID)
+
ctx, cancel := s.withTimeout(r)
defer cancel()
+ if !s.IsOnline() {
+ if err := s.enqueueMessageDelete(grantID, accountEmail, emailID); err == nil {
+ s.deleteCachedEmail(accountEmail, emailID)
+ writeJSON(w, http.StatusOK, UpdateEmailResponse{
+ Success: true,
+ Message: "Email delete queued until connection is restored",
+ })
+ return
+ }
+ }
+
err := s.nylasClient.DeleteMessage(ctx, grantID, emailID)
if err != nil {
+ if s.shouldQueueEmailAction(err) {
+ if queueErr := s.enqueueMessageDelete(grantID, accountEmail, emailID); queueErr == nil {
+ s.SetOnline(false)
+ s.deleteCachedEmail(accountEmail, emailID)
+ writeJSON(w, http.StatusOK, UpdateEmailResponse{
+ Success: true,
+ Message: "Email delete queued until connection is restored",
+ })
+ return
+ }
+ }
writeJSON(w, http.StatusInternalServerError, UpdateEmailResponse{
Success: false,
Error: "Failed to delete email: " + err.Error(),
@@ -283,12 +330,116 @@ func (s *Server) handleDeleteEmail(w http.ResponseWriter, r *http.Request, email
return
}
+ s.deleteCachedEmail(accountEmail, emailID)
+
writeJSON(w, http.StatusOK, UpdateEmailResponse{
Success: true,
Message: "Email deleted",
})
}
+func (s *Server) queryCachedEmails(store *cache.EmailStore, params *domain.MessageQueryParams, folderID, fromFilter, searchQuery string) ([]*cache.CachedEmail, error) {
+ if searchQuery == "" && fromFilter == "" {
+ return store.List(cache.ListOptions{
+ Limit: params.Limit,
+ FolderID: folderID,
+ UnreadOnly: params.Unread != nil && *params.Unread,
+ StarredOnly: params.Starred != nil && *params.Starred,
+ })
+ }
+
+ query := cache.ParseSearchQuery(searchQuery)
+ if fromFilter != "" {
+ query.From = fromFilter
+ }
+ if folderID != "" {
+ query.In = folderID
+ }
+ if params.Unread != nil {
+ query.IsUnread = params.Unread
+ }
+ if params.Starred != nil {
+ query.IsStarred = params.Starred
+ }
+
+ return store.SearchWithQuery(query, params.Limit)
+}
+
+func cachedEmailsToResponse(cached []*cache.CachedEmail, limit int) EmailsResponse {
+ resp := EmailsResponse{
+ Emails: make([]EmailResponse, 0, len(cached)),
+ HasMore: limit > 0 && len(cached) >= limit,
+ }
+ for _, email := range cached {
+ resp.Emails = append(resp.Emails, cachedEmailToResponse(email))
+ }
+ return resp
+}
+
+func (s *Server) shouldQueueEmailAction(err error) bool {
+ if !s.offlineQueueEnabled() {
+ return false
+ }
+ if !s.IsOnline() {
+ return true
+ }
+ var netErr net.Error
+ return errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded)
+}
+
+func (s *Server) offlineQueueEnabled() bool {
+ return s.offlineQueueConfigured()
+}
+
+func (s *Server) enqueueMessageUpdate(grantID, accountEmail, emailID string, updateReq *domain.UpdateMessageRequest) error {
+ if accountEmail == "" || !s.offlineQueueEnabled() {
+ return errors.New("offline queue unavailable")
+ }
+
+ return s.withOfflineQueue(accountEmail, func(queue *cache.OfflineQueue) error {
+ return queue.Enqueue(cache.ActionUpdateMessage, emailID, cache.UpdateMessagePayload{
+ GrantID: grantID,
+ EmailID: emailID,
+ Unread: updateReq.Unread,
+ Starred: updateReq.Starred,
+ Folders: updateReq.Folders,
+ })
+ })
+}
+
+func (s *Server) enqueueMessageDelete(grantID, accountEmail, emailID string) error {
+ if accountEmail == "" || !s.offlineQueueEnabled() {
+ return errors.New("offline queue unavailable")
+ }
+
+ return s.withOfflineQueue(accountEmail, func(queue *cache.OfflineQueue) error {
+ return queue.Enqueue(cache.ActionDelete, emailID, cache.DeleteMessagePayload{
+ GrantID: grantID,
+ EmailID: emailID,
+ })
+ })
+}
+
+func (s *Server) updateCachedEmail(accountEmail, emailID string, unread, starred *bool, folders []string) {
+ if accountEmail == "" || !s.cacheAvailable() {
+ return
+ }
+
+ _ = s.withEmailStore(accountEmail, func(store *cache.EmailStore) error {
+ return store.UpdateMessage(emailID, unread, starred, folders)
+ })
+}
+
+func (s *Server) deleteCachedEmail(accountEmail, emailID string) {
+ if accountEmail == "" || !s.cacheAvailable() {
+ return
+ }
+
+ _ = s.withEmailStore(accountEmail, func(store *cache.EmailStore) error {
+ return store.Delete(emailID)
+ })
+}
+
// emailToResponse converts a domain message to an API response.
func emailToResponse(m domain.Message, includeBody bool) EmailResponse {
resp := EmailResponse{
diff --git a/internal/air/handlers_email_cache_runtime_test.go b/internal/air/handlers_email_cache_runtime_test.go
new file mode 100644
index 0000000..f544cb8
--- /dev/null
+++ b/internal/air/handlers_email_cache_runtime_test.go
@@ -0,0 +1,599 @@
+package air
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ nylasmock "github.com/nylas/cli/internal/adapters/nylas"
+ "github.com/nylas/cli/internal/air/cache"
+ "github.com/nylas/cli/internal/domain"
+)
+
+type testGrantStore struct {
+ grants []domain.GrantInfo
+ defaultGrant string
+}
+
+type failingCacheRuntimeManager struct {
+ closeErr error
+}
+
+func (m *failingCacheRuntimeManager) GetDB(string) (*sql.DB, error) { return nil, nil }
+func (m *failingCacheRuntimeManager) Close() error { return m.closeErr }
+func (m *failingCacheRuntimeManager) ClearCache(string) error { return nil }
+func (m *failingCacheRuntimeManager) ClearAllCaches() error { return nil }
+func (m *failingCacheRuntimeManager) ListCachedAccounts() ([]string, error) { return nil, nil }
+func (m *failingCacheRuntimeManager) GetStats(string) (*cache.CacheStats, error) { return nil, nil }
+func (m *failingCacheRuntimeManager) DBPath(string) string { return "" }
+
+func (s *testGrantStore) SaveGrant(info domain.GrantInfo) error {
+ s.grants = append(s.grants, info)
+ return nil
+}
+
+func (s *testGrantStore) GetGrant(grantID string) (*domain.GrantInfo, error) {
+ for i := range s.grants {
+ if s.grants[i].ID == grantID {
+ return &s.grants[i], nil
+ }
+ }
+ return nil, domain.ErrGrantNotFound
+}
+
+func (s *testGrantStore) GetGrantByEmail(email string) (*domain.GrantInfo, error) {
+ for i := range s.grants {
+ if s.grants[i].Email == email {
+ return &s.grants[i], nil
+ }
+ }
+ return nil, domain.ErrGrantNotFound
+}
+
+func (s *testGrantStore) ListGrants() ([]domain.GrantInfo, error) { return s.grants, nil }
+func (s *testGrantStore) DeleteGrant(grantID string) error { return nil }
+func (s *testGrantStore) SetDefaultGrant(grantID string) error {
+ s.defaultGrant = grantID
+ return nil
+}
+func (s *testGrantStore) GetDefaultGrant() (string, error) { return s.defaultGrant, nil }
+func (s *testGrantStore) ClearGrants() error {
+ s.grants = nil
+ s.defaultGrant = ""
+ return nil
+}
+
+func newCachedTestServer(t *testing.T) (*Server, *nylasmock.MockClient, string) {
+ t.Helper()
+
+ tmpDir := t.TempDir()
+ manager, err := cache.NewManager(cache.Config{BasePath: tmpDir})
+ if err != nil {
+ t.Fatalf("new cache manager: %v", err)
+ }
+
+ settings := cache.DefaultSettings()
+ settings.Enabled = true
+ settings.OfflineQueueEnabled = true
+
+ email := "user@example.com"
+ grantID := "grant-123"
+ client := nylasmock.NewMockClient()
+
+ server := &Server{
+ cacheManager: manager,
+ cacheSettings: settings,
+ grantStore: &testGrantStore{
+ grants: []domain.GrantInfo{{
+ ID: grantID,
+ Email: email,
+ Provider: domain.ProviderGoogle,
+ }},
+ defaultGrant: grantID,
+ },
+ nylasClient: client,
+ offlineQueues: make(map[string]*cache.OfflineQueue),
+ syncStopCh: make(chan struct{}),
+ isOnline: true,
+ }
+
+ t.Cleanup(func() {
+ _ = manager.Close()
+ })
+
+ return server, client, email
+}
+
+func putCachedEmail(t *testing.T, server *Server, accountEmail string, email *cache.CachedEmail) {
+ t.Helper()
+
+ store, err := server.getEmailStore(accountEmail)
+ if err != nil {
+ t.Fatalf("get email store: %v", err)
+ }
+ if err := store.Put(email); err != nil {
+ t.Fatalf("put cached email: %v", err)
+ }
+}
+
+func TestHandleListEmails_UsesCacheFilters(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ query string
+ wantID string
+ }{
+ {name: "from filter", query: "/api/emails?from=alice@example.com", wantID: "email-alice"},
+ {name: "search query", query: "/api/emails?search=Quarterly", wantID: "email-alice"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ server, client, accountEmail := newCachedTestServer(t)
+ client.GetMessagesWithParamsFunc = func(_ context.Context, _ string, _ *domain.MessageQueryParams) ([]domain.Message, error) {
+ t.Fatal("expected cache hit without API request")
+ return nil, nil
+ }
+
+ putCachedEmail(t, server, accountEmail, &cache.CachedEmail{
+ ID: "email-alice",
+ FolderID: "inbox",
+ Subject: "Quarterly planning",
+ Snippet: "Q2 planning notes",
+ FromName: "Alice",
+ FromEmail: "alice@example.com",
+ Date: time.Now(),
+ Unread: true,
+ CachedAt: time.Now(),
+ })
+ putCachedEmail(t, server, accountEmail, &cache.CachedEmail{
+ ID: "email-bob",
+ FolderID: "inbox",
+ Subject: "Budget review",
+ Snippet: "FYI",
+ FromName: "Bob",
+ FromEmail: "bob@example.com",
+ Date: time.Now().Add(-time.Minute),
+ CachedAt: time.Now(),
+ })
+
+ req := httptest.NewRequest(http.MethodGet, tt.query, nil)
+ w := httptest.NewRecorder()
+
+ server.handleListEmails(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+
+ var resp EmailsResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+
+ if len(resp.Emails) != 1 {
+ t.Fatalf("expected 1 email, got %d", len(resp.Emails))
+ }
+ if resp.Emails[0].ID != tt.wantID {
+ t.Fatalf("expected %s, got %s", tt.wantID, resp.Emails[0].ID)
+ }
+ })
+ }
+}
+
+func TestHandleUpdateEmail_UpdatesCacheOnSuccess(t *testing.T) {
+ t.Parallel()
+
+ server, client, accountEmail := newCachedTestServer(t)
+ putCachedEmail(t, server, accountEmail, &cache.CachedEmail{
+ ID: "email-1",
+ FolderID: "inbox",
+ Subject: "Hello",
+ FromEmail: "sender@example.com",
+ Date: time.Now(),
+ Unread: true,
+ Starred: false,
+ CachedAt: time.Now(),
+ })
+
+ reqBody := bytes.NewBufferString(`{"unread":false,"starred":true,"folders":["archive"]}`)
+ req := httptest.NewRequest(http.MethodPut, "/api/emails/email-1", reqBody)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ server.handleUpdateEmail(w, req, "email-1")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+ if !client.UpdateMessageCalled {
+ t.Fatal("expected UpdateMessage to be called")
+ }
+
+ store, err := server.getEmailStore(accountEmail)
+ if err != nil {
+ t.Fatalf("get email store: %v", err)
+ }
+ cached, err := store.Get("email-1")
+ if err != nil {
+ t.Fatalf("get cached email: %v", err)
+ }
+
+ if cached.Unread {
+ t.Fatal("expected cached email to be marked read")
+ }
+ if !cached.Starred {
+ t.Fatal("expected cached email to be starred")
+ }
+ if cached.FolderID != "archive" {
+ t.Fatalf("expected folder archive, got %s", cached.FolderID)
+ }
+}
+
+func TestHandleDeleteEmail_QueuesOfflineAndRemovesCachedEmail(t *testing.T) {
+ t.Parallel()
+
+ server, client, accountEmail := newCachedTestServer(t)
+ server.SetOnline(false)
+ putCachedEmail(t, server, accountEmail, &cache.CachedEmail{
+ ID: "email-1",
+ FolderID: "inbox",
+ Subject: "Hello",
+ FromEmail: "sender@example.com",
+ Date: time.Now(),
+ CachedAt: time.Now(),
+ })
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/emails/email-1", nil)
+ w := httptest.NewRecorder()
+
+ server.handleDeleteEmail(w, req, "email-1")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+ if client.DeleteMessageCalled {
+ t.Fatal("did not expect DeleteMessage API call while offline")
+ }
+
+ queue, err := server.getOfflineQueue(accountEmail)
+ if err != nil {
+ t.Fatalf("get offline queue: %v", err)
+ }
+ count, err := queue.Count()
+ if err != nil {
+ t.Fatalf("count offline queue: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("expected 1 queued action, got %d", count)
+ }
+
+ store, err := server.getEmailStore(accountEmail)
+ if err != nil {
+ t.Fatalf("get email store: %v", err)
+ }
+ if _, err := store.Get("email-1"); !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("expected cached email to be removed, got err=%v", err)
+ }
+
+ statusReq := httptest.NewRequest(http.MethodGet, "/api/cache/status", nil)
+ statusRes := httptest.NewRecorder()
+ server.handleCacheStatus(statusRes, statusReq)
+
+ var status CacheStatusResponse
+ if err := json.NewDecoder(statusRes.Body).Decode(&status); err != nil {
+ t.Fatalf("decode cache status: %v", err)
+ }
+ if status.PendingActions != 1 {
+ t.Fatalf("expected 1 pending action, got %d", status.PendingActions)
+ }
+}
+
+func TestHandleDeleteEmail_RemovesCachedEmailOnSuccess(t *testing.T) {
+ t.Parallel()
+
+ server, client, accountEmail := newCachedTestServer(t)
+ putCachedEmail(t, server, accountEmail, &cache.CachedEmail{
+ ID: "email-1",
+ FolderID: "inbox",
+ Subject: "Hello",
+ FromEmail: "sender@example.com",
+ Date: time.Now(),
+ CachedAt: time.Now(),
+ })
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/emails/email-1", nil)
+ w := httptest.NewRecorder()
+
+ server.handleDeleteEmail(w, req, "email-1")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+ if !client.DeleteMessageCalled {
+ t.Fatal("expected DeleteMessage API call")
+ }
+
+ store, err := server.getEmailStore(accountEmail)
+ if err != nil {
+ t.Fatalf("get email store: %v", err)
+ }
+ if _, err := store.Get("email-1"); !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("expected cached email to be removed, got err=%v", err)
+ }
+}
+
+func TestInitCacheRuntime_UsesEncryptedManagerWhenEnabled(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ settings, err := cache.LoadSettings(tmpDir)
+ if err != nil {
+ t.Fatalf("load settings: %v", err)
+ }
+ if err := settings.Update(func(s *cache.Settings) {
+ s.Enabled = true
+ s.EncryptionEnabled = true
+ }); err != nil {
+ t.Fatalf("update settings: %v", err)
+ }
+
+ server := &Server{
+ cacheSettings: settings,
+ offlineQueues: make(map[string]*cache.OfflineQueue),
+ syncStopCh: make(chan struct{}),
+ isOnline: true,
+ }
+
+ server.initCacheRuntime()
+
+ if server.cacheManager == nil {
+ t.Fatal("expected cache manager to be initialized")
+ }
+ if _, ok := server.cacheManager.(*cache.EncryptedManager); !ok {
+ t.Fatalf("expected encrypted cache manager, got %T", server.cacheManager)
+ }
+}
+
+func TestReconfigureCacheRuntime_WaitsForInFlightCacheAccess(t *testing.T) {
+ t.Parallel()
+
+ server, _, accountEmail := newCachedTestServer(t)
+ server.nylasClient = nil
+ server.SetOnline(false)
+ putCachedEmail(t, server, accountEmail, &cache.CachedEmail{
+ ID: "email-1",
+ FolderID: "inbox",
+ Subject: "Hello",
+ FromEmail: "sender@example.com",
+ Date: time.Now(),
+ CachedAt: time.Now(),
+ })
+
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ accessDone := make(chan error, 1)
+ go func() {
+ accessDone <- server.withEmailStore(accountEmail, func(store *cache.EmailStore) error {
+ close(entered)
+ <-release
+ _, err := store.Get("email-1")
+ return err
+ })
+ }()
+
+ <-entered
+
+ reconfigureDone := make(chan error, 1)
+ go func() {
+ reconfigureDone <- server.reconfigureCacheRuntime()
+ }()
+
+ select {
+ case err := <-reconfigureDone:
+ t.Fatalf("reconfigure returned before in-flight access completed: %v", err)
+ case <-time.After(50 * time.Millisecond):
+ }
+
+ close(release)
+
+ if err := <-accessDone; err != nil {
+ t.Fatalf("in-flight cache access failed: %v", err)
+ }
+ if err := <-reconfigureDone; err != nil {
+ t.Fatalf("reconfigure cache runtime: %v", err)
+ }
+
+ t.Cleanup(func() {
+ _ = server.Stop()
+ })
+}
+
+func TestReconfigureCacheRuntime_UnlocksRuntimeMutexOnCloseError(t *testing.T) {
+ t.Parallel()
+
+ settings := cache.DefaultSettings()
+ settings.Enabled = true
+
+ server := &Server{
+ cacheManager: &failingCacheRuntimeManager{closeErr: errors.New("close failed")},
+ cacheSettings: settings,
+ offlineQueues: make(map[string]*cache.OfflineQueue),
+ syncStopCh: make(chan struct{}),
+ isOnline: true,
+ }
+
+ if err := server.reconfigureCacheRuntime(); err == nil {
+ t.Fatal("expected close failure from reconfigure")
+ }
+
+ done := make(chan struct{})
+ go func() {
+ _ = server.hasCacheRuntime()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(200 * time.Millisecond):
+ t.Fatal("runtime mutex remained locked after reconfigure error")
+ }
+}
+
+func TestSyncEmails_DoesNotHoldRuntimeLockAcrossFetch(t *testing.T) {
+ server, client, accountEmail := newCachedTestServer(t)
+ server.SetOnline(true)
+
+ fetchStarted := make(chan struct{})
+ releaseFetch := make(chan struct{})
+ client.GetMessagesFunc = func(_ context.Context, _ string, _ int) ([]domain.Message, error) {
+ close(fetchStarted)
+ <-releaseFetch
+ return []domain.Message{}, nil
+ }
+
+ syncDone := make(chan struct{})
+ go func() {
+ server.syncEmails(context.Background(), accountEmail, "grant-123")
+ close(syncDone)
+ }()
+
+ <-fetchStarted
+
+ lockReleased := make(chan struct{})
+ go func() {
+ server.runtimeMu.Lock()
+ _ = server.cacheManager
+ server.runtimeMu.Unlock()
+ close(lockReleased)
+ }()
+
+ select {
+ case <-lockReleased:
+ case <-time.After(2 * time.Second):
+ t.Fatal("runtime lock remained blocked while remote fetch was in progress")
+ }
+
+ close(releaseFetch)
+
+ select {
+ case <-syncDone:
+ // Under -race on shared CI runners, sqlite writes after the fetch unblocks
+ // can take materially longer than the lock probe itself. The behavioral
+ // guarantee this test cares about is that the runtime lock is not held
+ // across the remote fetch, not that the full sync finishes within a tight
+ // local-only deadline.
+ case <-time.After(20 * time.Second):
+ t.Fatal("syncEmails did not finish after fetch was released")
+ }
+
+ t.Cleanup(func() {
+ _ = server.Stop()
+ })
+}
+
+func TestProcessOfflineQueues_UsesQueuedGrantID(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ enqueue func(t *testing.T, server *Server, accountEmail string)
+ assertReplayed func(t *testing.T, client *nylasmock.MockClient)
+ }{
+ {
+ name: "delete action",
+ enqueue: func(t *testing.T, server *Server, accountEmail string) {
+ t.Helper()
+ if err := server.enqueueMessageDelete("grant-123", accountEmail, "email-1"); err != nil {
+ t.Fatalf("enqueue delete: %v", err)
+ }
+ },
+ assertReplayed: func(t *testing.T, client *nylasmock.MockClient) {
+ t.Helper()
+ if !client.DeleteMessageCalled {
+ t.Fatal("expected DeleteMessage to be replayed")
+ }
+ if client.LastMessageID != "email-1" {
+ t.Fatalf("expected delete to target email-1, got %s", client.LastMessageID)
+ }
+ },
+ },
+ {
+ name: "update message action",
+ enqueue: func(t *testing.T, server *Server, accountEmail string) {
+ t.Helper()
+ unread := false
+ starred := true
+ if err := server.enqueueMessageUpdate("grant-123", accountEmail, "email-2", &domain.UpdateMessageRequest{
+ Unread: &unread,
+ Starred: &starred,
+ Folders: []string{"archive"},
+ }); err != nil {
+ t.Fatalf("enqueue update: %v", err)
+ }
+ },
+ assertReplayed: func(t *testing.T, client *nylasmock.MockClient) {
+ t.Helper()
+ if !client.UpdateMessageCalled {
+ t.Fatal("expected UpdateMessage to be replayed")
+ }
+ if client.LastMessageID != "email-2" {
+ t.Fatalf("expected update to target email-2, got %s", client.LastMessageID)
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ server, client, accountEmail := newCachedTestServer(t)
+ server.SetOnline(false)
+
+ tt.enqueue(t, server, accountEmail)
+
+ grantStore := server.grantStore.(*testGrantStore)
+ grantStore.grants = []domain.GrantInfo{
+ {
+ ID: "grant-other",
+ Email: accountEmail,
+ Provider: domain.ProviderGoogle,
+ },
+ {
+ ID: "grant-123",
+ Email: accountEmail,
+ Provider: domain.ProviderGoogle,
+ },
+ }
+
+ server.SetOnline(true)
+
+ tt.assertReplayed(t, client)
+ if client.LastGrantID != "grant-123" {
+ t.Fatalf("expected replay to use queued grant grant-123, got %s", client.LastGrantID)
+ }
+
+ queue, err := server.getOfflineQueue(accountEmail)
+ if err != nil {
+ t.Fatalf("get offline queue: %v", err)
+ }
+ count, err := queue.Count()
+ if err != nil {
+ t.Fatalf("queue count: %v", err)
+ }
+ if count != 0 {
+ t.Fatalf("expected queue to be drained, got %d pending action(s)", count)
+ }
+ })
+ }
+}
diff --git a/internal/air/handlers_events.go b/internal/air/handlers_events.go
index a7b1676..b684c70 100644
--- a/internal/air/handlers_events.go
+++ b/internal/air/handlers_events.go
@@ -52,25 +52,28 @@ func (s *Server) handleListEvents(w http.ResponseWriter, r *http.Request) {
accountEmail := s.getAccountEmail(grantID)
// Try cache first (only for first page)
- if cursor == "" && s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
- if store, err := s.getEventStore(accountEmail); err == nil {
- cacheOpts := cache.EventListOptions{
- CalendarID: calendarID,
- Start: time.Unix(params.Start, 0),
- End: time.Unix(params.End, 0),
- Limit: params.Limit,
+ if cursor == "" && s.cacheAvailable() {
+ cacheOpts := cache.EventListOptions{
+ CalendarID: calendarID,
+ Start: time.Unix(params.Start, 0),
+ End: time.Unix(params.End, 0),
+ Limit: params.Limit,
+ }
+ var cached []*cache.CachedEvent
+ if err := s.withEventStore(accountEmail, func(store *cache.EventStore) error {
+ var err error
+ cached, err = store.List(cacheOpts)
+ return err
+ }); err == nil && len(cached) > 0 {
+ resp := EventsResponse{
+ Events: make([]EventResponse, 0, len(cached)),
+ HasMore: len(cached) >= params.Limit,
}
- if cached, err := store.List(cacheOpts); err == nil && len(cached) > 0 {
- resp := EventsResponse{
- Events: make([]EventResponse, 0, len(cached)),
- HasMore: len(cached) >= params.Limit,
- }
- for _, e := range cached {
- resp.Events = append(resp.Events, cachedEventToResponse(e))
- }
- writeJSON(w, http.StatusOK, resp)
- return
+ for _, e := range cached {
+ resp.Events = append(resp.Events, cachedEventToResponse(e))
}
+ writeJSON(w, http.StatusOK, resp)
+ return
}
}
diff --git a/internal/air/handlers_rules_policy.go b/internal/air/handlers_rules_policy.go
new file mode 100644
index 0000000..c592f29
--- /dev/null
+++ b/internal/air/handlers_rules_policy.go
@@ -0,0 +1,172 @@
+package air
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/nylas/cli/internal/domain"
+)
+
+const rulesPolicyUnsupportedMessage = "Policy & Rules are only available for Nylas-managed accounts."
+
+func (s *Server) handleListPolicies(w http.ResponseWriter, r *http.Request) {
+ if !requireMethod(w, r, http.MethodGet) {
+ return
+ }
+ if s.handleDemoMode(w, PoliciesResponse{Policies: demoPolicies()}) {
+ return
+ }
+ if !s.requireConfig(w) {
+ return
+ }
+
+ grant, ok := s.requireDefaultGrantInfo(w)
+ if !ok {
+ return
+ }
+ if grant.Provider != domain.ProviderNylas {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": rulesPolicyUnsupportedMessage})
+ return
+ }
+
+ ctx, cancel := s.withTimeout(r)
+ defer cancel()
+
+ account, err := s.nylasClient.GetAgentAccount(ctx, grant.ID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "Failed to fetch default agent account: " + err.Error(),
+ })
+ return
+ }
+
+ policyID := strings.TrimSpace(account.Settings.PolicyID)
+ if policyID == "" {
+ writeJSON(w, http.StatusOK, PoliciesResponse{Policies: []domain.Policy{}})
+ return
+ }
+
+ policy, err := s.nylasClient.GetPolicy(ctx, policyID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "Failed to fetch policy: " + err.Error(),
+ })
+ return
+ }
+
+ writeJSON(w, http.StatusOK, PoliciesResponse{Policies: []domain.Policy{*policy}})
+}
+
+func (s *Server) handleListRules(w http.ResponseWriter, r *http.Request) {
+ if !requireMethod(w, r, http.MethodGet) {
+ return
+ }
+ if s.handleDemoMode(w, RulesResponse{Rules: demoRules()}) {
+ return
+ }
+ if !s.requireConfig(w) {
+ return
+ }
+
+ grant, ok := s.requireDefaultGrantInfo(w)
+ if !ok {
+ return
+ }
+ if grant.Provider != domain.ProviderNylas {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": rulesPolicyUnsupportedMessage})
+ return
+ }
+
+ ctx, cancel := s.withTimeout(r)
+ defer cancel()
+
+ account, err := s.nylasClient.GetAgentAccount(ctx, grant.ID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "Failed to fetch default agent account: " + err.Error(),
+ })
+ return
+ }
+
+ policyID := strings.TrimSpace(account.Settings.PolicyID)
+ if policyID == "" {
+ writeJSON(w, http.StatusOK, RulesResponse{Rules: []domain.Rule{}})
+ return
+ }
+
+ policy, err := s.nylasClient.GetPolicy(ctx, policyID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "Failed to fetch policy for rules: " + err.Error(),
+ })
+ return
+ }
+
+ ruleIDs := make(map[string]struct{}, len(policy.Rules))
+ for _, ruleID := range policy.Rules {
+ ruleID = strings.TrimSpace(ruleID)
+ if ruleID == "" {
+ continue
+ }
+ ruleIDs[ruleID] = struct{}{}
+ }
+ if len(ruleIDs) == 0 {
+ writeJSON(w, http.StatusOK, RulesResponse{Rules: []domain.Rule{}})
+ return
+ }
+
+ allRules, err := s.nylasClient.ListRules(ctx)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "Failed to fetch rules: " + err.Error(),
+ })
+ return
+ }
+
+ rules := make([]domain.Rule, 0, len(ruleIDs))
+ for _, rule := range allRules {
+ if _, ok := ruleIDs[rule.ID]; !ok {
+ continue
+ }
+ rules = append(rules, rule)
+ }
+
+ writeJSON(w, http.StatusOK, RulesResponse{Rules: rules})
+}
+
+func demoPolicies() []domain.Policy {
+ return []domain.Policy{
+ {
+ ID: "policy-demo-default",
+ Name: "Default Tenant Policy",
+ ApplicationID: "app-demo",
+ OrganizationID: "org-demo",
+ Rules: []string{"rule-demo-inbound"},
+ },
+ }
+}
+
+func demoRules() []domain.Rule {
+ enabled := true
+
+ return []domain.Rule{
+ {
+ ID: "rule-demo-inbound",
+ Name: "Block risky inbound senders",
+ Description: "Flags inbound messages from blocked domains before they reach the inbox.",
+ Enabled: &enabled,
+ Trigger: "inbound",
+ Match: &domain.RuleMatch{
+ Operator: "all",
+ Conditions: []domain.RuleCondition{{
+ Field: "from.domain",
+ Operator: "is",
+ Value: "blocked.example",
+ }},
+ },
+ Actions: []domain.RuleAction{{
+ Type: "mark_as_spam",
+ }},
+ },
+ }
+}
diff --git a/internal/air/handlers_rules_policy_test.go b/internal/air/handlers_rules_policy_test.go
new file mode 100644
index 0000000..d3e7a45
--- /dev/null
+++ b/internal/air/handlers_rules_policy_test.go
@@ -0,0 +1,190 @@
+package air
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ nylasmock "github.com/nylas/cli/internal/adapters/nylas"
+ "github.com/nylas/cli/internal/domain"
+)
+
+func newRulesPolicyTestServer(provider domain.Provider) *Server {
+ return &Server{
+ grantStore: &testGrantStore{
+ grants: []domain.GrantInfo{{
+ ID: "grant-123",
+ Email: "managed@example.com",
+ Provider: provider,
+ }},
+ defaultGrant: "grant-123",
+ },
+ nylasClient: nylasmock.NewMockClient(),
+ }
+}
+
+func TestHandleListPolicies_NylasProvider(t *testing.T) {
+ t.Parallel()
+
+ server := newRulesPolicyTestServer(domain.ProviderNylas)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/policies", nil)
+ w := httptest.NewRecorder()
+
+ server.handleListPolicies(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+
+ var resp PoliciesResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+
+ if len(resp.Policies) == 0 {
+ t.Fatal("expected at least one policy")
+ }
+ if len(resp.Policies) != 1 {
+ t.Fatalf("expected exactly one policy for the default agent account, got %d", len(resp.Policies))
+ }
+ if resp.Policies[0].ID != "policy-1" {
+ t.Fatalf("expected policy-1, got %q", resp.Policies[0].ID)
+ }
+ if resp.Policies[0].Name == "" {
+ t.Fatal("expected policy name to be populated")
+ }
+}
+
+func TestHandleListRules_NylasProvider(t *testing.T) {
+ t.Parallel()
+
+ server := newRulesPolicyTestServer(domain.ProviderNylas)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/rules", nil)
+ w := httptest.NewRecorder()
+
+ server.handleListRules(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+
+ var resp RulesResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+
+ if len(resp.Rules) == 0 {
+ t.Fatal("expected at least one rule")
+ }
+ if len(resp.Rules) != 1 {
+ t.Fatalf("expected exactly one rule linked to the default policy, got %d", len(resp.Rules))
+ }
+ if resp.Rules[0].ID != "rule-1" {
+ t.Fatalf("expected rule-1, got %q", resp.Rules[0].ID)
+ }
+ if resp.Rules[0].Trigger == "" {
+ t.Fatal("expected rule trigger to be populated")
+ }
+}
+
+func TestHandleRulesPolicy_RejectsNonNylasProvider(t *testing.T) {
+ t.Parallel()
+
+ server := newRulesPolicyTestServer(domain.ProviderGoogle)
+
+ tests := []struct {
+ name string
+ handler func(http.ResponseWriter, *http.Request)
+ path string
+ }{
+ {name: "policies", handler: server.handleListPolicies, path: "/api/policies"},
+ {name: "rules", handler: server.handleListRules, path: "/api/rules"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
+ w := httptest.NewRecorder()
+
+ tt.handler(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected status 400, got %d", w.Code)
+ }
+
+ var resp map[string]string
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode error response: %v", err)
+ }
+ if resp["error"] != rulesPolicyUnsupportedMessage {
+ t.Fatalf("expected unsupported provider message %q, got %q", rulesPolicyUnsupportedMessage, resp["error"])
+ }
+ })
+ }
+}
+
+func TestBaseTemplate_PolicyRulesEntryIsEmailScoped(t *testing.T) {
+ t.Parallel()
+
+ templates, err := loadTemplates()
+ if err != nil {
+ t.Fatalf("load templates: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ provider string
+ expectEntry bool
+ expectView bool
+ }{
+ {name: "nylas provider", provider: string(domain.ProviderNylas), expectEntry: true, expectView: true},
+ {name: "google provider", provider: string(domain.ProviderGoogle), expectEntry: false, expectView: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var out strings.Builder
+ data := PageData{
+ Configured: true,
+ Provider: tt.provider,
+ UserAvatar: "N",
+ UserEmail: "managed@example.com",
+ }
+
+ if err := templates.ExecuteTemplate(&out, "base", data); err != nil {
+ t.Fatalf("render template: %v", err)
+ }
+
+ html := out.String()
+ hasEntry := strings.Contains(html, `data-testid="email-policy-rules-trigger"`)
+ hasNavTab := strings.Contains(html, `data-testid="nav-tab-rules-policy"`)
+ hasView := strings.Contains(html, `data-testid="rules-policy-view"`)
+ hasLabel := strings.Contains(html, `Policy & Rules`)
+ hasAccountEmailAttr := strings.Contains(html, `data-account-email="`+data.UserEmail+`"`)
+ hasGrantIDAttr := strings.Contains(html, `data-grant-id="`+data.DefaultGrantID+`"`)
+
+ if hasEntry != tt.expectEntry {
+ t.Fatalf("expected email entry presence %t, got %t", tt.expectEntry, hasEntry)
+ }
+ if hasNavTab {
+ t.Fatal("expected Policy & Rules to stay out of top-level navigation")
+ }
+ if hasView != tt.expectView {
+ t.Fatalf("expected view presence %t, got %t", tt.expectView, hasView)
+ }
+ if hasLabel != tt.expectEntry {
+ t.Fatalf("expected Policy & Rules label presence %t, got %t", tt.expectEntry, hasLabel)
+ }
+ if hasAccountEmailAttr != tt.expectView {
+ t.Fatalf("expected account email data attribute presence %t, got %t", tt.expectView, hasAccountEmailAttr)
+ }
+ if hasGrantIDAttr != tt.expectView {
+ t.Fatalf("expected grant id data attribute presence %t, got %t", tt.expectView, hasGrantIDAttr)
+ }
+ })
+ }
+}
diff --git a/internal/air/handlers_types.go b/internal/air/handlers_types.go
index 23af390..3e80165 100644
--- a/internal/air/handlers_types.go
+++ b/internal/air/handlers_types.go
@@ -66,6 +66,16 @@ type FoldersResponse struct {
Folders []FolderResponse `json:"folders"`
}
+// PoliciesResponse represents the policy list API response.
+type PoliciesResponse struct {
+ Policies []domain.Policy `json:"policies"`
+}
+
+// RulesResponse represents the rule list API response.
+type RulesResponse struct {
+ Rules []domain.Rule `json:"rules"`
+}
+
// EmailParticipantResponse represents an email participant.
type EmailParticipantResponse struct {
Name string `json:"name"`
diff --git a/internal/air/provider_support_test.go b/internal/air/provider_support_test.go
new file mode 100644
index 0000000..849eaf3
--- /dev/null
+++ b/internal/air/provider_support_test.go
@@ -0,0 +1,175 @@
+package air
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ configmock "github.com/nylas/cli/internal/adapters/config"
+ keyringmock "github.com/nylas/cli/internal/adapters/keyring"
+ "github.com/nylas/cli/internal/air/cache"
+ authapp "github.com/nylas/cli/internal/app/auth"
+ "github.com/nylas/cli/internal/domain"
+)
+
+func newPageDataTestServer(grants []domain.GrantInfo, defaultGrant string) *Server {
+ return &Server{
+ configSvc: authapp.NewConfigService(
+ configmock.NewMockConfigStore(),
+ keyringmock.NewMockSecretStore(),
+ ),
+ grantStore: &testGrantStore{
+ grants: grants,
+ defaultGrant: defaultGrant,
+ },
+ hasAPIKey: true,
+ }
+}
+
+func TestBuildPageData_SelectsSupportedDefaultGrant(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ defaultGrant string
+ wantDefaultGrantID string
+ wantUserEmail string
+ wantProvider string
+ wantSupportedGrants int
+ }{
+ {
+ name: "nylas default remains selected",
+ defaultGrant: "grant-nylas",
+ wantDefaultGrantID: "grant-nylas",
+ wantUserEmail: "nylas@example.com",
+ wantProvider: string(domain.ProviderNylas),
+ wantSupportedGrants: 2,
+ },
+ {
+ name: "unsupported default falls back to first supported grant",
+ defaultGrant: "grant-imap",
+ wantDefaultGrantID: "grant-google",
+ wantUserEmail: "google@example.com",
+ wantProvider: string(domain.ProviderGoogle),
+ wantSupportedGrants: 2,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ server := newPageDataTestServer([]domain.GrantInfo{
+ {ID: "grant-google", Email: "google@example.com", Provider: domain.ProviderGoogle},
+ {ID: "grant-nylas", Email: "nylas@example.com", Provider: domain.ProviderNylas},
+ {ID: "grant-imap", Email: "imap@example.com", Provider: domain.ProviderIMAP},
+ }, tt.defaultGrant)
+
+ data := server.buildPageData()
+
+ if !data.Configured {
+ t.Fatal("expected page data to be configured")
+ }
+ if data.DefaultGrantID != tt.wantDefaultGrantID {
+ t.Fatalf("expected default grant %q, got %q", tt.wantDefaultGrantID, data.DefaultGrantID)
+ }
+ if data.UserEmail != tt.wantUserEmail {
+ t.Fatalf("expected user email %q, got %q", tt.wantUserEmail, data.UserEmail)
+ }
+ if data.Provider != tt.wantProvider {
+ t.Fatalf("expected provider %q, got %q", tt.wantProvider, data.Provider)
+ }
+ if len(data.Grants) != tt.wantSupportedGrants {
+ t.Fatalf("expected %d supported grants, got %d", tt.wantSupportedGrants, len(data.Grants))
+ }
+ if data.AccountsCount != tt.wantSupportedGrants {
+ t.Fatalf("expected %d accounts, got %d", tt.wantSupportedGrants, data.AccountsCount)
+ }
+
+ for _, grant := range data.Grants {
+ if grant.Provider == string(domain.ProviderIMAP) {
+ t.Fatal("did not expect imap grant in page data")
+ }
+ }
+ })
+ }
+}
+
+func TestHandleCacheSync_FiltersSupportedProviders(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ query string
+ wantCount int
+ }{
+ {
+ name: "syncs all supported providers",
+ query: "/api/cache/sync",
+ wantCount: 2,
+ },
+ {
+ name: "syncs only requested supported provider",
+ query: "/api/cache/sync?email=nylas@example.com",
+ wantCount: 1,
+ },
+ {
+ name: "does not sync requested unsupported provider",
+ query: "/api/cache/sync?email=inbox@example.com",
+ wantCount: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ manager, err := cache.NewManager(cache.Config{BasePath: t.TempDir()})
+ if err != nil {
+ t.Fatalf("new cache manager: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = manager.Close()
+ })
+
+ server := &Server{
+ cacheManager: manager,
+ grantStore: &testGrantStore{
+ grants: []domain.GrantInfo{
+ {ID: "grant-google", Email: "google@example.com", Provider: domain.ProviderGoogle},
+ {ID: "grant-nylas", Email: "nylas@example.com", Provider: domain.ProviderNylas},
+ {ID: "grant-inbox", Email: "inbox@example.com", Provider: domain.ProviderInbox},
+ {ID: "grant-imap", Email: "imap@example.com", Provider: domain.ProviderIMAP},
+ },
+ defaultGrant: "grant-google",
+ },
+ isOnline: true,
+ }
+
+ req := httptest.NewRequest(http.MethodPost, tt.query, nil)
+ w := httptest.NewRecorder()
+
+ server.handleCacheSync(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", w.Code)
+ }
+
+ var resp CacheSyncResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+
+ if !resp.Success {
+ t.Fatalf("expected success response, got error %q", resp.Error)
+ }
+
+ wantMessage := fmt.Sprintf("Synced %d account(s)", tt.wantCount)
+ if resp.Message != wantMessage {
+ t.Fatalf("expected message %q, got %q", wantMessage, resp.Message)
+ }
+ })
+ }
+}
diff --git a/internal/air/server.go b/internal/air/server.go
index 2ca1aae..35c0fa7 100644
--- a/internal/air/server.go
+++ b/internal/air/server.go
@@ -29,14 +29,18 @@ type Server struct {
hasAPIKey bool // True if API key is configured (from env vars or keyring)
// Cache components
- cacheManager *cache.Manager
- cacheSettings *cache.Settings
- photoStore *cache.PhotoStore // Contact photo cache
- offlineQueues map[string]*cache.OfflineQueue // Per-email offline queues
- syncStopCh chan struct{} // Channel to stop background sync
- syncWg sync.WaitGroup // Wait group for sync goroutines
- isOnline bool // Online status
- onlineMu sync.RWMutex // Protects isOnline
+ cacheManager cacheRuntimeManager
+ cacheSettings *cache.Settings
+ photoStore *cache.PhotoStore // Contact photo cache
+ offlineQueues map[string]*cache.OfflineQueue // Per-email offline queues
+ offlineQueuesMu sync.RWMutex // Protects offlineQueues
+ runtimeMu sync.RWMutex // Protects runtime cache and photo store swaps
+ syncMu sync.Mutex // Protects background sync lifecycle
+ syncStopCh chan struct{} // Channel to stop background sync
+ syncWg sync.WaitGroup // Wait group for sync goroutines
+ syncRunning bool // Tracks whether background sync workers are running
+ isOnline bool // Online status
+ onlineMu sync.RWMutex // Protects isOnline
// Productivity features (Phase 6)
splitInboxConfig *SplitInboxConfig // Split inbox configuration
diff --git a/internal/air/server_lifecycle.go b/internal/air/server_lifecycle.go
index a5edaac..76e57ce 100644
--- a/internal/air/server_lifecycle.go
+++ b/internal/air/server_lifecycle.go
@@ -72,7 +72,7 @@ func NewServer(addr string) *Server {
// initCacheRuntime initializes runtime cache components for the server.
// This is intentionally deferred until Start() so NewServer remains lightweight.
func (s *Server) initCacheRuntime() {
- if s.demoMode || s.cacheManager != nil {
+ if s.demoMode || s.hasCacheRuntime() {
return
}
@@ -93,34 +93,10 @@ func (s *Server) initCacheRuntime() {
return
}
- cacheCfg = s.cacheSettings.ToConfig(cacheCfg.BasePath)
-
- cacheManager, err := cache.NewManager(cacheCfg)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Warning: Failed to initialize cache manager: %v\n", err)
- return
- }
- s.cacheManager = cacheManager
-
- photoDB, err := cache.OpenSharedDB(cacheCfg.BasePath, "photos.db")
- if err != nil {
- fmt.Fprintf(os.Stderr, "Warning: Failed to open photo database: %v\n", err)
+ if err := s.reconfigureCacheRuntime(); err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: Failed to initialize cache runtime: %v\n", err)
return
}
-
- photoStore, err := cache.NewPhotoStore(photoDB, cacheCfg.BasePath, cache.DefaultPhotoTTL)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Warning: Failed to initialize photo store: %v\n", err)
- return
- }
- s.photoStore = photoStore
-
- // Prune expired photos asynchronously after startup.
- go func() {
- if pruned, err := photoStore.Prune(); err == nil && pruned > 0 {
- fmt.Fprintf(os.Stderr, "Pruned %d expired photos from cache\n", pruned)
- }
- }()
}
// NewDemoServer creates an Air server in demo mode with sample data.
@@ -145,6 +121,8 @@ func (s *Server) Start() error {
mux.HandleFunc("/api/config", s.handleConfigStatus)
mux.HandleFunc("/api/grants", s.handleListGrants)
mux.HandleFunc("/api/grants/default", s.handleSetDefaultGrant)
+ mux.HandleFunc("/api/policies", s.handleListPolicies)
+ mux.HandleFunc("/api/rules", s.handleListRules)
// API routes - Email (Phase 3)
mux.HandleFunc("/api/folders", s.handleListFolders)
@@ -296,7 +274,7 @@ func (s *Server) Start() error {
s.initCacheRuntime()
// Start background sync if cache is available and enabled.
- if !s.demoMode && s.cacheManager != nil && s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() {
+ if !s.demoMode {
s.startBackgroundSync()
}
@@ -323,15 +301,22 @@ func (s *Server) Start() error {
// Stop gracefully stops the server and background processes.
func (s *Server) Stop() error {
- // Signal background sync to stop
- close(s.syncStopCh)
+ s.stopBackgroundSync()
- // Wait for sync goroutines to finish
- s.syncWg.Wait()
+ s.runtimeMu.Lock()
+ defer s.runtimeMu.Unlock()
+
+ if s.photoStore != nil {
+ if err := s.photoStore.Close(); err != nil {
+ return err
+ }
+ s.photoStore = nil
+ }
- // Close cache manager
if s.cacheManager != nil {
- return s.cacheManager.Close()
+ err := s.cacheManager.Close()
+ s.cacheManager = nil
+ return err
}
return nil
@@ -351,7 +336,7 @@ func (s *Server) SetOnline(online bool) {
s.onlineMu.Unlock()
// If coming back online, process offline queue
- if online && s.cacheManager != nil {
+ if online && s.hasCacheRuntime() {
s.processOfflineQueues()
}
}
diff --git a/internal/air/server_offline.go b/internal/air/server_offline.go
index 1dbbd9b..a216200 100644
--- a/internal/air/server_offline.go
+++ b/internal/air/server_offline.go
@@ -2,6 +2,7 @@ package air
import (
"context"
+ "errors"
"fmt"
"github.com/nylas/cli/internal/air/cache"
@@ -10,55 +11,146 @@ import (
// processOfflineQueues processes all pending offline actions.
func (s *Server) processOfflineQueues() {
- for email, queue := range s.offlineQueues {
- s.processOfflineQueue(email, queue)
+ for _, email := range s.offlineQueueEmails() {
+ s.processOfflineQueue(email)
}
}
// processOfflineQueue processes a single account's offline queue.
-func (s *Server) processOfflineQueue(email string, queue *cache.OfflineQueue) {
+func (s *Server) processOfflineQueue(email string) {
if s.nylasClient == nil || !s.IsOnline() {
return
}
- // Get the grant ID for this email
- var grantID string
- grants, err := s.grantStore.ListGrants()
- if err != nil {
- return
- }
- for _, g := range grants {
- if g.Email == email {
- grantID = g.ID
- break
- }
- }
- if grantID == "" {
- return
- }
-
ctx := context.Background()
for {
- action, err := queue.Dequeue()
+ action, err := s.peekOfflineAction(email)
if err != nil || action == nil {
- break
+ return
+ }
+
+ grantID, err := s.resolveQueuedActionGrantID(email, action)
+ if err != nil {
+ if action.Attempts >= 3 {
+ _ = s.removeOfflineAction(email, action.ID)
+ } else {
+ _ = s.markOfflineActionFailed(email, action.ID, err)
+ }
+ return
}
- // Process the action
err = s.processOfflineAction(ctx, grantID, action)
if err != nil {
- // Mark as failed and re-queue if retries left
- if action.Attempts < 3 {
- _ = queue.MarkFailed(action.ID, err)
+ if action.Attempts >= 3 {
+ _ = s.removeOfflineAction(email, action.ID)
+ } else {
+ _ = s.markOfflineActionFailed(email, action.ID, err)
}
+ return
+ }
+
+ if err := s.removeOfflineAction(email, action.ID); err != nil {
+ return
+ }
+ }
+}
+
+func (s *Server) peekOfflineAction(email string) (*cache.QueuedAction, error) {
+ var action *cache.QueuedAction
+ err := s.withOfflineQueue(email, func(queue *cache.OfflineQueue) error {
+ var err error
+ action, err = queue.Peek()
+ return err
+ })
+ return action, err
+}
+
+func (s *Server) markOfflineActionFailed(email string, actionID int64, markErr error) error {
+ return s.withOfflineQueue(email, func(queue *cache.OfflineQueue) error {
+ return queue.MarkFailed(actionID, markErr)
+ })
+}
+
+func (s *Server) removeOfflineAction(email string, actionID int64) error {
+ return s.withOfflineQueue(email, func(queue *cache.OfflineQueue) error {
+ return queue.Remove(actionID)
+ })
+}
+
+func (s *Server) resolveQueuedActionGrantID(accountEmail string, action *cache.QueuedAction) (string, error) {
+ if action == nil {
+ return "", errors.New("queued action is nil")
+ }
+
+ if grantID := queuedActionGrantID(action); grantID != "" {
+ grant, err := s.grantStore.GetGrant(grantID)
+ if err != nil || grant == nil {
+ return "", fmt.Errorf("queued grant %s unavailable", grantID)
+ }
+ return grantID, nil
+ }
+
+ grants, err := s.grantStore.ListGrants()
+ if err != nil {
+ return "", err
+ }
+ for _, grant := range grants {
+ if grant.Email == accountEmail {
+ return grant.ID, nil
+ }
+ }
+
+ return "", fmt.Errorf("no grant found for account %s", accountEmail)
+}
+
+func queuedActionGrantID(action *cache.QueuedAction) string {
+ switch action.Type {
+ case cache.ActionUpdateMessage:
+ var payload cache.UpdateMessagePayload
+ if action.GetActionData(&payload) == nil {
+ return payload.GrantID
+ }
+ case cache.ActionMarkRead, cache.ActionMarkUnread:
+ var payload cache.MarkReadPayload
+ if action.GetActionData(&payload) == nil {
+ return payload.GrantID
+ }
+ case cache.ActionStar, cache.ActionUnstar:
+ var payload cache.StarPayload
+ if action.GetActionData(&payload) == nil {
+ return payload.GrantID
+ }
+ case cache.ActionMove:
+ var payload cache.MovePayload
+ if action.GetActionData(&payload) == nil {
+ return payload.GrantID
+ }
+ case cache.ActionDelete:
+ var payload cache.DeleteMessagePayload
+ if action.GetActionData(&payload) == nil {
+ return payload.GrantID
}
}
+
+ return ""
}
// processOfflineAction processes a single offline action.
func (s *Server) processOfflineAction(ctx context.Context, grantID string, action *cache.QueuedAction) error {
switch action.Type {
+ case cache.ActionUpdateMessage:
+ var payload cache.UpdateMessagePayload
+ if err := action.GetActionData(&payload); err != nil {
+ return err
+ }
+ _, err := s.nylasClient.UpdateMessage(ctx, grantID, payload.EmailID, &domain.UpdateMessageRequest{
+ Unread: payload.Unread,
+ Starred: payload.Starred,
+ Folders: payload.Folders,
+ })
+ return err
+
case cache.ActionMarkRead, cache.ActionMarkUnread:
var payload cache.MarkReadPayload
if err := action.GetActionData(&payload); err != nil {
@@ -80,6 +172,10 @@ func (s *Server) processOfflineAction(ctx context.Context, grantID string, actio
return err
case cache.ActionDelete:
+ var payload cache.DeleteMessagePayload
+ if err := action.GetActionData(&payload); err == nil && payload.EmailID != "" {
+ return s.nylasClient.DeleteMessage(ctx, grantID, payload.EmailID)
+ }
return s.nylasClient.DeleteMessage(ctx, grantID, action.ResourceID)
case cache.ActionMove:
diff --git a/internal/air/server_stores.go b/internal/air/server_stores.go
index 0e07036..7c052f5 100644
--- a/internal/air/server_stores.go
+++ b/internal/air/server_stores.go
@@ -1,12 +1,127 @@
package air
import (
+ "database/sql"
"fmt"
"net/http"
"github.com/nylas/cli/internal/air/cache"
+ "github.com/nylas/cli/internal/domain"
)
+var errCacheNotInitialized = fmt.Errorf("cache not initialized")
+
+func (s *Server) hasCacheRuntime() bool {
+ s.runtimeMu.RLock()
+ defer s.runtimeMu.RUnlock()
+ return s.cacheManager != nil
+}
+
+func (s *Server) hasPhotoStore() bool {
+ s.runtimeMu.RLock()
+ defer s.runtimeMu.RUnlock()
+ return s.photoStore != nil
+}
+
+func (s *Server) cacheAvailable() bool {
+ return s.cacheSettings != nil && s.cacheSettings.IsCacheEnabled() && s.hasCacheRuntime()
+}
+
+func (s *Server) offlineQueueConfigured() bool {
+ return s.cacheSettings != nil && s.cacheSettings.Get().OfflineQueueEnabled && s.hasCacheRuntime()
+}
+
+func (s *Server) withCacheManager(fn func(cacheRuntimeManager) error) error {
+ s.runtimeMu.RLock()
+ defer s.runtimeMu.RUnlock()
+
+ if s.cacheManager == nil {
+ return errCacheNotInitialized
+ }
+
+ return fn(s.cacheManager)
+}
+
+func (s *Server) withAccountDB(email string, fn func(*sql.DB) error) error {
+ return s.withCacheManager(func(manager cacheRuntimeManager) error {
+ db, err := manager.GetDB(email)
+ if err != nil {
+ return err
+ }
+ return fn(db)
+ })
+}
+
+func (s *Server) withEmailStore(email string, fn func(*cache.EmailStore) error) error {
+ return s.withAccountDB(email, func(db *sql.DB) error {
+ return fn(cache.NewEmailStore(db))
+ })
+}
+
+func (s *Server) withEventStore(email string, fn func(*cache.EventStore) error) error {
+ return s.withAccountDB(email, func(db *sql.DB) error {
+ return fn(cache.NewEventStore(db))
+ })
+}
+
+func (s *Server) withContactStore(email string, fn func(*cache.ContactStore) error) error {
+ return s.withAccountDB(email, func(db *sql.DB) error {
+ return fn(cache.NewContactStore(db))
+ })
+}
+
+func (s *Server) withFolderStore(email string, fn func(*cache.FolderStore) error) error {
+ return s.withAccountDB(email, func(db *sql.DB) error {
+ return fn(cache.NewFolderStore(db))
+ })
+}
+
+func (s *Server) withPhotoStore(fn func(*cache.PhotoStore) error) error {
+ s.runtimeMu.RLock()
+ defer s.runtimeMu.RUnlock()
+
+ if s.photoStore == nil {
+ return fmt.Errorf("photo store not initialized")
+ }
+
+ return fn(s.photoStore)
+}
+
+func (s *Server) withOfflineQueue(email string, fn func(*cache.OfflineQueue) error) error {
+ s.runtimeMu.RLock()
+ defer s.runtimeMu.RUnlock()
+
+ if s.cacheManager == nil {
+ return errCacheNotInitialized
+ }
+
+ s.offlineQueuesMu.RLock()
+ queue := s.offlineQueues[email]
+ s.offlineQueuesMu.RUnlock()
+
+ if queue == nil {
+ db, err := s.cacheManager.GetDB(email)
+ if err != nil {
+ return err
+ }
+
+ queue, err = cache.NewOfflineQueue(db)
+ if err != nil {
+ return err
+ }
+
+ s.offlineQueuesMu.Lock()
+ if existing := s.offlineQueues[email]; existing != nil {
+ queue = existing
+ } else {
+ s.offlineQueues[email] = queue
+ }
+ s.offlineQueuesMu.Unlock()
+ }
+
+ return fn(queue)
+}
+
// requireDefaultGrant gets the default grant ID, writing an error response if not available.
// Returns the grant ID and true if successful, or empty string and false if error written.
// Callers should return immediately when ok is false.
@@ -21,62 +136,127 @@ func (s *Server) requireDefaultGrant(w http.ResponseWriter) (grantID string, ok
return grantID, true
}
-// getEmailStore returns the email store for the given email account.
-func (s *Server) getEmailStore(email string) (*cache.EmailStore, error) {
- if s.cacheManager == nil {
- return nil, fmt.Errorf("cache not initialized")
+// requireDefaultGrantInfo gets the default grant info, writing an error response if not available.
+// Returns the grant info and true if successful, or an empty grant and false if error written.
+func (s *Server) requireDefaultGrantInfo(w http.ResponseWriter) (grant domain.GrantInfo, ok bool) {
+ grantID, ok := s.requireDefaultGrant(w)
+ if !ok {
+ return domain.GrantInfo{}, false
}
- db, err := s.cacheManager.GetDB(email)
- if err != nil {
- return nil, err
+
+ info, err := s.grantStore.GetGrant(grantID)
+ if err != nil || info == nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "Default account is no longer available. Please choose another account.",
+ })
+ return domain.GrantInfo{}, false
}
- return cache.NewEmailStore(db), nil
+
+ return *info, true
+}
+
+// getEmailStore returns the email store for the given email account.
+func (s *Server) getEmailStore(email string) (*cache.EmailStore, error) {
+ var store *cache.EmailStore
+ err := s.withAccountDB(email, func(db *sql.DB) error {
+ store = cache.NewEmailStore(db)
+ return nil
+ })
+ return store, err
}
// getEventStore returns the event store for the given email account.
func (s *Server) getEventStore(email string) (*cache.EventStore, error) {
- if s.cacheManager == nil {
- return nil, fmt.Errorf("cache not initialized")
- }
- db, err := s.cacheManager.GetDB(email)
- if err != nil {
- return nil, err
- }
- return cache.NewEventStore(db), nil
+ var store *cache.EventStore
+ err := s.withAccountDB(email, func(db *sql.DB) error {
+ store = cache.NewEventStore(db)
+ return nil
+ })
+ return store, err
}
// getContactStore returns the contact store for the given email account.
func (s *Server) getContactStore(email string) (*cache.ContactStore, error) {
- if s.cacheManager == nil {
- return nil, fmt.Errorf("cache not initialized")
- }
- db, err := s.cacheManager.GetDB(email)
- if err != nil {
- return nil, err
- }
- return cache.NewContactStore(db), nil
+ var store *cache.ContactStore
+ err := s.withAccountDB(email, func(db *sql.DB) error {
+ store = cache.NewContactStore(db)
+ return nil
+ })
+ return store, err
}
// getFolderStore returns the folder store for the given email account.
func (s *Server) getFolderStore(email string) (*cache.FolderStore, error) {
- if s.cacheManager == nil {
- return nil, fmt.Errorf("cache not initialized")
- }
- db, err := s.cacheManager.GetDB(email)
- if err != nil {
- return nil, err
- }
- return cache.NewFolderStore(db), nil
+ var store *cache.FolderStore
+ err := s.withAccountDB(email, func(db *sql.DB) error {
+ store = cache.NewFolderStore(db)
+ return nil
+ })
+ return store, err
}
// getSyncStore returns the sync store for the given email account.
func (s *Server) getSyncStore(email string) (*cache.SyncStore, error) {
+ var store *cache.SyncStore
+ err := s.withAccountDB(email, func(db *sql.DB) error {
+ store = cache.NewSyncStore(db)
+ return nil
+ })
+ return store, err
+}
+
+// getOfflineQueue returns the offline queue for the given email account.
+func (s *Server) getOfflineQueue(email string) (*cache.OfflineQueue, error) {
+ var queue *cache.OfflineQueue
+ err := s.withOfflineQueue(email, func(q *cache.OfflineQueue) error {
+ queue = q
+ return nil
+ })
+ return queue, err
+}
+
+func (s *Server) initializeOfflineQueuesLocked() error {
if s.cacheManager == nil {
- return nil, fmt.Errorf("cache not initialized")
+ return nil
}
- db, err := s.cacheManager.GetDB(email)
+
+ accounts, err := s.cacheManager.ListCachedAccounts()
if err != nil {
- return nil, err
+ return err
+ }
+
+ s.offlineQueuesMu.Lock()
+ defer s.offlineQueuesMu.Unlock()
+
+ s.offlineQueues = make(map[string]*cache.OfflineQueue, len(accounts))
+ for _, email := range accounts {
+ db, err := s.cacheManager.GetDB(email)
+ if err != nil {
+ return err
+ }
+ queue, err := cache.NewOfflineQueue(db)
+ if err != nil {
+ return err
+ }
+ s.offlineQueues[email] = queue
+ }
+
+ return nil
+}
+
+func (s *Server) clearOfflineQueues() {
+ s.offlineQueuesMu.Lock()
+ s.offlineQueues = make(map[string]*cache.OfflineQueue)
+ s.offlineQueuesMu.Unlock()
+}
+
+func (s *Server) offlineQueueEmails() []string {
+ s.offlineQueuesMu.RLock()
+ defer s.offlineQueuesMu.RUnlock()
+
+ emails := make([]string, 0, len(s.offlineQueues))
+ for email := range s.offlineQueues {
+ emails = append(emails, email)
}
- return cache.NewSyncStore(db), nil
+ return emails
}
diff --git a/internal/air/server_sync.go b/internal/air/server_sync.go
index 80686e5..c239cb3 100644
--- a/internal/air/server_sync.go
+++ b/internal/air/server_sync.go
@@ -2,32 +2,74 @@ package air
import (
"context"
+ "database/sql"
"time"
"github.com/nylas/cli/internal/air/cache"
+ "github.com/nylas/cli/internal/domain"
)
// startBackgroundSync starts background sync goroutines for all accounts.
func (s *Server) startBackgroundSync() {
- // Get all grants
+ s.syncMu.Lock()
+ defer s.syncMu.Unlock()
+
+ if s.syncRunning || s.demoMode || s.grantStore == nil || s.cacheSettings == nil || !s.cacheSettings.IsCacheEnabled() || !s.hasCacheRuntime() {
+ return
+ }
+
grants, err := s.grantStore.ListGrants()
if err != nil || len(grants) == 0 {
return
}
- // Start sync for each supported account
+ stopCh := make(chan struct{})
+ started := 0
for _, grant := range grants {
if !grant.Provider.IsSupportedByAir() {
continue
}
s.syncWg.Add(1)
- go s.syncAccountLoop(grant.Email, grant.ID)
+ started++
+ go s.syncAccountLoop(stopCh, grant.Email, grant.ID)
}
+
+ if started == 0 {
+ return
+ }
+
+ s.syncStopCh = stopCh
+ s.syncRunning = true
+}
+
+// stopBackgroundSync stops background sync goroutines and waits for them to exit.
+func (s *Server) stopBackgroundSync() {
+ s.syncMu.Lock()
+ if !s.syncRunning {
+ s.syncMu.Unlock()
+ return
+ }
+
+ stopCh := s.syncStopCh
+ s.syncStopCh = nil
+ s.syncRunning = false
+ s.syncMu.Unlock()
+
+ if stopCh != nil {
+ close(stopCh)
+ }
+ s.syncWg.Wait()
+}
+
+// restartBackgroundSync reapplies background sync settings immediately.
+func (s *Server) restartBackgroundSync() {
+ s.stopBackgroundSync()
+ s.startBackgroundSync()
}
// syncAccountLoop runs the sync loop for a single account.
-func (s *Server) syncAccountLoop(email, grantID string) {
+func (s *Server) syncAccountLoop(stopCh <-chan struct{}, email, grantID string) {
defer s.syncWg.Done()
interval := s.cacheSettings.GetSyncInterval()
@@ -43,7 +85,7 @@ func (s *Server) syncAccountLoop(email, grantID string) {
for {
select {
- case <-s.syncStopCh:
+ case <-stopCh:
return
case <-ticker.C:
s.syncAccount(email, grantID)
@@ -75,23 +117,10 @@ func (s *Server) syncAccount(email, grantID string) {
// syncEmails syncs emails from the API to cache.
func (s *Server) syncEmails(ctx context.Context, email, grantID string) {
- store, err := s.getEmailStore(email)
- if err != nil {
- return
- }
-
- syncStore, err := s.getSyncStore(email)
- if err != nil {
+ if s.nylasClient == nil || !s.hasCacheRuntime() {
return
}
- // Get last sync state
- state, _ := syncStore.Get("emails")
- if state == nil {
- state = &cache.SyncState{Resource: "emails"}
- }
-
- // Fetch emails from API
messages, err := s.nylasClient.GetMessages(ctx, grantID, 100)
if err != nil {
s.SetOnline(false)
@@ -99,89 +128,109 @@ func (s *Server) syncEmails(ctx context.Context, email, grantID string) {
}
s.SetOnline(true)
- // Cache emails
- for i := range messages {
- cached := domainMessageToCached(&messages[i])
- _ = store.Put(cached)
- }
+ _ = s.withAccountDB(email, func(db *sql.DB) error {
+ store := cache.NewEmailStore(db)
+ syncStore := cache.NewSyncStore(db)
- // Update sync state
- state.LastSync = time.Now()
- _ = syncStore.Set(state)
+ for i := range messages {
+ cached := domainMessageToCached(&messages[i])
+ _ = store.Put(cached)
+ }
+
+ state, _ := syncStore.Get("emails")
+ if state == nil {
+ state = &cache.SyncState{Resource: "emails"}
+ }
+ state.LastSync = time.Now()
+ _ = syncStore.Set(state)
+ return nil
+ })
}
// syncFolders syncs folders from the API to cache.
func (s *Server) syncFolders(ctx context.Context, email, grantID string) {
- store, err := s.getFolderStore(email)
- if err != nil {
+ if s.nylasClient == nil || !s.hasCacheRuntime() {
return
}
- // Fetch folders from API
folders, err := s.nylasClient.GetFolders(ctx, grantID)
if err != nil {
return
}
- // Cache folders
- for i := range folders {
- f := &folders[i]
- cached := &cache.CachedFolder{
- ID: f.ID,
- Name: f.Name,
- Type: f.SystemFolder,
- TotalCount: f.TotalCount,
- UnreadCount: f.UnreadCount,
- CachedAt: time.Now(),
+ _ = s.withFolderStore(email, func(store *cache.FolderStore) error {
+ for i := range folders {
+ f := &folders[i]
+ cached := &cache.CachedFolder{
+ ID: f.ID,
+ Name: f.Name,
+ Type: f.SystemFolder,
+ TotalCount: f.TotalCount,
+ UnreadCount: f.UnreadCount,
+ CachedAt: time.Now(),
+ }
+ _ = store.Put(cached)
}
- _ = store.Put(cached)
- }
+ return nil
+ })
}
// syncEvents syncs calendar events from the API to cache.
func (s *Server) syncEvents(ctx context.Context, email, grantID string) {
- store, err := s.getEventStore(email)
- if err != nil {
+ if s.nylasClient == nil || !s.hasCacheRuntime() {
return
}
- // Fetch calendars first
calendars, err := s.nylasClient.GetCalendars(ctx, grantID)
if err != nil {
return
}
- // Fetch events for each calendar
+ type calendarEvents struct {
+ calendarID string
+ events []domain.Event
+ }
+ eventGroups := make([]calendarEvents, 0, len(calendars))
+
for i := range calendars {
cal := &calendars[i]
events, err := s.nylasClient.GetEvents(ctx, grantID, cal.ID, nil)
if err != nil {
continue
}
-
- for j := range events {
- cached := domainEventToCached(&events[j], cal.ID)
- _ = store.Put(cached)
+ eventGroups = append(eventGroups, calendarEvents{
+ calendarID: cal.ID,
+ events: events,
+ })
+ }
+
+ _ = s.withEventStore(email, func(store *cache.EventStore) error {
+ for _, group := range eventGroups {
+ for i := range group.events {
+ cached := domainEventToCached(&group.events[i], group.calendarID)
+ _ = store.Put(cached)
+ }
}
- }
+ return nil
+ })
}
// syncContacts syncs contacts from the API to cache.
func (s *Server) syncContacts(ctx context.Context, email, grantID string) {
- store, err := s.getContactStore(email)
- if err != nil {
+ if s.nylasClient == nil || !s.hasCacheRuntime() {
return
}
- // Fetch contacts from API
contacts, err := s.nylasClient.GetContacts(ctx, grantID, nil)
if err != nil {
return
}
- // Cache contacts
- for i := range contacts {
- cached := domainContactToCached(&contacts[i])
- _ = store.Put(cached)
- }
+ _ = s.withContactStore(email, func(store *cache.ContactStore) error {
+ for i := range contacts {
+ cached := domainContactToCached(&contacts[i])
+ _ = store.Put(cached)
+ }
+ return nil
+ })
}
diff --git a/internal/air/server_sync_test.go b/internal/air/server_sync_test.go
index fa87f94..33921a7 100644
--- a/internal/air/server_sync_test.go
+++ b/internal/air/server_sync_test.go
@@ -123,7 +123,7 @@ func TestSyncAccountLoop_StopsOnChannel(t *testing.T) {
// Start the loop in a goroutine
done := make(chan struct{})
go func() {
- server.syncAccountLoop("test@example.com", "grant-123")
+ server.syncAccountLoop(stopCh, "test@example.com", "grant-123")
close(done)
}()
@@ -185,31 +185,44 @@ func TestServer_SyncWaitGroup_Usage(t *testing.T) {
}
}
-func TestServer_SyncStopChannel_Creation(t *testing.T) {
+func TestServer_SyncLifecycle_NoWorkers(t *testing.T) {
t.Parallel()
- // Create stop channel
- stopCh := make(chan struct{})
- server := &Server{
- syncStopCh: stopCh,
- }
+ server := &Server{}
+ server.stopBackgroundSync()
+ server.restartBackgroundSync()
+}
- // Channel should be open initially
- select {
- case <-server.syncStopCh:
- t.Error("Channel should not be closed initially")
- default:
- // Expected - channel is open
+func TestRestartBackgroundSync_ReplacesStopChannel(t *testing.T) {
+ t.Parallel()
+
+ server, _, _ := newCachedTestServer(t)
+ server.nylasClient = nil
+ server.SetOnline(false)
+ server.startBackgroundSync()
+ t.Cleanup(func() {
+ server.stopBackgroundSync()
+ })
+
+ if !server.syncRunning {
+ t.Fatal("expected sync workers to be running")
}
- // Close the channel
- close(stopCh)
+ initialStopCh := server.syncStopCh
+ server.restartBackgroundSync()
- // Channel should now be closed
+ if !server.syncRunning {
+ t.Fatal("expected sync workers to remain running after restart")
+ }
+ if server.syncStopCh == nil {
+ t.Fatal("expected restarted sync workers to have a stop channel")
+ }
+ if server.syncStopCh == initialStopCh {
+ t.Fatal("expected restart to replace the stop channel")
+ }
select {
- case <-server.syncStopCh:
- // Expected - channel is closed
+ case <-initialStopCh:
default:
- t.Error("Channel should be closed after close()")
+ t.Fatal("expected restart to stop the previous workers")
}
}
diff --git a/internal/air/server_template.go b/internal/air/server_template.go
index fb69f45..4a1e9e5 100644
--- a/internal/air/server_template.go
+++ b/internal/air/server_template.go
@@ -59,7 +59,7 @@ func (s *Server) buildPageData() PageData {
data.HasAPIKey = s.hasAPIKey || status.HasAPIKey
}
- // Get real grants (filter to supported providers: Google, Microsoft)
+ // Get real grants filtered to providers supported by Air.
// Also used to determine if configured (need API key AND at least one grant)
grants, err := s.grantStore.ListGrants()
hasGrants := err == nil && len(grants) > 0
diff --git a/internal/air/static/css/components-account.css b/internal/air/static/css/components-account.css
index 0f4fc41..ae54e69 100644
--- a/internal/air/static/css/components-account.css
+++ b/internal/air/static/css/components-account.css
@@ -4,6 +4,7 @@
.account-switcher-container {
position: relative;
+ flex: 0 0 auto;
}
.account-provider {
@@ -18,7 +19,9 @@
top: 100%;
right: 0;
margin-top: 8px;
- min-width: 280px;
+ min-width: 320px;
+ width: max-content;
+ max-width: min(92vw, 480px);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 12px;
@@ -72,11 +75,13 @@
.account-dropdown-info {
flex: 1;
+ min-width: 0;
}
.account-dropdown-email {
font-size: 13px;
font-weight: 500;
+ overflow-wrap: anywhere;
}
.account-dropdown-provider {
@@ -92,6 +97,7 @@
font-weight: 600;
border-radius: 4px;
text-transform: uppercase;
+ flex-shrink: 0;
}
.account-dropdown-divider {
diff --git a/internal/air/static/css/email-list.css b/internal/air/static/css/email-list.css
index 88fac13..d431c58 100644
--- a/internal/air/static/css/email-list.css
+++ b/internal/air/static/css/email-list.css
@@ -30,6 +30,12 @@
letter-spacing: var(--tracking-tight);
}
+.list-header-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
/* Filter Tabs */
.filter-tabs {
display: flex;
@@ -85,6 +91,12 @@
color: white;
}
+.email-view-switch {
+ padding: 8px 14px;
+ font-size: var(--text-xs);
+ white-space: nowrap;
+}
+
/* Email List Container */
.email-list {
overflow-y: auto;
diff --git a/internal/air/static/css/main.css b/internal/air/static/css/main.css
index e6abfeb..4a933fd 100644
--- a/internal/air/static/css/main.css
+++ b/internal/air/static/css/main.css
@@ -36,6 +36,7 @@
@import url('ai-features.css');
@import url('keyboard-shortcuts.css');
@import url('notetaker.css');
+@import url('rules-policy.css');
@import url('accessibility-core.css');
@import url('accessibility-aria.css');
@import url('responsive.css');
diff --git a/internal/air/static/css/navigation.css b/internal/air/static/css/navigation.css
index 3d48921..08b3b0b 100644
--- a/internal/air/static/css/navigation.css
+++ b/internal/air/static/css/navigation.css
@@ -108,6 +108,7 @@
display: flex;
align-items: center;
gap: var(--space-3);
+ min-width: 0;
}
/* Search Trigger */
@@ -125,6 +126,7 @@
cursor: pointer;
transition: all var(--duration-normal) var(--ease-out);
min-width: 220px;
+ flex: 0 1 320px;
}
.search-trigger:hover {
@@ -159,6 +161,9 @@
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--duration-normal) var(--ease-out);
+ min-width: 280px;
+ max-width: min(42vw, 360px);
+ flex: 0 0 auto;
}
.account-switcher:hover {
@@ -184,6 +189,8 @@
display: flex;
flex-direction: column;
gap: var(--space-0-5);
+ min-width: 0;
+ flex: 1;
}
.account-name {
@@ -191,6 +198,7 @@
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--text-primary);
+ white-space: nowrap;
}
.account-email {
diff --git a/internal/air/static/css/rules-policy.css b/internal/air/static/css/rules-policy.css
new file mode 100644
index 0000000..92b5475
--- /dev/null
+++ b/internal/air/static/css/rules-policy.css
@@ -0,0 +1,211 @@
+.rules-policy-view {
+ display: none;
+ flex: 1;
+ overflow: hidden;
+}
+
+.rules-policy-view.active {
+ display: flex;
+ flex-direction: column;
+}
+
+.rules-policy-page-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 24px 24px 0;
+}
+
+.rules-policy-page-kicker {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+.rules-policy-page-title {
+ margin: 8px 0 0;
+ font-size: 28px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ color: var(--text-primary);
+}
+
+.rules-policy-page-subtitle {
+ margin: 8px 0 0;
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+.rules-policy-shell {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 20px;
+ width: 100%;
+ padding: 24px;
+ overflow: auto;
+}
+
+.rules-policy-panel {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ overflow: hidden;
+}
+
+.rules-policy-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 20px 20px 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.rules-policy-title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.rules-policy-subtitle {
+ margin: 6px 0 0;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.rules-policy-list {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 20px;
+ overflow: auto;
+}
+
+.rules-policy-card {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 18px;
+ border: 1px solid rgba(139, 92, 246, 0.16);
+ border-radius: 14px;
+ background: rgba(139, 92, 246, 0.06);
+}
+
+.rules-policy-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.rules-policy-card-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.rules-policy-card-meta {
+ margin: 6px 0 0;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+.rules-policy-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: rgba(99, 102, 241, 0.16);
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.rules-policy-pill.muted {
+ background: rgba(161, 161, 170, 0.16);
+ color: var(--text-secondary);
+}
+
+.rules-policy-section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.rules-policy-section-label {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+.rules-policy-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.rules-policy-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 10px;
+ border-radius: 10px;
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.rules-policy-tag-mono {
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
+.rules-policy-description {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+}
+
+.rules-policy-empty .empty-state,
+.rules-policy-error .empty-state {
+ min-height: 220px;
+}
+
+@media (max-width: 1100px) {
+ .rules-policy-shell {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 768px) {
+ .rules-policy-page-header {
+ flex-direction: column;
+ align-items: stretch;
+ padding: 16px 16px 0;
+ gap: 12px;
+ }
+
+ .rules-policy-shell {
+ padding: 16px;
+ gap: 16px;
+ }
+
+ .rules-policy-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
diff --git a/internal/air/static/js/api-agent.js b/internal/air/static/js/api-agent.js
new file mode 100644
index 0000000..6342096
--- /dev/null
+++ b/internal/air/static/js/api-agent.js
@@ -0,0 +1,12 @@
+/**
+ * API Agent - Rules and policy endpoints for Nylas-managed accounts
+ */
+Object.assign(AirAPI, {
+ async getPolicies() {
+ return this.request('/policies');
+ },
+
+ async getRules() {
+ return this.request('/rules');
+ }
+});
diff --git a/internal/air/static/js/app-core.js b/internal/air/static/js/app-core.js
index 632d791..94bc1d7 100644
--- a/internal/air/static/js/app-core.js
+++ b/internal/air/static/js/app-core.js
@@ -68,6 +68,8 @@
// View Switching
function showView(view, event) {
+ const navView = view === 'rulesPolicy' ? 'email' : view;
+
// Update nav tabs
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.classList.remove('active');
@@ -82,25 +84,17 @@
clickedTab.setAttribute('aria-selected', 'true');
}
} else {
- // Fallback: activate based on view name
- const tabs = document.querySelectorAll('.nav-tab');
- const viewIndex = view === 'email' ? 0 : view === 'calendar' ? 1 : 2;
- if (tabs[viewIndex]) {
- tabs[viewIndex].classList.add('active');
- tabs[viewIndex].setAttribute('aria-selected', 'true');
+ const fallbackTab = document.querySelector(`.nav-tab[data-view="${navView}"]`);
+ if (fallbackTab) {
+ fallbackTab.classList.add('active');
+ fallbackTab.setAttribute('aria-selected', 'true');
}
}
// Hide all views
- const emailView = document.getElementById('emailView');
- const calendarView = document.getElementById('calendarView');
- const contactsView = document.getElementById('contactsView');
- const notetakerView = document.getElementById('notetakerView');
-
- if (emailView) emailView.classList.remove('active');
- if (calendarView) calendarView.classList.remove('active');
- if (contactsView) contactsView.classList.remove('active');
- if (notetakerView) notetakerView.classList.remove('active');
+ document.querySelectorAll('[data-air-view]').forEach((viewEl) => {
+ viewEl.classList.remove('active');
+ });
// Show selected view
const targetView = document.getElementById(view + 'View');
@@ -111,6 +105,9 @@
if (view === 'notetaker' && typeof NotetakerModule !== 'undefined') {
NotetakerModule.loadNotetakers();
}
+ if (view === 'rulesPolicy' && typeof RulesPolicyManager !== 'undefined') {
+ RulesPolicyManager.loadAll();
+ }
}
// Update mobile nav if present
@@ -118,14 +115,21 @@
item.classList.remove('active');
});
const mobileNavItems = document.querySelectorAll('.mobile-nav-item');
- const mobileIndex = view === 'email' ? 1 : view === 'calendar' ? 2 : 3;
+ const mobileIndex = navView === 'email' ? 1 : navView === 'calendar' ? 2 : 3;
if (mobileNavItems[mobileIndex]) {
mobileNavItems[mobileIndex].classList.add('active');
}
// Announce view change for screen readers
if (typeof announce === 'function') {
- announce(`Switched to ${view} view`);
+ const labels = {
+ email: 'Email',
+ calendar: 'Calendar',
+ contacts: 'Contacts',
+ notetaker: 'Notetaker',
+ rulesPolicy: 'Policy and Rules'
+ };
+ announce(`Switched to ${labels[view] || view} view`);
}
// Lazy load data for the view (only loads once)
diff --git a/internal/air/static/js/app-keyboard.js b/internal/air/static/js/app-keyboard.js
index 0ea9afb..ebfafb1 100644
--- a/internal/air/static/js/app-keyboard.js
+++ b/internal/air/static/js/app-keyboard.js
@@ -14,9 +14,12 @@
// Skip shortcuts when modifier keys are pressed (allow Cmd+R refresh, etc.)
if (!e.target.matches('input, textarea, [contenteditable]') && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (e.key === 'c') { e.preventDefault(); toggleCompose(); }
- if (e.key === '1') { document.querySelector('.nav-tab').click(); }
- if (e.key === '2') { document.querySelectorAll('.nav-tab')[1].click(); }
- if (e.key === '3') { document.querySelectorAll('.nav-tab')[2].click(); }
+ const shortcutTab = document.querySelector(`.nav-tab[data-shortcut="${e.key}"]`);
+ if (shortcutTab) {
+ e.preventDefault();
+ shortcutTab.click();
+ return;
+ }
if (e.key === 'e') { showToast('success', 'Archived', 'Moved to archive'); }
if (e.key === 'r') {
// Reply to selected email
diff --git a/internal/air/static/js/compose.js b/internal/air/static/js/compose.js
index 8afcea8..e01105e 100644
--- a/internal/air/static/js/compose.js
+++ b/internal/air/static/js/compose.js
@@ -90,10 +90,11 @@ const ComposeManager = {
els.modal.classList.add('active');
els.modal.setAttribute('aria-hidden', 'false');
- // Focus first input
- setTimeout(() => {
- if (els.to) els.to.focus();
- }, 100);
+ // Focus the primary recipient field immediately so keyboard input
+ // doesn't race against a delayed autofocus timer.
+ if (els.to) {
+ els.to.focus();
+ }
},
openReply(email) {
diff --git a/internal/air/static/js/rules-policy.js b/internal/air/static/js/rules-policy.js
new file mode 100644
index 0000000..7346fff
--- /dev/null
+++ b/internal/air/static/js/rules-policy.js
@@ -0,0 +1,329 @@
+/**
+ * Policy & Rules Module - Nylas-managed mailbox inspection
+ */
+window.RulesPolicyManager = {
+ policiesLoaded: false,
+ rulesLoaded: false,
+
+ async loadAll(force = false) {
+ await Promise.all([
+ this.loadPolicies(force),
+ this.loadRules(force),
+ ]);
+ },
+
+ async loadPolicies(force = false) {
+ if (this.policiesLoaded && !force) {
+ return;
+ }
+
+ const container = document.getElementById('policyList');
+ if (!container || typeof AirAPI === 'undefined') {
+ return;
+ }
+
+ container.innerHTML = this.loadingMarkup('Loading policies...');
+
+ try {
+ const response = await AirAPI.getPolicies();
+ this.renderPolicies(response.policies || []);
+ this.policiesLoaded = true;
+ } catch (error) {
+ console.error('Failed to load policies:', error);
+ this.renderError(container, 'policies', error);
+ }
+ },
+
+ async loadRules(force = false) {
+ if (this.rulesLoaded && !force) {
+ return;
+ }
+
+ const container = document.getElementById('ruleList');
+ if (!container || typeof AirAPI === 'undefined') {
+ return;
+ }
+
+ container.innerHTML = this.loadingMarkup('Loading rules...');
+
+ try {
+ const response = await AirAPI.getRules();
+ this.renderRules(response.rules || []);
+ this.rulesLoaded = true;
+ } catch (error) {
+ console.error('Failed to load rules:', error);
+ this.renderError(container, 'rules', error);
+ }
+ },
+
+ async refreshPolicies() {
+ this.policiesLoaded = false;
+ await this.loadPolicies(true);
+ },
+
+ async refreshRules() {
+ this.rulesLoaded = false;
+ await this.loadRules(true);
+ },
+
+ renderPolicies(policies) {
+ const container = document.getElementById('policyList');
+ if (!container) {
+ return;
+ }
+
+ const assignedMailbox = this.getAssignedMailbox();
+
+ if (!policies.length) {
+ container.innerHTML = this.emptyMarkup(
+ '🛡️',
+ 'No policies configured',
+ 'This Nylas account does not expose any managed policies right now.'
+ );
+ container.classList.add('rules-policy-empty');
+ return;
+ }
+
+ container.classList.remove('rules-policy-empty');
+ container.innerHTML = policies.map((policy) => {
+ const tags = [];
+ if (Array.isArray(policy.rules) && policy.rules.length) {
+ tags.push(`${policy.rules.length} linked rule${policy.rules.length === 1 ? '' : 's'}`);
+ }
+ if (policy.application_id) {
+ tags.push(`App ${policy.application_id}`);
+ }
+ if (policy.organization_id) {
+ tags.push(`Org ${policy.organization_id}`);
+ }
+
+ const limitTags = this.policyLimitTags(policy);
+ const optionTags = this.policyOptionTags(policy);
+
+ return `
+ ${this.escape(rule.description)}${this.escape(policy.name || policy.id || 'Unnamed policy')}
+
+ ${this.escape(rule.name || rule.id || 'Unnamed rule')}
+
+