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(policy.name || policy.id || 'Unnamed policy')}

+

${this.escape(policy.id || 'No policy ID')}

+
+ Policy +
+ ${(assignedMailbox.email || assignedMailbox.grantID) ? ` +
+ +
+ ${assignedMailbox.email ? `${this.escape(assignedMailbox.email)}` : ''} + ${assignedMailbox.grantID ? `${this.escape(assignedMailbox.grantID)}` : ''} +
+
` : ''} + ${tags.length ? ` +
+ +
${tags.map((tag) => `${this.escape(tag)}`).join('')}
+
` : ''} + ${limitTags.length ? ` +
+ +
${limitTags.map((tag) => `${this.escape(tag)}`).join('')}
+
` : ''} + ${optionTags.length ? ` +
+ +
${optionTags.map((tag) => `${this.escape(tag)}`).join('')}
+
` : ''} +
+ `; + }).join(''); + }, + + renderRules(rules) { + const container = document.getElementById('ruleList'); + if (!container) { + return; + } + + if (!rules.length) { + container.innerHTML = this.emptyMarkup( + '⚙️', + 'No rules configured', + 'This Nylas account does not expose any managed rules right now.' + ); + container.classList.add('rules-policy-empty'); + return; + } + + container.classList.remove('rules-policy-empty'); + container.innerHTML = rules.map((rule) => { + const enabled = rule.enabled !== false; + const conditions = Array.isArray(rule.match?.conditions) ? rule.match.conditions : []; + const actions = Array.isArray(rule.actions) ? rule.actions : []; + + return ` +
+
+
+

${this.escape(rule.name || rule.id || 'Unnamed rule')}

+

${this.escape(rule.id || 'No rule ID')}

+
+ ${enabled ? 'Enabled' : 'Disabled'} +
+ ${rule.description ? `

${this.escape(rule.description)}

` : ''} +
+ +
+ ${this.escape(rule.trigger || 'unspecified')} + ${typeof rule.priority === 'number' ? `Priority ${this.escape(String(rule.priority))}` : ''} +
+
+
+ +
+ ${conditions.length ? conditions.map((condition) => `${this.escape(this.formatCondition(condition))}`).join('') : 'No conditions'} +
+
+
+ +
+ ${actions.length ? actions.map((action) => `${this.escape(this.formatAction(action))}`).join('') : 'No actions'} +
+
+
+ `; + }).join(''); + }, + + renderError(container, resourceName, error) { + container.classList.add('rules-policy-error'); + const message = error?.message || `Failed to load ${resourceName}.`; + container.innerHTML = this.emptyMarkup( + '⚠️', + `Unable to load ${resourceName}`, + message + ); + }, + + loadingMarkup(message) { + return this.emptyMarkup('⏳', 'Loading', message); + }, + + emptyMarkup(icon, title, message) { + return ` +
+
${icon}
+
${this.escape(title)}
+
${this.escape(message)}
+
+ `; + }, + + policyLimitTags(policy) { + const tags = []; + const limits = policy.limits || {}; + + if (typeof limits.limit_attachment_size_limit === 'number') { + tags.push(`Attachment size ${this.humanBytes(limits.limit_attachment_size_limit)}`); + } + if (typeof limits.limit_attachment_count_limit === 'number') { + tags.push(`Attachment count ${limits.limit_attachment_count_limit}`); + } + if (typeof limits.limit_storage_total === 'number') { + tags.push(`Storage ${this.humanBytes(limits.limit_storage_total)}`); + } + if (typeof limits.limit_count_daily_message_per_grant === 'number') { + tags.push(`Daily messages ${limits.limit_count_daily_message_per_grant}`); + } + if (typeof limits.limit_inbox_retention_period === 'number') { + tags.push(`Inbox retention ${limits.limit_inbox_retention_period}d`); + } + if (typeof limits.limit_spam_retention_period === 'number') { + tags.push(`Spam retention ${limits.limit_spam_retention_period}d`); + } + + return tags; + }, + + policyOptionTags(policy) { + const tags = []; + const options = policy.options || {}; + const spamDetection = policy.spam_detection || {}; + + if (Array.isArray(options.additional_folders) && options.additional_folders.length) { + tags.push(`${options.additional_folders.length} extra folder${options.additional_folders.length === 1 ? '' : 's'}`); + } + if (options.use_cidr_aliasing === true) { + tags.push('CIDR aliasing'); + } + if (spamDetection.use_list_dnsbl === true) { + tags.push('DNSBL checks'); + } + if (spamDetection.use_header_anomaly_detection === true) { + tags.push('Header anomaly detection'); + } + if (typeof spamDetection.spam_sensitivity === 'number') { + tags.push(`Spam sensitivity ${spamDetection.spam_sensitivity}`); + } + + return tags; + }, + + formatCondition(condition) { + const value = this.compactValue(condition.value); + return `${condition.field || 'field'} ${condition.operator || 'is'} ${value}`; + }, + + getAssignedMailbox() { + const view = document.getElementById('rulesPolicyView'); + if (!view) { + return { email: '', grantID: '' }; + } + + return { + email: view.dataset.accountEmail || '', + grantID: view.dataset.grantId || '', + }; + }, + + formatAction(action) { + const value = this.compactValue(action.value); + return value ? `${action.type || 'action'} → ${value}` : (action.type || 'action'); + }, + + compactValue(value) { + if (value === null || value === undefined || value === '') { + return ''; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + try { + return JSON.stringify(value); + } catch (_error) { + return String(value); + } + }, + + humanBytes(bytes) { + if (!bytes || bytes < 1024) { + return `${bytes || 0} B`; + } + + const units = ['KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = -1; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; + }, + + escape(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +}; diff --git a/internal/air/templates/base.gohtml b/internal/air/templates/base.gohtml index 5e7a6ff..db23ea1 100644 --- a/internal/air/templates/base.gohtml +++ b/internal/air/templates/base.gohtml @@ -109,6 +109,7 @@ {{template "calendar-view" .}} {{template "contacts-view" .}} {{template "notetaker-view" .}} + {{if eq .Provider "nylas"}}{{template "rules-policy-view" .}}{{end}} {{template "status-bar" .}} @@ -131,6 +132,7 @@ + @@ -173,6 +175,7 @@ + diff --git a/internal/air/templates/pages/calendar.gohtml b/internal/air/templates/pages/calendar.gohtml index 34514ac..55ac5f5 100644 --- a/internal/air/templates/pages/calendar.gohtml +++ b/internal/air/templates/pages/calendar.gohtml @@ -1,6 +1,6 @@ {{define "calendar-view"}} -
+
diff --git a/internal/air/templates/pages/notetaker.gohtml b/internal/air/templates/pages/notetaker.gohtml index d431cca..59739c9 100644 --- a/internal/air/templates/pages/notetaker.gohtml +++ b/internal/air/templates/pages/notetaker.gohtml @@ -1,6 +1,6 @@ {{define "notetaker-view"}} -
+
diff --git a/internal/air/templates/pages/rules_policy.gohtml b/internal/air/templates/pages/rules_policy.gohtml new file mode 100644 index 0000000..94659dd --- /dev/null +++ b/internal/air/templates/pages/rules_policy.gohtml @@ -0,0 +1,50 @@ +{{define "rules-policy-view"}} + +
+
+
+
Email
+

Policy & Rules

+

Mailbox controls exposed by this Nylas-managed account.

+
+ +
+
+
+
+
+

Policies

+

Managed mailbox policies available for this Nylas account.

+
+ +
+
+
+
🛡️
+
Policies load on demand
+
Open this view on a Nylas-managed account to inspect active policies.
+
+
+
+ +
+
+
+

Rules

+

Inbound processing rules configured for this Nylas account.

+
+ +
+
+
+
⚙️
+
Rules load on demand
+
Open this view on a Nylas-managed account to inspect active rules.
+
+
+
+
+
+{{end}} diff --git a/internal/air/templates/partials/header.gohtml b/internal/air/templates/partials/header.gohtml index e695006..a585fe2 100644 --- a/internal/air/templates/partials/header.gohtml +++ b/internal/air/templates/partials/header.gohtml @@ -7,13 +7,13 @@ Nylas Air