diff --git a/.claude/HOOKS-CONFIG.md b/.claude/HOOKS-CONFIG.md index ab81a8b..2b47cac 100644 --- a/.claude/HOOKS-CONFIG.md +++ b/.claude/HOOKS-CONFIG.md @@ -86,7 +86,7 @@ Add to the `"hooks"` section in `.claude/settings.json`: - Never blocks **context-injector.sh** (UserPromptSubmit) -- Injects reminders based on prompt keywords (test, security, api, playwright, commit) +- Injects reminders based on prompt keywords (test, security, api, commit) - Never blocks --- diff --git a/.claude/README.md b/.claude/README.md index 5100a27..4f7f685 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -10,9 +10,9 @@ This directory contains skills, workflows, rules, agents, and shared patterns fo .claude/ ├── commands/ # 19 actionable skills (invokable workflows) ├── rules/ # 4 development rules (auto-applied) -├── agents/ # 9 specialized agents +├── agents/ # 7 specialized agents ├── hooks/ # 6 hook scripts (2 wired, 4 available) -├── shared/patterns/ # 3 reusable pattern files +├── shared/patterns/ # 2 reusable pattern files ├── settings.json # Security hooks & permissions ├── HOOKS-CONFIG.md # Hook configuration guide └── README.md # This file @@ -84,7 +84,7 @@ This directory contains skills, workflows, rules, agents, and shared patterns fo --- -## Agents (8 Specialized) +## Agents (7 Specialized) | Agent | Purpose | |-------|---------| @@ -94,7 +94,6 @@ This directory contains skills, workflows, rules, agents, and shared patterns fo | `security-auditor` | Security vulnerability analysis | | `documentation-writer` | Documentation updates | | `codebase-explorer` | Fast codebase exploration | -| `frontend-agent` | JS/CSS/Go templates | | `mistake-learner` | Abstract mistakes to learnings | **References:** `agents/references/` contains helper-reference, security-checklist, doc-standards. @@ -116,13 +115,12 @@ This directory contains skills, workflows, rules, agents, and shared patterns fo --- -## Shared Patterns (3 Files) +## Shared Patterns (2 Files) | Pattern | Purpose | |---------|---------| | `go-test-patterns.md` | Table-driven tests, mocks, testify | -| `integration-test-patterns.md` | CLI + Air integration tests | -| `playwright-patterns.md` | Selectors, templates, commands | +| `integration-test-patterns.md` | CLI integration tests | --- diff --git a/.claude/agents/README.md b/.claude/agents/README.md index 3ad97a6..11c675d 100644 --- a/.claude/agents/README.md +++ b/.claude/agents/README.md @@ -14,7 +14,6 @@ This directory contains specialized agents for the Nylas CLI codebase. | `security-auditor` | opus | Security vulnerability analysis | Safe | | `documentation-writer` | sonnet | Documentation updates | Limited | | `codebase-explorer` | haiku | Exploration | Safe | -| `frontend-agent` | sonnet | UI/CSS/JS | Limited | | `mistake-learner` | sonnet | Learning capture | Serial only | --- @@ -27,23 +26,22 @@ Use parallel agents to explore or review the 745-file codebase without exhaustin | Task | Agents | Why | |------|--------|-----| -| Full codebase exploration | 5 | One per major directory | -| Feature search | 4 | Search cli, adapters, air, tui simultaneously | +| Full codebase exploration | 4 | One per major directory | +| Feature search | 3 | Search cli, adapters, tui simultaneously | | Multi-file PR review | 4 | Review different files in parallel | | Test coverage analysis | 4 | Analyze different packages | ### Invocation Patterns ``` -# Full exploration (4 agents) -"Explore using 4 parallel agents: +# Full exploration (3 agents) +"Explore using 3 parallel agents: - Agent 1: internal/cli/ - Agent 2: internal/adapters/ - - Agent 3: internal/air/ - - Agent 4: internal/domain/ + ports/ + tui/" + - Agent 3: internal/domain/ + ports/ + tui/" -# Feature search (4 agents) -"Find all email-related code using 4 agents across cli, adapters, air, tui" +# Feature search (3 agents) +"Find all email-related code using 3 agents across cli, adapters, tui" # PR review (4 agents) "Review these 8 files using 4 parallel code-reviewer agents" @@ -55,7 +53,6 @@ Use parallel agents to explore or review the 745-file codebase without exhaustin |-----------|-------|----------------| | `internal/cli/` | 268 | HIGH | | `internal/adapters/` | 158 | HIGH | -| `internal/air/` | 117 | HIGH | | `internal/tui/` | 77 | MEDIUM | | `internal/domain/` | 21 | LOW (shared) | @@ -81,7 +78,6 @@ Use parallel agents to explore or review the 745-file codebase without exhaustin | `documentation-writer` | Different file writers | Another doc-writer | | `code-writer` | Different directory writers | Same-file writers | | `test-writer` | Different package writers | Same-package writers | -| `frontend-agent` | code-writer (Go only) | Another frontend-agent | | `mistake-learner` | None (serial only) | All write agents | ### Key Benefit @@ -118,7 +114,7 @@ Parallel agents have **isolated context windows** - prevents "dumb Claude mid-se | Model | Cost | Use For | |-------|------|---------| | **opus** | $$$ | Quality/security validation (code-reviewer, security-auditor) | -| **sonnet** | $$ | Implementation (code-writer, test-writer, documentation-writer, frontend-agent) | +| **sonnet** | $$ | Implementation (code-writer, test-writer, documentation-writer) | | **haiku** | $ | Exploration (codebase-explorer) | **Principle:** Use the cheapest model that delivers acceptable quality for the task. diff --git a/.claude/agents/code-writer.md b/.claude/agents/code-writer.md index 119370b..3693618 100644 --- a/.claude/agents/code-writer.md +++ b/.claude/agents/code-writer.md @@ -18,7 +18,7 @@ You are an expert code writer for the Nylas CLI polyglot codebase. You write pro | Can run with | Cannot run with | |--------------|-----------------| | codebase-explorer, code-reviewer | Another code-writer (same files) | -| frontend-agent (different dirs) | test-writer (same package) | +| - | test-writer (same package) | | - | mistake-learner | **Rule:** Only parallelize if working on DISJOINT files. @@ -30,7 +30,6 @@ You are an expert code writer for the Nylas CLI polyglot codebase. You write pro | Language | Patterns You Follow | |----------|---------------------| | **Go** | Hexagonal architecture, table-driven tests, error wrapping | -| **Frontend** | See `frontend-agent.md` for JS/CSS/templates | --- @@ -204,13 +203,6 @@ After writing code, report: 2. Implement in `internal/adapters/nylas/{resource}.go` 3. Add mock in `internal/adapters/nylas/mock_{resource}.go` -### Adding Frontend Feature - -1. Add handler in `internal/air/handlers_*.go` -2. Add template in `internal/air/templates/` -3. Add styles in `internal/air/static/css/` -4. Add JavaScript in `internal/air/static/js/` - --- ## Rules diff --git a/.claude/agents/frontend-agent.md b/.claude/agents/frontend-agent.md deleted file mode 100644 index ec3e8c2..0000000 --- a/.claude/agents/frontend-agent.md +++ /dev/null @@ -1,192 +0,0 @@ ---- -name: frontend-agent -description: Frontend specialist for vanilla JavaScript, CSS, and Go templates. Use for both Air (port 7365) and UI (port 7363) web interfaces. -tools: Read, Write, Edit, Grep, Glob, Bash(node --check:*), Bash(npx prettier:*), Bash(npx playwright:*) -model: sonnet -parallelization: limited -scope: internal/air/static/*, internal/air/templates/*, internal/ui/static/*, internal/ui/templates/* ---- - -# Frontend Specialist - -You write frontend code for the Nylas CLI web interfaces. - -## Web Interfaces - -| Interface | Port | Location | Purpose | -|-----------|------|----------|---------| -| **Air** | 7365 | `internal/air/` | Full web app (email, calendar, contacts) | -| **UI** | 7363 | `internal/ui/` | Command explorer / demo interface | - -Both use vanilla JavaScript, CSS, and Go templates (.gohtml). - -## Parallelization - -⚠️ **LIMITED parallel safety** - Writes to frontend files. - -| Can run with | Cannot run with | -|--------------|-----------------| -| codebase-explorer, code-reviewer | Another frontend-agent | -| code-writer (Go files only) | code-writer (CSS/JS files) | -| test-writer | mistake-learner | - -**Scope:** This agent ONLY modifies files in `internal/air/static/` and `internal/air/templates/`. - -**For common patterns (CSS variables, BEM, event delegation, fetch):** See `.claude/agents/code-writer.md` - ---- - -## Tech Stack (No Frameworks!) - -| Technology | Rules | -|------------|-------| -| **JavaScript** | Vanilla ES6+, no npm dependencies in browser | -| **CSS** | Custom properties, BEM-like naming, no preprocessors | -| **Templates** | Go html/template (.gohtml files) | - ---- - -## CSS Organization - -### Air (`internal/air/static/css/`) -``` -├── main.css # Core imports and variables -├── accessibility-*.css # ARIA, focus states, skip links -├── calendar-*.css # Calendar grid, modals, events -├── components-*.css # Reusable UI (buttons, cards, etc.) -├── contacts-*.css # Contact views, modals -├── features-*.css # Feature-specific styles -├── productivity-*.css # Scheduled send, undo, templates -└── settings-*.css # Settings panels, AI config -``` - -### UI (`internal/ui/static/css/`) -``` -├── base.css # Core styles and variables -├── layout.css # Page layout -├── components-*.css # UI components (forms, panels) -├── commands.css # Command-specific styles -└── utilities.css # Helper classes -``` - ---- - -## Accessibility (UNIQUE - not in code-writer) - -### Focus States -```css -:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; -} -``` - -### Skip Link -```css -.skip-link { - position: absolute; - top: -40px; - left: 0; - z-index: 100; -} - -.skip-link:focus { - top: 0; -} -``` - -### Reduced Motion -```css -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - transition-duration: 0.01ms !important; - } -} -``` - ---- - -## Go Templates (UNIQUE - not in code-writer) - -### Template Structure -```html -{{/* internal/air/templates/email.gohtml */}} -{{define "email"}} - - -
- - -No emails found
-{{end}} -``` - ---- - -## Progressive Enhancement - -```javascript -// Check for modern API support before using -if ('IntersectionObserver' in window) { - const observer = new IntersectionObserver(handleIntersect); - images.forEach(img => observer.observe(img)); -} else { - // Fallback for older browsers - images.forEach(img => img.src = img.dataset.src); -} -``` - ---- - -## Security Rules - -| Pattern | Safe | Unsafe | -|---------|------|--------| -| Display text | `el.textContent = data` | `el.innerHTML = data` | -| Create elements | `document.createElement()` | Template strings with user data | -| URL params | Validate/sanitize | Direct interpolation | - -**Go templates auto-escape by default - trust them for HTML output.** - ---- - -## Checklist - -- [ ] Uses CSS custom properties for colors/spacing -- [ ] Follows BEM-like naming convention -- [ ] Mobile-first responsive design -- [ ] Focus states for accessibility -- [ ] Reduced motion support -- [ ] No npm dependencies in browser JS -- [ ] Event delegation for repeated elements -- [ ] Uses textContent (never innerHTML with user data) -- [ ] Files ≤500 lines (see `file-size-limits.md`) diff --git a/.claude/agents/mistake-learner.md b/.claude/agents/mistake-learner.md index 2da4dbc..60533da 100644 --- a/.claude/agents/mistake-learner.md +++ b/.claude/agents/mistake-learner.md @@ -18,7 +18,7 @@ You analyze mistakes caught during development and update CLAUDE.md with abstrac | Can run with | Cannot run with | |--------------|-----------------| | codebase-explorer, code-reviewer | code-writer, test-writer | -| - | frontend-agent, another mistake-learner | +| - | another mistake-learner | **Rule:** Run this agent LAST after all other write operations complete. @@ -65,7 +65,7 @@ Transform specific incidents into general principles: | Specific | Abstracted | |----------|-----------| -| "Forgot to return after http.Error in handleGetEmail" | "Air handlers: ALWAYS return after error responses" | +| "Forgot to return after http.Error in a webhook handler" | "HTTP handlers: ALWAYS return after error responses" | | "Test failed because mock wasn't set up" | "Go tests: ALWAYS verify mock functions are set before asserting" | | "Calendar showed wrong time in March" | "Calendar: ALWAYS handle DST transitions in time comparisons" | @@ -86,8 +86,7 @@ Choose the right LEARNINGS subsection: Examples: ``` - Go tests: ALWAYS use t.Cleanup() for resource teardown (prevents leaks) -- Air handlers: NEVER write response body after WriteHeader() (causes superfluous warning) -- Playwright: ALWAYS waitForSelector before click (Air lazy-loads content) +- HTTP handlers: NEVER write response body after WriteHeader() (causes superfluous warning) ``` ### 5. Update CLAUDE.md diff --git a/.claude/agents/references/helper-reference.md b/.claude/agents/references/helper-reference.md index 3dd3037..5d022ff 100644 --- a/.claude/agents/references/helper-reference.md +++ b/.claude/agents/references/helper-reference.md @@ -83,15 +83,6 @@ Complete reference for reusable helper functions. **ALWAYS check these before wr --- -## Air Web Helpers (`internal/air/`) - -| Helper | Purpose | Location | -|--------|---------|----------| -| `s.requireDefaultGrant(w)` | Validate grant exists | `server_stores.go` | -| `ParseBool(r, param)` | Parse bool query param | `query_helpers.go` | - ---- - ## Common Duplicates to Avoid These patterns have been duplicated before - ALWAYS check first: @@ -106,7 +97,6 @@ These patterns have been duplicated before - ALWAYS check first: | Field validation | `common.ValidateRequired()`, `ValidateRequiredFlag()` | | URL validation | `common.ValidateURL(name, value)` | | Email validation | `common.ValidateEmail(name, value)` | -| Grant validation in Air | `s.requireDefaultGrant(w)` | | Pagination handling | `common.FetchAllPages[T]()` | | Error formatting for CLI | `common.WrapError(err)` or wrap with `fmt.Errorf` | | Retry with backoff | `common.WithRetry(ctx, config, fn)` | @@ -126,7 +116,6 @@ These patterns have been duplicated before - ALWAYS check first: | CLI-wide utilities | `internal/cli/common/` | | HTTP/API helpers | `internal/adapters/nylas/client.go` | | Feature-specific | `internal/cli/{feature}/helpers.go` | -| Air web UI | `internal/air/server_helpers.go` | | Secret storage | `internal/adapters/keyring/keyring.go` | | Secret constants | `internal/ports/secrets.go` | diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md index 959acec..8031c0f 100644 --- a/.claude/agents/test-writer.md +++ b/.claude/agents/test-writer.md @@ -1,15 +1,15 @@ --- name: test-writer -description: Expert test writer for Go unit/integration tests AND Playwright E2E tests. Generates comprehensive, maintainable tests. Use PROACTIVELY after code-writer completes. -tools: Read, Write, Edit, Grep, Glob, Bash(go test:*), Bash(go build:*), Bash(npx playwright:*), Bash(go tool cover:*), Bash(make test-cleanup:*) +description: Expert test writer for Go unit/integration tests. Generates comprehensive, maintainable tests. Use PROACTIVELY after code-writer completes. +tools: Read, Write, Edit, Grep, Glob, Bash(go test:*), Bash(go build:*), Bash(go tool cover:*), Bash(make test-cleanup:*) model: sonnet parallelization: limited -scope: internal/cli/integration/*, internal/air/integration_*, tests/* +scope: internal/cli/integration/* --- # Test Writer Agent -You are an expert test writer for the Nylas CLI polyglot codebase. You write comprehensive tests across three domains: +You are an expert test writer for the Nylas CLI Go codebase. You write comprehensive tests across two domains: ## Parallelization @@ -19,18 +19,15 @@ You are an expert test writer for the Nylas CLI polyglot codebase. You write com |--------------|-----------------| | codebase-explorer, code-reviewer | Another test-writer (same package) | | code-writer (different package) | mistake-learner | -| frontend-agent | - | **Rule:** Only parallelize if writing tests for DIFFERENT packages. 1. **Go Unit Tests** - Table-driven, with mocks 2. **Go Integration Tests** - Real API calls, rate-limited -3. **Playwright E2E Tests** - Browser automation for Air & UI **Shared Patterns:** - Go unit tests: `.claude/shared/patterns/go-test-patterns.md` - Integration tests: `.claude/shared/patterns/integration-test-patterns.md` -- Playwright E2E: `.claude/shared/patterns/playwright-patterns.md` **See also:** `.claude/commands/generate-tests.md` for interactive test generation workflow. @@ -44,16 +41,10 @@ You are an expert test writer for the Nylas CLI polyglot codebase. You write com - **Assertions:** Use `testify/assert` and `testify/require` ### Go Integration Tests -- **Location:** `internal/cli/integration/` or `internal/air/integration_*.go` +- **Location:** `internal/cli/integration/` - **Build tags:** `//go:build integration` - **CRITICAL:** Always use `acquireRateLimit(t)` for API calls -### Playwright E2E -- **Air:** Port 7365, `tests/air/e2e/` -- **UI:** Port 7363, `tests/ui/e2e/` -- **Selectors:** getByRole > getByText > getByLabel > getByTestId -- **NEVER:** CSS selectors, XPath - --- ## Coverage & Categories @@ -99,18 +90,9 @@ This agent is the **tester** in the development pipeline: ```bash make test-unit # Unit tests make test-integration # CLI integration -make test-air-integration # Air integration make test-coverage # Coverage report ``` -### Playwright Tests -```bash -npx playwright test # All tests -npx playwright test --project=air # Air only -npx playwright test --project=ui # UI only -npx playwright test --ui # Interactive -``` - --- ## Output Format @@ -123,9 +105,6 @@ After writing tests, report: ### Go Tests - `path/to/file_test.go` - [N test cases for Function] -### Playwright Tests -- `tests/air/e2e/feature.spec.js` - [N specs for Feature] - ## Coverage Impact - Before: X% - After: Y% @@ -136,8 +115,7 @@ After writing tests, report: ## Rules 1. **Table-driven tests** for Go (see `go-test-patterns.md`) -2. **Semantic selectors** for Playwright (see `playwright-patterns.md`) -3. **Rate limiting** for integration (see `integration-test-patterns.md`) -4. **Independent tests** - No shared state -5. **Descriptive names** - Test name describes scenario -6. **Test behavior** - Not implementation details +2. **Rate limiting** for integration (see `integration-test-patterns.md`) +3. **Independent tests** - No shared state +4. **Descriptive names** - Test name describes scenario +5. **Test behavior** - Not implementation details diff --git a/.claude/commands/generate-tests.md b/.claude/commands/generate-tests.md index d36bcf6..43da44a 100644 --- a/.claude/commands/generate-tests.md +++ b/.claude/commands/generate-tests.md @@ -1,17 +1,16 @@ --- name: generate-tests -description: Generate unit, integration, or E2E tests for Go code following project patterns and coverage targets +description: Generate unit or integration tests for Go code following project patterns and coverage targets allowed-tools: Read, Write, Edit, Grep, Glob, Bash(go test:*), Bash(go build:*) --- # Generate Tests -Generate unit, integration, or E2E tests for Go code. +Generate unit or integration tests for Go code. **Patterns:** See `.claude/shared/patterns/` for templates: - `go-test-patterns.md` - Unit test patterns - `integration-test-patterns.md` - CLI integration tests + rate limiting -- `playwright-patterns.md` - E2E browser tests **Agent:** See `.claude/agents/test-writer.md` for autonomous test generation. @@ -19,7 +18,7 @@ Generate unit, integration, or E2E tests for Go code. 1. Ask me for: - Which file/function to test - - Test type: unit, integration, or E2E + - Test type: unit or integration - Specific scenarios to cover (optional) 2. Read the appropriate pattern file for templates. diff --git a/.claude/commands/run-tests.md b/.claude/commands/run-tests.md index 57b9457..83e31da 100644 --- a/.claude/commands/run-tests.md +++ b/.claude/commands/run-tests.md @@ -94,9 +94,8 @@ make ci # Quick CI (no integration tests) make test-unit # Unit tests only make test-race # Unit tests with race detector make test-integration # CLI integration tests -make test-air-integration # Air web UI integration tests make test-coverage # Generate coverage report make test-cleanup # Clean up test resources ``` -**CRITICAL:** Air tests create real resources. Always use `make ci-full` for automatic cleanup. +**CRITICAL:** Integration tests create real resources. Always use `make ci-full` for automatic cleanup. diff --git a/.claude/hooks/context-injector.sh b/.claude/hooks/context-injector.sh index 09ece7a..8834a59 100755 --- a/.claude/hooks/context-injector.sh +++ b/.claude/hooks/context-injector.sh @@ -21,16 +21,6 @@ if echo "$PROMPT" | grep -qi "api\|endpoint\|nylas"; then echo "API Note: Use Nylas v3 ONLY. Reference: https://developer.nylas.com/docs/api/v3/" fi -# If prompt mentions Playwright or E2E, remind about selectors -if echo "$PROMPT" | grep -qi "playwright\|e2e\|browser\|selector"; then - echo "Playwright reminder: Use semantic selectors (getByRole > getByText > getByLabel). Never CSS/XPath." -fi - -# If prompt mentions CSS or styling, remind about patterns -if echo "$PROMPT" | grep -qi "css\|style\|frontend\|ui"; then - echo "CSS reminder: Use BEM naming, CSS custom properties. See .claude/rules/file-size-limits.md" -fi - # If prompt mentions commit or PR, remind about rules if echo "$PROMPT" | grep -qi "commit\|push\|pr\|pull request"; then echo "Git reminder: Run 'make ci-full' before committing. Never commit secrets." diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index d8afc14..291ab2d 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -11,7 +11,6 @@ Consolidated testing rules for the Nylas CLI project. **Detailed Patterns:** - Go unit tests: `.claude/shared/patterns/go-test-patterns.md` - Integration tests: `.claude/shared/patterns/integration-test-patterns.md` -- Playwright E2E: `.claude/shared/patterns/playwright-patterns.md` --- @@ -27,11 +26,6 @@ Consolidated testing rules for the Nylas CLI project. - **Build tags:** `//go:build integration` and `// +build integration` - **Function:** `TestCLI_CommandName` -### Air Integration Tests -- **Location:** `internal/air/integration_*.go` -- **Build tags:** `//go:build integration` and `// +build integration` -- **Function:** `TestIntegration_FeatureName` - --- ## Rate Limiting (CRITICAL) diff --git a/.claude/shared/patterns/integration-test-patterns.md b/.claude/shared/patterns/integration-test-patterns.md index 6f249a5..0b986c1 100644 --- a/.claude/shared/patterns/integration-test-patterns.md +++ b/.claude/shared/patterns/integration-test-patterns.md @@ -51,59 +51,6 @@ func TestCLI_FeatureName(t *testing.T) { --- -## Air Integration Tests (Web UI) - -### Location & Build Tags - -```go -//go:build integration -// +build integration - -package air -``` - -Location: `internal/air/integration_*.go` - -### Files (10 total): -- `integration_base_test.go` - Shared helpers (`testServer()`, utilities) -- `integration_core_test.go` - Config, Grants, Folders, Index -- `integration_email_test.go` - Email and draft operations -- `integration_calendar_test.go` - Calendar, events, availability -- `integration_contacts_test.go` - Contact operations -- `integration_cache_test.go` - Cache operations -- `integration_ai_test.go` - AI features (summarize, smart compose, thread analysis) -- `integration_middleware_test.go` - Middleware tests -- `integration_bundles_test.go` - Email bundles, categorization -- `integration_productivity_test.go` - Scheduled send, undo send, snooze - ---- - -## Air Integration Test Template - -```go -func TestIntegration_Feature(t *testing.T) { - server := testServer(t) // Uses shared helper - - req := httptest.NewRequest(http.MethodGet, "/api/endpoint", nil) - w := httptest.NewRecorder() - - server.handleEndpoint(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp ResponseType - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Assertions here -} -``` - ---- - ## Rate Limiting (CRITICAL) ALWAYS use rate limiting for API calls in parallel tests: @@ -151,7 +98,7 @@ t.Cleanup(func() { }) ``` -**CRITICAL:** Air tests create real resources. Always use: +**CRITICAL:** Integration tests create real resources. Always use: ```bash make ci-full # RECOMMENDED: Complete CI with automatic cleanup make test-cleanup # Manual cleanup if needed @@ -166,5 +113,4 @@ make test-cleanup # Manual cleanup if needed ```bash make ci-full # Complete CI pipeline (RECOMMENDED) make test-integration # CLI integration tests -make test-air-integration # Air web UI integration tests ``` diff --git a/.claude/shared/patterns/playwright-patterns.md b/.claude/shared/patterns/playwright-patterns.md deleted file mode 100644 index 82e8fba..0000000 --- a/.claude/shared/patterns/playwright-patterns.md +++ /dev/null @@ -1,130 +0,0 @@ -# Playwright E2E Test Patterns - -Shared patterns for Playwright E2E tests in the Nylas CLI project. - ---- - -## Two Test Projects - -| Project | Port | Location | Purpose | -|---------|------|----------|---------| -| **Air** | 7365 | `tests/air/e2e/` | Modern web email client | -| **UI** | 7363 | `tests/ui/e2e/` | CLI admin interface | - ---- - -## Selector Strategy (Priority Order) - -| Priority | Selector Type | Example | -|----------|--------------|---------| -| 1 | Role selectors | `getByRole('button', { name: 'Send' })` | -| 2 | Text selectors | `getByText('Welcome')` | -| 3 | Label selectors | `getByLabel('Email')` | -| 4 | Test IDs | `getByTestId('submit-btn')` (last resort) | - -**NEVER use:** CSS selectors, XPath, fragile DOM paths - ---- - -## Air Test Template - -```javascript -// tests/air/e2e/feature.spec.js -const { test, expect } = require('@playwright/test'); - -test.describe('Feature Name', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('.app-loaded'); - }); - - test('user can perform action', async ({ page }) => { - // Arrange - navigate to correct state - await page.getByRole('link', { name: 'Inbox' }).click(); - - // Act - perform the action - await page.getByRole('button', { name: 'Compose' }).click(); - await page.getByLabel('To').fill('test@example.com'); - await page.getByLabel('Subject').fill('Test Subject'); - await page.getByRole('button', { name: 'Send' }).click(); - - // Assert - verify the result - await expect(page.getByText('Message sent')).toBeVisible(); - }); - - test('handles error gracefully', async ({ page }) => { - // Test error scenarios - await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText('Please fill required fields')).toBeVisible(); - }); -}); -``` - ---- - -## UI Test Template - -```javascript -// tests/ui/e2e/admin.spec.js -const { test, expect } = require('@playwright/test'); - -test.describe('Admin Panel', () => { - test('should load dashboard', async ({ page }) => { - await page.goto('/'); - await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); - }); - - test('should display grants list', async ({ page }) => { - await page.goto('/grants'); - await expect(page.getByRole('table')).toBeVisible(); - }); -}); -``` - ---- - -## Shared Fixtures & Helpers - -**Fixtures:** `tests/shared/fixtures/` -- `mock-contacts.json` -- `mock-emails.json` -- `mock-events.json` - -**Helpers:** `tests/shared/helpers/` -- `air-selectors.js` -- `ui-selectors.js` -- `api-mocks.js` - ---- - -## Test Categories - -| Category | What to Test | -|----------|--------------| -| Happy Path | Normal user workflows | -| Error Cases | Invalid inputs, network failures | -| Edge Cases | Empty states, long strings, unicode | -| Boundary | Pagination, limits, first/last items | - ---- - -## Commands - -```bash -npx playwright test # Run all tests -npx playwright test --project=air # Air only -npx playwright test --project=ui # UI only -npx playwright test tests/air/e2e/email.spec.js # Specific file -npx playwright test --ui # Interactive mode -npx playwright test --debug # Debug mode -``` - ---- - -## Rules - -1. **Semantic selectors** - No CSS/XPath -2. **Independent tests** - No shared state -3. **Descriptive names** - Test name describes scenario -4. **Arrange-Act-Assert** - Clear test structure -5. **Test behavior** - Not implementation details diff --git a/CLAUDE-QUICKSTART.md b/CLAUDE-QUICKSTART.md index 9005183..268a1e9 100644 --- a/CLAUDE-QUICKSTART.md +++ b/CLAUDE-QUICKSTART.md @@ -121,10 +121,9 @@ Your AI-powered development assistant with self-learning capabilities. # Basic usage - searches all layers /parallel-explore "email sending functionality" -# Spawns 4-5 codebase-explorer agents across: +# Spawns up to 4 codebase-explorer agents across: # - internal/cli/ (commands, flags) # - internal/adapters/ (API integrations) -# - internal/air/ (web UI handlers) # - internal/tui/ (terminal UI) # - internal/domain/ (core types) ``` @@ -165,24 +164,12 @@ Use for: ``` ### test-writer (Sonnet) -**Best for:** All test generation (Go + Playwright) +**Best for:** Go unit and integration test generation ``` Go Tests: Table-driven with t.Run(), testify assertions, rate-limited -Playwright: Semantic selectors only (getByRole > getByText > getByTestId) Patterns: .claude/shared/patterns/go-test-patterns.md - .claude/shared/patterns/playwright-patterns.md -``` - -### frontend-agent (Sonnet) -**Best for:** JavaScript, CSS, Go templates - -``` -Tech stack: -- Vanilla ES6+ JavaScript (no npm in browser) -- CSS custom properties, BEM naming -- Go html/template (.gohtml) ``` ### mistake-learner (Sonnet) @@ -246,7 +233,6 @@ Triggers: - "test" → Testing patterns reminder - "security" → Security scan reminder - "api" → Nylas v3 API reminder -- "playwright" → Semantic selector reminder - "css" → BEM naming reminder - "commit" → Git rules reminder ``` @@ -286,7 +272,6 @@ CLAUDE.md has a self-updating LEARNINGS section: ### Project-Specific Gotchas Things unique to this codebase: -- Playwright selectors: ALWAYS use semantic selectors (getByRole > getByText > getByLabel > getByTestId) - Go tests: ALWAYS use table-driven tests with t.Run() - Integration tests: ALWAYS use acquireRateLimit(t) before API calls - Directory permissions: Use 0750, not 0755 (gosec G301) @@ -317,8 +302,7 @@ Hard-won knowledge: /diary # Use specialized agents for their domain -# - test-writer for tests (Go + Playwright) -# - frontend-agent for CSS/JS +# - test-writer for tests (Go) # - codebase-explorer for finding code ``` @@ -328,7 +312,6 @@ Hard-won knowledge: # Don't skip session start - you lose context # Don't ignore mistakes - they'll repeat # Don't skip /diary - learnings are lost -# Don't use CSS selectors in Playwright - use semantic selectors ``` --- diff --git a/CLAUDE.md b/CLAUDE.md index 521ba53..76ffbc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,8 +74,6 @@ Go 1.24+ · Hexagonal architecture (CLI → Port → Adapter) · Cobra CLI · ** | `make test-coverage` | Coverage report | | `make help` | List all available targets | | `nylas init` | First-time setup wizard | -| `nylas air` | Start Air web UI (localhost:7365) | -| `nylas chat` | Start AI chat interface (localhost:7367) | **Debugging path:** `ports/nylas.go` → `adapters/nylas/client.go` → `cli/Test body
", "Test body", 1234567890) - if err != nil { - t.Fatalf("Failed to insert test data: %v", err) - } - - // Create destination database - dstPath := filepath.Join(tmpDir, "dst.db") - dstDB, err := sql.Open(driverName, dstPath) - if err != nil { - t.Fatalf("Failed to create destination database: %v", err) - } - defer func() { _ = dstDB.Close() }() - - // Initialize schema in destination - if err := initSchema(dstDB); err != nil { - t.Fatalf("Failed to init schema in destination: %v", err) - } - - // Copy the emails table - if err := copyTable(srcDB, dstDB, "emails"); err != nil { - t.Fatalf("copyTable() error: %v", err) - } - - // Verify data was copied - var count int - if err := dstDB.QueryRow("SELECT COUNT(*) FROM emails").Scan(&count); err != nil { - t.Fatalf("Failed to count copied rows: %v", err) - } - - if count != 1 { - t.Errorf("copyTable() copied %d rows, want 1", count) - } - - // Verify the data integrity - var subject string - if err := dstDB.QueryRow("SELECT subject FROM emails WHERE id = ?", "email-1").Scan(&subject); err != nil { - t.Fatalf("Failed to query copied data: %v", err) - } - - if subject != "Test Subject" { - t.Errorf("Copied subject = %q, want %q", subject, "Test Subject") - } -} - -func TestCopyTable_InvalidTable(t *testing.T) { - tmpDir := t.TempDir() - - srcPath := filepath.Join(tmpDir, "src.db") - srcDB, err := sql.Open(driverName, srcPath) - if err != nil { - t.Fatalf("Failed to create source database: %v", err) - } - defer func() { _ = srcDB.Close() }() - - dstPath := filepath.Join(tmpDir, "dst.db") - dstDB, err := sql.Open(driverName, dstPath) - if err != nil { - t.Fatalf("Failed to create destination database: %v", err) - } - defer func() { _ = dstDB.Close() }() - - // Attempt to copy an invalid table (SQL injection attempt) - err = copyTable(srcDB, dstDB, "users; DROP TABLE emails;--") - if err == nil { - t.Error("copyTable() should reject invalid table names") - } -} - -func TestCopyTable_EmptyTable(t *testing.T) { - tmpDir := t.TempDir() - - // Create source database with empty table - srcPath := filepath.Join(tmpDir, "src.db") - srcDB, err := sql.Open(driverName, srcPath) - if err != nil { - t.Fatalf("Failed to create source database: %v", err) - } - defer func() { _ = srcDB.Close() }() - - if err := initSchema(srcDB); err != nil { - t.Fatalf("Failed to init schema: %v", err) - } - - // Create destination database - dstPath := filepath.Join(tmpDir, "dst.db") - dstDB, err := sql.Open(driverName, dstPath) - if err != nil { - t.Fatalf("Failed to create destination database: %v", err) - } - defer func() { _ = dstDB.Close() }() - - if err := initSchema(dstDB); err != nil { - t.Fatalf("Failed to init schema: %v", err) - } - - // Copy empty table - should not error - if err := copyTable(srcDB, dstDB, "emails"); err != nil { - t.Errorf("copyTable() on empty table error: %v", err) - } -} - -func TestNewEncryptedManager(t *testing.T) { - tmpDir := t.TempDir() - - cfg := Config{BasePath: tmpDir} - encCfg := EncryptionConfig{Enabled: false} - - mgr, err := NewEncryptedManager(cfg, encCfg) - if err != nil { - t.Fatalf("NewEncryptedManager() error: %v", err) - } - if mgr == nil { - t.Fatal("NewEncryptedManager() returned nil") - } - defer func() { _ = mgr.Close() }() - - if mgr.Manager == nil { - t.Error("NewEncryptedManager().Manager is nil") - } - - if mgr.keys == nil { - t.Error("NewEncryptedManager().keys is nil") - } -} - -func TestEncryptedManager_GetDB_EncryptionDisabled(t *testing.T) { - tmpDir := t.TempDir() - - cfg := Config{BasePath: tmpDir} - encCfg := EncryptionConfig{Enabled: false} - - mgr, err := NewEncryptedManager(cfg, encCfg) - if err != nil { - t.Fatalf("NewEncryptedManager() error: %v", err) - } - defer func() { _ = mgr.Close() }() - - // With encryption disabled, should use the regular Manager.GetDB - db, err := mgr.GetDB("test@example.com") - if err != nil { - t.Fatalf("GetDB() error: %v", err) - } - - if db == nil { - t.Fatal("GetDB() returned nil") - } - - // Verify it's working - _, err = db.Exec("SELECT 1") - if err != nil { - t.Errorf("Database query failed: %v", err) - } -} - -func TestEncryptedManager_ClearCache_EncryptionDisabled(t *testing.T) { - tmpDir := t.TempDir() - - cfg := Config{BasePath: tmpDir} - encCfg := EncryptionConfig{Enabled: false} - - mgr, err := NewEncryptedManager(cfg, encCfg) - if err != nil { - t.Fatalf("NewEncryptedManager() error: %v", err) - } - defer func() { _ = mgr.Close() }() - - email := "test@example.com" - - // Create a database first - _, err = mgr.GetDB(email) - if err != nil { - t.Fatalf("GetDB() error: %v", err) - } - - // Clear the cache - err = mgr.ClearCache(email) - if err != nil { - t.Fatalf("ClearCache() error: %v", err) - } - - // Verify database file is gone - dbPath := mgr.DBPath(email) - if _, err := os.Stat(dbPath); !os.IsNotExist(err) { - t.Error("Database file should be deleted after ClearCache") - } -} - -func TestMigrateToEncrypted_NonexistentDB(t *testing.T) { - tmpDir := t.TempDir() - - cfg := Config{BasePath: tmpDir} - encCfg := EncryptionConfig{Enabled: true, KeyID: "test"} - - mgr, err := NewEncryptedManager(cfg, encCfg) - if err != nil { - t.Fatalf("NewEncryptedManager() error: %v", err) - } - defer func() { _ = mgr.Close() }() - - // Migration of nonexistent database should not error - err = mgr.MigrateToEncrypted("nonexistent@example.com") - if err != nil { - t.Errorf("MigrateToEncrypted() on nonexistent DB error: %v", err) - } -} - -func TestMigrateToUnencrypted_NonexistentDB(t *testing.T) { - tmpDir := t.TempDir() - - cfg := Config{BasePath: tmpDir} - encCfg := EncryptionConfig{Enabled: true, KeyID: "test"} - - mgr, err := NewEncryptedManager(cfg, encCfg) - if err != nil { - t.Fatalf("NewEncryptedManager() error: %v", err) - } - defer func() { _ = mgr.Close() }() - - // Migration of nonexistent database should not error - err = mgr.MigrateToUnencrypted("nonexistent@example.com") - if err != nil { - t.Errorf("MigrateToUnencrypted() on nonexistent DB error: %v", err) - } -} - -func TestCopyTable_AllowedTables(t *testing.T) { - tmpDir := t.TempDir() - - srcPath := filepath.Join(tmpDir, "src.db") - srcDB, err := sql.Open(driverName, srcPath) - if err != nil { - t.Fatalf("Failed to create source database: %v", err) - } - defer func() { _ = srcDB.Close() }() - - if err := initSchema(srcDB); err != nil { - t.Fatalf("Failed to init schema: %v", err) - } - - dstPath := filepath.Join(tmpDir, "dst.db") - dstDB, err := sql.Open(driverName, dstPath) - if err != nil { - t.Fatalf("Failed to create destination database: %v", err) - } - defer func() { _ = dstDB.Close() }() - - if err := initSchema(dstDB); err != nil { - t.Fatalf("Failed to init schema: %v", err) - } - - // Test all allowed tables can be copied - for table := range allowedTables { - t.Run(table, func(t *testing.T) { - err := copyTable(srcDB, dstDB, table) - if err != nil { - t.Errorf("copyTable(%q) error: %v", table, err) - } - }) - } -} diff --git a/internal/air/cache/events.go b/internal/air/cache/events.go deleted file mode 100644 index 15fb154..0000000 --- a/internal/air/cache/events.go +++ /dev/null @@ -1,294 +0,0 @@ -package cache - -import ( - "database/sql" - "encoding/json" - "fmt" - "time" -) - -// CachedEvent represents a calendar event stored in the cache. -type CachedEvent struct { - ID string `json:"id"` - CalendarID string `json:"calendar_id,omitempty"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Location string `json:"location,omitempty"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - AllDay bool `json:"all_day"` - Recurring bool `json:"recurring"` - RRule string `json:"rrule,omitempty"` - Participants []string `json:"participants,omitempty"` - Status string `json:"status,omitempty"` - Busy bool `json:"busy"` - CachedAt time.Time `json:"cached_at"` -} - -// EventStore provides event caching operations. -type EventStore struct { - db *sql.DB -} - -// NewEventStore creates an event store for a database. -func NewEventStore(db *sql.DB) *EventStore { - return &EventStore{db: db} -} - -// Put stores an event in the cache. -func (s *EventStore) Put(event *CachedEvent) error { - participantsJSON, _ := json.Marshal(event.Participants) - - _, err := s.db.Exec(` - INSERT OR REPLACE INTO events ( - id, calendar_id, title, description, location, - start_time, end_time, all_day, recurring, rrule, - participants_json, status, busy, cached_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - event.ID, event.CalendarID, event.Title, event.Description, event.Location, - event.StartTime.Unix(), event.EndTime.Unix(), boolToInt(event.AllDay), - boolToInt(event.Recurring), event.RRule, string(participantsJSON), - event.Status, boolToInt(event.Busy), time.Now().Unix(), - ) - return err -} - -// PutBatch stores multiple events in a transaction. -func (s *EventStore) PutBatch(events []*CachedEvent) error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin transaction: %w", err) - } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - - stmt, err := tx.Prepare(` - INSERT OR REPLACE INTO events ( - id, calendar_id, title, description, location, - start_time, end_time, all_day, recurring, rrule, - participants_json, status, busy, cached_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `) - if err != nil { - return fmt.Errorf("prepare statement: %w", err) - } - defer func() { _ = stmt.Close() }() - - now := time.Now().Unix() - for _, event := range events { - participantsJSON, _ := json.Marshal(event.Participants) - - _, err = stmt.Exec( - event.ID, event.CalendarID, event.Title, event.Description, event.Location, - event.StartTime.Unix(), event.EndTime.Unix(), boolToInt(event.AllDay), - boolToInt(event.Recurring), event.RRule, string(participantsJSON), - event.Status, boolToInt(event.Busy), now, - ) - if err != nil { - return fmt.Errorf("insert event %s: %w", event.ID, err) - } - } - - return tx.Commit() -} - -// Get retrieves an event by ID. -func (s *EventStore) Get(id string) (*CachedEvent, error) { - row := s.db.QueryRow(` - SELECT id, calendar_id, title, description, location, - start_time, end_time, all_day, recurring, rrule, - participants_json, status, busy, cached_at - FROM events WHERE id = ? - `, id) - - return scanEvent(row) -} - -// EventListOptions configures event listing. -type EventListOptions struct { - CalendarID string - Start time.Time - End time.Time - Limit int - Offset int -} - -// List retrieves events with filtering. -func (s *EventStore) List(opts EventListOptions) ([]*CachedEvent, error) { - query := ` - SELECT id, calendar_id, title, description, location, - start_time, end_time, all_day, recurring, rrule, - participants_json, status, busy, cached_at - FROM events - WHERE 1=1 - ` - var args []any - - if opts.CalendarID != "" { - query += " AND calendar_id = ?" - args = append(args, opts.CalendarID) - } - if !opts.Start.IsZero() { - query += " AND end_time >= ?" - args = append(args, opts.Start.Unix()) - } - if !opts.End.IsZero() { - query += " AND start_time <= ?" - args = append(args, opts.End.Unix()) - } - - query += " ORDER BY start_time ASC" - - if opts.Limit > 0 { - query += fmt.Sprintf(" LIMIT %d", opts.Limit) - } - if opts.Offset > 0 { - query += fmt.Sprintf(" OFFSET %d", opts.Offset) - } - - rows, err := s.db.Query(query, args...) - if err != nil { - return nil, fmt.Errorf("query events: %w", err) - } - defer func() { _ = rows.Close() }() - - var events []*CachedEvent - for rows.Next() { - event, err := scanEventRow(rows) - if err != nil { - return nil, fmt.Errorf("scan event: %w", err) - } - events = append(events, event) - } - - return events, rows.Err() -} - -// ListByDateRange retrieves events within a date range. -func (s *EventStore) ListByDateRange(start, end time.Time) ([]*CachedEvent, error) { - return s.List(EventListOptions{Start: start, End: end}) -} - -// Search performs full-text search on events. -func (s *EventStore) Search(query string, limit int) ([]*CachedEvent, error) { - if limit <= 0 { - limit = 50 - } - - rows, err := s.db.Query(` - SELECT e.id, e.calendar_id, e.title, e.description, e.location, - e.start_time, e.end_time, e.all_day, e.recurring, e.rrule, - e.participants_json, e.status, e.busy, e.cached_at - FROM events e - WHERE e.rowid IN ( - SELECT rowid FROM events_fts WHERE events_fts MATCH ? - ) - ORDER BY e.start_time ASC - LIMIT ? - `, query, limit) - if err != nil { - return nil, fmt.Errorf("search events: %w", err) - } - defer func() { _ = rows.Close() }() - - var events []*CachedEvent - for rows.Next() { - event, err := scanEventRow(rows) - if err != nil { - return nil, fmt.Errorf("scan event: %w", err) - } - events = append(events, event) - } - - return events, rows.Err() -} - -// Delete removes an event from the cache. -func (s *EventStore) Delete(id string) error { - _, err := s.db.Exec("DELETE FROM events WHERE id = ?", id) - return err -} - -// DeleteByCalendar removes all events for a calendar. -func (s *EventStore) DeleteByCalendar(calendarID string) error { - _, err := s.db.Exec("DELETE FROM events WHERE calendar_id = ?", calendarID) - return err -} - -// Count returns the number of cached events. -func (s *EventStore) Count() (int, error) { - var count int - err := s.db.QueryRow("SELECT COUNT(*) FROM events").Scan(&count) - return count, err -} - -// GetUpcoming retrieves upcoming events. -func (s *EventStore) GetUpcoming(limit int) ([]*CachedEvent, error) { - now := time.Now() - return s.List(EventListOptions{ - Start: now, - Limit: limit, - }) -} - -func scanEvent(row *sql.Row) (*CachedEvent, error) { - var event CachedEvent - var participantsJSON sql.NullString - var startUnix, endUnix, cachedAtUnix int64 - var allDay, recurring, busy int - - err := row.Scan( - &event.ID, &event.CalendarID, &event.Title, &event.Description, &event.Location, - &startUnix, &endUnix, &allDay, &recurring, &event.RRule, - &participantsJSON, &event.Status, &busy, &cachedAtUnix, - ) - if err != nil { - return nil, err - } - - event.StartTime = time.Unix(startUnix, 0) - event.EndTime = time.Unix(endUnix, 0) - event.CachedAt = time.Unix(cachedAtUnix, 0) - event.AllDay = allDay == 1 - event.Recurring = recurring == 1 - event.Busy = busy == 1 - - if participantsJSON.Valid { - _ = json.Unmarshal([]byte(participantsJSON.String), &event.Participants) - } - - return &event, nil -} - -func scanEventRow(rows *sql.Rows) (*CachedEvent, error) { - var event CachedEvent - var participantsJSON sql.NullString - var startUnix, endUnix, cachedAtUnix int64 - var allDay, recurring, busy int - - err := rows.Scan( - &event.ID, &event.CalendarID, &event.Title, &event.Description, &event.Location, - &startUnix, &endUnix, &allDay, &recurring, &event.RRule, - &participantsJSON, &event.Status, &busy, &cachedAtUnix, - ) - if err != nil { - return nil, err - } - - event.StartTime = time.Unix(startUnix, 0) - event.EndTime = time.Unix(endUnix, 0) - event.CachedAt = time.Unix(cachedAtUnix, 0) - event.AllDay = allDay == 1 - event.Recurring = recurring == 1 - event.Busy = busy == 1 - - if participantsJSON.Valid { - _ = json.Unmarshal([]byte(participantsJSON.String), &event.Participants) - } - - return &event, nil -} diff --git a/internal/air/cache/folders.go b/internal/air/cache/folders.go deleted file mode 100644 index b98618e..0000000 --- a/internal/air/cache/folders.go +++ /dev/null @@ -1,199 +0,0 @@ -package cache - -import ( - "database/sql" - "fmt" - "time" -) - -// CachedFolder represents an email folder stored in the cache. -type CachedFolder struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type,omitempty"` // inbox, sent, drafts, trash, etc. - UnreadCount int `json:"unread_count"` - TotalCount int `json:"total_count"` - CachedAt time.Time `json:"cached_at"` -} - -// FolderStore provides folder caching operations. -type FolderStore struct { - db *sql.DB -} - -// NewFolderStore creates a folder store for a database. -func NewFolderStore(db *sql.DB) *FolderStore { - return &FolderStore{db: db} -} - -// Put stores a folder in the cache. -func (s *FolderStore) Put(folder *CachedFolder) error { - _, err := s.db.Exec(` - INSERT OR REPLACE INTO folders ( - id, name, type, unread_count, total_count, cached_at - ) VALUES (?, ?, ?, ?, ?, ?) - `, - folder.ID, folder.Name, folder.Type, - folder.UnreadCount, folder.TotalCount, time.Now().Unix(), - ) - return err -} - -// PutBatch stores multiple folders in a transaction. -func (s *FolderStore) PutBatch(folders []*CachedFolder) error { - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin transaction: %w", err) - } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - - stmt, err := tx.Prepare(` - INSERT OR REPLACE INTO folders ( - id, name, type, unread_count, total_count, cached_at - ) VALUES (?, ?, ?, ?, ?, ?) - `) - if err != nil { - return fmt.Errorf("prepare statement: %w", err) - } - defer func() { _ = stmt.Close() }() - - now := time.Now().Unix() - for _, folder := range folders { - _, err = stmt.Exec( - folder.ID, folder.Name, folder.Type, - folder.UnreadCount, folder.TotalCount, now, - ) - if err != nil { - return fmt.Errorf("insert folder %s: %w", folder.ID, err) - } - } - - return tx.Commit() -} - -// Get retrieves a folder by ID. -func (s *FolderStore) Get(id string) (*CachedFolder, error) { - row := s.db.QueryRow(` - SELECT id, name, type, unread_count, total_count, cached_at - FROM folders WHERE id = ? - `, id) - - return scanFolder(row) -} - -// GetByType retrieves a folder by type (inbox, sent, drafts, trash). -func (s *FolderStore) GetByType(folderType string) (*CachedFolder, error) { - row := s.db.QueryRow(` - SELECT id, name, type, unread_count, total_count, cached_at - FROM folders WHERE type = ? - `, folderType) - - return scanFolder(row) -} - -// List retrieves all folders. -func (s *FolderStore) List() ([]*CachedFolder, error) { - rows, err := s.db.Query(` - SELECT id, name, type, unread_count, total_count, cached_at - FROM folders - ORDER BY - CASE type - WHEN 'inbox' THEN 1 - WHEN 'drafts' THEN 2 - WHEN 'sent' THEN 3 - WHEN 'trash' THEN 4 - WHEN 'spam' THEN 5 - ELSE 6 - END, - name ASC - `) - if err != nil { - return nil, fmt.Errorf("query folders: %w", err) - } - defer func() { _ = rows.Close() }() - - var folders []*CachedFolder - for rows.Next() { - folder, err := scanFolderRow(rows) - if err != nil { - return nil, fmt.Errorf("scan folder: %w", err) - } - folders = append(folders, folder) - } - - return folders, rows.Err() -} - -// UpdateCounts updates the unread and total counts for a folder. -func (s *FolderStore) UpdateCounts(id string, unreadCount, totalCount int) error { - _, err := s.db.Exec(` - UPDATE folders SET unread_count = ?, total_count = ? - WHERE id = ? - `, unreadCount, totalCount, id) - return err -} - -// IncrementUnread increments or decrements the unread count. -func (s *FolderStore) IncrementUnread(id string, delta int) error { - _, err := s.db.Exec(` - UPDATE folders SET unread_count = unread_count + ? - WHERE id = ? - `, delta, id) - return err -} - -// Delete removes a folder from the cache. -func (s *FolderStore) Delete(id string) error { - _, err := s.db.Exec("DELETE FROM folders WHERE id = ?", id) - return err -} - -// Count returns the number of cached folders. -func (s *FolderStore) Count() (int, error) { - var count int - err := s.db.QueryRow("SELECT COUNT(*) FROM folders").Scan(&count) - return count, err -} - -// GetTotalUnread returns the total unread count across all folders. -func (s *FolderStore) GetTotalUnread() (int, error) { - var count int - err := s.db.QueryRow("SELECT COALESCE(SUM(unread_count), 0) FROM folders").Scan(&count) - return count, err -} - -func scanFolder(row *sql.Row) (*CachedFolder, error) { - var folder CachedFolder - var cachedAtUnix int64 - - err := row.Scan( - &folder.ID, &folder.Name, &folder.Type, - &folder.UnreadCount, &folder.TotalCount, &cachedAtUnix, - ) - if err != nil { - return nil, err - } - - folder.CachedAt = time.Unix(cachedAtUnix, 0) - return &folder, nil -} - -func scanFolderRow(rows *sql.Rows) (*CachedFolder, error) { - var folder CachedFolder - var cachedAtUnix int64 - - err := rows.Scan( - &folder.ID, &folder.Name, &folder.Type, - &folder.UnreadCount, &folder.TotalCount, &cachedAtUnix, - ) - if err != nil { - return nil, err - } - - folder.CachedAt = time.Unix(cachedAtUnix, 0) - return &folder, nil -} diff --git a/internal/air/cache/offline.go b/internal/air/cache/offline.go deleted file mode 100644 index 96afed6..0000000 --- a/internal/air/cache/offline.go +++ /dev/null @@ -1,314 +0,0 @@ -package cache - -import ( - "database/sql" - "encoding/json" - "fmt" - "time" -) - -// ActionType represents the type of queued action. -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" - ActionDelete ActionType = "delete" - ActionMove ActionType = "move" - ActionSend ActionType = "send" - ActionSaveDraft ActionType = "save_draft" - ActionDeleteDraft ActionType = "delete_draft" - ActionCreateEvent ActionType = "create_event" - ActionUpdateEvent ActionType = "update_event" - ActionDeleteEvent ActionType = "delete_event" - ActionCreateContact ActionType = "create_contact" - ActionUpdateContact ActionType = "update_contact" - ActionDeleteContact ActionType = "delete_contact" -) - -// QueuedAction represents an action to be synced when online. -type QueuedAction struct { - ID int64 `json:"id"` - Type ActionType `json:"type"` - ResourceID string `json:"resource_id"` - Payload string `json:"payload"` // JSON-encoded action data - CreatedAt time.Time `json:"created_at"` - Attempts int `json:"attempts"` - LastError string `json:"last_error,omitempty"` -} - -// OfflineQueue manages queued actions for offline support. -type OfflineQueue struct { - db *sql.DB -} - -// NewOfflineQueue creates a new offline queue. -func NewOfflineQueue(db *sql.DB) (*OfflineQueue, error) { - // Create queue table if not exists - _, err := db.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 nil, fmt.Errorf("create offline_queue table: %w", err) - } - - // Create index - _, _ = db.Exec("CREATE INDEX IF NOT EXISTS idx_offline_queue_created ON offline_queue(created_at)") - - return &OfflineQueue{db: db}, nil -} - -// Enqueue adds an action to the queue. -func (q *OfflineQueue) Enqueue(actionType ActionType, resourceID string, payload any) error { - payloadJSON, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal payload: %w", err) - } - - _, err = q.db.Exec(` - INSERT INTO offline_queue (type, resource_id, payload, created_at) - VALUES (?, ?, ?, ?) - `, string(actionType), resourceID, string(payloadJSON), time.Now().Unix()) - - return err -} - -// Dequeue retrieves and removes the oldest action. -func (q *OfflineQueue) Dequeue() (*QueuedAction, error) { - tx, err := q.db.Begin() - if err != nil { - return nil, err - } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - - row := tx.QueryRow(` - SELECT id, type, resource_id, payload, created_at, attempts, last_error - FROM offline_queue - ORDER BY created_at ASC - LIMIT 1 - `) - - var action QueuedAction - var createdAtUnix int64 - var lastError sql.NullString - - err = row.Scan( - &action.ID, &action.Type, &action.ResourceID, &action.Payload, - &createdAtUnix, &action.Attempts, &lastError, - ) - if err == sql.ErrNoRows { - _ = tx.Rollback() - return nil, nil - } - if err != nil { - return nil, err - } - - action.CreatedAt = time.Unix(createdAtUnix, 0) - action.LastError = lastError.String - - // Remove from queue - _, err = tx.Exec("DELETE FROM offline_queue WHERE id = ?", action.ID) - if err != nil { - return nil, err - } - - return &action, tx.Commit() -} - -// Peek retrieves the oldest action without removing it. -func (q *OfflineQueue) Peek() (*QueuedAction, error) { - row := q.db.QueryRow(` - SELECT id, type, resource_id, payload, created_at, attempts, last_error - FROM offline_queue - ORDER BY created_at ASC - LIMIT 1 - `) - - var action QueuedAction - var createdAtUnix int64 - var lastError sql.NullString - - err := row.Scan( - &action.ID, &action.Type, &action.ResourceID, &action.Payload, - &createdAtUnix, &action.Attempts, &lastError, - ) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - action.CreatedAt = time.Unix(createdAtUnix, 0) - action.LastError = lastError.String - - return &action, nil -} - -// List retrieves all queued actions. -func (q *OfflineQueue) List() ([]*QueuedAction, error) { - rows, err := q.db.Query(` - SELECT id, type, resource_id, payload, created_at, attempts, last_error - FROM offline_queue - ORDER BY created_at ASC - `) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var actions []*QueuedAction - for rows.Next() { - var action QueuedAction - var createdAtUnix int64 - var lastError sql.NullString - - err := rows.Scan( - &action.ID, &action.Type, &action.ResourceID, &action.Payload, - &createdAtUnix, &action.Attempts, &lastError, - ) - if err != nil { - return nil, err - } - - action.CreatedAt = time.Unix(createdAtUnix, 0) - action.LastError = lastError.String - actions = append(actions, &action) - } - - return actions, rows.Err() -} - -// Count returns the number of queued actions. -func (q *OfflineQueue) Count() (int, error) { - var count int - err := q.db.QueryRow("SELECT COUNT(*) FROM offline_queue").Scan(&count) - return count, err -} - -// MarkFailed increments the attempt count and records the error. -func (q *OfflineQueue) MarkFailed(id int64, err error) error { - _, dbErr := q.db.Exec(` - UPDATE offline_queue - SET attempts = attempts + 1, last_error = ? - WHERE id = ? - `, err.Error(), id) - return dbErr -} - -// Remove deletes an action from the queue. -func (q *OfflineQueue) Remove(id int64) error { - _, err := q.db.Exec("DELETE FROM offline_queue WHERE id = ?", id) - return err -} - -// Clear removes all queued actions. -func (q *OfflineQueue) Clear() error { - _, err := q.db.Exec("DELETE FROM offline_queue") - return err -} - -// RemoveStale removes actions older than the given duration. -func (q *OfflineQueue) RemoveStale(maxAge time.Duration) (int, error) { - cutoff := time.Now().Add(-maxAge).Unix() - result, err := q.db.Exec("DELETE FROM offline_queue WHERE created_at < ?", cutoff) - if err != nil { - return 0, err - } - affected, _ := result.RowsAffected() - return int(affected), nil -} - -// RemoveByResourceID removes all actions for a specific resource. -func (q *OfflineQueue) RemoveByResourceID(resourceID string) error { - _, err := q.db.Exec("DELETE FROM offline_queue WHERE resource_id = ?", resourceID) - return err -} - -// HasPendingActions returns true if there are queued actions. -func (q *OfflineQueue) HasPendingActions() (bool, error) { - count, err := q.Count() - return count > 0, err -} - -// GetActionData unmarshals the payload into the given type. -func (a *QueuedAction) GetActionData(v any) error { - return json.Unmarshal([]byte(a.Payload), v) -} - -// Email action payloads - -// 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 queued form of domain.UpdateMessageRequest. -// Folders has no `omitempty` so the nil-vs-empty distinction round-trips -// through SQLite; see domain.UpdateMessageRequest. -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"` -} - -// 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"` - CC []string `json:"cc,omitempty"` - BCC []string `json:"bcc,omitempty"` - Subject string `json:"subject"` - Body string `json:"body"` - ReplyTo string `json:"reply_to,omitempty"` -} - -// DraftPayload is the payload for draft actions. -type DraftPayload struct { - DraftID string `json:"draft_id,omitempty"` - To []string `json:"to"` - CC []string `json:"cc,omitempty"` - BCC []string `json:"bcc,omitempty"` - Subject string `json:"subject"` - Body string `json:"body"` -} diff --git a/internal/air/cache/photos.go b/internal/air/cache/photos.go deleted file mode 100644 index 6d0f646..0000000 --- a/internal/air/cache/photos.go +++ /dev/null @@ -1,336 +0,0 @@ -package cache - -import ( - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -// DefaultPhotoTTL is the default time-to-live for cached photos (30 days). -const DefaultPhotoTTL = 30 * 24 * time.Hour - -// errInvalidContactID is returned when the caller hands the photo store an -// identifier that would be unsafe to compose into a filesystem path. -var errInvalidContactID = errors.New("invalid contact ID for photo cache") - -// validateContactID rejects identifiers that would let a malicious or -// malformed upstream contact escape the photo cache directory. Photo files -// are stored at filepath.Join(basePath, contactID), so anything containing -// a path separator, "..", control bytes, or NUL is unsafe. We also bound -// the length so a hostile API response can't push us into "name too long" -// errors at the filesystem layer. -func validateContactID(id string) error { - if id == "" || len(id) > 128 { - return errInvalidContactID - } - if strings.ContainsAny(id, "/\\\x00") { - return errInvalidContactID - } - if id == "." || id == ".." || strings.Contains(id, "..") { - return errInvalidContactID - } - for i := 0; i < len(id); i++ { - c := id[i] - if c < 0x20 || c == 0x7f { - return errInvalidContactID - } - } - return nil -} - -// CachedPhoto represents a contact photo stored in the cache. -type CachedPhoto struct { - ContactID string `json:"contact_id"` - ContentType string `json:"content_type"` - Size int64 `json:"size"` - LocalPath string `json:"local_path"` - CachedAt time.Time `json:"cached_at"` - AccessedAt time.Time `json:"accessed_at"` -} - -// PhotoStore provides contact photo caching operations. -type PhotoStore struct { - db *sql.DB - basePath string - ttl time.Duration -} - -// NewPhotoStore creates a photo store. -func NewPhotoStore(db *sql.DB, basePath string, ttl time.Duration) (*PhotoStore, error) { - if ttl == 0 { - ttl = DefaultPhotoTTL - } - - // Create photos table if not exists - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS photos ( - contact_id TEXT PRIMARY KEY, - content_type TEXT NOT NULL, - size INTEGER NOT NULL, - local_path TEXT NOT NULL, - cached_at INTEGER NOT NULL, - accessed_at INTEGER NOT NULL - ) - `) - if err != nil { - return nil, fmt.Errorf("create photos table: %w", err) - } - - // Create index for cleanup queries - _, _ = db.Exec("CREATE INDEX IF NOT EXISTS idx_photos_cached_at ON photos(cached_at)") - - // Ensure photos directory exists - photosDir := filepath.Join(basePath, "photos") - if err := os.MkdirAll(photosDir, 0700); err != nil { - return nil, fmt.Errorf("create photos directory: %w", err) - } - - return &PhotoStore{ - db: db, - basePath: photosDir, - ttl: ttl, - }, nil -} - -// Put stores a contact photo. -func (s *PhotoStore) Put(contactID, contentType string, data []byte) error { - if err := validateContactID(contactID); err != nil { - return err - } - localPath := filepath.Join(s.basePath, contactID) - if err := os.WriteFile(localPath, data, 0600); err != nil { - return fmt.Errorf("write photo file: %w", err) - } - - now := time.Now() - - // Save metadata to database - _, err := s.db.Exec(` - INSERT OR REPLACE INTO photos ( - contact_id, content_type, size, local_path, cached_at, accessed_at - ) VALUES (?, ?, ?, ?, ?, ?) - `, - contactID, contentType, len(data), localPath, now.Unix(), now.Unix(), - ) - if err != nil { - // Clean up file on database error - _ = os.Remove(localPath) - return fmt.Errorf("save photo metadata: %w", err) - } - - return nil -} - -// Get retrieves a cached photo if it exists and is not expired. -// Returns nil, nil if the photo is not cached or expired. -func (s *PhotoStore) Get(contactID string) ([]byte, string, error) { - if err := validateContactID(contactID); err != nil { - return nil, "", err - } - row := s.db.QueryRow(` - SELECT content_type, size, local_path, cached_at - FROM photos WHERE contact_id = ? - `, contactID) - - var contentType, localPath string - var size, cachedAtUnix int64 - - err := row.Scan(&contentType, &size, &localPath, &cachedAtUnix) - if err == sql.ErrNoRows { - return nil, "", nil // Not cached - } - if err != nil { - return nil, "", fmt.Errorf("query photo: %w", err) - } - - // Check if expired - cachedAt := time.Unix(cachedAtUnix, 0) - if time.Since(cachedAt) > s.ttl { - // Expired - delete and return nil - _ = s.Delete(contactID) - return nil, "", nil - } - - // Read photo from file - // #nosec G304 -- localPath constructed from validated cache directory and contact ID - data, err := os.ReadFile(localPath) - if err != nil { - // File missing - delete metadata and return nil - _ = s.Delete(contactID) - return nil, "", nil - } - - // Update accessed time - _, _ = s.db.Exec("UPDATE photos SET accessed_at = ? WHERE contact_id = ?", time.Now().Unix(), contactID) - - return data, contentType, nil -} - -// IsValid checks if a cached photo exists and is not expired. -func (s *PhotoStore) IsValid(contactID string) bool { - if err := validateContactID(contactID); err != nil { - return false - } - var cachedAtUnix int64 - err := s.db.QueryRow("SELECT cached_at FROM photos WHERE contact_id = ?", contactID).Scan(&cachedAtUnix) - if err != nil { - return false - } - - cachedAt := time.Unix(cachedAtUnix, 0) - return time.Since(cachedAt) <= s.ttl -} - -// Delete removes a cached photo. -func (s *PhotoStore) Delete(contactID string) error { - if err := validateContactID(contactID); err != nil { - return err - } - // Get local path first - var localPath string - err := s.db.QueryRow("SELECT local_path FROM photos WHERE contact_id = ?", contactID).Scan(&localPath) - if err == nil && localPath != "" { - _ = os.Remove(localPath) - } - - // Delete from database - _, err = s.db.Exec("DELETE FROM photos WHERE contact_id = ?", contactID) - return err -} - -// Prune removes expired photos. -func (s *PhotoStore) Prune() (int, error) { - cutoff := time.Now().Add(-s.ttl).Unix() - - // Get expired photos - rows, err := s.db.Query("SELECT contact_id, local_path FROM photos WHERE cached_at < ?", cutoff) - if err != nil { - return 0, fmt.Errorf("query expired photos: %w", err) - } - defer func() { _ = rows.Close() }() - - var toDelete []string - for rows.Next() { - var contactID, localPath string - if err := rows.Scan(&contactID, &localPath); err == nil { - toDelete = append(toDelete, contactID) - _ = os.Remove(localPath) - } - } - - if len(toDelete) == 0 { - return 0, nil - } - - // Delete from database - _, err = s.db.Exec("DELETE FROM photos WHERE cached_at < ?", cutoff) - if err != nil { - return 0, fmt.Errorf("delete expired photos: %w", err) - } - - return len(toDelete), nil -} - -// Count returns the number of cached photos. -func (s *PhotoStore) Count() (int, error) { - var count int - err := s.db.QueryRow("SELECT COUNT(*) FROM photos").Scan(&count) - return count, err -} - -// TotalSize returns the total size of cached photos in bytes. -func (s *PhotoStore) TotalSize() (int64, error) { - var size int64 - err := s.db.QueryRow("SELECT COALESCE(SUM(size), 0) FROM photos").Scan(&size) - 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 - rows, err := s.db.Query("SELECT contact_id FROM photos") - if err != nil { - return 0, err - } - - knownIDs := make(map[string]bool) - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil { - knownIDs[id] = true - } - } - _ = rows.Close() - - // Walk the photos directory and remove unknown files - count := 0 - entries, err := os.ReadDir(s.basePath) - if err != nil { - return 0, err - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - if !knownIDs[entry.Name()] { - _ = os.Remove(filepath.Join(s.basePath, entry.Name())) - count++ - } - } - - return count, nil -} - -// PhotoCacheStats contains statistics about the photo cache. -type PhotoCacheStats struct { - Count int - TotalSize int64 - TTLDays int - Oldest time.Time - Newest time.Time -} - -// GetStats returns photo cache statistics. -func (s *PhotoStore) GetStats() (*PhotoCacheStats, error) { - stats := &PhotoCacheStats{ - TTLDays: int(s.ttl.Hours() / 24), - } - - count, err := s.Count() - if err != nil { - return nil, err - } - stats.Count = count - - size, err := s.TotalSize() - if err != nil { - return nil, err - } - stats.TotalSize = size - - var oldestUnix, newestUnix int64 - _ = s.db.QueryRow("SELECT MIN(cached_at) FROM photos").Scan(&oldestUnix) - _ = s.db.QueryRow("SELECT MAX(cached_at) FROM photos").Scan(&newestUnix) - - if oldestUnix > 0 { - stats.Oldest = time.Unix(oldestUnix, 0) - } - if newestUnix > 0 { - stats.Newest = time.Unix(newestUnix, 0) - } - - return stats, nil -} diff --git a/internal/air/cache/photos_validation_test.go b/internal/air/cache/photos_validation_test.go deleted file mode 100644 index 49b66bd..0000000 --- a/internal/air/cache/photos_validation_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package cache - -import ( - "strings" - "testing" -) - -// TestValidateContactID_Rejects covers IDs that must NOT be allowed to flow -// into filepath.Join(basePath, contactID). A photo store that accepts these -// would let a hostile API response punch out of the cache directory or -// corrupt unrelated files. -func TestValidateContactID_Rejects(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - id string - }{ - {"empty", ""}, - {"dot", "."}, - {"dotdot", ".."}, - {"slash traversal", "../etc/passwd"}, - {"backslash traversal", "..\\windows\\system32"}, - {"forward slash", "abc/def"}, - {"backslash", "abc\\def"}, - {"null byte", "abc\x00def"}, - {"control byte", "abc\x01def"}, - {"DEL byte", "abc\x7fdef"}, - {"contains dotdot", "foo..bar"}, - {"long ID", strings.Repeat("a", 129)}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - if err := validateContactID(tc.id); err == nil { - t.Errorf("expected error for %q, got nil", tc.id) - } - }) - } -} - -func TestValidateContactID_Accepts(t *testing.T) { - t.Parallel() - - cases := []string{ - "abc123", - "contact-uuid-123", - "USER_42", - "a", - strings.Repeat("a", 128), // boundary - } - for _, id := range cases { - if err := validateContactID(id); err != nil { - t.Errorf("expected %q to validate, got %v", id, err) - } - } -} - -func TestPhotoStore_Put_RejectsTraversal(t *testing.T) { - t.Parallel() - - db := setupTestDB(t) - tmpDir := t.TempDir() - store, err := NewPhotoStore(db, tmpDir, DefaultPhotoTTL) - if err != nil { - t.Fatalf("NewPhotoStore: %v", err) - } - - if err := store.Put("../escape", "image/png", []byte{0x89, 0x50, 0x4E, 0x47}); err == nil { - t.Fatal("Put should reject path-traversal contactID") - } -} - -func TestPhotoStore_Get_RejectsTraversal(t *testing.T) { - t.Parallel() - - db := setupTestDB(t) - tmpDir := t.TempDir() - store, err := NewPhotoStore(db, tmpDir, DefaultPhotoTTL) - if err != nil { - t.Fatalf("NewPhotoStore: %v", err) - } - if _, _, err := store.Get("..\\escape"); err == nil { - t.Fatal("Get should reject path-traversal contactID") - } -} diff --git a/internal/air/cache/schema.go b/internal/air/cache/schema.go deleted file mode 100644 index ac38855..0000000 --- a/internal/air/cache/schema.go +++ /dev/null @@ -1,333 +0,0 @@ -package cache - -import ( - "database/sql" - "fmt" -) - -// schemaVersion is used for migrations. -const schemaVersion = 1 - -// initSchema creates the database schema if it doesn't exist. -func initSchema(db *sql.DB) error { - // Check current schema version - var version int - err := db.QueryRow("PRAGMA user_version").Scan(&version) - if err != nil { - return fmt.Errorf("get schema version: %w", err) - } - - if version >= schemaVersion { - return nil // Already up to date - } - - // Create tables in a transaction - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("begin transaction: %w", err) - } - defer func() { - if err != nil { - _ = tx.Rollback() - } - }() - - // Create emails table - _, err = tx.Exec(` - CREATE TABLE IF NOT EXISTS emails ( - id TEXT PRIMARY KEY, - thread_id TEXT, - folder_id TEXT, - subject TEXT, - snippet TEXT, - from_name TEXT, - from_email TEXT, - to_json TEXT, - cc_json TEXT, - bcc_json TEXT, - date INTEGER, - unread INTEGER DEFAULT 1, - starred INTEGER DEFAULT 0, - has_attachments INTEGER DEFAULT 0, - body_html TEXT, - body_text TEXT, - headers_json TEXT, - cached_at INTEGER - ) - `) - if err != nil { - return fmt.Errorf("create emails table: %w", err) - } - - // Create FTS5 index for emails - _, err = tx.Exec(` - CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5( - subject, - snippet, - body_text, - from_name, - from_email, - content='emails', - content_rowid='rowid' - ) - `) - if err != nil { - return fmt.Errorf("create emails_fts: %w", err) - } - - // Create triggers to keep FTS in sync - _, err = tx.Exec(` - CREATE TRIGGER IF NOT EXISTS emails_ai AFTER INSERT ON emails BEGIN - INSERT INTO emails_fts(rowid, subject, snippet, body_text, from_name, from_email) - VALUES (new.rowid, new.subject, new.snippet, new.body_text, new.from_name, new.from_email); - END - `) - if err != nil { - return fmt.Errorf("create emails_ai trigger: %w", err) - } - - _, err = tx.Exec(` - CREATE TRIGGER IF NOT EXISTS emails_ad AFTER DELETE ON emails BEGIN - INSERT INTO emails_fts(emails_fts, rowid, subject, snippet, body_text, from_name, from_email) - VALUES ('delete', old.rowid, old.subject, old.snippet, old.body_text, old.from_name, old.from_email); - END - `) - if err != nil { - return fmt.Errorf("create emails_ad trigger: %w", err) - } - - _, err = tx.Exec(` - CREATE TRIGGER IF NOT EXISTS emails_au AFTER UPDATE ON emails BEGIN - INSERT INTO emails_fts(emails_fts, rowid, subject, snippet, body_text, from_name, from_email) - VALUES ('delete', old.rowid, old.subject, old.snippet, old.body_text, old.from_name, old.from_email); - INSERT INTO emails_fts(rowid, subject, snippet, body_text, from_name, from_email) - VALUES (new.rowid, new.subject, new.snippet, new.body_text, new.from_name, new.from_email); - END - `) - if err != nil { - return fmt.Errorf("create emails_au trigger: %w", err) - } - - // Create events table - _, err = tx.Exec(` - CREATE TABLE IF NOT EXISTS events ( - id TEXT PRIMARY KEY, - calendar_id TEXT, - title TEXT, - description TEXT, - location TEXT, - start_time INTEGER, - end_time INTEGER, - all_day INTEGER DEFAULT 0, - recurring INTEGER DEFAULT 0, - rrule TEXT, - participants_json TEXT, - status TEXT, - busy INTEGER DEFAULT 1, - cached_at INTEGER - ) - `) - if err != nil { - return fmt.Errorf("create events table: %w", err) - } - - // Create FTS5 index for events - _, err = tx.Exec(` - CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( - title, - description, - location, - content='events', - content_rowid='rowid' - ) - `) - if err != nil { - return fmt.Errorf("create events_fts: %w", err) - } - - // Create triggers for events FTS - _, err = tx.Exec(` - CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON events BEGIN - INSERT INTO events_fts(rowid, title, description, location) - VALUES (new.rowid, new.title, new.description, new.location); - END - `) - if err != nil { - return fmt.Errorf("create events_ai trigger: %w", err) - } - - _, err = tx.Exec(` - CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON events BEGIN - INSERT INTO events_fts(events_fts, rowid, title, description, location) - VALUES ('delete', old.rowid, old.title, old.description, old.location); - END - `) - if err != nil { - return fmt.Errorf("create events_ad trigger: %w", err) - } - - // Create contacts table - _, err = tx.Exec(` - CREATE TABLE IF NOT EXISTS contacts ( - id TEXT PRIMARY KEY, - given_name TEXT, - surname TEXT, - display_name TEXT, - email TEXT, - phone TEXT, - company TEXT, - job_title TEXT, - notes TEXT, - photo_url TEXT, - groups_json TEXT, - cached_at INTEGER - ) - `) - if err != nil { - return fmt.Errorf("create contacts table: %w", err) - } - - // Create FTS5 index for contacts - _, err = tx.Exec(` - CREATE VIRTUAL TABLE IF NOT EXISTS contacts_fts USING fts5( - given_name, - surname, - display_name, - email, - company, - content='contacts', - content_rowid='rowid' - ) - `) - if err != nil { - return fmt.Errorf("create contacts_fts: %w", err) - } - - // Create triggers for contacts FTS - _, err = tx.Exec(` - CREATE TRIGGER IF NOT EXISTS contacts_ai AFTER INSERT ON contacts BEGIN - INSERT INTO contacts_fts(rowid, given_name, surname, display_name, email, company) - VALUES (new.rowid, new.given_name, new.surname, new.display_name, new.email, new.company); - END - `) - if err != nil { - return fmt.Errorf("create contacts_ai trigger: %w", err) - } - - _, err = tx.Exec(` - CREATE TRIGGER IF NOT EXISTS contacts_ad AFTER DELETE ON contacts BEGIN - INSERT INTO contacts_fts(contacts_fts, rowid, given_name, surname, display_name, email, company) - VALUES ('delete', old.rowid, old.given_name, old.surname, old.display_name, old.email, old.company); - END - `) - if err != nil { - return fmt.Errorf("create contacts_ad trigger: %w", err) - } - - // Create folders table - _, err = tx.Exec(` - CREATE TABLE IF NOT EXISTS folders ( - id TEXT PRIMARY KEY, - name TEXT, - type TEXT, - unread_count INTEGER DEFAULT 0, - total_count INTEGER DEFAULT 0, - cached_at INTEGER - ) - `) - if err != nil { - return fmt.Errorf("create folders table: %w", err) - } - - // Create calendars table - _, err = tx.Exec(` - CREATE TABLE IF NOT EXISTS calendars ( - id TEXT PRIMARY KEY, - name TEXT, - description TEXT, - is_primary INTEGER DEFAULT 0, - read_only INTEGER DEFAULT 0, - hex_color TEXT, - cached_at INTEGER - ) - `) - if err != nil { - return fmt.Errorf("create calendars table: %w", err) - } - - // Create sync_state table - _, err = tx.Exec(` - CREATE TABLE IF NOT EXISTS sync_state ( - resource TEXT PRIMARY KEY, - last_sync INTEGER, - cursor TEXT, - metadata_json TEXT - ) - `) - if err != nil { - 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)", - "CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails(thread_id)", - "CREATE INDEX IF NOT EXISTS idx_emails_date ON emails(date DESC)", - "CREATE INDEX IF NOT EXISTS idx_emails_unread ON emails(unread) WHERE unread = 1", - "CREATE INDEX IF NOT EXISTS idx_emails_starred ON emails(starred) WHERE starred = 1", - "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 { - return fmt.Errorf("create index: %w", err) - } - } - - // Update schema version - _, err = tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)) - if err != nil { - return fmt.Errorf("set schema version: %w", err) - } - - return tx.Commit() -} diff --git a/internal/air/cache/search.go b/internal/air/cache/search.go deleted file mode 100644 index 205366d..0000000 --- a/internal/air/cache/search.go +++ /dev/null @@ -1,323 +0,0 @@ -package cache - -import ( - "database/sql" - "fmt" - "regexp" - "strings" - "time" -) - -// SearchQuery represents a parsed search query with operators. -type SearchQuery struct { - // Text is the free-text search portion - Text string - // Operators are field-specific filters - From string - To string - Subject string - HasAttachment *bool - IsUnread *bool - IsStarred *bool - After time.Time - Before time.Time - In string // Folder ID -} - -// operatorRegex matches search operators like "from:john@example.com" -var operatorRegex = regexp.MustCompile(`(\w+):("[^"]+"|[^\s]+)`) - -// ParseSearchQuery parses a search string into structured query. -// Supports operators: from:, to:, subject:, has:attachment, is:unread, is:starred, after:, before:, in: -func ParseSearchQuery(query string) *SearchQuery { - sq := &SearchQuery{} - remaining := query - - // Extract operators - matches := operatorRegex.FindAllStringSubmatch(query, -1) - for _, match := range matches { - if len(match) != 3 { - continue - } - operator := strings.ToLower(match[1]) - value := strings.Trim(match[2], `"`) - - switch operator { - case "from": - sq.From = value - case "to": - sq.To = value - case "subject": - sq.Subject = value - case "has": - if strings.EqualFold(value, "attachment") || strings.EqualFold(value, "attachments") { - t := true - sq.HasAttachment = &t - } - case "is": - switch strings.ToLower(value) { - case "unread": - t := true - sq.IsUnread = &t - case "read": - f := false - sq.IsUnread = &f - case "starred": - t := true - sq.IsStarred = &t - } - case "after": - if t, err := parseDate(value); err == nil { - sq.After = t - } - case "before": - if t, err := parseDate(value); err == nil { - sq.Before = t - } - case "in": - sq.In = value - } - - // Remove matched operator from remaining text - remaining = strings.Replace(remaining, match[0], "", 1) - } - - // Clean up remaining text - sq.Text = strings.TrimSpace(remaining) - - return sq -} - -// parseDate parses common date formats. -func parseDate(s string) (time.Time, error) { - formats := []string{ - "2006-01-02", - "2006/01/02", - "01/02/2006", - "Jan 2, 2006", - "January 2, 2006", - } - - for _, format := range formats { - if t, err := time.Parse(format, s); err == nil { - return t, nil - } - } - - // Handle relative dates - s = strings.ToLower(s) - now := time.Now() - - switch s { - case "today": - return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), nil - case "yesterday": - return time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, now.Location()), nil - case "week", "thisweek", "this-week": - weekday := int(now.Weekday()) - return now.AddDate(0, 0, -weekday), nil - case "month", "thismonth", "this-month": - return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()), nil - } - - // Handle "Xd", "Xw", "Xm" ago - if strings.HasSuffix(s, "d") || strings.HasSuffix(s, "w") || strings.HasSuffix(s, "m") { - var num int - var unit string - if _, err := fmt.Sscanf(s, "%d%s", &num, &unit); err == nil { - switch unit { - case "d": - return now.AddDate(0, 0, -num), nil - case "w": - return now.AddDate(0, 0, -num*7), nil - case "m": - return now.AddDate(0, -num, 0), nil - } - } - } - - return time.Time{}, fmt.Errorf("unknown date format: %s", s) -} - -// SearchEmails performs an advanced search with operator support. -func (s *EmailStore) SearchAdvanced(query string, limit int) ([]*CachedEmail, error) { - sq := ParseSearchQuery(query) - return s.SearchWithQuery(sq, limit) -} - -// SearchWithQuery performs a search using a parsed query. -func (s *EmailStore) SearchWithQuery(sq *SearchQuery, limit int) ([]*CachedEmail, error) { - if limit <= 0 { - limit = 50 - } - - var conditions []string - var args []any - - // FTS search for text - if sq.Text != "" { - conditions = append(conditions, "e.rowid IN (SELECT rowid FROM emails_fts WHERE emails_fts MATCH ?)") - args = append(args, sq.Text) - } - - // Subject search (can use FTS or LIKE) - if sq.Subject != "" { - conditions = append(conditions, "e.subject LIKE ?") - args = append(args, "%"+sq.Subject+"%") - } - - // From filter - if sq.From != "" { - conditions = append(conditions, "(e.from_email LIKE ? OR e.from_name LIKE ?)") - args = append(args, "%"+sq.From+"%", "%"+sq.From+"%") - } - - // To filter - if sq.To != "" { - conditions = append(conditions, "e.to_json LIKE ?") - args = append(args, "%"+sq.To+"%") - } - - // Has attachment filter - if sq.HasAttachment != nil && *sq.HasAttachment { - conditions = append(conditions, "e.has_attachments = 1") - } - - // Unread filter - if sq.IsUnread != nil { - if *sq.IsUnread { - conditions = append(conditions, "e.unread = 1") - } else { - conditions = append(conditions, "e.unread = 0") - } - } - - // Starred filter - if sq.IsStarred != nil && *sq.IsStarred { - conditions = append(conditions, "e.starred = 1") - } - - // Date filters - if !sq.After.IsZero() { - conditions = append(conditions, "e.date >= ?") - args = append(args, sq.After.Unix()) - } - if !sq.Before.IsZero() { - conditions = append(conditions, "e.date < ?") - args = append(args, sq.Before.Unix()) - } - - // Folder filter - if sq.In != "" { - conditions = append(conditions, "e.folder_id = ?") - args = append(args, sq.In) - } - - // Build query - baseQuery := ` - SELECT e.id, e.thread_id, e.folder_id, e.subject, e.snippet, - e.from_name, e.from_email, e.to_json, e.cc_json, e.bcc_json, - e.date, e.unread, e.starred, e.has_attachments, - e.body_html, e.body_text, e.cached_at - FROM emails e - ` - - if len(conditions) > 0 { - baseQuery += " WHERE " + strings.Join(conditions, " AND ") - } - - baseQuery += " ORDER BY e.date DESC LIMIT ?" - args = append(args, limit) - - rows, err := s.db.Query(baseQuery, args...) - if err != nil { - return nil, fmt.Errorf("search emails: %w", err) - } - defer func() { _ = rows.Close() }() - - // Pre-allocate slice with expected capacity - emails := make([]*CachedEmail, 0, limit) - for rows.Next() { - email, err := scanEmailGeneric(rows) - if err != nil { - return nil, fmt.Errorf("scan email: %w", err) - } - emails = append(emails, email) - } - - return emails, rows.Err() -} - -// UnifiedSearch searches across emails, events, and contacts. -type UnifiedSearchResult struct { - Type string // "email", "event", "contact" - ID string - Title string - Subtitle string - Date time.Time -} - -// UnifiedSearch performs search across all data types. -func UnifiedSearch(db *sql.DB, query string, limit int) ([]*UnifiedSearchResult, error) { - if limit <= 0 { - limit = 20 - } - - perType := limit / 3 - if perType < 5 { - perType = 5 - } - - var results []*UnifiedSearchResult - - // Search emails - emailStore := NewEmailStore(db) - emails, err := emailStore.Search(query, perType) - if err == nil { - for _, e := range emails { - results = append(results, &UnifiedSearchResult{ - Type: "email", - ID: e.ID, - Title: e.Subject, - Subtitle: e.FromName + " <" + e.FromEmail + ">", - Date: e.Date, - }) - } - } - - // Search events - eventStore := NewEventStore(db) - events, err := eventStore.Search(query, perType) - if err == nil { - for _, e := range events { - results = append(results, &UnifiedSearchResult{ - Type: "event", - ID: e.ID, - Title: e.Title, - Subtitle: e.Location, - Date: e.StartTime, - }) - } - } - - // Search contacts - contactStore := NewContactStore(db) - contacts, err := contactStore.Search(query, perType) - if err == nil { - for _, c := range contacts { - name := c.DisplayName - if name == "" { - name = c.GivenName + " " + c.Surname - } - results = append(results, &UnifiedSearchResult{ - Type: "contact", - ID: c.ID, - Title: strings.TrimSpace(name), - Subtitle: c.Email, - Date: c.CachedAt, - }) - } - } - - return results, nil -} diff --git a/internal/air/cache/settings.go b/internal/air/cache/settings.go deleted file mode 100644 index 5ee0ca3..0000000 --- a/internal/air/cache/settings.go +++ /dev/null @@ -1,293 +0,0 @@ -package cache - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "time" -) - -// Settings holds all cache configuration. -type Settings struct { - // Cache behavior - Enabled bool `json:"cache_enabled"` - MaxSizeMB int `json:"cache_max_size_mb"` - TTLDays int `json:"cache_ttl_days"` - SyncIntervalMinutes int `json:"sync_interval_minutes"` - OfflineQueueEnabled bool `json:"offline_queue_enabled"` - EncryptionEnabled bool `json:"encryption_enabled"` - - // Attachment settings - AttachmentCacheEnabled bool `json:"attachment_cache_enabled"` - AttachmentMaxSizeMB int `json:"attachment_max_size_mb"` - - // Sync settings - InitialSyncDays int `json:"initial_sync_days"` - BackgroundSyncEnabled bool `json:"background_sync_enabled"` - - // UI preferences (also stored here for convenience) - Theme string `json:"theme,omitempty"` // "dark", "light", "system" - DefaultView string `json:"default_view,omitempty"` // "email", "calendar", "contacts" - CompactMode bool `json:"compact_mode,omitempty"` - PreviewPosition string `json:"preview_position,omitempty"` // "right", "bottom", "off" - - mu sync.RWMutex `json:"-"` - filePath string `json:"-"` -} - -// DefaultSettings returns default cache settings. -func DefaultSettings() *Settings { - return &Settings{ - Enabled: true, - MaxSizeMB: 500, - TTLDays: 30, - SyncIntervalMinutes: 5, - OfflineQueueEnabled: true, - EncryptionEnabled: false, - AttachmentCacheEnabled: true, - AttachmentMaxSizeMB: 100, - InitialSyncDays: 30, - BackgroundSyncEnabled: true, - Theme: "dark", - DefaultView: "email", - CompactMode: false, - PreviewPosition: "right", - } -} - -// LoadSettings loads settings from file, or creates default if not exists. -func LoadSettings(basePath string) (*Settings, error) { - filePath := filepath.Join(basePath, "settings.json") - - settings := DefaultSettings() - settings.filePath = filePath - - // #nosec G304 -- filePath constructed from validated cache directory - data, err := os.ReadFile(filePath) - if os.IsNotExist(err) { - // Create default settings file - if err := settings.Save(); err != nil { - return nil, fmt.Errorf("create default settings: %w", err) - } - return settings, nil - } - if err != nil { - return nil, fmt.Errorf("read settings file: %w", err) - } - - if err := json.Unmarshal(data, settings); err != nil { - return nil, fmt.Errorf("parse settings: %w", err) - } - - return settings, nil -} - -// Save writes settings to file. -func (s *Settings) Save() error { - s.mu.RLock() - defer s.mu.RUnlock() - - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return fmt.Errorf("marshal settings: %w", err) - } - - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(s.filePath), 0700); err != nil { - return fmt.Errorf("create settings directory: %w", err) - } - - if err := os.WriteFile(s.filePath, data, 0600); err != nil { - return fmt.Errorf("write settings file: %w", err) - } - - return nil -} - -// Get returns a copy of settings (thread-safe). -func (s *Settings) Get() Settings { - s.mu.RLock() - defer s.mu.RUnlock() - - // Return a copy - return Settings{ - Enabled: s.Enabled, - MaxSizeMB: s.MaxSizeMB, - TTLDays: s.TTLDays, - SyncIntervalMinutes: s.SyncIntervalMinutes, - OfflineQueueEnabled: s.OfflineQueueEnabled, - EncryptionEnabled: s.EncryptionEnabled, - AttachmentCacheEnabled: s.AttachmentCacheEnabled, - AttachmentMaxSizeMB: s.AttachmentMaxSizeMB, - InitialSyncDays: s.InitialSyncDays, - BackgroundSyncEnabled: s.BackgroundSyncEnabled, - Theme: s.Theme, - DefaultView: s.DefaultView, - CompactMode: s.CompactMode, - PreviewPosition: s.PreviewPosition, - } -} - -// Update updates settings with the provided function. -func (s *Settings) Update(fn func(*Settings)) error { - s.mu.Lock() - fn(s) - s.mu.Unlock() - - return s.Save() -} - -// SetEnabled enables or disables caching. -func (s *Settings) SetEnabled(enabled bool) error { - return s.Update(func(s *Settings) { - s.Enabled = enabled - }) -} - -// SetMaxSize sets the maximum cache size in MB. -func (s *Settings) SetMaxSize(sizeMB int) error { - if sizeMB < 50 { - sizeMB = 50 // Minimum 50MB - } - if sizeMB > 10000 { - sizeMB = 10000 // Maximum 10GB - } - return s.Update(func(s *Settings) { - s.MaxSizeMB = sizeMB - }) -} - -// SetEncryption enables or disables encryption. -func (s *Settings) SetEncryption(enabled bool) error { - return s.Update(func(s *Settings) { - s.EncryptionEnabled = enabled - }) -} - -// SetTheme sets the UI theme. -func (s *Settings) SetTheme(theme string) error { - if theme != "dark" && theme != "light" && theme != "system" { - theme = "dark" - } - return s.Update(func(s *Settings) { - s.Theme = theme - }) -} - -// GetSyncInterval returns the sync interval as a duration. -func (s *Settings) GetSyncInterval() time.Duration { - s.mu.RLock() - defer s.mu.RUnlock() - return time.Duration(s.SyncIntervalMinutes) * time.Minute -} - -// GetTTL returns the cache TTL as a duration. -func (s *Settings) GetTTL() time.Duration { - s.mu.RLock() - defer s.mu.RUnlock() - return time.Duration(s.TTLDays) * 24 * time.Hour -} - -// GetMaxSizeBytes returns the maximum cache size in bytes. -func (s *Settings) GetMaxSizeBytes() int64 { - s.mu.RLock() - defer s.mu.RUnlock() - return int64(s.MaxSizeMB) * 1024 * 1024 -} - -// IsEncryptionEnabled returns whether encryption is enabled. -func (s *Settings) IsEncryptionEnabled() bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.EncryptionEnabled -} - -// IsCacheEnabled returns whether caching is enabled. -func (s *Settings) IsCacheEnabled() bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.Enabled -} - -// ToConfig converts settings to cache Config. -func (s *Settings) ToConfig(basePath string) Config { - s.mu.RLock() - defer s.mu.RUnlock() - - return Config{ - BasePath: basePath, - MaxSizeMB: s.MaxSizeMB, - TTLDays: s.TTLDays, - SyncIntervalMinutes: s.SyncIntervalMinutes, - } -} - -// ToEncryptionConfig converts settings to EncryptionConfig. -func (s *Settings) ToEncryptionConfig() EncryptionConfig { - s.mu.RLock() - defer s.mu.RUnlock() - - return EncryptionConfig{ - Enabled: s.EncryptionEnabled, - } -} - -// BasePath returns the directory containing the settings file. -func (s *Settings) BasePath() string { - s.mu.RLock() - defer s.mu.RUnlock() - - if s.filePath == "" { - return "" - } - - return filepath.Dir(s.filePath) -} - -// Validate checks if settings are valid. -func (s *Settings) Validate() error { - s.mu.RLock() - defer s.mu.RUnlock() - - if s.MaxSizeMB < 50 { - return fmt.Errorf("cache_max_size_mb must be at least 50") - } - if s.TTLDays < 1 { - return fmt.Errorf("cache_ttl_days must be at least 1") - } - if s.SyncIntervalMinutes < 1 { - return fmt.Errorf("sync_interval_minutes must be at least 1") - } - if s.InitialSyncDays < 1 { - return fmt.Errorf("initial_sync_days must be at least 1") - } - - return nil -} - -// Reset restores default settings. -func (s *Settings) Reset() error { - defaults := DefaultSettings() - - s.mu.Lock() - // Copy all fields except mu and filePath (don't copy mutex) - s.Enabled = defaults.Enabled - s.MaxSizeMB = defaults.MaxSizeMB - s.TTLDays = defaults.TTLDays - s.SyncIntervalMinutes = defaults.SyncIntervalMinutes - s.OfflineQueueEnabled = defaults.OfflineQueueEnabled - s.EncryptionEnabled = defaults.EncryptionEnabled - s.AttachmentCacheEnabled = defaults.AttachmentCacheEnabled - s.AttachmentMaxSizeMB = defaults.AttachmentMaxSizeMB - s.InitialSyncDays = defaults.InitialSyncDays - s.BackgroundSyncEnabled = defaults.BackgroundSyncEnabled - s.Theme = defaults.Theme - s.DefaultView = defaults.DefaultView - s.CompactMode = defaults.CompactMode - s.PreviewPosition = defaults.PreviewPosition - s.mu.Unlock() - - return s.Save() -} diff --git a/internal/air/cache/settings_test.go b/internal/air/cache/settings_test.go deleted file mode 100644 index f36cdb7..0000000 --- a/internal/air/cache/settings_test.go +++ /dev/null @@ -1,470 +0,0 @@ -package cache - -import ( - "os" - "path/filepath" - "testing" -) - -// Tests for settings.go functions - -// ================================ -// SETTINGS.GO TESTS -// ================================ - -func TestLoadSettings_NewFile(t *testing.T) { - tmpDir := t.TempDir() - - settings, err := LoadSettings(tmpDir) - if err != nil { - t.Fatalf("LoadSettings() error: %v", err) - } - - if settings == nil { - t.Fatal("LoadSettings() returned nil") - return - } - - // Should have default values - if !settings.Enabled { - t.Error("Default Enabled should be true") - } - if settings.MaxSizeMB != 500 { - t.Errorf("Default MaxSizeMB = %d, want 500", settings.MaxSizeMB) - } - - // File should exist now - settingsPath := filepath.Join(tmpDir, "settings.json") - if _, err := os.Stat(settingsPath); os.IsNotExist(err) { - t.Error("Settings file should have been created") - } -} - -func TestDefaultSettingsBasePathIsEmptyUntilLoaded(t *testing.T) { - settings := DefaultSettings() - - if got := settings.BasePath(); got != "" { - t.Fatalf("DefaultSettings().BasePath() = %q, want empty path", got) - } -} - -func TestLoadSettings_ExistingFile(t *testing.T) { - tmpDir := t.TempDir() - - // Create a custom settings file - settingsPath := filepath.Join(tmpDir, "settings.json") - customSettings := `{ - "cache_enabled": false, - "cache_max_size_mb": 1000, - "cache_ttl_days": 60, - "theme": "light" - }` - if err := os.WriteFile(settingsPath, []byte(customSettings), 0600); err != nil { - t.Fatalf("Failed to create settings file: %v", err) - } - - settings, err := LoadSettings(tmpDir) - if err != nil { - t.Fatalf("LoadSettings() error: %v", err) - } - - if settings.Enabled { - t.Error("Enabled should be false from custom settings") - } - if settings.MaxSizeMB != 1000 { - t.Errorf("MaxSizeMB = %d, want 1000", settings.MaxSizeMB) - } - if settings.TTLDays != 60 { - t.Errorf("TTLDays = %d, want 60", settings.TTLDays) - } - if settings.Theme != "light" { - t.Errorf("Theme = %q, want 'light'", settings.Theme) - } -} - -func TestLoadSettings_InvalidJSON(t *testing.T) { - tmpDir := t.TempDir() - - // Create an invalid JSON file - settingsPath := filepath.Join(tmpDir, "settings.json") - if err := os.WriteFile(settingsPath, []byte("not valid json{"), 0600); err != nil { - t.Fatalf("Failed to create settings file: %v", err) - } - - _, err := LoadSettings(tmpDir) - if err == nil { - t.Error("LoadSettings() should error on invalid JSON") - } -} - -func TestSettings_SetMaxSize_Bounds(t *testing.T) { - tmpDir := t.TempDir() - - settings, err := LoadSettings(tmpDir) - if err != nil { - t.Fatalf("LoadSettings() error: %v", err) - } - - // Test minimum bound - if err := settings.SetMaxSize(10); err != nil { - t.Fatalf("SetMaxSize(10) error: %v", err) - } - if settings.MaxSizeMB != 50 { - t.Errorf("MaxSizeMB should be clamped to minimum 50, got %d", settings.MaxSizeMB) - } - - // Test maximum bound - if err := settings.SetMaxSize(20000); err != nil { - t.Fatalf("SetMaxSize(20000) error: %v", err) - } - if settings.MaxSizeMB != 10000 { - t.Errorf("MaxSizeMB should be clamped to maximum 10000, got %d", settings.MaxSizeMB) - } -} - -func TestSettings_SetTheme_Invalid(t *testing.T) { - tmpDir := t.TempDir() - - settings, err := LoadSettings(tmpDir) - if err != nil { - t.Fatalf("LoadSettings() error: %v", err) - } - - // Invalid theme should default to "dark" - if err := settings.SetTheme("invalid_theme"); err != nil { - t.Fatalf("SetTheme() error: %v", err) - } - if settings.Theme != "dark" { - t.Errorf("Theme should default to 'dark', got %q", settings.Theme) - } - - // Valid themes should work - for _, theme := range []string{"light", "dark", "system"} { - if err := settings.SetTheme(theme); err != nil { - t.Fatalf("SetTheme(%q) error: %v", theme, err) - } - if settings.Theme != theme { - t.Errorf("Theme = %q, want %q", settings.Theme, theme) - } - } -} - -func TestSettings_Validate_AllErrors(t *testing.T) { - tests := []struct { - name string - modify func(*Settings) - wantError string - }{ - { - name: "MaxSizeMB too small", - modify: func(s *Settings) { s.MaxSizeMB = 10 }, - wantError: "cache_max_size_mb", - }, - { - name: "TTLDays too small", - modify: func(s *Settings) { s.TTLDays = 0 }, - wantError: "cache_ttl_days", - }, - { - name: "SyncIntervalMinutes too small", - modify: func(s *Settings) { s.SyncIntervalMinutes = 0 }, - wantError: "sync_interval_minutes", - }, - { - name: "InitialSyncDays too small", - modify: func(s *Settings) { s.InitialSyncDays = 0 }, - wantError: "initial_sync_days", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - settings := DefaultSettings() - tt.modify(settings) - - err := settings.Validate() - if err == nil { - t.Error("Validate() should return an error") - } - }) - } -} - -func TestSettings_GetSyncInterval(t *testing.T) { - settings := DefaultSettings() - settings.SyncIntervalMinutes = 10 - - interval := settings.GetSyncInterval() - expected := 10 * 60 * 1000000000 // 10 minutes in nanoseconds - - if interval.Nanoseconds() != int64(expected) { - t.Errorf("GetSyncInterval() = %v, want %v nanoseconds", interval, expected) - } -} - -func TestSettings_GetTTL(t *testing.T) { - settings := DefaultSettings() - settings.TTLDays = 30 - - ttl := settings.GetTTL() - expected := 30 * 24 * 60 * 60 * 1000000000 // 30 days in nanoseconds - - if ttl.Nanoseconds() != int64(expected) { - t.Errorf("GetTTL() = %v, want %v nanoseconds", ttl, expected) - } -} - -func TestSettings_GetMaxSizeBytes(t *testing.T) { - settings := DefaultSettings() - settings.MaxSizeMB = 500 - - bytes := settings.GetMaxSizeBytes() - expected := int64(500 * 1024 * 1024) - - if bytes != expected { - t.Errorf("GetMaxSizeBytes() = %d, want %d", bytes, expected) - } -} - -func TestSettings_IsEncryptionEnabled(t *testing.T) { - settings := DefaultSettings() - - // Default is false - if settings.IsEncryptionEnabled() { - t.Error("IsEncryptionEnabled() should be false by default") - } - - settings.EncryptionEnabled = true - if !settings.IsEncryptionEnabled() { - t.Error("IsEncryptionEnabled() should be true after setting") - } -} - -func TestSettings_IsCacheEnabled(t *testing.T) { - settings := DefaultSettings() - - // Default is true - if !settings.IsCacheEnabled() { - t.Error("IsCacheEnabled() should be true by default") - } - - settings.Enabled = false - if settings.IsCacheEnabled() { - t.Error("IsCacheEnabled() should be false after disabling") - } -} - -func TestSettings_SetEnabled(t *testing.T) { - tmpDir := t.TempDir() - - settings, err := LoadSettings(tmpDir) - if err != nil { - t.Fatalf("LoadSettings() error: %v", err) - } - - // Disable caching - if err := settings.SetEnabled(false); err != nil { - t.Fatalf("SetEnabled(false) error: %v", err) - } - - if settings.Enabled { - t.Error("Enabled should be false after SetEnabled(false)") - } - - // Re-enable - if err := settings.SetEnabled(true); err != nil { - t.Fatalf("SetEnabled(true) error: %v", err) - } - - if !settings.Enabled { - t.Error("Enabled should be true after SetEnabled(true)") - } -} - -func TestSettings_SetEncryption(t *testing.T) { - tmpDir := t.TempDir() - - settings, err := LoadSettings(tmpDir) - if err != nil { - t.Fatalf("LoadSettings() error: %v", err) - } - - // Enable encryption - if err := settings.SetEncryption(true); err != nil { - t.Fatalf("SetEncryption(true) error: %v", err) - } - - if !settings.EncryptionEnabled { - t.Error("EncryptionEnabled should be true after SetEncryption(true)") - } - - // Disable encryption - if err := settings.SetEncryption(false); err != nil { - t.Fatalf("SetEncryption(false) error: %v", err) - } - - if settings.EncryptionEnabled { - t.Error("EncryptionEnabled should be false after SetEncryption(false)") - } -} - -func TestSettings_Get_ThreadSafe(t *testing.T) { - settings := DefaultSettings() - settings.MaxSizeMB = 750 - - copy := settings.Get() - - // Verify copy has same values - if copy.MaxSizeMB != 750 { - t.Errorf("Get().MaxSizeMB = %d, want 750", copy.MaxSizeMB) - } - - // Modify copy shouldn't affect original - copy.MaxSizeMB = 1000 - if settings.MaxSizeMB != 750 { - t.Error("Modifying copy should not affect original") - } -} - -func TestSettings_ToConfig(t *testing.T) { - settings := DefaultSettings() - settings.MaxSizeMB = 600 - settings.TTLDays = 45 - settings.SyncIntervalMinutes = 10 - - config := settings.ToConfig("/test/path") - - if config.BasePath != "/test/path" { - t.Errorf("ToConfig().BasePath = %q, want %q", config.BasePath, "/test/path") - } - if config.MaxSizeMB != 600 { - t.Errorf("ToConfig().MaxSizeMB = %d, want 600", config.MaxSizeMB) - } - if config.TTLDays != 45 { - t.Errorf("ToConfig().TTLDays = %d, want 45", config.TTLDays) - } - if config.SyncIntervalMinutes != 10 { - t.Errorf("ToConfig().SyncIntervalMinutes = %d, want 10", config.SyncIntervalMinutes) - } -} - -func TestSettings_ToEncryptionConfig(t *testing.T) { - settings := DefaultSettings() - settings.EncryptionEnabled = true - - encConfig := settings.ToEncryptionConfig() - - if !encConfig.Enabled { - t.Error("ToEncryptionConfig().Enabled should be true") - } - - settings.EncryptionEnabled = false - encConfig = settings.ToEncryptionConfig() - - if encConfig.Enabled { - t.Error("ToEncryptionConfig().Enabled should be false") - } -} - -func TestSettings_Reset(t *testing.T) { - tmpDir := t.TempDir() - - settings, err := LoadSettings(tmpDir) - if err != nil { - t.Fatalf("LoadSettings() error: %v", err) - } - - // Modify settings - settings.Enabled = false - settings.MaxSizeMB = 1000 - settings.Theme = "light" - - // Reset to defaults - if err := settings.Reset(); err != nil { - t.Fatalf("Reset() error: %v", err) - } - - defaults := DefaultSettings() - if settings.Enabled != defaults.Enabled { - t.Errorf("After Reset(), Enabled = %v, want %v", settings.Enabled, defaults.Enabled) - } - if settings.MaxSizeMB != defaults.MaxSizeMB { - t.Errorf("After Reset(), MaxSizeMB = %d, want %d", settings.MaxSizeMB, defaults.MaxSizeMB) - } - if settings.Theme != defaults.Theme { - t.Errorf("After Reset(), Theme = %q, want %q", settings.Theme, defaults.Theme) - } -} - -func TestSettings_Update(t *testing.T) { - tmpDir := t.TempDir() - - settings, err := LoadSettings(tmpDir) - if err != nil { - t.Fatalf("LoadSettings() error: %v", err) - } - - // Use Update to modify multiple fields - err = settings.Update(func(s *Settings) { - s.MaxSizeMB = 750 - s.TTLDays = 60 - s.Theme = "light" - }) - - if err != nil { - t.Fatalf("Update() error: %v", err) - } - - if settings.MaxSizeMB != 750 { - t.Errorf("After Update(), MaxSizeMB = %d, want 750", settings.MaxSizeMB) - } - if settings.TTLDays != 60 { - t.Errorf("After Update(), TTLDays = %d, want 60", settings.TTLDays) - } - if settings.Theme != "light" { - t.Errorf("After Update(), Theme = %q, want 'light'", settings.Theme) - } - - // Verify settings were saved to file - reloaded, err := LoadSettings(tmpDir) - if err != nil { - t.Fatalf("LoadSettings() after Update() error: %v", err) - } - - if reloaded.MaxSizeMB != 750 { - t.Errorf("Reloaded MaxSizeMB = %d, want 750", reloaded.MaxSizeMB) - } -} - -func TestDefaultSettings_Values(t *testing.T) { - defaults := DefaultSettings() - - tests := []struct { - name string - got any - expected any - }{ - {"Enabled", defaults.Enabled, true}, - {"MaxSizeMB", defaults.MaxSizeMB, 500}, - {"TTLDays", defaults.TTLDays, 30}, - {"SyncIntervalMinutes", defaults.SyncIntervalMinutes, 5}, - {"OfflineQueueEnabled", defaults.OfflineQueueEnabled, true}, - {"EncryptionEnabled", defaults.EncryptionEnabled, false}, - {"AttachmentCacheEnabled", defaults.AttachmentCacheEnabled, true}, - {"AttachmentMaxSizeMB", defaults.AttachmentMaxSizeMB, 100}, - {"InitialSyncDays", defaults.InitialSyncDays, 30}, - {"BackgroundSyncEnabled", defaults.BackgroundSyncEnabled, true}, - {"Theme", defaults.Theme, "dark"}, - {"DefaultView", defaults.DefaultView, "email"}, - {"CompactMode", defaults.CompactMode, false}, - {"PreviewPosition", defaults.PreviewPosition, "right"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.got != tt.expected { - t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.expected) - } - }) - } -} diff --git a/internal/air/cache/sync.go b/internal/air/cache/sync.go deleted file mode 100644 index 56d958f..0000000 --- a/internal/air/cache/sync.go +++ /dev/null @@ -1,116 +0,0 @@ -package cache - -import ( - "database/sql" - "encoding/json" - "time" -) - -// SyncState tracks sync progress for a resource. -type SyncState struct { - Resource string - LastSync time.Time - Cursor string - Metadata map[string]string -} - -// SyncStore provides sync state operations. -type SyncStore struct { - db *sql.DB -} - -// NewSyncStore creates a sync store for a database. -func NewSyncStore(db *sql.DB) *SyncStore { - return &SyncStore{db: db} -} - -// Get retrieves the sync state for a resource. -func (s *SyncStore) Get(resource string) (*SyncState, error) { - row := s.db.QueryRow(` - SELECT resource, last_sync, cursor, metadata_json - FROM sync_state - WHERE resource = ? - `, resource) - - var state SyncState - var lastSync int64 - var cursor, metadataJSON sql.NullString - - err := row.Scan(&state.Resource, &lastSync, &cursor, &metadataJSON) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - - state.LastSync = time.Unix(lastSync, 0) - state.Cursor = cursor.String - - if metadataJSON.Valid && metadataJSON.String != "" { - _ = json.Unmarshal([]byte(metadataJSON.String), &state.Metadata) - } - - return &state, nil -} - -// Set updates the sync state for a resource. -func (s *SyncStore) Set(state *SyncState) error { - metadataJSON := "" - if state.Metadata != nil { - data, _ := json.Marshal(state.Metadata) - metadataJSON = string(data) - } - - _, err := s.db.Exec(` - INSERT OR REPLACE INTO sync_state (resource, last_sync, cursor, metadata_json) - VALUES (?, ?, ?, ?) - `, state.Resource, state.LastSync.Unix(), state.Cursor, metadataJSON) - return err -} - -// UpdateCursor updates just the cursor for a resource. -func (s *SyncStore) UpdateCursor(resource, cursor string) error { - _, err := s.db.Exec(` - UPDATE sync_state SET cursor = ?, last_sync = ? - WHERE resource = ? - `, cursor, time.Now().Unix(), resource) - return err -} - -// MarkSynced updates the last sync time for a resource. -func (s *SyncStore) MarkSynced(resource string) error { - _, err := s.db.Exec(` - INSERT INTO sync_state (resource, last_sync, cursor, metadata_json) - VALUES (?, ?, '', '') - ON CONFLICT(resource) DO UPDATE SET last_sync = excluded.last_sync - `, resource, time.Now().Unix()) - return err -} - -// Delete removes sync state for a resource. -func (s *SyncStore) Delete(resource string) error { - _, err := s.db.Exec("DELETE FROM sync_state WHERE resource = ?", resource) - return err -} - -// NeedsSync returns true if the resource needs to be synced. -func (s *SyncStore) NeedsSync(resource string, maxAge time.Duration) (bool, error) { - state, err := s.Get(resource) - if err != nil { - return true, err - } - if state == nil { - return true, nil - } - return time.Since(state.LastSync) > maxAge, nil -} - -// Resource types for sync state. -const ( - ResourceEmails = "emails" - ResourceEvents = "events" - ResourceContacts = "contacts" - ResourceFolders = "folders" - ResourceCalendars = "calendars" -) diff --git a/internal/air/cache_runtime.go b/internal/air/cache_runtime.go deleted file mode 100644 index ea12cd1..0000000 --- a/internal/air/cache_runtime.go +++ /dev/null @@ -1,186 +0,0 @@ -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() - - // Wait for any in-flight background tasks (e.g. photo prune) before - // closing or replacing photoStore/cacheManager — otherwise a running - // prune would be operating on a closed *sql.DB. - s.bgWg.Wait() - - 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 { - s.bgWg.Go(func() { - defer func() { - if r := recover(); r != nil { - fmt.Fprintf(os.Stderr, "Photo cache prune panic: %v\n", r) - } - }() - 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) { - pruned, err := photoStore.Prune() - switch { - case err != nil: - fmt.Fprintf(os.Stderr, "Photo cache prune failed: %v\n", err) - case pruned > 0: - fmt.Fprintf(os.Stderr, "Pruned %d expired photos from cache\n", pruned) - } -} diff --git a/internal/air/data.go b/internal/air/data.go deleted file mode 100644 index c06d686..0000000 --- a/internal/air/data.go +++ /dev/null @@ -1,326 +0,0 @@ -package air - -import "time" - -// PageData contains all data needed to render the Air UI. -type PageData struct { - // User info - UserName string - UserEmail string - UserAvatar string - - // Auth & Config (Phase 2) - Configured bool - ClientID string - Region string - HasAPIKey bool - DefaultGrantID string - Provider string - Grants []GrantInfo - - // Email data - Folders []Folder - Emails []Email - SelectedEmail *Email - - // Calendar data - Calendars []Calendar - Events []Event - - // Contacts data - Contacts []Contact - - // UI state - ActiveView string // "email", "calendar", "contacts" - ActiveFolder string - UnreadCount int - SyncedAt time.Time - AccountsCount int -} - -// GrantInfo represents an authenticated account for the UI. -type GrantInfo struct { - ID string - Email string - Provider string - IsDefault bool -} - -// Folder represents an email folder. -type Folder struct { - ID string - Name string - Icon string - Count int - UnreadCount int - IsActive bool -} - -// Label represents an email label. -type Label struct { - Name string - Color string -} - -// Email represents an email message. -type Email struct { - ID string - From string - FromEmail string - FromAvatar string - Subject string - Preview string - Body string - Time string - IsUnread bool - IsStarred bool - IsSelected bool - HasAttachment bool - AttachmentCount int - Priority string // "high", "normal", "low" - HasAISummary bool - Attachments []Attachment -} - -// Attachment represents an email attachment. -type Attachment struct { - Name string - Size string - Icon string -} - -// Calendar represents a calendar. -type Calendar struct { - ID string - Name string - Color string - IsPrimary bool -} - -// Event represents a calendar event. -type Event struct { - ID string - Title string - Description string - StartTime string - EndTime string - Location string - IsFocusTime bool - IsAISuggestion bool - Attendees []Attendee - Tags []string -} - -// Attendee represents an event attendee. -type Attendee struct { - Name string - Avatar string - Color string -} - -// Contact represents a contact. -type Contact struct { - ID string - Name string - Email string - Role string - Company string - Avatar string - AvatarColor string - IsVIP bool - Letter string // For alphabetical grouping -} - -// buildMockPageData creates mock data for Phase 1 (static design). -func buildMockPageData() PageData { - return PageData{ - UserName: "Nylas", - UserEmail: "nylas@example.com", - UserAvatar: "N", - Configured: true, - ClientID: "demo-client-id", - Region: "us", - HasAPIKey: true, - DefaultGrantID: "demo-grant-nylas", - Provider: "nylas", - ActiveView: "email", - ActiveFolder: "inbox", - UnreadCount: 23, - SyncedAt: time.Now(), - AccountsCount: 1, - Grants: []GrantInfo{{ - ID: "demo-grant-nylas", - Email: "nylas@example.com", - Provider: "nylas", - IsDefault: true, - }}, - - Folders: []Folder{ - {ID: "inbox", Name: "Inbox", Icon: "inbox", Count: 23, UnreadCount: 23, IsActive: true}, - {ID: "starred", Name: "Starred", Icon: "star", Count: 8}, - {ID: "snoozed", Name: "Snoozed", Icon: "clock", Count: 3}, - {ID: "sent", Name: "Sent", Icon: "send"}, - {ID: "drafts", Name: "Drafts", Icon: "file", Count: 2}, - {ID: "scheduled", Name: "Scheduled", Icon: "calendar"}, - {ID: "trash", Name: "Trash", Icon: "trash"}, - }, - - Emails: []Email{ - { - ID: "1", - From: "Sarah Chen", - FromEmail: "sarah.chen@company.com", - FromAvatar: "SC", - Subject: "Q4 Product Roadmap Review", - Preview: "Hi team, I've attached the updated roadmap for Q4...", - Body: mockEmailBody(), - Time: "2m", - IsUnread: true, - IsStarred: true, - IsSelected: true, - HasAttachment: true, - AttachmentCount: 2, - Priority: "high", - HasAISummary: true, - Attachments: []Attachment{ - {Name: "Q4_Roadmap_v2.pdf", Size: "2.4 MB", Icon: "file-text"}, - {Name: "Timeline_Changes.xlsx", Size: "156 KB", Icon: "file-spreadsheet"}, - }, - }, - { - ID: "2", - From: "GitHub", - FromEmail: "notifications@github.com", - FromAvatar: "GH", - Subject: "[nylas/cli] PR #142: Add focus time feature", - Preview: "mergify[bot] merged 1 commit into main...", - Time: "15m", - IsUnread: true, - }, - { - ID: "3", - From: "Alex Johnson", - FromEmail: "demo@example.com", - FromAvatar: "AJ", - Subject: "Re: Meeting Tomorrow", - Preview: "That works for me. I'll send a calendar invite...", - Time: "1h", - }, - { - ID: "4", - From: "Stripe", - FromEmail: "billing@stripe.com", - FromAvatar: "ST", - Subject: "Your December invoice is ready", - Preview: "Your invoice for December 2024 is now available...", - Time: "3h", - IsStarred: true, - }, - { - ID: "5", - From: "Design Weekly", - FromEmail: "newsletter@designweekly.com", - FromAvatar: "DW", - Subject: "This week in design: AI tools reshaping...", - Preview: "The latest trends, tools, and inspiration...", - Time: "5h", - }, - { - ID: "6", - From: "Michael Park", - FromEmail: "michael.park@company.com", - FromAvatar: "MP", - Subject: "API Integration Documentation", - Preview: "Here's the documentation you requested...", - Time: "Yesterday", - HasAttachment: true, - AttachmentCount: 1, - }, - }, - - SelectedEmail: &Email{ - ID: "1", - From: "Sarah Chen", - FromEmail: "sarah.chen@company.com", - FromAvatar: "SC", - Subject: "Q4 Product Roadmap Review", - Body: mockEmailBody(), - Time: "2m", - IsStarred: true, - HasAttachment: true, - AttachmentCount: 2, - Priority: "high", - Attachments: []Attachment{ - {Name: "Q4_Roadmap_v2.pdf", Size: "2.4 MB", Icon: "file-text"}, - {Name: "Timeline_Changes.xlsx", Size: "156 KB", Icon: "file-spreadsheet"}, - }, - }, - - Calendars: []Calendar{ - {ID: "work", Name: "Work", Color: "#8b5cf6", IsPrimary: true}, - {ID: "personal", Name: "Personal", Color: "#22c55e"}, - {ID: "family", Name: "Family", Color: "#f59e0b"}, - }, - - Events: []Event{ - { - ID: "e1", - Title: "Focus Time", - Description: "Deep work block - protected by AI", - StartTime: "9:00 AM", - EndTime: "11:00 AM", - IsFocusTime: true, - }, - { - ID: "e2", - Title: "Team Standup", - Description: "Weekly sync with engineering", - StartTime: "11:30 AM", - EndTime: "12:00 PM", - Attendees: []Attendee{ - {Name: "SC", Color: "var(--gradient-1)"}, - {Name: "AJ", Color: "var(--gradient-2)"}, - {Name: "+3", Color: "var(--gradient-3)"}, - }, - }, - { - ID: "e3", - Title: "Product Review", - Description: "Q4 roadmap discussion", - StartTime: "2:00 PM", - EndTime: "3:00 PM", - Tags: []string{"Zoom"}, - }, - { - ID: "e4", - Title: "Catch-up with Sarah", - Description: "Optimal slot across 3 timezones", - StartTime: "4:00 PM", - EndTime: "4:30 PM", - IsAISuggestion: true, - }, - }, - - Contacts: []Contact{ - {ID: "c1", Name: "Alex Johnson", Email: "demo@example.com", Role: "Senior Engineer", Avatar: "AJ", AvatarColor: "var(--gradient-1)", IsVIP: true, Letter: "A"}, - {ID: "c2", Name: "Amanda Peters", Email: "amanda.peters@stripe.com", Role: "Product Manager at Stripe", Avatar: "AP", AvatarColor: "var(--gradient-4)", Letter: "A"}, - {ID: "c3", Name: "James Davis", Email: "james.davis@techcorp.com", Role: "CEO at TechCorp", Avatar: "JD", AvatarColor: "var(--gradient-2)", IsVIP: true, Letter: "J"}, - {ID: "c4", Name: "Michael Park", Email: "michael.park@github.com", Role: "Developer Relations at GitHub", Avatar: "MP", AvatarColor: "linear-gradient(135deg, #a8e6cf 0%, #88d8b0 100%)", Letter: "M"}, - {ID: "c5", Name: "Sarah Chen", Email: "sarah.chen@company.com", Role: "VP Engineering at Acme Inc", Avatar: "SC", AvatarColor: "var(--gradient-2)", IsVIP: true, Letter: "S"}, - }, - } -} - -// mockEmailBody returns mock HTML email body content. -func mockEmailBody() string { - return `Hi team,
-I've attached the updated roadmap for Q4. Please review the timeline changes and let me know if you have any concerns.
-Key Updates:
-Please review and provide feedback by end of day Friday.
-Best,
Sarah
Full body
", - Time: "2h", - IsUnread: true, - IsStarred: false, - IsSelected: true, - HasAttachment: true, - AttachmentCount: 2, - Priority: "high", - HasAISummary: true, - Attachments: []Attachment{ - {Name: "file.pdf", Size: "1 MB", Icon: "file"}, - }, - } - - if email.Subject != "Test Subject" { - t.Error("expected Subject 'Test Subject'") - } - if email.AttachmentCount != 2 { - t.Error("expected AttachmentCount 2") - } - if len(email.Attachments) != 1 { - t.Error("expected 1 attachment") - } -} - -func TestEventType(t *testing.T) { - t.Parallel() - - event := Event{ - ID: "ev1", - Title: "Team Meeting", - Description: "Weekly sync", - StartTime: "10:00 AM", - EndTime: "11:00 AM", - Location: "Room A", - IsFocusTime: false, - IsAISuggestion: true, - Attendees: []Attendee{ - {Name: "JD", Avatar: "JD", Color: "#fff"}, - }, - Tags: []string{"meeting", "weekly"}, - } - - if event.Title != "Team Meeting" { - t.Error("expected Title 'Team Meeting'") - } - if !event.IsAISuggestion { - t.Error("expected IsAISuggestion to be true") - } - if len(event.Attendees) != 1 { - t.Error("expected 1 attendee") - } - if len(event.Tags) != 2 { - t.Error("expected 2 tags") - } -} - -func TestContactType(t *testing.T) { - t.Parallel() - - contact := Contact{ - ID: "c1", - Name: "John Doe", - Email: "john@example.com", - Role: "Engineer", - Company: "Acme Inc", - Avatar: "JD", - AvatarColor: "#8b5cf6", - IsVIP: true, - Letter: "J", - } - - if contact.Name != "John Doe" { - t.Error("expected Name 'John Doe'") - } - if !contact.IsVIP { - t.Error("expected IsVIP to be true") - } - if contact.Letter != "J" { - t.Error("expected Letter 'J'") - } -} diff --git a/internal/air/handlers_ai_complete.go b/internal/air/handlers_ai_complete.go deleted file mode 100644 index 73d165f..0000000 --- a/internal/air/handlers_ai_complete.go +++ /dev/null @@ -1,256 +0,0 @@ -package air - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "os/exec" - "strconv" - "strings" - "time" - - "github.com/nylas/cli/internal/httputil" -) - -const smartComposeTimeout = 5 * time.Second - -var runSmartComposeCommand = func(ctx context.Context, prompt string) ([]byte, error) { - //nolint:gosec // G204: Command is hardcoded "claude" binary, prompt is passed as a single argument. - cmd := exec.Command("claude", "-p", prompt) - return runCommandOutput(ctx, cmd) -} - -// CompleteRequest represents a smart compose request -type CompleteRequest struct { - Text string `json:"text"` - MaxLength int `json:"maxLength"` - Context string `json:"context,omitempty"` -} - -// CompleteResponse represents a smart compose response -type CompleteResponse struct { - Suggestion string `json:"suggestion"` - Confidence float64 `json:"confidence"` -} - -// handleAIComplete handles smart compose autocomplete requests -func (s *Server) handleAIComplete(w http.ResponseWriter, r *http.Request) { - var req CompleteRequest - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.Text == "" { - httputil.WriteJSON(w, http.StatusOK, CompleteResponse{Suggestion: "", Confidence: 0}) - return - } - - if req.MaxLength == 0 { - req.MaxLength = 100 - } - - suggestion := getAICompletion(r.Context(), req.Text, req.MaxLength) - - httputil.WriteJSON(w, http.StatusOK, CompleteResponse{ - Suggestion: suggestion, - Confidence: 0.8, - }) -} - -// getAICompletion gets completion from Claude via CLI -func getAICompletion(ctx context.Context, text string, maxLen int) string { - prompt := buildCompletionPrompt(text, maxLen) - - ctx, cancel := context.WithTimeout(ctx, smartComposeTimeout) - defer cancel() - - output, err := runSmartComposeCommand(ctx, prompt) - if err != nil { - return "" - } - - suggestion := strings.TrimSpace(string(output)) - - // Limit length - if len(suggestion) > maxLen { - // Try to break at word boundary - if idx := strings.LastIndex(suggestion[:maxLen], " "); idx > 0 { - suggestion = suggestion[:idx] - } else { - suggestion = suggestion[:maxLen] - } - } - - return suggestion -} - -// buildCompletionPrompt creates prompt for autocomplete -func buildCompletionPrompt(text string, maxLen int) string { - return strings.Join([]string{ - "You are an email autocomplete assistant.", - "Complete the following email text naturally.", - "Only provide the completion, not the original text.", - "Keep it concise and professional.", - fmt.Sprintf("Maximum %s characters.", strconv.Itoa(maxLen)), - "", - "Text to complete:", - text, - "", - "Completion:", - }, "\n") -} - -func runCommandOutput(ctx context.Context, cmd *exec.Cmd) ([]byte, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - - configureChildProcessGroup(cmd) - - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Start(); err != nil { - return nil, err - } - - waitCh := make(chan error, 1) - go func() { - waitCh <- cmd.Wait() - }() - - select { - case err := <-waitCh: - if err != nil { - return nil, err - } - return stdout.Bytes(), nil - case <-ctx.Done(): - _ = killCommandTree(cmd) - <-waitCh - return nil, ctx.Err() - } -} - -// NLSearchRequest represents a natural language search request -type NLSearchRequest struct { - Query string `json:"query"` -} - -// NLSearchResponse represents parsed search parameters -type NLSearchResponse struct { - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - Subject string `json:"subject,omitempty"` - DateAfter string `json:"dateAfter,omitempty"` - DateBefore string `json:"dateBefore,omitempty"` - HasAttach bool `json:"hasAttachment,omitempty"` - IsUnread bool `json:"isUnread,omitempty"` - Keywords string `json:"keywords,omitempty"` -} - -// handleNLSearch handles natural language search queries -func (s *Server) handleNLSearch(w http.ResponseWriter, r *http.Request) { - var req NLSearchRequest - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.Query == "" { - http.Error(w, "Query required", http.StatusBadRequest) - return - } - - result := parseNaturalLanguageSearch(req.Query) - - httputil.WriteJSON(w, http.StatusOK, result) -} - -// parseNaturalLanguageSearch converts NL query to search params -func parseNaturalLanguageSearch(query string) NLSearchResponse { - result := NLSearchResponse{} - queryLower := strings.ToLower(query) - - // Parse time-based patterns FIRST (before "from" to avoid conflicts) - if strings.Contains(queryLower, "last week") { - result.DateAfter = "7d" - } else if strings.Contains(queryLower, "yesterday") { - result.DateAfter = "1d" - } else if strings.Contains(queryLower, "today") { - result.DateAfter = "0d" - } else if strings.Contains(queryLower, "this month") { - result.DateAfter = "30d" - } - - // Parse "from X" patterns (skip time words) - timeWords := map[string]bool{"last": true, "yesterday": true, "today": true, "this": true} - if strings.Contains(queryLower, "from ") && !strings.Contains(queryLower, "from last") && - !strings.Contains(queryLower, "from yesterday") && !strings.Contains(queryLower, "from today") { - parts := strings.SplitN(queryLower, "from ", 2) - if len(parts) > 1 { - words := strings.Fields(parts[1]) - if len(words) > 0 && !timeWords[words[0]] { - result.From = words[0] - } - } - } - - // Parse "to X" patterns - if strings.Contains(queryLower, "to ") { - parts := strings.SplitN(queryLower, "to ", 2) - if len(parts) > 1 { - words := strings.Fields(parts[1]) - if len(words) > 0 { - result.To = words[0] - } - } - } - - // Parse attachment pattern - if strings.Contains(queryLower, "attachment") || - strings.Contains(queryLower, "attached") { - result.HasAttach = true - } - - // Parse unread pattern - if strings.Contains(queryLower, "unread") { - result.IsUnread = true - } - - // Extract remaining keywords - keywords := extractKeywords(queryLower) - if len(keywords) > 0 { - result.Keywords = strings.Join(keywords, " ") - } - - return result -} - -// extractKeywords extracts search keywords from query -func extractKeywords(query string) []string { - stopWords := map[string]bool{ - "from": true, "to": true, "about": true, "with": true, - "the": true, "a": true, "an": true, "and": true, - "or": true, "in": true, "on": true, "at": true, - "last": true, "week": true, "month": true, "yesterday": true, - "today": true, "emails": true, "email": true, "messages": true, - } - - words := strings.Fields(query) - keywords := []string{} - - for _, word := range words { - word = strings.Trim(word, ".,!?") - if !stopWords[word] && len(word) > 2 { - keywords = append(keywords, word) - } - } - - return keywords -} diff --git a/internal/air/handlers_ai_complete_test.go b/internal/air/handlers_ai_complete_test.go deleted file mode 100644 index 030ad24..0000000 --- a/internal/air/handlers_ai_complete_test.go +++ /dev/null @@ -1,346 +0,0 @@ -package air - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestParseNaturalLanguageSearch(t *testing.T) { - tests := []struct { - name string - query string - expected NLSearchResponse - }{ - { - name: "from pattern", - query: "emails from john", - expected: NLSearchResponse{ - From: "john", - }, - }, - { - name: "to pattern", - query: "emails to sarah about project", - expected: NLSearchResponse{ - To: "sarah", - Keywords: "project", - }, - }, - { - name: "last week", - query: "emails from last week", - expected: NLSearchResponse{ - DateAfter: "7d", - }, - }, - { - name: "yesterday", - query: "messages from yesterday", - expected: NLSearchResponse{ - DateAfter: "1d", - }, - }, - { - name: "today", - query: "emails from today", - expected: NLSearchResponse{ - DateAfter: "0d", - }, - }, - { - name: "this month", - query: "all emails this month", - expected: NLSearchResponse{ - DateAfter: "30d", - }, - }, - { - name: "with attachment", - query: "emails with attachment", - expected: NLSearchResponse{ - HasAttach: true, - }, - }, - { - name: "attached files", - query: "messages with attached files", - expected: NLSearchResponse{ - HasAttach: true, - Keywords: "files", - }, - }, - { - name: "unread", - query: "unread emails", - expected: NLSearchResponse{ - IsUnread: true, - }, - }, - { - name: "complex query", - query: "unread emails from john last week about project", - expected: NLSearchResponse{ - From: "john", - DateAfter: "7d", - IsUnread: true, - Keywords: "project", - }, - }, - { - name: "keywords only", - query: "invoice quarterly report", - expected: NLSearchResponse{ - Keywords: "invoice quarterly report", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parseNaturalLanguageSearch(tt.query) - - if result.From != tt.expected.From { - t.Errorf("From = %q, want %q", result.From, tt.expected.From) - } - if result.To != tt.expected.To { - t.Errorf("To = %q, want %q", result.To, tt.expected.To) - } - if result.DateAfter != tt.expected.DateAfter { - t.Errorf("DateAfter = %q, want %q", result.DateAfter, tt.expected.DateAfter) - } - if result.HasAttach != tt.expected.HasAttach { - t.Errorf("HasAttach = %v, want %v", result.HasAttach, tt.expected.HasAttach) - } - if result.IsUnread != tt.expected.IsUnread { - t.Errorf("IsUnread = %v, want %v", result.IsUnread, tt.expected.IsUnread) - } - }) - } -} - -func TestExtractKeywords(t *testing.T) { - tests := []struct { - query string - expected []string - }{ - { - query: "project update meeting", - expected: []string{"project", "update", "meeting"}, - }, - { - query: "the invoice from last week", - expected: []string{"invoice"}, - }, - { - query: "emails about quarterly report", - expected: []string{"quarterly", "report"}, - }, - { - query: "a an the and or", - expected: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.query, func(t *testing.T) { - result := extractKeywords(tt.query) - - if len(result) != len(tt.expected) { - t.Errorf("got %d keywords, want %d", len(result), len(tt.expected)) - return - } - - for i, kw := range result { - if kw != tt.expected[i] { - t.Errorf("keyword[%d] = %q, want %q", i, kw, tt.expected[i]) - } - } - }) - } -} - -func TestHandleAICompleteEmptyText(t *testing.T) { - server := &Server{} - - body := CompleteRequest{Text: "", MaxLength: 100} - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/ai/complete", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIComplete(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var result CompleteResponse - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if result.Suggestion != "" { - t.Errorf("expected empty suggestion for empty text") - } -} - -func TestHandleAICompleteInvalidBody(t *testing.T) { - server := &Server{} - - req := httptest.NewRequest(http.MethodPost, "/api/ai/complete", bytes.NewReader([]byte("invalid"))) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIComplete(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } -} - -func TestHandleNLSearch(t *testing.T) { - server := &Server{} - - body := NLSearchRequest{Query: "emails from john last week"} - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/ai/nl-search", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleNLSearch(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var result NLSearchResponse - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if result.From != "john" { - t.Errorf("expected From 'john', got %q", result.From) - } - - if result.DateAfter != "7d" { - t.Errorf("expected DateAfter '7d', got %q", result.DateAfter) - } -} - -func TestHandleNLSearchEmptyQuery(t *testing.T) { - server := &Server{} - - body := NLSearchRequest{Query: ""} - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/ai/nl-search", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleNLSearch(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } -} - -func TestHandleNLSearchInvalidBody(t *testing.T) { - server := &Server{} - - req := httptest.NewRequest(http.MethodPost, "/api/ai/nl-search", bytes.NewReader([]byte("invalid"))) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleNLSearch(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } -} - -func TestBuildCompletionPrompt(t *testing.T) { - text := "Hello, I wanted to follow up on" - maxLen := 50 - - prompt := buildCompletionPrompt(text, maxLen) - - if prompt == "" { - t.Error("expected non-empty prompt") - } - - if len(prompt) < len(text) { - t.Error("prompt should include the input text") - } - - if !strings.Contains(prompt, "Maximum 50 characters.") { - t.Fatalf("expected prompt to include max length, got %q", prompt) - } -} - -func TestGetAICompletion_UsesTimeoutAndTruncatesOutput(t *testing.T) { - originalRunner := runSmartComposeCommand - t.Cleanup(func() { - runSmartComposeCommand = originalRunner - }) - - runSmartComposeCommand = func(ctx context.Context, prompt string) ([]byte, error) { - if _, ok := ctx.Deadline(); !ok { - t.Fatal("expected smart compose runner to receive a deadline") - } - if !strings.Contains(prompt, "Maximum 10 characters.") { - t.Fatalf("expected prompt to contain max length, got %q", prompt) - } - return []byte("hello world again"), nil - } - - suggestion := getAICompletion(context.Background(), "Hello", 10) - if suggestion != "hello" { - t.Fatalf("expected truncated suggestion %q, got %q", "hello", suggestion) - } -} - -func TestHandleAIComplete_ReturnsEmptySuggestionWhenRequestContextIsCanceled(t *testing.T) { - originalRunner := runSmartComposeCommand - t.Cleanup(func() { - runSmartComposeCommand = originalRunner - }) - - runSmartComposeCommand = func(ctx context.Context, prompt string) ([]byte, error) { - <-ctx.Done() - return nil, ctx.Err() - } - - server := &Server{} - - body := CompleteRequest{Text: "Draft a reply", MaxLength: 100} - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/ai/complete", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - ctx, cancel := context.WithCancel(req.Context()) - cancel() - req = req.WithContext(ctx) - - w := httptest.NewRecorder() - server.handleAIComplete(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w.Code) - } - - var resp CompleteResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Suggestion != "" { - t.Fatalf("expected empty suggestion when command context is canceled, got %q", resp.Suggestion) - } -} diff --git a/internal/air/handlers_ai_complete_unix_test.go b/internal/air/handlers_ai_complete_unix_test.go deleted file mode 100644 index f53c387..0000000 --- a/internal/air/handlers_ai_complete_unix_test.go +++ /dev/null @@ -1,94 +0,0 @@ -//go:build !windows - -package air - -import ( - "context" - "errors" - "os" - "os/exec" - "strconv" - "strings" - "syscall" - "testing" - "time" -) - -func TestRunCommandOutput_KillsChildProcessGroupOnCancel(t *testing.T) { - t.Parallel() - - pidFile := t.TempDir() + "/child.pid" - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - cmd := exec.Command("sh", "-c", `sleep 30 & echo $! > "$CHILD_PID_FILE"; wait`) - cmd.Env = append(os.Environ(), "CHILD_PID_FILE="+pidFile) - - doneCh := make(chan error, 1) - go func() { - _, err := runCommandOutput(ctx, cmd) - doneCh <- err - }() - - childPID := waitForChildPID(t, pidFile) - cancel() - - select { - case err := <-doneCh: - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context cancellation, got %v", err) - } - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for command cancellation") - } - - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if err := syscall.Kill(childPID, 0); errors.Is(err, syscall.ESRCH) { - return - } - time.Sleep(25 * time.Millisecond) - } - - t.Fatalf("expected child process %d to be terminated with its parent process group", childPID) -} - -func TestRunCommandOutput_DoesNotStartWhenContextAlreadyCanceled(t *testing.T) { - t.Parallel() - - sentinel := t.TempDir() + "/started" - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - cmd := exec.Command("sh", "-c", `touch "$SENTINEL"`) - cmd.Env = append(os.Environ(), "SENTINEL="+sentinel) - - _, err := runCommandOutput(ctx, cmd) - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context cancellation, got %v", err) - } - - if _, statErr := os.Stat(sentinel); !errors.Is(statErr, os.ErrNotExist) { - t.Fatalf("expected command not to start, stat error=%v", statErr) - } -} - -func waitForChildPID(t *testing.T, pidFile string) int { - t.Helper() - - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - data, err := os.ReadFile(pidFile) - if err == nil && len(strings.TrimSpace(string(data))) > 0 { - pid, convErr := strconv.Atoi(strings.TrimSpace(string(data))) - if convErr != nil { - t.Fatalf("parse child pid: %v", convErr) - } - return pid - } - time.Sleep(25 * time.Millisecond) - } - - t.Fatalf("timed out waiting for child pid file %s", pidFile) - return 0 -} diff --git a/internal/air/handlers_ai_config.go b/internal/air/handlers_ai_config.go deleted file mode 100644 index 348c8c0..0000000 --- a/internal/air/handlers_ai_config.go +++ /dev/null @@ -1,222 +0,0 @@ -package air - -import ( - "encoding/json" - "maps" - "net/http" - "strings" - "sync" - - "github.com/nylas/cli/internal/httputil" -) - -// AIConfig represents AI provider configuration -type AIConfig struct { - Provider string `json:"provider"` // claude, openai, ollama, groq - Model string `json:"model"` // claude-3-opus, gpt-4, etc. - APIKey string `json:"apiKey,omitempty"` - BaseURL string `json:"baseUrl,omitempty"` - MaxTokens int `json:"maxTokens"` - Temperature float64 `json:"temperature"` - TaskModels map[string]string `json:"taskModels"` // task -> model mapping - UsageBudget float64 `json:"usageBudget"` // Monthly budget in USD - UsageSpent float64 `json:"usageSpent"` // Current month spend - Enabled bool `json:"enabled"` -} - -// AIUsageStats represents AI usage statistics -type AIUsageStats struct { - TotalRequests int `json:"totalRequests"` - TotalTokens int `json:"totalTokens"` - TotalCost float64 `json:"totalCost"` - RequestsByTask map[string]int `json:"requestsByTask"` - TokensByTask map[string]int `json:"tokensByTask"` -} - -// aiConfigStore holds AI configuration -type aiConfigStore struct { - config *AIConfig - stats *AIUsageStats - mu sync.RWMutex -} - -var aiStore = &aiConfigStore{ - config: &AIConfig{ - Provider: "claude", - Model: "claude-3-haiku-20240307", - MaxTokens: 1024, - Temperature: 0.7, - TaskModels: map[string]string{ - "summarize": "claude-3-haiku-20240307", - "smart_reply": "claude-3-haiku-20240307", - "smart_compose": "claude-3-haiku-20240307", - "categorize": "claude-3-haiku-20240307", - }, - UsageBudget: 50.0, - UsageSpent: 0.0, - Enabled: true, - }, - stats: &AIUsageStats{ - RequestsByTask: make(map[string]int), - TokensByTask: make(map[string]int), - }, -} - -// handleAIConfigRoute dispatches AI config requests by method -func (s *Server) handleAIConfigRoute(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.handleGetAIConfig(w, r) - case http.MethodPut, http.MethodPost: - s.handleUpdateAIConfig(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleGetAIConfig returns current AI configuration -func (s *Server) handleGetAIConfig(w http.ResponseWriter, r *http.Request) { - aiStore.mu.RLock() - defer aiStore.mu.RUnlock() - - // Mask API key for security - config := *aiStore.config - if config.APIKey != "" { - config.APIKey = "***" + config.APIKey[max(0, len(config.APIKey)-4):] - } - - httputil.WriteJSON(w, http.StatusOK, config) -} - -// handleUpdateAIConfig updates AI configuration -func (s *Server) handleUpdateAIConfig(w http.ResponseWriter, r *http.Request) { - var req AIConfig - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - aiStore.mu.Lock() - defer aiStore.mu.Unlock() - - // Update fields - if req.Provider != "" { - aiStore.config.Provider = req.Provider - } - if req.Model != "" { - aiStore.config.Model = req.Model - } - // Saved value is masked for the read path as `***` + last 4 chars; never - // overwrite the real key with a masked value that came back via PUT. - if req.APIKey != "" && !strings.HasPrefix(req.APIKey, "***") { - aiStore.config.APIKey = req.APIKey - } - if req.BaseURL != "" { - aiStore.config.BaseURL = req.BaseURL - } - if req.MaxTokens > 0 { - aiStore.config.MaxTokens = req.MaxTokens - } - if req.Temperature >= 0 { - aiStore.config.Temperature = req.Temperature - } - if req.TaskModels != nil { - maps.Copy(aiStore.config.TaskModels, req.TaskModels) - } - if req.UsageBudget > 0 { - aiStore.config.UsageBudget = req.UsageBudget - } - aiStore.config.Enabled = req.Enabled - - httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "updated"}) -} - -// handleTestAIConnection tests the AI provider connection -func (s *Server) handleTestAIConnection(w http.ResponseWriter, r *http.Request) { - aiStore.mu.RLock() - config := aiStore.config - aiStore.mu.RUnlock() - - // Simulate connection test - result := map[string]any{ - "success": true, - "provider": config.Provider, - "model": config.Model, - "message": "Connection successful", - } - - if config.APIKey == "" && config.Provider != "ollama" { - result["success"] = false - result["message"] = "API key required" - } - - httputil.WriteJSON(w, http.StatusOK, result) -} - -// handleGetAIUsage returns AI usage statistics -func (s *Server) handleGetAIUsage(w http.ResponseWriter, r *http.Request) { - aiStore.mu.RLock() - defer aiStore.mu.RUnlock() - - // Guard against zero budget: division would yield +Inf, which encoding/json - // refuses to marshal and would surface as a 500. - var percentUsed float64 - if aiStore.config.UsageBudget > 0 { - percentUsed = (aiStore.config.UsageSpent / aiStore.config.UsageBudget) * 100 - } - - response := map[string]any{ - "stats": aiStore.stats, - "budget": aiStore.config.UsageBudget, - "spent": aiStore.config.UsageSpent, - "remaining": aiStore.config.UsageBudget - aiStore.config.UsageSpent, - "percentUsed": percentUsed, - } - - httputil.WriteJSON(w, http.StatusOK, response) -} - -// GetAIProviders returns available AI providers -func (s *Server) handleGetAIProviders(w http.ResponseWriter, r *http.Request) { - providers := []map[string]any{ - { - "id": "claude", - "name": "Anthropic Claude", - "models": []string{"claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307"}, - "requiresKey": true, - }, - { - "id": "openai", - "name": "OpenAI", - "models": []string{"gpt-4-turbo", "gpt-4", "gpt-3.5-turbo"}, - "requiresKey": true, - }, - { - "id": "ollama", - "name": "Ollama (Local)", - "models": []string{"llama2", "mistral", "codellama"}, - "requiresKey": false, - }, - { - "id": "groq", - "name": "Groq", - "models": []string{"mixtral-8x7b-32768", "llama2-70b-4096"}, - "requiresKey": true, - }, - } - - httputil.WriteJSON(w, http.StatusOK, providers) -} - -// RecordAIUsage records AI usage for a task -func RecordAIUsage(task string, tokens int, cost float64) { - aiStore.mu.Lock() - defer aiStore.mu.Unlock() - - aiStore.stats.TotalRequests++ - aiStore.stats.TotalTokens += tokens - aiStore.stats.TotalCost += cost - aiStore.stats.RequestsByTask[task]++ - aiStore.stats.TokensByTask[task] += tokens - aiStore.config.UsageSpent += cost -} diff --git a/internal/air/handlers_ai_config_unit_test.go b/internal/air/handlers_ai_config_unit_test.go deleted file mode 100644 index 2289d8f..0000000 --- a/internal/air/handlers_ai_config_unit_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package air - -import ( - "encoding/json" - "math" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// TestHandleGetAIUsage_ZeroBudget verifies that a zero usage budget does not -// produce +Inf percentUsed (which encoding/json would refuse to marshal, -// surfacing as a 500 to the client). -func TestHandleGetAIUsage_ZeroBudget(t *testing.T) { - srv := &Server{} - - aiStore.mu.Lock() - prevBudget := aiStore.config.UsageBudget - prevSpent := aiStore.config.UsageSpent - aiStore.config.UsageBudget = 0 - aiStore.config.UsageSpent = 1.0 - aiStore.mu.Unlock() - t.Cleanup(func() { - aiStore.mu.Lock() - aiStore.config.UsageBudget = prevBudget - aiStore.config.UsageSpent = prevSpent - aiStore.mu.Unlock() - }) - - req := httptest.NewRequest(http.MethodGet, "/api/ai/usage", nil) - w := httptest.NewRecorder() - srv.handleGetAIUsage(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - - pct, ok := resp["percentUsed"].(float64) - if !ok { - t.Fatalf("percentUsed missing or not a number: %#v", resp["percentUsed"]) - } - if math.IsInf(pct, 0) || math.IsNaN(pct) { - t.Fatalf("percentUsed should be finite for zero budget, got %v", pct) - } - if pct != 0 { - t.Errorf("expected 0 percent used when budget is zero, got %v", pct) - } -} - -// TestHandleUpdateAIConfig_RejectsMaskedKey verifies that posting back the -// masked API key returned by GET (e.g. "***1234") does NOT overwrite the -// stored real key. Previously the check was "!= \"***\"", which let any -// "***xxxx" payload through. -func TestHandleUpdateAIConfig_RejectsMaskedKey(t *testing.T) { - srv := &Server{} - - aiStore.mu.Lock() - prev := aiStore.config.APIKey - aiStore.config.APIKey = "secret-real-key-1234" - aiStore.mu.Unlock() - t.Cleanup(func() { - aiStore.mu.Lock() - aiStore.config.APIKey = prev - aiStore.mu.Unlock() - }) - - body := `{"apiKey": "***1234"}` - req := httptest.NewRequest(http.MethodPut, "/api/ai/config", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.handleUpdateAIConfig(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - aiStore.mu.RLock() - defer aiStore.mu.RUnlock() - if aiStore.config.APIKey != "secret-real-key-1234" { - t.Errorf("masked payload overwrote real API key: now %q", aiStore.config.APIKey) - } -} - -// TestHandleUpdateAIConfig_AcceptsRealKey verifies that a non-masked key -// still updates the stored value. -func TestHandleUpdateAIConfig_AcceptsRealKey(t *testing.T) { - srv := &Server{} - - aiStore.mu.Lock() - prev := aiStore.config.APIKey - aiStore.config.APIKey = "old-key" - aiStore.mu.Unlock() - t.Cleanup(func() { - aiStore.mu.Lock() - aiStore.config.APIKey = prev - aiStore.mu.Unlock() - }) - - body := `{"apiKey": "new-real-key-abcd"}` - req := httptest.NewRequest(http.MethodPut, "/api/ai/config", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - srv.handleUpdateAIConfig(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - aiStore.mu.RLock() - defer aiStore.mu.RUnlock() - if aiStore.config.APIKey != "new-real-key-abcd" { - t.Errorf("real key was not stored: got %q", aiStore.config.APIKey) - } -} diff --git a/internal/air/handlers_ai_core_test.go b/internal/air/handlers_ai_core_test.go deleted file mode 100644 index 0035bdc..0000000 --- a/internal/air/handlers_ai_core_test.go +++ /dev/null @@ -1,435 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -// ================================ -// AI HANDLER TESTS -// ================================ - -func TestHandleAISummarize_MethodNotAllowed(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/ai/summarize", nil) - w := httptest.NewRecorder() - - server.handleAISummarize(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleAISummarize_EmptyBody(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", nil) - w := httptest.NewRecorder() - - server.handleAISummarize(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp AIResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } - - if resp.Error != "Invalid request body" { - t.Errorf("expected error 'Invalid request body', got '%s'", resp.Error) - } -} - -func TestHandleAISummarize_InvalidJSON(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - body := bytes.NewBufferString("{invalid json}") - req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", body) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAISummarize(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp AIResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } -} - -func TestHandleAISummarize_EmptyPrompt(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - reqBody := AIRequest{ - EmailID: "test-email-id", - Prompt: "", - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAISummarize(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp AIResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } - - if resp.Error != "Prompt is required" { - t.Errorf("expected error 'Prompt is required', got '%s'", resp.Error) - } -} - -func TestHandleAISummarize_MissingClaudeCLI(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - reqBody := AIRequest{ - EmailID: "test-email-id", - Prompt: "Summarize this email: Hello world", - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAISummarize(w, req) - - // Should return 500 if claude CLI is not installed - // The error message should mention Claude Code CLI - if w.Code != http.StatusInternalServerError && w.Code != http.StatusOK { - t.Errorf("expected status 500 or 200, got %d", w.Code) - } - - var resp AIResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // If claude is not installed, we expect an error - // If it is installed (dev machine), we expect success - if w.Code == http.StatusInternalServerError { - if resp.Success { - t.Error("expected Success to be false for 500 response") - } - if resp.Error == "" { - t.Error("expected non-empty error message") - } - } -} - -func TestAIRequest_JSONMarshaling(t *testing.T) { - t.Parallel() - - req := AIRequest{ - EmailID: "email-123", - Prompt: "Summarize this email", - } - - data, err := json.Marshal(req) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded AIRequest - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.EmailID != req.EmailID { - t.Errorf("expected EmailID %s, got %s", req.EmailID, decoded.EmailID) - } - - if decoded.Prompt != req.Prompt { - t.Errorf("expected Prompt %s, got %s", req.Prompt, decoded.Prompt) - } -} - -func TestAIResponse_JSONMarshaling(t *testing.T) { - t.Parallel() - - resp := AIResponse{ - Success: true, - Summary: "This is a summary", - Error: "", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded AIResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.Success != resp.Success { - t.Errorf("expected Success %v, got %v", resp.Success, decoded.Success) - } - - if decoded.Summary != resp.Summary { - t.Errorf("expected Summary %s, got %s", resp.Summary, decoded.Summary) - } -} - -func TestAIResponse_ErrorOmitsEmpty(t *testing.T) { - t.Parallel() - - resp := AIResponse{ - Success: true, - Summary: "Summary", - Error: "", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - // Error field should be omitted when empty - if bytes.Contains(data, []byte(`"error"`)) { - t.Error("expected error field to be omitted when empty") - } -} - -func TestAIResponse_ErrorIncludedWhenPresent(t *testing.T) { - t.Parallel() - - resp := AIResponse{ - Success: false, - Summary: "", - Error: "Something went wrong", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - // Error field should be present - if !bytes.Contains(data, []byte(`"error"`)) { - t.Error("expected error field to be present when not empty") - } -} - -// ================================ -// SMART REPLIES HANDLER TESTS -// ================================ - -func TestHandleAISmartReplies_MethodNotAllowed(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/ai/smart-replies", nil) - w := httptest.NewRecorder() - - server.handleAISmartReplies(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleAISmartReplies_EmptyBody(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPost, "/api/ai/smart-replies", nil) - w := httptest.NewRecorder() - - server.handleAISmartReplies(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp SmartReplyResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } -} - -func TestHandleAISmartReplies_MissingBody(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - reqBody := SmartReplyRequest{ - EmailID: "test-email-id", - Subject: "Test Subject", - From: "sender@example.com", - Body: "", // Empty body - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/smart-replies", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAISmartReplies(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp SmartReplyResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } - - if resp.Error != "Email body is required" { - t.Errorf("expected error 'Email body is required', got '%s'", resp.Error) - } -} - -func TestSmartReplyRequest_JSONMarshaling(t *testing.T) { - t.Parallel() - - req := SmartReplyRequest{ - EmailID: "email-123", - Subject: "Test Subject", - From: "sender@example.com", - Body: "Hello, this is a test email", - ReplyType: "reply", - } - - data, err := json.Marshal(req) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded SmartReplyRequest - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.EmailID != req.EmailID { - t.Errorf("expected EmailID %s, got %s", req.EmailID, decoded.EmailID) - } - - if decoded.Subject != req.Subject { - t.Errorf("expected Subject %s, got %s", req.Subject, decoded.Subject) - } - - if decoded.From != req.From { - t.Errorf("expected From %s, got %s", req.From, decoded.From) - } - - if decoded.Body != req.Body { - t.Errorf("expected Body %s, got %s", req.Body, decoded.Body) - } -} - -func TestSmartReplyResponse_JSONMarshaling(t *testing.T) { - t.Parallel() - - resp := SmartReplyResponse{ - Success: true, - Replies: []string{"Reply 1", "Reply 2", "Reply 3"}, - Error: "", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded SmartReplyResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.Success != resp.Success { - t.Errorf("expected Success %v, got %v", resp.Success, decoded.Success) - } - - if len(decoded.Replies) != len(resp.Replies) { - t.Errorf("expected %d replies, got %d", len(resp.Replies), len(decoded.Replies)) - } -} - -func TestParseRepliesFromText(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - expected int - }{ - { - name: "numbered list", - input: "1. Thanks for your email!\n2. I'll get back to you soon.\n3. Let me check on that.", - expected: 3, - }, - { - name: "plain lines", - input: "Thanks for reaching out\nI appreciate your message\nLooking forward to hearing from you", - expected: 3, - }, - { - name: "short lines ignored", - input: "Hi\nOk\nThanks for your detailed message about the project.", - expected: 1, - }, - { - name: "empty input", - input: "", - expected: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parseRepliesFromText(tt.input) - if len(result) != tt.expected { - t.Errorf("expected %d replies, got %d: %v", tt.expected, len(result), result) - } - }) - } -} diff --git a/internal/air/handlers_ai_features_advanced_test.go b/internal/air/handlers_ai_features_advanced_test.go deleted file mode 100644 index 3d28aa7..0000000 --- a/internal/air/handlers_ai_features_advanced_test.go +++ /dev/null @@ -1,355 +0,0 @@ -//go:build integration - -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestAutoLabelResponse_ErrorOmitsEmpty(t *testing.T) { - t.Parallel() - - resp := AutoLabelResponse{ - Success: true, - Labels: []string{"inbox"}, - Category: "fyi", - Priority: "normal", - Error: "", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - // Error field should be omitted when empty - if bytes.Contains(data, []byte(`"error"`)) { - t.Error("expected error field to be omitted when empty") - } -} - -func TestAutoLabelResponse_ValidPriorities(t *testing.T) { - t.Parallel() - - validPriorities := []string{"high", "normal", "low"} - - for _, priority := range validPriorities { - resp := AutoLabelResponse{ - Success: true, - Priority: priority, - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal with priority %s: %v", priority, err) - } - - var decoded AutoLabelResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal with priority %s: %v", priority, err) - } - - if decoded.Priority != priority { - t.Errorf("expected Priority %s, got %s", priority, decoded.Priority) - } - } -} - -func TestAutoLabelResponse_ValidCategories(t *testing.T) { - t.Parallel() - - validCategories := []string{"meeting", "task", "fyi", "question", "social", "newsletter", "promotion", "urgent", "personal", "work"} - - for _, category := range validCategories { - resp := AutoLabelResponse{ - Success: true, - Category: category, - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal with category %s: %v", category, err) - } - - var decoded AutoLabelResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal with category %s: %v", category, err) - } - - if decoded.Category != category { - t.Errorf("expected Category %s, got %s", category, decoded.Category) - } - } -} - -// ================================ -// THREAD SUMMARY HANDLER TESTS -// ================================ - -func TestHandleAIThreadSummary_MethodNotAllowed(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/ai/thread-summary", nil) - w := httptest.NewRecorder() - - server.handleAIThreadSummary(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleAIThreadSummary_EmptyBody(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPost, "/api/ai/thread-summary", nil) - w := httptest.NewRecorder() - - server.handleAIThreadSummary(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp ThreadSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } - - if resp.Error != "Invalid request body" { - t.Errorf("expected error 'Invalid request body', got '%s'", resp.Error) - } -} - -func TestHandleAIThreadSummary_NoMessages(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - reqBody := ThreadSummaryRequest{ - ThreadID: "thread-123", - Messages: []ThreadMessage{}, // Empty messages - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/thread-summary", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIThreadSummary(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp ThreadSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } - - if resp.Error != "At least one message is required" { - t.Errorf("expected error 'At least one message is required', got '%s'", resp.Error) - } -} - -func TestThreadSummaryRequest_JSONMarshaling(t *testing.T) { - t.Parallel() - - req := ThreadSummaryRequest{ - ThreadID: "thread-123", - Messages: []ThreadMessage{ - { - From: "alice@example.com", - Subject: "Project Update", - Body: "Here's the update on the project.", - Date: 1703980800, - }, - { - From: "bob@example.com", - Subject: "Re: Project Update", - Body: "Thanks for the update!", - Date: 1703984400, - }, - }, - } - - data, err := json.Marshal(req) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded ThreadSummaryRequest - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.ThreadID != req.ThreadID { - t.Errorf("expected ThreadID %s, got %s", req.ThreadID, decoded.ThreadID) - } - - if len(decoded.Messages) != len(req.Messages) { - t.Errorf("expected %d messages, got %d", len(req.Messages), len(decoded.Messages)) - } - - if decoded.Messages[0].From != req.Messages[0].From { - t.Errorf("expected first message From %s, got %s", req.Messages[0].From, decoded.Messages[0].From) - } -} - -func TestThreadMessage_JSONMarshaling(t *testing.T) { - t.Parallel() - - msg := ThreadMessage{ - From: "sender@example.com", - Subject: "Test Subject", - Body: "This is the message body", - Date: 1703980800, - } - - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded ThreadMessage - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.From != msg.From { - t.Errorf("expected From %s, got %s", msg.From, decoded.From) - } - - if decoded.Subject != msg.Subject { - t.Errorf("expected Subject %s, got %s", msg.Subject, decoded.Subject) - } - - if decoded.Body != msg.Body { - t.Errorf("expected Body %s, got %s", msg.Body, decoded.Body) - } - - if decoded.Date != msg.Date { - t.Errorf("expected Date %d, got %d", msg.Date, decoded.Date) - } -} - -func TestThreadSummaryResponse_JSONMarshaling(t *testing.T) { - t.Parallel() - - resp := ThreadSummaryResponse{ - Success: true, - Summary: "Discussion about project timeline and deliverables.", - KeyPoints: []string{"Deadline is Friday", "Budget approved", "Team assigned"}, - ActionItems: []string{"Send report by EOD", "Schedule follow-up meeting"}, - Participants: []string{"alice@example.com", "bob@example.com", "charlie@example.com"}, - Timeline: "Started Monday, updates shared Wednesday, decision made Friday", - NextSteps: "Await final approval from stakeholders", - MessageCount: 5, - Error: "", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded ThreadSummaryResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.Success != resp.Success { - t.Errorf("expected Success %v, got %v", resp.Success, decoded.Success) - } - - if decoded.Summary != resp.Summary { - t.Errorf("expected Summary %s, got %s", resp.Summary, decoded.Summary) - } - - if len(decoded.KeyPoints) != len(resp.KeyPoints) { - t.Errorf("expected %d key points, got %d", len(resp.KeyPoints), len(decoded.KeyPoints)) - } - - if len(decoded.ActionItems) != len(resp.ActionItems) { - t.Errorf("expected %d action items, got %d", len(resp.ActionItems), len(decoded.ActionItems)) - } - - if len(decoded.Participants) != len(resp.Participants) { - t.Errorf("expected %d participants, got %d", len(resp.Participants), len(decoded.Participants)) - } - - if decoded.Timeline != resp.Timeline { - t.Errorf("expected Timeline %s, got %s", resp.Timeline, decoded.Timeline) - } - - if decoded.NextSteps != resp.NextSteps { - t.Errorf("expected NextSteps %s, got %s", resp.NextSteps, decoded.NextSteps) - } - - if decoded.MessageCount != resp.MessageCount { - t.Errorf("expected MessageCount %d, got %d", resp.MessageCount, decoded.MessageCount) - } -} - -func TestThreadSummaryResponse_ErrorOmitsEmpty(t *testing.T) { - t.Parallel() - - resp := ThreadSummaryResponse{ - Success: true, - Summary: "Summary", - KeyPoints: []string{}, - ActionItems: []string{}, - Participants: []string{}, - MessageCount: 1, - Error: "", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - // Error field should be omitted when empty - if bytes.Contains(data, []byte(`"error"`)) { - t.Error("expected error field to be omitted when empty") - } -} - -func TestThreadSummaryResponse_NextStepsOmitsEmpty(t *testing.T) { - t.Parallel() - - resp := ThreadSummaryResponse{ - Success: true, - Summary: "Summary", - KeyPoints: []string{}, - ActionItems: []string{}, - Participants: []string{}, - NextSteps: "", - MessageCount: 1, - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - // NextSteps field should be omitted when empty - if bytes.Contains(data, []byte(`"next_steps"`)) { - t.Error("expected next_steps field to be omitted when empty") - } -} diff --git a/internal/air/handlers_ai_features_basic_test.go b/internal/air/handlers_ai_features_basic_test.go deleted file mode 100644 index 3ae6a18..0000000 --- a/internal/air/handlers_ai_features_basic_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -// ================================ -// ENHANCED SUMMARY HANDLER TESTS -// ================================ - -func TestHandleAIEnhancedSummary_MethodNotAllowed(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/ai/enhanced-summary", nil) - w := httptest.NewRecorder() - - server.handleAIEnhancedSummary(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleAIEnhancedSummary_EmptyBody(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPost, "/api/ai/enhanced-summary", nil) - w := httptest.NewRecorder() - - server.handleAIEnhancedSummary(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp EnhancedSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } -} - -func TestHandleAIEnhancedSummary_MissingEmailBody(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - reqBody := EnhancedSummaryRequest{ - EmailID: "test-email-id", - Subject: "Test Subject", - From: "sender@example.com", - Body: "", // Empty body - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/enhanced-summary", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIEnhancedSummary(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp EnhancedSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } - - if resp.Error != "Email body is required" { - t.Errorf("expected error 'Email body is required', got '%s'", resp.Error) - } -} - -func TestEnhancedSummaryRequest_JSONMarshaling(t *testing.T) { - t.Parallel() - - req := EnhancedSummaryRequest{ - EmailID: "email-123", - Subject: "Test Subject", - From: "sender@example.com", - Body: "Hello, this is a test email with some content.", - } - - data, err := json.Marshal(req) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded EnhancedSummaryRequest - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.EmailID != req.EmailID { - t.Errorf("expected EmailID %s, got %s", req.EmailID, decoded.EmailID) - } - - if decoded.Subject != req.Subject { - t.Errorf("expected Subject %s, got %s", req.Subject, decoded.Subject) - } -} - -func TestEnhancedSummaryResponse_JSONMarshaling(t *testing.T) { - t.Parallel() - - resp := EnhancedSummaryResponse{ - Success: true, - Summary: "This is a summary of the email.", - ActionItems: []string{"Review document", "Send follow-up"}, - Sentiment: "positive", - Category: "task", - Error: "", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded EnhancedSummaryResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.Success != resp.Success { - t.Errorf("expected Success %v, got %v", resp.Success, decoded.Success) - } - - if decoded.Summary != resp.Summary { - t.Errorf("expected Summary %s, got %s", resp.Summary, decoded.Summary) - } - - if len(decoded.ActionItems) != len(resp.ActionItems) { - t.Errorf("expected %d action items, got %d", len(resp.ActionItems), len(decoded.ActionItems)) - } - - if decoded.Sentiment != resp.Sentiment { - t.Errorf("expected Sentiment %s, got %s", resp.Sentiment, decoded.Sentiment) - } - - if decoded.Category != resp.Category { - t.Errorf("expected Category %s, got %s", resp.Category, decoded.Category) - } -} - -func TestEnhancedSummaryResponse_ValidSentiments(t *testing.T) { - t.Parallel() - - validSentiments := []string{"positive", "neutral", "negative", "urgent"} - - for _, sentiment := range validSentiments { - resp := EnhancedSummaryResponse{ - Success: true, - Sentiment: sentiment, - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal with sentiment %s: %v", sentiment, err) - } - - var decoded EnhancedSummaryResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal with sentiment %s: %v", sentiment, err) - } - - if decoded.Sentiment != sentiment { - t.Errorf("expected Sentiment %s, got %s", sentiment, decoded.Sentiment) - } - } -} - -func TestEnhancedSummaryResponse_ValidCategories(t *testing.T) { - t.Parallel() - - validCategories := []string{"meeting", "task", "fyi", "question", "social"} - - for _, category := range validCategories { - resp := EnhancedSummaryResponse{ - Success: true, - Category: category, - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal with category %s: %v", category, err) - } - - var decoded EnhancedSummaryResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal with category %s: %v", category, err) - } - - if decoded.Category != category { - t.Errorf("expected Category %s, got %s", category, decoded.Category) - } - } -} - -// ================================ -// AUTO-LABEL HANDLER TESTS -// ================================ - -func TestHandleAIAutoLabel_MethodNotAllowed(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/ai/auto-label", nil) - w := httptest.NewRecorder() - - server.handleAIAutoLabel(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleAIAutoLabel_EmptyBody(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPost, "/api/ai/auto-label", nil) - w := httptest.NewRecorder() - - server.handleAIAutoLabel(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp AutoLabelResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } - - if resp.Error != "Invalid request body" { - t.Errorf("expected error 'Invalid request body', got '%s'", resp.Error) - } -} - -func TestHandleAIAutoLabel_MissingContent(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - reqBody := AutoLabelRequest{ - EmailID: "test-email-id", - Subject: "", // Empty subject - Body: "", // Empty body - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/auto-label", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIAutoLabel(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp AutoLabelResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected Success to be false") - } - - if resp.Error != "Email subject or body is required" { - t.Errorf("expected error 'Email subject or body is required', got '%s'", resp.Error) - } -} - -func TestAutoLabelRequest_JSONMarshaling(t *testing.T) { - t.Parallel() - - req := AutoLabelRequest{ - EmailID: "email-123", - Subject: "Meeting Tomorrow", - From: "boss@company.com", - Body: "Let's discuss the project status", - } - - data, err := json.Marshal(req) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded AutoLabelRequest - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.EmailID != req.EmailID { - t.Errorf("expected EmailID %s, got %s", req.EmailID, decoded.EmailID) - } - - if decoded.Subject != req.Subject { - t.Errorf("expected Subject %s, got %s", req.Subject, decoded.Subject) - } - - if decoded.From != req.From { - t.Errorf("expected From %s, got %s", req.From, decoded.From) - } - - if decoded.Body != req.Body { - t.Errorf("expected Body %s, got %s", req.Body, decoded.Body) - } -} - -func TestAutoLabelResponse_JSONMarshaling(t *testing.T) { - t.Parallel() - - resp := AutoLabelResponse{ - Success: true, - Labels: []string{"work", "project-x", "urgent"}, - Category: "task", - Priority: "high", - Error: "", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded AutoLabelResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.Success != resp.Success { - t.Errorf("expected Success %v, got %v", resp.Success, decoded.Success) - } - - if len(decoded.Labels) != len(resp.Labels) { - t.Errorf("expected %d labels, got %d", len(resp.Labels), len(decoded.Labels)) - } - - if decoded.Category != resp.Category { - t.Errorf("expected Category %s, got %s", resp.Category, decoded.Category) - } - - if decoded.Priority != resp.Priority { - t.Errorf("expected Priority %s, got %s", resp.Priority, decoded.Priority) - } -} diff --git a/internal/air/handlers_ai_smart.go b/internal/air/handlers_ai_smart.go deleted file mode 100644 index 52edfb6..0000000 --- a/internal/air/handlers_ai_smart.go +++ /dev/null @@ -1,225 +0,0 @@ -package air - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" -) - -// handleAISmartReplies handles POST /api/ai/smart-replies requests. -func (s *Server) handleAISmartReplies(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req SmartReplyRequest - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, SmartReplyResponse{ - Success: false, - Error: "Invalid request body", - }) - return - } - - if req.Body == "" { - writeJSON(w, http.StatusBadRequest, SmartReplyResponse{ - Success: false, - Error: "Email body is required", - }) - return - } - - // Truncate body for prompt - body := req.Body - if len(body) > 2000 { - body = body[:2000] + "..." - } - - // Build prompt for smart replies - prompt := fmt.Sprintf(`Generate exactly 3 short, professional email reply suggestions for this email. Each reply should be 1-2 sentences max. Return ONLY a JSON array of 3 strings, nothing else. - -From: %s -Subject: %s - -%s - -Return format: ["Reply 1", "Reply 2", "Reply 3"]`, req.From, req.Subject, body) - - result, err := runClaudeCommand(r.Context(), prompt) - if err != nil { - writeJSON(w, http.StatusInternalServerError, SmartReplyResponse{ - Success: false, - Error: err.Error(), - }) - return - } - - // Parse the JSON array from the result - var replies []string - // Try to extract JSON array from response - start := strings.Index(result, "[") - end := strings.LastIndex(result, "]") - if start != -1 && end != -1 && end > start { - jsonStr := result[start : end+1] - if err := json.Unmarshal([]byte(jsonStr), &replies); err != nil { - // Fallback: split by newlines if JSON parsing fails - replies = parseRepliesFromText(result) - } - } else { - replies = parseRepliesFromText(result) - } - - // Ensure we have exactly 3 replies - for len(replies) < 3 { - replies = append(replies, "Thanks for your email!") - } - if len(replies) > 3 { - replies = replies[:3] - } - - writeJSON(w, http.StatusOK, SmartReplyResponse{ - Success: true, - Replies: replies, - }) -} - -// parseRepliesFromText extracts reply suggestions from plain text. -func parseRepliesFromText(text string) []string { - var replies []string - lines := strings.Split(text, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - // Remove numbering like "1.", "2.", etc. - if len(line) > 2 && line[0] >= '1' && line[0] <= '9' && line[1] == '.' { - line = strings.TrimSpace(line[2:]) - } - // Remove quotes - line = strings.Trim(line, `"'`) - if len(line) > 10 && len(line) < 200 { - replies = append(replies, line) - } - } - return replies -} - -// handleAIAutoLabel handles POST /api/ai/auto-label requests. -func (s *Server) handleAIAutoLabel(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req AutoLabelRequest - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, AutoLabelResponse{ - Success: false, - Error: "Invalid request body", - }) - return - } - - if req.Body == "" && req.Subject == "" { - writeJSON(w, http.StatusBadRequest, AutoLabelResponse{ - Success: false, - Error: "Email subject or body is required", - }) - return - } - - // Truncate body for prompt - body := req.Body - if len(body) > 2000 { - body = body[:2000] + "..." - } - - // Build prompt for auto-labeling - prompt := fmt.Sprintf(`Analyze this email and suggest appropriate labels. Return ONLY valid JSON. - -From: %s -Subject: %s - -%s - -Return format: -{ - "labels": ["label1", "label2"], - "category": "one of: meeting|task|fyi|question|social|newsletter|promotion|urgent|personal|work", - "priority": "one of: high|normal|low" -} - -Rules: -- labels: 1-4 relevant labels (e.g., "finance", "project-x", "team", "client") -- category: Choose the PRIMARY purpose -- priority: Based on urgency and sender importance`, req.From, req.Subject, body) - - result, err := runClaudeCommand(r.Context(), prompt) - if err != nil { - writeJSON(w, http.StatusInternalServerError, AutoLabelResponse{ - Success: false, - Error: err.Error(), - }) - return - } - - // Parse JSON response - var parsed struct { - Labels []string `json:"labels"` - Category string `json:"category"` - Priority string `json:"priority"` - } - - // Try to extract JSON from response - start := strings.Index(result, "{") - end := strings.LastIndex(result, "}") - if start != -1 && end != -1 && end > start { - jsonStr := result[start : end+1] - if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { - // Fallback to defaults - writeJSON(w, http.StatusOK, AutoLabelResponse{ - Success: true, - Labels: []string{"inbox"}, - Category: "fyi", - Priority: "normal", - }) - return - } - } else { - writeJSON(w, http.StatusOK, AutoLabelResponse{ - Success: true, - Labels: []string{"inbox"}, - Category: "fyi", - Priority: "normal", - }) - return - } - - // Validate category - validCategories := map[string]bool{ - "meeting": true, "task": true, "fyi": true, "question": true, - "social": true, "newsletter": true, "promotion": true, - "urgent": true, "personal": true, "work": true, - } - if !validCategories[parsed.Category] { - parsed.Category = "fyi" - } - - // Validate priority - validPriorities := map[string]bool{"high": true, "normal": true, "low": true} - if !validPriorities[parsed.Priority] { - parsed.Priority = "normal" - } - - // Ensure at least one label - if len(parsed.Labels) == 0 { - parsed.Labels = []string{"inbox"} - } - - writeJSON(w, http.StatusOK, AutoLabelResponse{ - Success: true, - Labels: parsed.Labels, - Category: parsed.Category, - Priority: parsed.Priority, - }) -} diff --git a/internal/air/handlers_ai_summarize.go b/internal/air/handlers_ai_summarize.go deleted file mode 100644 index c56e582..0000000 --- a/internal/air/handlers_ai_summarize.go +++ /dev/null @@ -1,165 +0,0 @@ -package air - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" -) - -// handleAISummarize handles POST /api/ai/summarize requests. -func (s *Server) handleAISummarize(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req AIRequest - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, AIResponse{ - Success: false, - Error: "Invalid request body", - }) - return - } - - if req.Prompt == "" { - writeJSON(w, http.StatusBadRequest, AIResponse{ - Success: false, - Error: "Prompt is required", - }) - return - } - - // Run claude -p with the prompt - summary, err := runClaudeCommand(r.Context(), req.Prompt) - if err != nil { - writeJSON(w, http.StatusInternalServerError, AIResponse{ - Success: false, - Error: err.Error(), - }) - return - } - - writeJSON(w, http.StatusOK, AIResponse{ - Success: true, - Summary: summary, - }) -} - -// handleAIEnhancedSummary handles POST /api/ai/enhanced-summary requests. -func (s *Server) handleAIEnhancedSummary(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req EnhancedSummaryRequest - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, EnhancedSummaryResponse{ - Success: false, - Error: "Invalid request body", - }) - return - } - - if req.Body == "" { - writeJSON(w, http.StatusBadRequest, EnhancedSummaryResponse{ - Success: false, - Error: "Email body is required", - }) - return - } - - // Truncate body for prompt - body := req.Body - if len(body) > 3000 { - body = body[:3000] + "..." - } - - // Build prompt for enhanced summary - prompt := fmt.Sprintf(`Analyze this email and provide a structured response in JSON format. - -From: %s -Subject: %s - -%s - -Return ONLY valid JSON in this exact format: -{ - "summary": "2-3 sentence summary of the email", - "action_items": ["action 1", "action 2"], - "sentiment": "positive|neutral|negative|urgent", - "category": "meeting|task|fyi|question|social" -} - -Rules: -- action_items: List specific tasks or requests. Empty array if none. -- sentiment: Choose ONE based on tone and urgency -- category: Choose the PRIMARY purpose of the email`, req.From, req.Subject, body) - - result, err := runClaudeCommand(r.Context(), prompt) - if err != nil { - writeJSON(w, http.StatusInternalServerError, EnhancedSummaryResponse{ - Success: false, - Error: err.Error(), - }) - return - } - - // Parse JSON response - var parsed struct { - Summary string `json:"summary"` - ActionItems []string `json:"action_items"` - Sentiment string `json:"sentiment"` - Category string `json:"category"` - } - - // Try to extract JSON from response - start := strings.Index(result, "{") - end := strings.LastIndex(result, "}") - if start != -1 && end != -1 && end > start { - jsonStr := result[start : end+1] - if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { - // Fallback to basic summary - writeJSON(w, http.StatusOK, EnhancedSummaryResponse{ - Success: true, - Summary: result, - ActionItems: []string{}, - Sentiment: "neutral", - Category: "fyi", - }) - return - } - } else { - // No JSON found, use raw result as summary - writeJSON(w, http.StatusOK, EnhancedSummaryResponse{ - Success: true, - Summary: result, - ActionItems: []string{}, - Sentiment: "neutral", - Category: "fyi", - }) - return - } - - // Validate sentiment - validSentiments := map[string]bool{"positive": true, "neutral": true, "negative": true, "urgent": true} - if !validSentiments[parsed.Sentiment] { - parsed.Sentiment = "neutral" - } - - // Validate category - validCategories := map[string]bool{"meeting": true, "task": true, "fyi": true, "question": true, "social": true} - if !validCategories[parsed.Category] { - parsed.Category = "fyi" - } - - writeJSON(w, http.StatusOK, EnhancedSummaryResponse{ - Success: true, - Summary: parsed.Summary, - ActionItems: parsed.ActionItems, - Sentiment: parsed.Sentiment, - Category: parsed.Category, - }) -} diff --git a/internal/air/handlers_ai_thread.go b/internal/air/handlers_ai_thread.go deleted file mode 100644 index 2ddb52f..0000000 --- a/internal/air/handlers_ai_thread.go +++ /dev/null @@ -1,199 +0,0 @@ -package air - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "os/exec" - "strings" - "time" -) - -// handleAIThreadSummary handles POST /api/ai/thread-summary requests. -func (s *Server) handleAIThreadSummary(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req ThreadSummaryRequest - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, ThreadSummaryResponse{ - Success: false, - Error: "Invalid request body", - }) - return - } - - if len(req.Messages) == 0 { - writeJSON(w, http.StatusBadRequest, ThreadSummaryResponse{ - Success: false, - Error: "At least one message is required", - }) - return - } - - // Build conversation text from messages - var conversationBuilder strings.Builder - participants := make(map[string]bool) - - for i, msg := range req.Messages { - // Track participants - if msg.From != "" { - participants[msg.From] = true - } - - // Truncate individual message bodies - body := msg.Body - if len(body) > 1000 { - body = body[:1000] + "..." - } - - _, _ = fmt.Fprintf(&conversationBuilder, "--- Message %d ---\n", i+1) - _, _ = fmt.Fprintf(&conversationBuilder, "From: %s\n", msg.From) - if msg.Subject != "" { - _, _ = fmt.Fprintf(&conversationBuilder, "Subject: %s\n", msg.Subject) - } - conversationBuilder.WriteString(body) - conversationBuilder.WriteString("\n\n") - - // Limit total conversation length - if conversationBuilder.Len() > 6000 { - conversationBuilder.WriteString("... (additional messages truncated)") - break - } - } - - // Get participant list - participantList := make([]string, 0, len(participants)) - for p := range participants { - participantList = append(participantList, p) - } - - // Build prompt for thread summary - prompt := fmt.Sprintf(`Summarize this email thread conversation. Return ONLY valid JSON. - -%s - -Return format: -{ - "summary": "2-4 sentence overall summary of the thread", - "key_points": ["point 1", "point 2", "point 3"], - "action_items": ["action 1", "action 2"], - "timeline": "Brief timeline description (e.g., 'Started Monday with request, followed up Wednesday, resolved Friday')", - "next_steps": "What needs to happen next, if anything" -} - -Rules: -- summary: Capture the main topic and outcome of the conversation -- key_points: 2-5 most important points discussed -- action_items: Specific tasks mentioned (empty array if none) -- timeline: Brief description of how the conversation evolved -- next_steps: Clear next action if any, or empty string`, conversationBuilder.String()) - - result, err := runClaudeCommand(r.Context(), prompt) - if err != nil { - writeJSON(w, http.StatusInternalServerError, ThreadSummaryResponse{ - Success: false, - Error: err.Error(), - }) - return - } - - // Parse JSON response - var parsed struct { - Summary string `json:"summary"` - KeyPoints []string `json:"key_points"` - ActionItems []string `json:"action_items"` - Timeline string `json:"timeline"` - NextSteps string `json:"next_steps"` - } - - // Try to extract JSON from response - start := strings.Index(result, "{") - end := strings.LastIndex(result, "}") - if start != -1 && end != -1 && end > start { - jsonStr := result[start : end+1] - if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil { - // Fallback to raw result - writeJSON(w, http.StatusOK, ThreadSummaryResponse{ - Success: true, - Summary: result, - KeyPoints: []string{}, - ActionItems: []string{}, - Participants: participantList, - Timeline: "", - MessageCount: len(req.Messages), - }) - return - } - } else { - writeJSON(w, http.StatusOK, ThreadSummaryResponse{ - Success: true, - Summary: result, - KeyPoints: []string{}, - ActionItems: []string{}, - Participants: participantList, - Timeline: "", - MessageCount: len(req.Messages), - }) - return - } - - writeJSON(w, http.StatusOK, ThreadSummaryResponse{ - Success: true, - Summary: parsed.Summary, - KeyPoints: parsed.KeyPoints, - ActionItems: parsed.ActionItems, - Participants: participantList, - Timeline: parsed.Timeline, - NextSteps: parsed.NextSteps, - MessageCount: len(req.Messages), - }) -} - -// runClaudeCommand runs the claude CLI with the given prompt. -// -// The provided ctx is honored: cancelling it (e.g. when the HTTP request is -// aborted by the client) terminates the subprocess. A 30-second timeout is -// also applied on top of the caller's context so a runaway claude process -// can't block forever. -func runClaudeCommand(ctx context.Context, prompt string) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - // Find claude binary - claudePath, err := exec.LookPath("claude") - if err != nil { - return "", fmt.Errorf("claude code CLI not found: please install it from https://claude.ai/code") - } - - // Create command: echo "prompt" | claude -p - // #nosec G204 -- claudePath verified via exec.LookPath from system PATH, user prompt only in stdin (not in command path) - cmd := exec.CommandContext(ctx, claudePath, "-p") - cmd.Stdin = strings.NewReader(prompt) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err = cmd.Run() - if err != nil { - // Distinguish caller cancellation from our local 30s timeout. - switch ctx.Err() { - case context.DeadlineExceeded: - return "", fmt.Errorf("claude code timed out after 30 seconds") - case context.Canceled: - return "", ctx.Err() - } - // Return stderr if available - if stderr.Len() > 0 { - return "", fmt.Errorf("claude code error: %s", stderr.String()) - } - return "", fmt.Errorf("claude code error: %w", err) - } - - return strings.TrimSpace(stdout.String()), nil -} diff --git a/internal/air/handlers_ai_types.go b/internal/air/handlers_ai_types.go deleted file mode 100644 index b9f91a9..0000000 --- a/internal/air/handlers_ai_types.go +++ /dev/null @@ -1,92 +0,0 @@ -package air - -// AIRequest represents an AI summarization request. -type AIRequest struct { - EmailID string `json:"email_id"` - Prompt string `json:"prompt"` -} - -// AIResponse represents the AI response. -type AIResponse struct { - Success bool `json:"success"` - Summary string `json:"summary"` - Error string `json:"error,omitempty"` -} - -// SmartReplyRequest represents a request for smart reply suggestions. -type SmartReplyRequest struct { - EmailID string `json:"email_id"` - Subject string `json:"subject"` - From string `json:"from"` - Body string `json:"body"` - ReplyType string `json:"reply_type"` // "reply" or "reply_all" -} - -// SmartReplyResponse represents the smart reply suggestions. -type SmartReplyResponse struct { - Success bool `json:"success"` - Replies []string `json:"replies"` - Error string `json:"error,omitempty"` -} - -// EnhancedSummaryRequest represents an enhanced summary request. -type EnhancedSummaryRequest struct { - EmailID string `json:"email_id"` - Subject string `json:"subject"` - From string `json:"from"` - Body string `json:"body"` -} - -// EnhancedSummaryResponse represents the enhanced summary with action items and sentiment. -type EnhancedSummaryResponse struct { - Success bool `json:"success"` - Summary string `json:"summary"` - ActionItems []string `json:"action_items"` - Sentiment string `json:"sentiment"` // "positive", "neutral", "negative", "urgent" - Category string `json:"category"` // "meeting", "task", "fyi", "question", "social" - Error string `json:"error,omitempty"` -} - -// AutoLabelRequest represents a request to auto-label an email. -type AutoLabelRequest struct { - EmailID string `json:"email_id"` - Subject string `json:"subject"` - From string `json:"from"` - Body string `json:"body"` -} - -// AutoLabelResponse represents the auto-label response. -type AutoLabelResponse struct { - Success bool `json:"success"` - Labels []string `json:"labels"` - Category string `json:"category"` // Primary category - Priority string `json:"priority"` // "high", "normal", "low" - Error string `json:"error,omitempty"` -} - -// ThreadSummaryRequest represents a request to summarize a thread. -type ThreadSummaryRequest struct { - ThreadID string `json:"thread_id"` - Messages []ThreadMessage `json:"messages"` -} - -// ThreadMessage represents a message in a thread for summarization. -type ThreadMessage struct { - From string `json:"from"` - Subject string `json:"subject"` - Body string `json:"body"` - Date int64 `json:"date"` -} - -// ThreadSummaryResponse represents the thread summary response. -type ThreadSummaryResponse struct { - Success bool `json:"success"` - Summary string `json:"summary"` - KeyPoints []string `json:"key_points"` - ActionItems []string `json:"action_items"` - Participants []string `json:"participants"` - Timeline string `json:"timeline"` // Brief timeline of the conversation - NextSteps string `json:"next_steps,omitempty"` - MessageCount int `json:"message_count"` - Error string `json:"error,omitempty"` -} diff --git a/internal/air/handlers_analytics.go b/internal/air/handlers_analytics.go deleted file mode 100644 index a91f6dd..0000000 --- a/internal/air/handlers_analytics.go +++ /dev/null @@ -1,240 +0,0 @@ -package air - -import ( - "net/http" - "sync" - "time" - - "github.com/nylas/cli/internal/httputil" -) - -// EmailAnalytics represents email analytics data -type EmailAnalytics struct { - // Volume metrics - TotalReceived int `json:"totalReceived"` - TotalSent int `json:"totalSent"` - TotalArchived int `json:"totalArchived"` - TotalDeleted int `json:"totalDeleted"` - - // Response metrics - AvgResponseTime float64 `json:"avgResponseTimeHours"` - ResponseRate float64 `json:"responseRate"` // percentage - - // Top senders/recipients - TopSenders []SenderStats `json:"topSenders"` - TopRecipients []SenderStats `json:"topRecipients"` - - // Time-based metrics - BusiestHour int `json:"busiestHour"` // 0-23 - BusiestDay string `json:"busiestDay"` // Monday, Tuesday, etc. - HourlyVolume map[int]int `json:"hourlyVolume"` - DailyVolume map[string]int `json:"dailyVolume"` - WeeklyTrend []DayVolume `json:"weeklyTrend"` - - // Productivity metrics - InboxZeroCount int `json:"inboxZeroCount"` // Times achieved inbox zero - CurrentStreak int `json:"currentStreak"` // Consecutive inbox zero days - BestStreak int `json:"bestStreak"` - FocusTimeHours float64 `json:"focusTimeHours"` - - // Period info - PeriodStart time.Time `json:"periodStart"` - PeriodEnd time.Time `json:"periodEnd"` -} - -// SenderStats represents stats for a sender/recipient -type SenderStats struct { - Email string `json:"email"` - Name string `json:"name,omitempty"` - Count int `json:"count"` - AvgReply float64 `json:"avgReplyTimeHours,omitempty"` -} - -// DayVolume represents volume for a specific day -type DayVolume struct { - Date string `json:"date"` - Received int `json:"received"` - Sent int `json:"sent"` -} - -// FocusTimeSuggestion represents a suggested focus time block -type FocusTimeSuggestion struct { - StartHour int `json:"startHour"` - EndHour int `json:"endHour"` - Day string `json:"day"` - Reason string `json:"reason"` - Score int `json:"score"` // 1-100, higher is better -} - -// analyticsStore holds analytics data -type analyticsStore struct { - analytics *EmailAnalytics - mu sync.RWMutex -} - -var aStore = &analyticsStore{ - analytics: &EmailAnalytics{ - TotalReceived: 1250, - TotalSent: 320, - TotalArchived: 890, - TotalDeleted: 210, - AvgResponseTime: 2.5, - ResponseRate: 78.5, - TopSenders: []SenderStats{ - {Email: "team@company.com", Name: "Team Updates", Count: 145}, - {Email: "github@notifications.github.com", Name: "GitHub", Count: 98}, - {Email: "calendar@google.com", Name: "Google Calendar", Count: 67}, - }, - TopRecipients: []SenderStats{ - {Email: "boss@company.com", Name: "Manager", Count: 42, AvgReply: 1.2}, - {Email: "team@company.com", Name: "Team", Count: 38, AvgReply: 3.5}, - }, - BusiestHour: 10, - BusiestDay: "Tuesday", - HourlyVolume: map[int]int{ - 8: 15, 9: 45, 10: 78, 11: 65, 12: 32, - 13: 28, 14: 55, 15: 48, 16: 42, 17: 25, - }, - DailyVolume: map[string]int{ - "Monday": 180, "Tuesday": 220, "Wednesday": 195, - "Thursday": 175, "Friday": 150, "Saturday": 25, "Sunday": 15, - }, - WeeklyTrend: []DayVolume{ - {Date: "2024-12-22", Received: 45, Sent: 12}, - {Date: "2024-12-23", Received: 62, Sent: 18}, - {Date: "2024-12-24", Received: 38, Sent: 8}, - {Date: "2024-12-25", Received: 12, Sent: 2}, - {Date: "2024-12-26", Received: 55, Sent: 15}, - {Date: "2024-12-27", Received: 48, Sent: 14}, - {Date: "2024-12-28", Received: 35, Sent: 10}, - }, - InboxZeroCount: 15, - CurrentStreak: 3, - BestStreak: 7, - FocusTimeHours: 12.5, - PeriodStart: time.Now().AddDate(0, 0, -30), - PeriodEnd: time.Now(), - }, -} - -// handleGetAnalyticsDashboard returns the full analytics dashboard -func (s *Server) handleGetAnalyticsDashboard(w http.ResponseWriter, r *http.Request) { - aStore.mu.RLock() - defer aStore.mu.RUnlock() - - httputil.WriteJSON(w, http.StatusOK, aStore.analytics) -} - -// handleGetAnalyticsTrends returns email trends -func (s *Server) handleGetAnalyticsTrends(w http.ResponseWriter, r *http.Request) { - period := r.URL.Query().Get("period") // "week", "month", "quarter" - if period == "" { - period = "week" - } - - aStore.mu.RLock() - defer aStore.mu.RUnlock() - - response := map[string]any{ - "period": period, - "weeklyTrend": aStore.analytics.WeeklyTrend, - "hourlyVolume": aStore.analytics.HourlyVolume, - "dailyVolume": aStore.analytics.DailyVolume, - } - - httputil.WriteJSON(w, http.StatusOK, response) -} - -// handleGetFocusTimeSuggestions returns suggested focus time blocks -func (s *Server) handleGetFocusTimeSuggestions(w http.ResponseWriter, r *http.Request) { - aStore.mu.RLock() - hourlyVolume := aStore.analytics.HourlyVolume - aStore.mu.RUnlock() - - // Find low-volume hours for focus time - suggestions := make([]FocusTimeSuggestion, 0) - - // Morning focus block - morningVolume := 0 - for h := 6; h < 9; h++ { - morningVolume += hourlyVolume[h] - } - if morningVolume < 30 { - suggestions = append(suggestions, FocusTimeSuggestion{ - StartHour: 6, - EndHour: 9, - Day: "Weekdays", - Reason: "Low email volume in early morning", - Score: 85, - }) - } - - // Afternoon focus block - afternoonVolume := 0 - for h := 13; h < 15; h++ { - afternoonVolume += hourlyVolume[h] - } - if afternoonVolume < 60 { - suggestions = append(suggestions, FocusTimeSuggestion{ - StartHour: 13, - EndHour: 15, - Day: "Weekdays", - Reason: "Post-lunch lull in email activity", - Score: 72, - }) - } - - // Evening focus block - suggestions = append(suggestions, FocusTimeSuggestion{ - StartHour: 18, - EndHour: 20, - Day: "Weekdays", - Reason: "Most colleagues offline", - Score: 90, - }) - - httputil.WriteJSON(w, http.StatusOK, suggestions) -} - -// handleGetProductivityStats returns productivity metrics -func (s *Server) handleGetProductivityStats(w http.ResponseWriter, r *http.Request) { - aStore.mu.RLock() - defer aStore.mu.RUnlock() - - response := map[string]any{ - "responseRate": aStore.analytics.ResponseRate, - "avgResponseTime": aStore.analytics.AvgResponseTime, - "inboxZeroCount": aStore.analytics.InboxZeroCount, - "currentStreak": aStore.analytics.CurrentStreak, - "bestStreak": aStore.analytics.BestStreak, - "focusTimeHours": aStore.analytics.FocusTimeHours, - "emailsProcessed": aStore.analytics.TotalArchived + aStore.analytics.TotalDeleted, - } - - httputil.WriteJSON(w, http.StatusOK, response) -} - -// RecordEmailReceived records a received email -func RecordEmailReceived() { - aStore.mu.Lock() - defer aStore.mu.Unlock() - aStore.analytics.TotalReceived++ -} - -// RecordEmailSent records a sent email -func RecordEmailSent() { - aStore.mu.Lock() - defer aStore.mu.Unlock() - aStore.analytics.TotalSent++ -} - -// RecordInboxZero records achieving inbox zero -func RecordInboxZero() { - aStore.mu.Lock() - defer aStore.mu.Unlock() - aStore.analytics.InboxZeroCount++ - aStore.analytics.CurrentStreak++ - if aStore.analytics.CurrentStreak > aStore.analytics.BestStreak { - aStore.analytics.BestStreak = aStore.analytics.CurrentStreak - } -} diff --git a/internal/air/handlers_analytics_test.go b/internal/air/handlers_analytics_test.go deleted file mode 100644 index 953a151..0000000 --- a/internal/air/handlers_analytics_test.go +++ /dev/null @@ -1,343 +0,0 @@ -//go:build !integration - -package air - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRecordEmailReceived(t *testing.T) { - // Get initial value - aStore.mu.RLock() - initial := aStore.analytics.TotalReceived - aStore.mu.RUnlock() - - // Record received email - RecordEmailReceived() - - // Check increment - aStore.mu.RLock() - after := aStore.analytics.TotalReceived - aStore.mu.RUnlock() - - assert.Equal(t, initial+1, after, "TotalReceived should increment by 1") -} - -func TestRecordEmailSent(t *testing.T) { - // Get initial value - aStore.mu.RLock() - initial := aStore.analytics.TotalSent - aStore.mu.RUnlock() - - // Record sent email - RecordEmailSent() - - // Check increment - aStore.mu.RLock() - after := aStore.analytics.TotalSent - aStore.mu.RUnlock() - - assert.Equal(t, initial+1, after, "TotalSent should increment by 1") -} - -func TestRecordInboxZero(t *testing.T) { - // Reset analytics state for test - aStore.mu.Lock() - originalInboxZeroCount := aStore.analytics.InboxZeroCount - originalCurrentStreak := aStore.analytics.CurrentStreak - originalBestStreak := aStore.analytics.BestStreak - - // Set up known state - aStore.analytics.InboxZeroCount = 5 - aStore.analytics.CurrentStreak = 3 - aStore.analytics.BestStreak = 5 - aStore.mu.Unlock() - - // Record inbox zero - RecordInboxZero() - - // Check increments - aStore.mu.RLock() - inboxZeroCount := aStore.analytics.InboxZeroCount - currentStreak := aStore.analytics.CurrentStreak - bestStreak := aStore.analytics.BestStreak - aStore.mu.RUnlock() - - assert.Equal(t, 6, inboxZeroCount, "InboxZeroCount should increment by 1") - assert.Equal(t, 4, currentStreak, "CurrentStreak should increment by 1") - assert.Equal(t, 5, bestStreak, "BestStreak should not change when current < best") - - // Test when current streak exceeds best streak - aStore.mu.Lock() - aStore.analytics.CurrentStreak = 5 - aStore.analytics.BestStreak = 5 - aStore.mu.Unlock() - - RecordInboxZero() - - aStore.mu.RLock() - newBestStreak := aStore.analytics.BestStreak - aStore.mu.RUnlock() - - assert.Equal(t, 6, newBestStreak, "BestStreak should update when current > best") - - // Restore original values - aStore.mu.Lock() - aStore.analytics.InboxZeroCount = originalInboxZeroCount - aStore.analytics.CurrentStreak = originalCurrentStreak - aStore.analytics.BestStreak = originalBestStreak - aStore.mu.Unlock() -} - -func TestRecordInboxZero_UpdatesBestStreak(t *testing.T) { - // Set up state where current streak will exceed best - aStore.mu.Lock() - originalInboxZeroCount := aStore.analytics.InboxZeroCount - originalCurrentStreak := aStore.analytics.CurrentStreak - originalBestStreak := aStore.analytics.BestStreak - - aStore.analytics.CurrentStreak = 10 - aStore.analytics.BestStreak = 10 - aStore.mu.Unlock() - - // Record inbox zero - RecordInboxZero() - - // Check that best streak is updated - aStore.mu.RLock() - newCurrentStreak := aStore.analytics.CurrentStreak - newBestStreak := aStore.analytics.BestStreak - aStore.mu.RUnlock() - - assert.Equal(t, 11, newCurrentStreak) - assert.Equal(t, 11, newBestStreak) - - // Restore original values - aStore.mu.Lock() - aStore.analytics.InboxZeroCount = originalInboxZeroCount - aStore.analytics.CurrentStreak = originalCurrentStreak - aStore.analytics.BestStreak = originalBestStreak - aStore.mu.Unlock() -} - -func TestHandleGetAnalyticsDashboard(t *testing.T) { - s := &Server{} - - req := httptest.NewRequest(http.MethodGet, "/api/analytics/dashboard", nil) - w := httptest.NewRecorder() - - s.handleGetAnalyticsDashboard(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) - - var analytics EmailAnalytics - err := json.NewDecoder(resp.Body).Decode(&analytics) - require.NoError(t, err) - - // Check that we got valid analytics data - assert.GreaterOrEqual(t, analytics.TotalReceived, 0) - assert.GreaterOrEqual(t, analytics.TotalSent, 0) -} - -func TestHandleGetAnalyticsTrends(t *testing.T) { - s := &Server{} - - tests := []struct { - name string - period string - expectedField string - }{ - { - name: "default period (week)", - period: "", - expectedField: "week", - }, - { - name: "week period", - period: "week", - expectedField: "week", - }, - { - name: "month period", - period: "month", - expectedField: "month", - }, - { - name: "quarter period", - period: "quarter", - expectedField: "quarter", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - url := "/api/analytics/trends" - if tt.period != "" { - url += "?period=" + tt.period - } - - req := httptest.NewRequest(http.MethodGet, url, nil) - w := httptest.NewRecorder() - - s.handleGetAnalyticsTrends(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) - - var result map[string]any - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - assert.Equal(t, tt.expectedField, result["period"]) - assert.NotNil(t, result["weeklyTrend"]) - assert.NotNil(t, result["hourlyVolume"]) - assert.NotNil(t, result["dailyVolume"]) - }) - } -} - -func TestHandleGetFocusTimeSuggestions(t *testing.T) { - s := &Server{} - - req := httptest.NewRequest(http.MethodGet, "/api/analytics/focus-time", nil) - w := httptest.NewRecorder() - - s.handleGetFocusTimeSuggestions(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) - - var suggestions []FocusTimeSuggestion - err := json.NewDecoder(resp.Body).Decode(&suggestions) - require.NoError(t, err) - - // Should have at least the evening suggestion which is always added - assert.NotEmpty(t, suggestions) - - // Check that evening suggestion is present - hasEvening := false - for _, s := range suggestions { - if s.StartHour == 18 && s.EndHour == 20 { - hasEvening = true - assert.Equal(t, "Weekdays", s.Day) - assert.Equal(t, 90, s.Score) - } - } - assert.True(t, hasEvening, "Should have evening focus suggestion") -} - -func TestHandleGetProductivityStats(t *testing.T) { - s := &Server{} - - req := httptest.NewRequest(http.MethodGet, "/api/analytics/productivity", nil) - w := httptest.NewRecorder() - - s.handleGetProductivityStats(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) - - var result map[string]any - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - // Check all expected fields are present - assert.Contains(t, result, "responseRate") - assert.Contains(t, result, "avgResponseTime") - assert.Contains(t, result, "inboxZeroCount") - assert.Contains(t, result, "currentStreak") - assert.Contains(t, result, "bestStreak") - assert.Contains(t, result, "focusTimeHours") - assert.Contains(t, result, "emailsProcessed") -} - -func TestFocusTimeSuggestion_Fields(t *testing.T) { - suggestion := FocusTimeSuggestion{ - StartHour: 6, - EndHour: 9, - Day: "Weekdays", - Reason: "Low email volume", - Score: 85, - } - - assert.Equal(t, 6, suggestion.StartHour) - assert.Equal(t, 9, suggestion.EndHour) - assert.Equal(t, "Weekdays", suggestion.Day) - assert.Equal(t, "Low email volume", suggestion.Reason) - assert.Equal(t, 85, suggestion.Score) -} - -func TestEmailAnalytics_Fields(t *testing.T) { - analytics := EmailAnalytics{ - TotalReceived: 100, - TotalSent: 50, - TotalArchived: 80, - TotalDeleted: 20, - AvgResponseTime: 2.5, - ResponseRate: 75.0, - BusiestHour: 10, - BusiestDay: "Tuesday", - InboxZeroCount: 5, - CurrentStreak: 3, - BestStreak: 7, - FocusTimeHours: 10.5, - } - - assert.Equal(t, 100, analytics.TotalReceived) - assert.Equal(t, 50, analytics.TotalSent) - assert.Equal(t, 80, analytics.TotalArchived) - assert.Equal(t, 20, analytics.TotalDeleted) - assert.Equal(t, 2.5, analytics.AvgResponseTime) - assert.Equal(t, 75.0, analytics.ResponseRate) - assert.Equal(t, 10, analytics.BusiestHour) - assert.Equal(t, "Tuesday", analytics.BusiestDay) - assert.Equal(t, 5, analytics.InboxZeroCount) - assert.Equal(t, 3, analytics.CurrentStreak) - assert.Equal(t, 7, analytics.BestStreak) - assert.Equal(t, 10.5, analytics.FocusTimeHours) -} - -func TestSenderStats_Fields(t *testing.T) { - stats := SenderStats{ - Email: "test@example.com", - Name: "Test User", - Count: 42, - AvgReply: 1.5, - } - - assert.Equal(t, "test@example.com", stats.Email) - assert.Equal(t, "Test User", stats.Name) - assert.Equal(t, 42, stats.Count) - assert.Equal(t, 1.5, stats.AvgReply) -} - -func TestDayVolume_Fields(t *testing.T) { - volume := DayVolume{ - Date: "2024-01-15", - Received: 45, - Sent: 12, - } - - assert.Equal(t, "2024-01-15", volume.Date) - assert.Equal(t, 45, volume.Received) - assert.Equal(t, 12, volume.Sent) -} diff --git a/internal/air/handlers_availability.go b/internal/air/handlers_availability.go deleted file mode 100644 index d014eeb..0000000 --- a/internal/air/handlers_availability.go +++ /dev/null @@ -1,533 +0,0 @@ -package air - -import ( - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/nylas/cli/internal/domain" -) - -// ==================================== -// AVAILABILITY & FIND TIME HANDLERS -// ==================================== - -// parseInt64Param reads an int64 query parameter; an absent param yields zero. -func parseInt64Param(query url.Values, key string) (int64, error) { - raw := query.Get(key) - if raw == "" { - return 0, nil - } - v, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return 0, fmt.Errorf("invalid %s: %q is not a valid integer", key, raw) - } - return v, nil -} - -func parseIntParam(query url.Values, key string) (int, error) { - raw := query.Get(key) - if raw == "" { - return 0, nil - } - v, err := strconv.Atoi(raw) - if err != nil { - return 0, fmt.Errorf("invalid %s: %q is not a valid integer", key, raw) - } - return v, nil -} - -// AvailabilityRequest represents a request to find available times. -type AvailabilityRequest struct { - StartTime int64 `json:"start_time"` - EndTime int64 `json:"end_time"` - DurationMinutes int `json:"duration_minutes"` - Participants []string `json:"participants"` // Email addresses - IntervalMinutes int `json:"interval_minutes,omitempty"` -} - -// AvailabilityResponse represents available meeting slots. -type AvailabilityResponse struct { - Slots []AvailableSlotResponse `json:"slots"` - Message string `json:"message,omitempty"` -} - -// AvailableSlotResponse represents a single available time slot. -type AvailableSlotResponse struct { - StartTime int64 `json:"start_time"` - EndTime int64 `json:"end_time"` - Emails []string `json:"emails,omitempty"` -} - -// FreeBusyRequest represents a request to get free/busy info. -type FreeBusyRequest struct { - StartTime int64 `json:"start_time"` - EndTime int64 `json:"end_time"` - Emails []string `json:"emails"` -} - -// FreeBusyResponse represents free/busy data for participants. -type FreeBusyResponse struct { - Data []FreeBusyCalendarResponse `json:"data"` -} - -// FreeBusyCalendarResponse represents a calendar's busy times. -type FreeBusyCalendarResponse struct { - Email string `json:"email"` - TimeSlots []TimeSlotResponse `json:"time_slots"` -} - -// TimeSlotResponse represents a busy or free time slot. -type TimeSlotResponse struct { - StartTime int64 `json:"start_time"` - EndTime int64 `json:"end_time"` - Status string `json:"status"` // busy, free -} - -// ConflictsResponse represents conflicting events. -type ConflictsResponse struct { - Conflicts []EventConflict `json:"conflicts"` - HasMore bool `json:"has_more"` -} - -// EventConflict represents a scheduling conflict. -type EventConflict struct { - Event1 EventResponse `json:"event1"` - Event2 EventResponse `json:"event2"` -} - -// handleAvailability finds available meeting times. -func (s *Server) handleAvailability(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "Method not allowed") - return - } - - // Demo mode: return mock availability - if s.demoMode { - now := time.Now() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - writeJSON(w, http.StatusOK, AvailabilityResponse{ - Slots: []AvailableSlotResponse{ - {StartTime: today.Add(10 * time.Hour).Unix(), EndTime: today.Add(11 * time.Hour).Unix()}, - {StartTime: today.Add(14 * time.Hour).Unix(), EndTime: today.Add(15 * time.Hour).Unix()}, - {StartTime: today.Add(24*time.Hour + 9*time.Hour).Unix(), EndTime: today.Add(24*time.Hour + 10*time.Hour).Unix()}, - {StartTime: today.Add(24*time.Hour + 11*time.Hour).Unix(), EndTime: today.Add(24*time.Hour + 12*time.Hour).Unix()}, - {StartTime: today.Add(24*time.Hour + 15*time.Hour).Unix(), EndTime: today.Add(24*time.Hour + 16*time.Hour).Unix()}, - }, - Message: "Demo mode: showing sample availability", - }) - return - } - - // Check if configured - if !s.requireConfig(w) { - return - } - - // Parse request - var req AvailabilityRequest - if r.Method == http.MethodPost { - if !parseJSONBody(w, r, &req) { - return - } - } else { - // Parse from query params for GET. We validate each numeric param - // rather than swallowing errors — the previous version silently - // fell through to the default 7-day window when callers sent - // malformed input, which masked client bugs in test harnesses. - query := r.URL.Query() - var perr error - if req.StartTime, perr = parseInt64Param(query, "start_time"); perr != nil { - writeBadParamError(w, "start_time", perr) - return - } - if req.EndTime, perr = parseInt64Param(query, "end_time"); perr != nil { - writeBadParamError(w, "end_time", perr) - return - } - if req.DurationMinutes, perr = parseIntParam(query, "duration_minutes"); perr != nil { - writeBadParamError(w, "duration_minutes", perr) - return - } - if participants := query.Get("participants"); participants != "" { - req.Participants = strings.Split(participants, ",") - } - if req.IntervalMinutes, perr = parseIntParam(query, "interval_minutes"); perr != nil { - writeBadParamError(w, "interval_minutes", perr) - return - } - } - - // Validate request - if req.StartTime == 0 || req.EndTime == 0 { - // Default to next 7 days - now := time.Now() - req.StartTime = now.Unix() - req.EndTime = now.Add(7 * 24 * time.Hour).Unix() - } - if req.DurationMinutes == 0 { - req.DurationMinutes = 30 // Default 30 minutes - } - if req.IntervalMinutes == 0 { - req.IntervalMinutes = 15 // Default 15 min intervals - } - - // Round times to 5-minute intervals (Nylas API requirement) - req.StartTime = roundUpTo5Min(req.StartTime) - req.EndTime = roundUpTo5Min(req.EndTime) - - // Get current user's email if no participants specified - if len(req.Participants) == 0 { - email := s.getCurrentUserEmail() - if email != "" { - req.Participants = []string{email} - } - } - - if len(req.Participants) == 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "At least one participant email is required", - }) - return - } - - // Build domain request - domainReq := &domain.AvailabilityRequest{ - StartTime: req.StartTime, - EndTime: req.EndTime, - DurationMinutes: req.DurationMinutes, - IntervalMinutes: req.IntervalMinutes, - Participants: make([]domain.AvailabilityParticipant, 0, len(req.Participants)), - } - for _, email := range req.Participants { - domainReq.Participants = append(domainReq.Participants, domain.AvailabilityParticipant{ - Email: strings.TrimSpace(email), - }) - } - - // Call Nylas API - ctx, cancel := s.withTimeout(r) - defer cancel() - - result, err := s.nylasClient.GetAvailability(ctx, domainReq) - if err != nil { - writeUpstreamError(w, http.StatusInternalServerError, - "Failed to get availability — please try again", err) - return - } - - // Convert to response - resp := AvailabilityResponse{ - Slots: make([]AvailableSlotResponse, 0, len(result.Data.TimeSlots)), - } - for _, slot := range result.Data.TimeSlots { - resp.Slots = append(resp.Slots, AvailableSlotResponse{ - StartTime: slot.StartTime, - EndTime: slot.EndTime, - Emails: slot.Emails, - }) - } - - writeJSON(w, http.StatusOK, resp) -} - -// handleFreeBusy returns free/busy information for participants. -func (s *Server) handleFreeBusy(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "Method not allowed") - return - } - - // Special demo mode: return mock free/busy data - if s.demoMode { - now := time.Now() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - writeJSON(w, http.StatusOK, FreeBusyResponse{ - Data: []FreeBusyCalendarResponse{ - { - Email: "demo@example.com", - TimeSlots: []TimeSlotResponse{ - {StartTime: today.Add(9 * time.Hour).Unix(), EndTime: today.Add(10 * time.Hour).Unix(), Status: "busy"}, - {StartTime: today.Add(12 * time.Hour).Unix(), EndTime: today.Add(13 * time.Hour).Unix(), Status: "busy"}, - {StartTime: today.Add(14 * time.Hour).Unix(), EndTime: today.Add(15 * time.Hour).Unix(), Status: "busy"}, - }, - }, - }, - }) - return - } - grantID := s.withAuthGrant(w, nil) // Demo mode already handled above - if grantID == "" { - return - } - - // Parse request - var req FreeBusyRequest - if r.Method == http.MethodPost { - if !parseJSONBody(w, r, &req) { - return - } - } else { - // Parse from query params for GET (see availability handler for - // rationale on validating instead of swallowing parse errors). - query := r.URL.Query() - var perr error - if req.StartTime, perr = parseInt64Param(query, "start_time"); perr != nil { - writeBadParamError(w, "start_time", perr) - return - } - if req.EndTime, perr = parseInt64Param(query, "end_time"); perr != nil { - writeBadParamError(w, "end_time", perr) - return - } - if emails := query.Get("emails"); emails != "" { - req.Emails = strings.Split(emails, ",") - } - } - - // Validate and set defaults - if req.StartTime == 0 || req.EndTime == 0 { - // Default to next 7 days - now := time.Now() - req.StartTime = now.Unix() - req.EndTime = now.Add(7 * 24 * time.Hour).Unix() - } - - // Get current user's email if no emails specified - if len(req.Emails) == 0 { - email := s.getCurrentUserEmail() - if email != "" { - req.Emails = []string{email} - } - } - - if len(req.Emails) == 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "At least one email is required", - }) - return - } - - // Build domain request - domainReq := &domain.FreeBusyRequest{ - StartTime: req.StartTime, - EndTime: req.EndTime, - Emails: req.Emails, - } - - // Call Nylas API - ctx, cancel := s.withTimeout(r) - defer cancel() - - result, err := s.nylasClient.GetFreeBusy(ctx, grantID, domainReq) - if err != nil { - writeUpstreamError(w, http.StatusInternalServerError, - "Failed to get free/busy — please try again", err) - return - } - - // Convert to response - resp := FreeBusyResponse{ - Data: make([]FreeBusyCalendarResponse, 0, len(result.Data)), - } - for _, cal := range result.Data { - calResp := FreeBusyCalendarResponse{ - Email: cal.Email, - TimeSlots: make([]TimeSlotResponse, 0, len(cal.TimeSlots)), - } - for _, slot := range cal.TimeSlots { - calResp.TimeSlots = append(calResp.TimeSlots, TimeSlotResponse{ - StartTime: slot.StartTime, - EndTime: slot.EndTime, - Status: slot.Status, - }) - } - resp.Data = append(resp.Data, calResp) - } - - writeJSON(w, http.StatusOK, resp) -} - -// handleConflicts detects scheduling conflicts in events. -func (s *Server) handleConflicts(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodGet) { - return - } - - // Special demo mode: return sample conflicts - if s.demoMode { - now := time.Now() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - writeJSON(w, http.StatusOK, ConflictsResponse{ - Conflicts: []EventConflict{ - { - Event1: EventResponse{ - ID: "demo-event-conflict-1", - Title: "Team Meeting", - StartTime: today.Add(14 * time.Hour).Unix(), - EndTime: today.Add(15 * time.Hour).Unix(), - Busy: true, - }, - Event2: EventResponse{ - ID: "demo-event-conflict-2", - Title: "Client Call", - StartTime: today.Add(14*time.Hour + 30*time.Minute).Unix(), - EndTime: today.Add(15*time.Hour + 30*time.Minute).Unix(), - Busy: true, - }, - }, - }, - HasMore: false, - }) - return - } - grantID := s.withAuthGrant(w, nil) // Demo mode already handled above - if grantID == "" { - return - } - - // Parse query params - query := r.URL.Query() - calendarID := query.Get("calendar_id") - if calendarID == "" { - calendarID = "primary" - } - - // Parse time range - startTime, perr := parseInt64Param(query, "start_time") - if perr != nil { - writeBadParamError(w, "start_time", perr) - return - } - endTime, perr := parseInt64Param(query, "end_time") - if perr != nil { - writeBadParamError(w, "end_time", perr) - return - } - - // Default to current week - if startTime == 0 || endTime == 0 { - now := time.Now() - weekday := int(now.Weekday()) - startOfWeek := now.AddDate(0, 0, -weekday).Truncate(24 * time.Hour) - endOfWeek := startOfWeek.AddDate(0, 0, 7) - startTime = startOfWeek.Unix() - endTime = endOfWeek.Unix() - } - - // Fetch events - ctx, cancel := s.withTimeout(r) - defer cancel() - - params := &domain.EventQueryParams{ - Limit: 200, - Start: startTime, - End: endTime, - ExpandRecurring: true, - } - - result, err := s.nylasClient.GetEventsWithCursor(ctx, grantID, calendarID, params) - if err != nil { - writeUpstreamError(w, http.StatusInternalServerError, - "Failed to fetch events — please try again", err, - "calendarID", calendarID) - return - } - - // Find conflicts (overlapping busy events) - conflicts := findConflicts(result.Data) - - resp := ConflictsResponse{ - Conflicts: conflicts, - HasMore: false, - } - - writeJSON(w, http.StatusOK, resp) -} - -// findConflicts detects overlapping events. -func findConflicts(events []domain.Event) []EventConflict { - var conflicts []EventConflict - - // Filter to only busy events - var busyEvents []domain.Event - for _, e := range events { - if e.Busy && e.Status != "cancelled" { - busyEvents = append(busyEvents, e) - } - } - - // Check each pair for overlap - for i := 0; i < len(busyEvents); i++ { - for j := i + 1; j < len(busyEvents); j++ { - e1, e2 := busyEvents[i], busyEvents[j] - - // Get start/end times - start1, end1 := e1.When.StartTime, e1.When.EndTime - start2, end2 := e2.When.StartTime, e2.When.EndTime - - // Handle all-day events. Only override the upstream timestamps on - // successful parse; on malformed dates we'd otherwise produce a - // year-1 Unix timestamp and detect bogus conflicts. - start1, end1 = allDayBounds(e1.When, start1, end1) - start2, end2 = allDayBounds(e2.When, start2, end2) - - // Check for overlap: start1 < end2 && start2 < end1 - if start1 < end2 && start2 < end1 { - conflicts = append(conflicts, EventConflict{ - Event1: eventToResponse(e1), - Event2: eventToResponse(e2), - }) - } - } - } - - return conflicts -} - -// allDayBounds returns the [start, end] Unix timestamps for an all-day or -// multi-day event. If the When value carries a date string we cannot parse, -// the caller-supplied fallback (start, end) is returned untouched so a -// malformed upstream date never collapses the window to year 1. -func allDayBounds(when domain.EventWhen, start, end int64) (int64, int64) { - if !when.IsAllDay() { - return start, end - } - switch { - case when.Date != "": - t, err := time.Parse("2006-01-02", when.Date) - if err != nil { - return start, end - } - return t.Unix(), t.Add(24 * time.Hour).Unix() - case when.StartDate != "": - t, err := time.Parse("2006-01-02", when.StartDate) - if err != nil { - return start, end - } - newStart := t.Unix() - newEnd := newStart + 24*60*60 - if when.EndDate != "" { - if et, err := time.Parse("2006-01-02", when.EndDate); err == nil { - newEnd = et.Unix() - } - } - return newStart, newEnd - } - return start, end -} - -// roundUpTo5Min rounds a Unix timestamp up to the next 5-minute boundary. -// This is required by the Nylas API for availability requests. -func roundUpTo5Min(unixTime int64) int64 { - const fiveMinutes = 5 * 60 // 300 seconds - remainder := unixTime % fiveMinutes - if remainder == 0 { - return unixTime - } - return unixTime + (fiveMinutes - remainder) -} diff --git a/internal/air/handlers_availability_test.go b/internal/air/handlers_availability_test.go deleted file mode 100644 index 8680bc8..0000000 --- a/internal/air/handlers_availability_test.go +++ /dev/null @@ -1,507 +0,0 @@ -//go:build !integration - -package air - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestParseInt64Param pins the contract that empty values are treated as -// "not provided" (zero, no error) but malformed values surface a clear -// error — the previous handler-level `_ = strconv.ParseInt(...)` form -// silently coerced both into zero, which masked client bugs. -func TestParseInt64Param(t *testing.T) { - t.Parallel() - cases := []struct { - name string - value string - want int64 - wantErr bool - }{ - {"empty is zero, no error", "", 0, false}, - {"valid integer", "1700000000", 1700000000, false}, - {"negative valid", "-42", -42, false}, - {"non-numeric", "tomorrow", 0, true}, - {"trailing junk", "12abc", 0, true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - q := url.Values{} - if tc.value != "" { - q.Set("k", tc.value) - } - got, err := parseInt64Param(q, "k") - if tc.wantErr { - assert.Error(t, err) - assert.Contains(t, err.Error(), "k") - } else { - require.NoError(t, err) - assert.Equal(t, tc.want, got) - } - }) - } -} - -// TestHandleAvailability_BadIntegerParams verifies the regression: bad -// query params now produce 400 Bad Request instead of being silently -// substituted by the "next 7 days" default. We use a fully configured -// server (mock client + a default grant) so the request reaches the -// param-validation stage instead of bouncing on requireConfig. -func TestHandleAvailability_BadIntegerParams(t *testing.T) { - t.Parallel() - cases := []struct { - name string - url string - }{ - {"bad start_time", "/api/availability?start_time=tomorrow"}, - {"bad end_time", "/api/availability?end_time=never"}, - {"bad duration_minutes", "/api/availability?duration_minutes=halfhour"}, - {"bad interval_minutes", "/api/availability?interval_minutes=fifteenish"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - s, _, _ := newCachedTestServer(t) - req := httptest.NewRequest(http.MethodGet, tc.url, nil) - w := httptest.NewRecorder() - s.handleAvailability(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String()) - }) - } -} - -func TestHandleFreeBusy_BadIntegerParams(t *testing.T) { - t.Parallel() - s, _, _ := newCachedTestServer(t) - req := httptest.NewRequest(http.MethodGet, "/api/calendars/freebusy?start_time=oops", nil) - w := httptest.NewRecorder() - s.handleFreeBusy(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String()) -} - -// TestHandleConflicts_BadIntegerParams covers the parity gap left by the -// air-i003 review-pass: handleAvailability and handleFreeBusy got -// parseInt64Param-based validation AND BadIntegerParams tests, but -// handleConflicts only got the validation. A future refactor that -// re-introduces `_, _ = strconv.ParseInt` here would silently fall -// through to the default "current week" window with no test failure. -func TestHandleConflicts_BadIntegerParams(t *testing.T) { - t.Parallel() - cases := []struct { - name string - url string - }{ - {"bad start_time", "/api/calendar/conflicts?start_time=tomorrow"}, - {"bad end_time", "/api/calendar/conflicts?end_time=never"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - s, _, _ := newCachedTestServer(t) - req := httptest.NewRequest(http.MethodGet, tc.url, nil) - w := httptest.NewRecorder() - s.handleConflicts(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String()) - }) - } -} - -// TestHandleAvailability_BadParams_DoesNotEchoRawValue pins the privacy -// contract on the query-param error path. `parseInt64Param` builds -// -// fmt.Errorf("invalid %s: %q is not a valid integer", key, raw) -// -// and the handler hands that error string straight to the client via -// `writeError(w, http.StatusBadRequest, perr.Error())`. The %q-encoded -// `raw` is the attacker's input echoed back into a JSON response — -// reflective surface that contradicts the writeUpstreamError -// redaction discipline this PR introduced one file over. -// -// EXPECTED FAILURE today: the response body contains the literal raw -// query value. After the fix the body should be a generic message -// (e.g., "invalid start_time") and the raw value should appear only in -// slog attrs. -func TestHandleAvailability_BadParams_DoesNotEchoRawValue(t *testing.T) { - t.Parallel() - const sentinel = "tomorrow-XYZ-canary" - s, _, _ := newCachedTestServer(t) - req := httptest.NewRequest(http.MethodGet, - "/api/availability?start_time="+sentinel, nil) - w := httptest.NewRecorder() - s.handleAvailability(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.NotContains(t, w.Body.String(), sentinel, - "response must not reflect raw query value back to client") -} - -func TestHandleFreeBusy_BadParams_DoesNotEchoRawValue(t *testing.T) { - t.Parallel() - const sentinel = "never-XYZ-canary" - s, _, _ := newCachedTestServer(t) - req := httptest.NewRequest(http.MethodGet, - "/api/calendars/freebusy?start_time="+sentinel, nil) - w := httptest.NewRecorder() - s.handleFreeBusy(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.NotContains(t, w.Body.String(), sentinel, - "response must not reflect raw query value back to client") -} - -func TestHandleConflicts_BadParams_DoesNotEchoRawValue(t *testing.T) { - t.Parallel() - const sentinel = "yesterday-XYZ-canary" - s, _, _ := newCachedTestServer(t) - req := httptest.NewRequest(http.MethodGet, - "/api/calendar/conflicts?start_time="+sentinel, nil) - w := httptest.NewRecorder() - s.handleConflicts(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.NotContains(t, w.Body.String(), sentinel, - "response must not reflect raw query value back to client") -} - -// TestHandleAvailability_BadDurationMinutes_DoesNotEchoRawValue closes -// the parity gap in the privacy sweep. Today the sweep covers -// start_time/end_time but not duration_minutes/interval_minutes — -// handlers_availability.go:160-169 routes all four through the same -// writeBadParamError helper, so a regression that inlines -// `writeError(perr.Error())` at one of the inner branches would escape -// the existing tests. Lock-down: same code path, same canary. -func TestHandleAvailability_BadDurationMinutes_DoesNotEchoRawValue(t *testing.T) { - t.Parallel() - const sentinel = "halfhour-XYZ-duration-canary" - s, _, _ := newCachedTestServer(t) - req := httptest.NewRequest(http.MethodGet, - "/api/availability?duration_minutes="+sentinel, nil) - w := httptest.NewRecorder() - s.handleAvailability(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.NotContains(t, w.Body.String(), sentinel, - "duration_minutes raw value must not be reflected back to the client") -} - -func TestHandleAvailability_BadIntervalMinutes_DoesNotEchoRawValue(t *testing.T) { - t.Parallel() - const sentinel = "fifteenish-XYZ-interval-canary" - s, _, _ := newCachedTestServer(t) - // IntervalMinutes is parsed AFTER start/end/duration/participants; - // supply valid values for those so we reach the interval branch. - req := httptest.NewRequest(http.MethodGet, - "/api/availability?start_time=1700000000&end_time=1700100000&duration_minutes=30&interval_minutes="+sentinel, nil) - w := httptest.NewRecorder() - s.handleAvailability(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.NotContains(t, w.Body.String(), sentinel, - "interval_minutes raw value must not be reflected back to the client") -} - -// Test handler types -func TestAvailabilityRequest_Fields(t *testing.T) { - req := AvailabilityRequest{ - StartTime: 1704067200, - EndTime: 1704153600, - DurationMinutes: 30, - Participants: []string{"user1@example.com", "user2@example.com"}, - IntervalMinutes: 15, - } - - assert.Equal(t, int64(1704067200), req.StartTime) - assert.Equal(t, int64(1704153600), req.EndTime) - assert.Equal(t, 30, req.DurationMinutes) - assert.Equal(t, 15, req.IntervalMinutes) - assert.Len(t, req.Participants, 2) -} - -func TestAvailabilityResponse_Fields(t *testing.T) { - resp := AvailabilityResponse{ - Slots: []AvailableSlotResponse{ - {StartTime: 1704067200, EndTime: 1704070800, Emails: []string{"test@example.com"}}, - }, - Message: "Test message", - } - - assert.Len(t, resp.Slots, 1) - assert.Equal(t, "Test message", resp.Message) -} - -func TestAvailableSlotResponse_Fields(t *testing.T) { - slot := AvailableSlotResponse{ - StartTime: 1704067200, - EndTime: 1704070800, - Emails: []string{"user1@example.com", "user2@example.com"}, - } - - assert.Equal(t, int64(1704067200), slot.StartTime) - assert.Equal(t, int64(1704070800), slot.EndTime) - assert.Len(t, slot.Emails, 2) -} - -func TestFreeBusyRequest_Fields(t *testing.T) { - req := FreeBusyRequest{ - StartTime: 1704067200, - EndTime: 1704153600, - Emails: []string{"user@example.com"}, - } - - assert.Equal(t, int64(1704067200), req.StartTime) - assert.Equal(t, int64(1704153600), req.EndTime) - assert.Len(t, req.Emails, 1) -} - -func TestFreeBusyResponse_Fields(t *testing.T) { - resp := FreeBusyResponse{ - Data: []FreeBusyCalendarResponse{ - { - Email: "user@example.com", - TimeSlots: []TimeSlotResponse{ - {StartTime: 1704067200, EndTime: 1704070800, Status: "busy"}, - }, - }, - }, - } - - assert.Len(t, resp.Data, 1) - assert.Equal(t, "user@example.com", resp.Data[0].Email) - assert.Len(t, resp.Data[0].TimeSlots, 1) - assert.Equal(t, "busy", resp.Data[0].TimeSlots[0].Status) -} - -func TestFreeBusyCalendarResponse_Fields(t *testing.T) { - calResp := FreeBusyCalendarResponse{ - Email: "test@example.com", - TimeSlots: []TimeSlotResponse{ - {StartTime: 1704067200, EndTime: 1704070800, Status: "busy"}, - {StartTime: 1704074400, EndTime: 1704078000, Status: "free"}, - }, - } - - assert.Equal(t, "test@example.com", calResp.Email) - assert.Len(t, calResp.TimeSlots, 2) -} - -func TestTimeSlotResponse_Fields(t *testing.T) { - slot := TimeSlotResponse{ - StartTime: 1704067200, - EndTime: 1704070800, - Status: "free", - } - - assert.Equal(t, int64(1704067200), slot.StartTime) - assert.Equal(t, int64(1704070800), slot.EndTime) - assert.Equal(t, "free", slot.Status) -} - -func TestConflictsResponse_Fields(t *testing.T) { - resp := ConflictsResponse{ - Conflicts: []EventConflict{ - { - Event1: EventResponse{ID: "e1", Title: "Event 1"}, - Event2: EventResponse{ID: "e2", Title: "Event 2"}, - }, - }, - HasMore: false, - } - - assert.Len(t, resp.Conflicts, 1) - assert.False(t, resp.HasMore) -} - -func TestEventConflict_Fields(t *testing.T) { - conflict := EventConflict{ - Event1: EventResponse{ID: "e1", Title: "Meeting 1"}, - Event2: EventResponse{ID: "e2", Title: "Meeting 2"}, - } - - assert.Equal(t, "e1", conflict.Event1.ID) - assert.Equal(t, "e2", conflict.Event2.ID) -} - -// Demo mode handler tests -func TestHandleAvailability_DemoMode(t *testing.T) { - s := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodGet, "/api/availability", nil) - w := httptest.NewRecorder() - - s.handleAvailability(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result AvailabilityResponse - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - assert.NotEmpty(t, result.Slots) - assert.Contains(t, result.Message, "Demo mode") -} - -func TestHandleAvailability_DemoMode_POST(t *testing.T) { - s := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodPost, "/api/availability", nil) - w := httptest.NewRecorder() - - s.handleAvailability(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result AvailabilityResponse - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - assert.NotEmpty(t, result.Slots) -} - -func TestHandleAvailability_MethodNotAllowed(t *testing.T) { - s := &Server{} - - req := httptest.NewRequest(http.MethodDelete, "/api/availability", nil) - w := httptest.NewRecorder() - - s.handleAvailability(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) -} - -func TestHandleFreeBusy_DemoMode(t *testing.T) { - s := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodGet, "/api/calendars/freebusy", nil) - w := httptest.NewRecorder() - - s.handleFreeBusy(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result FreeBusyResponse - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - assert.NotEmpty(t, result.Data) - assert.Equal(t, "demo@example.com", result.Data[0].Email) -} - -func TestHandleFreeBusy_DemoMode_POST(t *testing.T) { - s := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodPost, "/api/calendars/freebusy", nil) - w := httptest.NewRecorder() - - s.handleFreeBusy(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) -} - -func TestHandleFreeBusy_MethodNotAllowed(t *testing.T) { - s := &Server{} - - req := httptest.NewRequest(http.MethodDelete, "/api/calendars/freebusy", nil) - w := httptest.NewRecorder() - - s.handleFreeBusy(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) -} - -func TestHandleConflicts_DemoMode(t *testing.T) { - s := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodGet, "/api/calendar/conflicts", nil) - w := httptest.NewRecorder() - - s.handleConflicts(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result ConflictsResponse - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - assert.NotEmpty(t, result.Conflicts) - assert.False(t, result.HasMore) -} - -func TestHandleConflicts_MethodNotAllowed(t *testing.T) { - s := &Server{} - - req := httptest.NewRequest(http.MethodPost, "/api/calendar/conflicts", nil) - w := httptest.NewRecorder() - - s.handleConflicts(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) -} - -func TestHandleAvailability_NotConfigured(t *testing.T) { - s := &Server{demoMode: false, nylasClient: nil} - - req := httptest.NewRequest(http.MethodGet, "/api/availability", nil) - w := httptest.NewRecorder() - - s.handleAvailability(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) -} - -func TestHandleFreeBusy_NotConfigured(t *testing.T) { - s := &Server{demoMode: false, nylasClient: nil} - - req := httptest.NewRequest(http.MethodGet, "/api/calendars/freebusy", nil) - w := httptest.NewRecorder() - - s.handleFreeBusy(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) -} - -func TestHandleConflicts_NotConfigured(t *testing.T) { - s := &Server{demoMode: false, nylasClient: nil} - - req := httptest.NewRequest(http.MethodGet, "/api/calendar/conflicts", nil) - w := httptest.NewRecorder() - - s.handleConflicts(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) -} diff --git a/internal/air/handlers_bundles.go b/internal/air/handlers_bundles.go deleted file mode 100644 index d27292b..0000000 --- a/internal/air/handlers_bundles.go +++ /dev/null @@ -1,374 +0,0 @@ -package air - -import ( - "encoding/json" - "log/slog" - "net/http" - "regexp" - "strconv" - "strings" - "sync" - "time" - - "github.com/nylas/cli/internal/httputil" -) - -// Bundle represents an email bundle/category -type Bundle struct { - ID string `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - Description string `json:"description"` - Rules []BundleRule `json:"rules"` - Collapsed bool `json:"collapsed"` - Count int `json:"count"` - UnreadCount int `json:"unreadCount"` - LastUpdated time.Time `json:"lastUpdated"` -} - -// BundleRule defines matching criteria for emails -type BundleRule struct { - Field string `json:"field"` // from, to, subject, domain - Operator string `json:"operator"` // contains, equals, matches, startsWith - Value string `json:"value"` - Priority int `json:"priority"` -} - -// BundledEmail represents an email assigned to a bundle -type BundledEmail struct { - EmailID string `json:"emailId"` - BundleID string `json:"bundleId"` - Score int `json:"score"` // Match confidence -} - -// BundleStore manages bundles in memory -type BundleStore struct { - bundles map[string]*Bundle - emails map[string]string // emailID -> bundleID - mu sync.RWMutex -} - -// NewBundleStore creates a new bundle store with defaults -func NewBundleStore() *BundleStore { - store := &BundleStore{ - bundles: make(map[string]*Bundle), - emails: make(map[string]string), - } - store.initDefaultBundles() - return store -} - -// initDefaultBundles sets up default smart bundles -func (bs *BundleStore) initDefaultBundles() { - defaults := []Bundle{ - { - ID: "newsletters", Name: "Newsletters", Icon: "📰", - Description: "Newsletter subscriptions", - Rules: []BundleRule{ - {Field: "from", Operator: "contains", Value: "newsletter"}, - {Field: "from", Operator: "contains", Value: "digest"}, - {Field: "subject", Operator: "contains", Value: "unsubscribe"}, - }, - Collapsed: true, - }, - { - ID: "receipts", Name: "Receipts & Orders", Icon: "🧾", - Description: "Purchase confirmations and receipts", - Rules: []BundleRule{ - {Field: "subject", Operator: "contains", Value: "receipt"}, - {Field: "subject", Operator: "contains", Value: "order confirm"}, - {Field: "subject", Operator: "contains", Value: "your order"}, - {Field: "from", Operator: "contains", Value: "noreply"}, - }, - Collapsed: true, - }, - { - ID: "social", Name: "Social", Icon: "👥", - Description: "Social media notifications", - Rules: []BundleRule{ - {Field: "domain", Operator: "equals", Value: "twitter.com"}, - {Field: "domain", Operator: "equals", Value: "facebook.com"}, - {Field: "domain", Operator: "equals", Value: "linkedin.com"}, - {Field: "domain", Operator: "equals", Value: "instagram.com"}, - }, - Collapsed: true, - }, - { - ID: "updates", Name: "Updates", Icon: "🔔", - Description: "Service updates and notifications", - Rules: []BundleRule{ - {Field: "from", Operator: "contains", Value: "notifications"}, - {Field: "from", Operator: "contains", Value: "updates"}, - {Field: "subject", Operator: "contains", Value: "update"}, - }, - Collapsed: true, - }, - { - ID: "promotions", Name: "Promotions", Icon: "🏷️", - Description: "Deals and promotional emails", - Rules: []BundleRule{ - {Field: "subject", Operator: "contains", Value: "% off"}, - {Field: "subject", Operator: "contains", Value: "sale"}, - {Field: "subject", Operator: "contains", Value: "discount"}, - {Field: "subject", Operator: "contains", Value: "deal"}, - }, - Collapsed: true, - }, - { - ID: "finance", Name: "Finance", Icon: "💰", - Description: "Banking and financial emails", - Rules: []BundleRule{ - {Field: "domain", Operator: "contains", Value: "bank"}, - {Field: "subject", Operator: "contains", Value: "statement"}, - {Field: "subject", Operator: "contains", Value: "transaction"}, - {Field: "subject", Operator: "contains", Value: "payment"}, - }, - Collapsed: true, - }, - { - ID: "travel", Name: "Travel", Icon: "✈️", - Description: "Flight, hotel, and travel bookings", - Rules: []BundleRule{ - {Field: "subject", Operator: "contains", Value: "booking"}, - {Field: "subject", Operator: "contains", Value: "itinerary"}, - {Field: "subject", Operator: "contains", Value: "flight"}, - {Field: "subject", Operator: "contains", Value: "reservation"}, - }, - Collapsed: true, - }, - } - - for i := range defaults { - // Create a heap-allocated copy to avoid pointer aliasing - bundle := new(Bundle) - *bundle = defaults[i] - bundle.LastUpdated = time.Now() - bs.bundles[bundle.ID] = bundle - } -} - -// Global bundle store -var bundleStore = NewBundleStore() - -// handleGetBundles returns all bundles -func (s *Server) handleGetBundles(w http.ResponseWriter, r *http.Request) { - bundleStore.mu.RLock() - defer bundleStore.mu.RUnlock() - - bundles := make([]*Bundle, 0, len(bundleStore.bundles)) - for _, b := range bundleStore.bundles { - bundles = append(bundles, b) - } - - httputil.WriteJSON(w, http.StatusOK, bundles) -} - -// handleBundleCategorize assigns an email to a bundle -func (s *Server) handleBundleCategorize(w http.ResponseWriter, r *http.Request) { - var req struct { - From string `json:"from"` - Subject string `json:"subject"` - EmailID string `json:"emailId"` - } - - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - bundleID := categorizeEmail(req.From, req.Subject) - - if req.EmailID != "" && bundleID != "" { - func() { - bundleStore.mu.Lock() - defer bundleStore.mu.Unlock() - bundleStore.emails[req.EmailID] = bundleID - if b, ok := bundleStore.bundles[bundleID]; ok { - b.Count++ - b.LastUpdated = time.Now() - } - }() - } - - httputil.WriteJSON(w, http.StatusOK, map[string]string{"bundleId": bundleID}) -} - -// categorizeEmail determines which bundle an email belongs to -func categorizeEmail(from, subject string) string { - bundleStore.mu.RLock() - defer bundleStore.mu.RUnlock() - - fromLower := strings.ToLower(from) - subjectLower := strings.ToLower(subject) - - // Extract domain from email - domain := extractDomain(from) - - bestMatch := "" - bestScore := 0 - - for _, bundle := range bundleStore.bundles { - score := 0 - for _, rule := range bundle.Rules { - if matchRule(rule, fromLower, subjectLower, domain) { - score += rule.Priority + 1 - } - } - if score > bestScore { - bestScore = score - bestMatch = bundle.ID - } - } - - return bestMatch -} - -// matchRule checks if a rule matches the email -func matchRule(rule BundleRule, from, subject, domain string) bool { - var fieldValue string - switch rule.Field { - case "from": - fieldValue = from - case "subject": - fieldValue = subject - case "domain": - fieldValue = domain - default: - return false - } - - valueLower := strings.ToLower(rule.Value) - - switch rule.Operator { - case "contains": - return strings.Contains(fieldValue, valueLower) - case "equals": - return fieldValue == valueLower - case "startsWith": - return strings.HasPrefix(fieldValue, valueLower) - case "matches": - // Compile-and-match through a cached compile so a pathological - // pattern can't recompile on every email. Bad patterns are - // rejected at write-time by validateBundleRule, but old/persisted - // rules go through this path too — fall through quietly to keep - // the matcher panic-free. - re, err := compiledBundleRegex(rule.Value) - if err != nil || re == nil { - return false - } - return re.MatchString(fieldValue) - default: - return false - } -} - -// bundleRegexCache caches compiled "matches" patterns. Bundle rules are -// re-applied to every message arrival, so re-running regexp.Compile per -// match would be both slow and an attack surface (catastrophic backtracking -// patterns get compiled and burned per call). -var ( - bundleRegexCache = make(map[string]*regexp.Regexp) - bundleRegexCacheMu sync.RWMutex -) - -func compiledBundleRegex(pattern string) (*regexp.Regexp, error) { - bundleRegexCacheMu.RLock() - if re, ok := bundleRegexCache[pattern]; ok { - bundleRegexCacheMu.RUnlock() - return re, nil - } - bundleRegexCacheMu.RUnlock() - - re, err := regexp.Compile(pattern) - if err != nil { - return nil, err - } - bundleRegexCacheMu.Lock() - bundleRegexCache[pattern] = re - bundleRegexCacheMu.Unlock() - return re, nil -} - -// validateBundleRule rejects rules whose regex fails to compile so we -// surface the error at PUT time instead of silently dropping every match. -func validateBundleRule(rule BundleRule) error { - if rule.Operator != "matches" || rule.Value == "" { - return nil - } - if _, err := compiledBundleRegex(rule.Value); err != nil { - return err - } - return nil -} - -// extractDomain extracts domain from email address -func extractDomain(email string) string { - atIdx := strings.LastIndex(email, "@") - if atIdx == -1 || atIdx >= len(email)-1 { - return "" - } - - domain := email[atIdx+1:] - // Remove trailing > if present - domain = strings.TrimSuffix(domain, ">") - return strings.ToLower(domain) -} - -// handleUpdateBundle updates a bundle configuration -func (s *Server) handleUpdateBundle(w http.ResponseWriter, r *http.Request) { - var bundle Bundle - if err := json.NewDecoder(limitedBody(w, r)).Decode(&bundle); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - for i, rule := range bundle.Rules { - if err := validateBundleRule(rule); err != nil { - slog.Warn("invalid bundle rule regex", "rule_index", i, "err", err) - http.Error(w, "Invalid regex in rule "+strconv.Itoa(i), http.StatusBadRequest) - return - } - } - - bundleStore.mu.Lock() - defer bundleStore.mu.Unlock() - - if existing, ok := bundleStore.bundles[bundle.ID]; ok { - // Only update provided fields - if bundle.Name != "" { - existing.Name = bundle.Name - } - if bundle.Icon != "" { - existing.Icon = bundle.Icon - } - // Only update rules if explicitly provided - if len(bundle.Rules) > 0 { - existing.Rules = bundle.Rules - } - existing.Collapsed = bundle.Collapsed - existing.LastUpdated = time.Now() - } - - httputil.WriteJSON(w, http.StatusOK, bundle) -} - -// handleGetBundleEmails returns emails for a specific bundle -func (s *Server) handleGetBundleEmails(w http.ResponseWriter, r *http.Request) { - bundleID := r.URL.Query().Get("bundleId") - if bundleID == "" { - http.Error(w, "bundleId required", http.StatusBadRequest) - return - } - - bundleStore.mu.RLock() - defer bundleStore.mu.RUnlock() - - emailIDs := []string{} - for emailID, bID := range bundleStore.emails { - if bID == bundleID { - emailIDs = append(emailIDs, emailID) - } - } - - httputil.WriteJSON(w, http.StatusOK, emailIDs) -} diff --git a/internal/air/handlers_bundles_test.go b/internal/air/handlers_bundles_test.go deleted file mode 100644 index 381aac8..0000000 --- a/internal/air/handlers_bundles_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestNewBundleStore(t *testing.T) { - store := NewBundleStore() - - if store == nil { - t.Fatal("expected non-nil store") - return - } - - if len(store.bundles) == 0 { - t.Error("expected default bundles to be initialized") - } - - // Check for expected default bundles - expectedBundles := []string{"newsletters", "receipts", "social", "updates", "promotions", "finance", "travel"} - for _, id := range expectedBundles { - if _, ok := store.bundles[id]; !ok { - t.Errorf("expected bundle %q to exist", id) - } - } -} - -func TestCategorizeEmail(t *testing.T) { - tests := []struct { - name string - from string - subject string - expected string - }{ - { - name: "newsletter detection", - from: "newsletter@example.com", - subject: "Weekly digest", - expected: "newsletters", - }, - { - name: "receipt detection", - from: "noreply@store.com", - subject: "Your order confirmation #12345", - expected: "receipts", - }, - { - name: "social - twitter", - from: "notify@twitter.com", - subject: "You have new followers", - expected: "social", - }, - { - name: "social - linkedin", - from: "messages@linkedin.com", - subject: "New connection request", - expected: "social", - }, - { - name: "promotion detection", - from: "deals@shop.com", - subject: "50% off sale ends today!", - expected: "promotions", - }, - { - name: "finance detection", - from: "alerts@mybank.com", - subject: "Your monthly statement is ready", - expected: "finance", - }, - { - name: "travel detection", - from: "bookings@airline.com", - subject: "Your flight itinerary", - expected: "travel", - }, - { - name: "no match - personal email", - from: "friend@gmail.com", - subject: "Hey, how are you?", - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := categorizeEmail(tt.from, tt.subject) - if result != tt.expected { - t.Errorf("categorizeEmail(%q, %q) = %q, want %q", - tt.from, tt.subject, result, tt.expected) - } - }) - } -} - -func TestExtractDomain(t *testing.T) { - tests := []struct { - email string - expected string - }{ - {"user@example.com", "example.com"}, - {"test@twitter.com", "twitter.com"}, - {"Thanks for the update. I'll review and get back to you.
", - To: []EmailParticipantResponse{{Name: "Sarah Chen", Email: "sarah@example.com"}}, - Date: now.Add(-1 * time.Hour).Unix(), - }, - { - ID: "demo-draft-002", - Subject: "Meeting Follow-up", - Body: "Hi team,
Following up on our discussion...
", - To: []EmailParticipantResponse{{Name: "Team", Email: "team@example.com"}}, - Date: now.Add(-2 * time.Hour).Unix(), - }, - } -} - -// demoFolders returns demo folder data. -func demoFolders() []FolderResponse { - return []FolderResponse{ - {ID: "inbox", Name: "Inbox", SystemFolder: "inbox", TotalCount: 156, UnreadCount: 23}, - {ID: "sent", Name: "Sent", SystemFolder: "sent", TotalCount: 89, UnreadCount: 0}, - {ID: "drafts", Name: "Drafts", SystemFolder: "drafts", TotalCount: 3, UnreadCount: 0}, - {ID: "trash", Name: "Trash", SystemFolder: "trash", TotalCount: 12, UnreadCount: 0}, - {ID: "spam", Name: "Spam", SystemFolder: "spam", TotalCount: 5, UnreadCount: 0}, - {ID: "archive", Name: "Archive", SystemFolder: "archive", TotalCount: 234, UnreadCount: 0}, - {ID: "starred", Name: "Starred", SystemFolder: "", TotalCount: 8, UnreadCount: 0}, - } -} diff --git a/internal/air/handlers_drafts_send_test.go b/internal/air/handlers_drafts_send_test.go deleted file mode 100644 index 027bb29..0000000 --- a/internal/air/handlers_drafts_send_test.go +++ /dev/null @@ -1,294 +0,0 @@ -package air - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - nylasmock "github.com/nylas/cli/internal/adapters/nylas" - "github.com/nylas/cli/internal/domain" -) - -// newSendTestServer builds a non-demo Server with a mock Nylas client and a -// grant store containing the supplied grants. defaultGrantID identifies which -// grant the server should treat as the active default. -func newSendTestServer(t *testing.T, grants []domain.GrantInfo, defaultGrantID string) (*Server, *nylasmock.MockClient) { - t.Helper() - - client := nylasmock.NewMockClient() - store := &testGrantStore{ - grants: append([]domain.GrantInfo(nil), grants...), - defaultGrant: defaultGrantID, - } - return &Server{ - grantStore: store, - nylasClient: client, - }, client -} - -// resetTransactionalMock clears the package-level SendTransactionalMessageFunc -// after each test that touches it. -func resetTransactionalMock(t *testing.T) { - t.Helper() - t.Cleanup(func() { - nylasmock.SendTransactionalMessageFunc = nil - }) -} - -func sendRequest(t *testing.T, body map[string]any) *http.Request { - t.Helper() - encoded, err := json.Marshal(body) - if err != nil { - t.Fatalf("marshal request: %v", err) - } - req := httptest.NewRequest(http.MethodPost, "/api/send", bytes.NewBuffer(encoded)) - req.Header.Set("Content-Type", "application/json") - return req -} - -func TestHandleSendMessage_RequestGrantWins_GoogleStaysGoogle(t *testing.T) { - resetTransactionalMock(t) - - const googleID = "grant-google" - const nylasID = "grant-nylas" - server, client := newSendTestServer(t, []domain.GrantInfo{ - {ID: nylasID, Email: "managed@example.nylas.email", Provider: domain.ProviderNylas}, - {ID: googleID, Email: "qasim.m@nylas.com", Provider: domain.ProviderGoogle}, - }, nylasID) // default points at Nylas to prove request grant wins - - var seenGrantID string - client.GetGrantFunc = func(_ context.Context, id string) (*domain.Grant, error) { - seenGrantID = id - return &domain.Grant{ID: id, Email: "qasim.m@nylas.com", Provider: domain.ProviderGoogle}, nil - } - - nylasmock.SendTransactionalMessageFunc = func(_ context.Context, _ string, _ *domain.SendMessageRequest) (*domain.Message, error) { - t.Fatalf("transactional endpoint must not be called for a Google-provider grant") - return nil, nil - } - - var sentGrantID string - var sentReq *domain.SendMessageRequest - client.SendMessageFunc = func(_ context.Context, id string, r *domain.SendMessageRequest) (*domain.Message, error) { - sentGrantID = id - sentReq = r - return &domain.Message{ID: "msg-1"}, nil - } - - w := httptest.NewRecorder() - server.handleSendMessage(w, sendRequest(t, map[string]any{ - "grant_id": googleID, - "to": []map[string]string{{"email": "to@example.com"}}, - "subject": "hi", - "body": "hello", - })) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) - } - if seenGrantID != googleID { - t.Errorf("GetGrant got %q, want %q", seenGrantID, googleID) - } - if sentGrantID != googleID { - t.Errorf("SendMessage got %q, want %q", sentGrantID, googleID) - } - if sentReq == nil || len(sentReq.From) != 0 { - t.Errorf("standard providers should not have From auto-populated, got %+v", sentReq) - } -} - -func TestHandleSendMessage_NylasGrantArchivesViaPerGrantSend(t *testing.T) { - // Per /v3/grants/{id}/messages/send + From is what archives a message to - // the Sent folder for Nylas-managed grants. The transactional endpoint is - // a non-archiving relay and must not be used here. - resetTransactionalMock(t) - - const nylasID = "grant-nylas" - const grantEmail = "support@managed.nylas.email" - server, client := newSendTestServer(t, []domain.GrantInfo{ - {ID: nylasID, Email: grantEmail, Provider: domain.ProviderNylas}, - }, nylasID) - - client.GetGrantFunc = func(_ context.Context, id string) (*domain.Grant, error) { - return &domain.Grant{ID: id, Email: grantEmail, Provider: domain.ProviderNylas}, nil - } - nylasmock.SendTransactionalMessageFunc = func(_ context.Context, _ string, _ *domain.SendMessageRequest) (*domain.Message, error) { - t.Fatal("transactional endpoint must not be used; per-grant send is what archives to Sent") - return nil, nil - } - - var sentGrantID string - var sentReq *domain.SendMessageRequest - client.SendMessageFunc = func(_ context.Context, id string, r *domain.SendMessageRequest) (*domain.Message, error) { - sentGrantID = id - sentReq = r - return &domain.Message{ID: "msg-archived"}, nil - } - - w := httptest.NewRecorder() - server.handleSendMessage(w, sendRequest(t, map[string]any{ - "grant_id": nylasID, - "to": []map[string]string{{"email": "to@example.com"}}, - "subject": "hi", - "body": "hello", - })) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) - } - if sentGrantID != nylasID { - t.Errorf("SendMessage grantID = %q, want %q", sentGrantID, nylasID) - } - if sentReq == nil || len(sentReq.From) != 1 || sentReq.From[0].Email != grantEmail { - t.Errorf("Nylas grant must have From auto-populated to %q, got %+v", grantEmail, sentReq) - } -} - -func TestHandleSendMessage_NoGrantID_FallsBackToDefault(t *testing.T) { - resetTransactionalMock(t) - - const googleID = "grant-google" - server, client := newSendTestServer(t, []domain.GrantInfo{ - {ID: googleID, Email: "qasim.m@nylas.com", Provider: domain.ProviderGoogle}, - }, googleID) - - client.GetGrantFunc = func(_ context.Context, id string) (*domain.Grant, error) { - return &domain.Grant{ID: id, Email: "qasim.m@nylas.com", Provider: domain.ProviderGoogle}, nil - } - var sentGrantID string - client.SendMessageFunc = func(_ context.Context, id string, _ *domain.SendMessageRequest) (*domain.Message, error) { - sentGrantID = id - return &domain.Message{ID: "msg-default"}, nil - } - - w := httptest.NewRecorder() - server.handleSendMessage(w, sendRequest(t, map[string]any{ - // no grant_id — server must use default - "to": []map[string]string{{"email": "to@example.com"}}, - "subject": "hi", - "body": "hello", - })) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) - } - if sentGrantID != googleID { - t.Errorf("SendMessage got %q, want default %q", sentGrantID, googleID) - } -} - -func TestHandleSendMessage_UnknownGrantID_Rejected(t *testing.T) { - resetTransactionalMock(t) - - server, client := newSendTestServer(t, []domain.GrantInfo{ - {ID: "grant-known", Email: "x@y.com", Provider: domain.ProviderGoogle}, - }, "grant-known") - - client.SendMessageFunc = func(_ context.Context, _ string, _ *domain.SendMessageRequest) (*domain.Message, error) { - t.Fatal("send must not run when grant_id does not belong to user") - return nil, nil - } - nylasmock.SendTransactionalMessageFunc = func(_ context.Context, _ string, _ *domain.SendMessageRequest) (*domain.Message, error) { - t.Fatal("transactional send must not run when grant_id is unknown") - return nil, nil - } - - w := httptest.NewRecorder() - server.handleSendMessage(w, sendRequest(t, map[string]any{ - "grant_id": "grant-evil", - "to": []map[string]string{{"email": "to@example.com"}}, - "subject": "hi", - "body": "hello", - })) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for unknown grant, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestResolveSendGrantID(t *testing.T) { - server, _ := newSendTestServer(t, []domain.GrantInfo{ - {ID: "g1", Email: "a@x.com", Provider: domain.ProviderGoogle}, - {ID: "g2", Email: "b@y.com", Provider: domain.ProviderNylas}, - }, "g1") - - tests := []struct { - name string - requested string - want string - wantErr error - }{ - {name: "empty falls back to default", requested: "", want: "g1"}, - {name: "valid request grant", requested: "g2", want: "g2"}, - {name: "unknown grant rejected", requested: "g-bogus", wantErr: errSendGrantNotFound}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := server.resolveSendGrantID(tt.requested, "g1") - if tt.wantErr != nil { - if !errors.Is(err, tt.wantErr) { - t.Fatalf("err = %v, want %v", err, tt.wantErr) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tt.want { - t.Errorf("got %q, want %q", got, tt.want) - } - }) - } -} - -func TestSendMessageForGrant_GetGrantError(t *testing.T) { - resetTransactionalMock(t) - server, client := newSendTestServer(t, []domain.GrantInfo{ - {ID: "g1", Email: "a@x.com", Provider: domain.ProviderGoogle}, - }, "g1") - client.GetGrantFunc = func(_ context.Context, _ string) (*domain.Grant, error) { - return nil, errors.New("boom") - } - - _, err := server.sendMessageForGrant(context.Background(), "g1", &domain.SendMessageRequest{}) - if err == nil || !strings.Contains(err.Error(), "fetch grant") { - t.Fatalf("expected fetch-grant wrapped error, got %v", err) - } -} - -func TestSendMessageForGrant_PreservesCallerFrom(t *testing.T) { - // If the caller already supplied a From, sendMessageForGrant must respect - // it (no auto-populate, no GetGrant call). - resetTransactionalMock(t) - - server, client := newSendTestServer(t, []domain.GrantInfo{ - {ID: "g1", Email: "real@x.com", Provider: domain.ProviderNylas}, - }, "g1") - client.GetGrantFunc = func(_ context.Context, _ string) (*domain.Grant, error) { - t.Fatal("GetGrant must not be called when From is already set") - return nil, nil - } - - var sentReq *domain.SendMessageRequest - client.SendMessageFunc = func(_ context.Context, _ string, r *domain.SendMessageRequest) (*domain.Message, error) { - sentReq = r - return &domain.Message{ID: "msg"}, nil - } - - original := []domain.EmailParticipant{{Email: "explicit@example.com", Name: "Caller"}} - _, err := server.sendMessageForGrant(context.Background(), "g1", &domain.SendMessageRequest{ - From: original, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if sentReq == nil || len(sentReq.From) != 1 || sentReq.From[0].Email != "explicit@example.com" { - t.Errorf("From was rewritten; want explicit@example.com, got %+v", sentReq) - } -} diff --git a/internal/air/handlers_drafts_test.go b/internal/air/handlers_drafts_test.go deleted file mode 100644 index 65ecce7..0000000 --- a/internal/air/handlers_drafts_test.go +++ /dev/null @@ -1,552 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -// ================================ -// DRAFTS HANDLER ADDITIONAL TESTS -// ================================ - -func TestHandleDrafts_GET(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/drafts", nil) - w := httptest.NewRecorder() - - server.handleDrafts(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp DraftsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if len(resp.Drafts) == 0 { - t.Error("expected non-empty drafts in demo mode") - } -} - -func TestHandleDrafts_POST(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - draft := map[string]any{ - "to": []map[string]string{ - {"email": "recipient@example.com", "name": "Recipient"}, - }, - "subject": "Draft Subject", - "body": "Draft body content", - } - body, _ := json.Marshal(draft) - req := httptest.NewRequest(http.MethodPost, "/api/drafts", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleDrafts(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleDrafts_PUT_NotAllowed(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPut, "/api/drafts", nil) - w := httptest.NewRecorder() - - server.handleDrafts(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleDrafts_DELETE_NotAllowed(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodDelete, "/api/drafts", nil) - w := httptest.NewRecorder() - - server.handleDrafts(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleListDrafts_Content(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/drafts", nil) - w := httptest.NewRecorder() - - server.handleListDrafts(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp DraftsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Verify draft structure - for _, draft := range resp.Drafts { - if draft.ID == "" { - t.Error("expected draft to have ID") - } - } -} - -func TestHandleListDrafts_WithLimit(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/drafts?limit=5", nil) - w := httptest.NewRecorder() - - server.handleListDrafts(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleCreateDraft_ValidRequest(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - draft := map[string]any{ - "to": []map[string]string{ - {"email": "recipient@example.com"}, - }, - "subject": "Test Draft", - "body": "Draft content", - } - body, _ := json.Marshal(draft) - req := httptest.NewRequest(http.MethodPost, "/api/drafts", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleCreateDraft(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleCreateDraft_InvalidJSON(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - body := bytes.NewBufferString("{invalid json}") - req := httptest.NewRequest(http.MethodPost, "/api/drafts", body) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleCreateDraft(w, req) - - // Demo mode may handle invalid JSON gracefully - if w.Code != http.StatusBadRequest && w.Code != http.StatusOK { - t.Errorf("expected status 400 or 200, got %d", w.Code) - } -} - -func TestHandleCreateDraft_EmptyBody(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPost, "/api/drafts", nil) - w := httptest.NewRecorder() - - server.handleCreateDraft(w, req) - - // Demo mode may handle empty body gracefully - if w.Code != http.StatusBadRequest && w.Code != http.StatusOK { - t.Errorf("expected status 400 or 200, got %d", w.Code) - } -} - -func TestHandleCreateDraft_WithCC(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - draft := map[string]any{ - "to": []map[string]string{ - {"email": "recipient@example.com"}, - }, - "cc": []map[string]string{ - {"email": "cc@example.com"}, - }, - "subject": "Test Draft with CC", - "body": "Content", - } - body, _ := json.Marshal(draft) - req := httptest.NewRequest(http.MethodPost, "/api/drafts", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleCreateDraft(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleCreateDraft_WithBCC(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - draft := map[string]any{ - "to": []map[string]string{ - {"email": "recipient@example.com"}, - }, - "bcc": []map[string]string{ - {"email": "bcc@example.com"}, - }, - "subject": "Test Draft with BCC", - "body": "Content", - } - body, _ := json.Marshal(draft) - req := httptest.NewRequest(http.MethodPost, "/api/drafts", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleCreateDraft(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleDraftByID_GET(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - // Get drafts from demo data - drafts := demoDrafts() - if len(drafts) == 0 { - t.Skip("no demo drafts available") - } - - draftID := drafts[0].ID - req := httptest.NewRequest(http.MethodGet, "/api/drafts/"+draftID, nil) - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleDraftByID_PUT(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - update := map[string]any{ - "subject": "Updated Subject", - "body": "Updated body", - } - body, _ := json.Marshal(update) - req := httptest.NewRequest(http.MethodPut, "/api/drafts/draft-1", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleDraftByID_DELETE(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodDelete, "/api/drafts/draft-1", nil) - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleDraftByID_PATCH_NotAllowed(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPatch, "/api/drafts/draft-1", nil) - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleDraftByID_NotFound(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/drafts/nonexistent-id", nil) - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected status 404, got %d", w.Code) - } -} - -func TestHandleUpdateDraft_UpdateSubject(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - update := map[string]any{ - "subject": "New Subject", - } - body, _ := json.Marshal(update) - req := httptest.NewRequest(http.MethodPut, "/api/drafts/draft-1", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleUpdateDraft_UpdateBody(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - update := map[string]any{ - "body": "New body content", - } - body, _ := json.Marshal(update) - req := httptest.NewRequest(http.MethodPut, "/api/drafts/draft-1", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleUpdateDraft_UpdateRecipients(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - update := map[string]any{ - "to": []map[string]string{ - {"email": "new-recipient@example.com"}, - }, - } - body, _ := json.Marshal(update) - req := httptest.NewRequest(http.MethodPut, "/api/drafts/draft-1", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleSendDraft_Valid(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - // Get drafts from demo data - drafts := demoDrafts() - if len(drafts) == 0 { - t.Skip("no demo drafts available") - } - - draftID := drafts[0].ID - req := httptest.NewRequest(http.MethodPost, "/api/drafts/"+draftID+"/send", nil) - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - // Send operation should succeed in demo mode - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleSendDraft_NotFound(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPost, "/api/drafts/nonexistent/send", nil) - w := httptest.NewRecorder() - - server.handleDraftByID(w, req) - - // Demo mode may handle nonexistent draft gracefully - if w.Code != http.StatusNotFound && w.Code != http.StatusOK { - t.Errorf("expected status 404 or 200, got %d", w.Code) - } -} - -func TestDraftResponseSerialization(t *testing.T) { - t.Parallel() - - resp := DraftResponse{ - ID: "draft-123", - Subject: "Draft Subject", - Body: "Draft Body", - To: []EmailParticipantResponse{ - {Email: "recipient@example.com", Name: "Recipient"}, - }, - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded DraftResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.ID != resp.ID { - t.Errorf("expected ID %s, got %s", resp.ID, decoded.ID) - } - - if decoded.Subject != resp.Subject { - t.Errorf("expected Subject %s, got %s", resp.Subject, decoded.Subject) - } - - if len(decoded.To) != 1 { - t.Errorf("expected 1 To, got %d", len(decoded.To)) - } -} - -func TestDraftsResponseSerialization(t *testing.T) { - t.Parallel() - - resp := DraftsResponse{ - Drafts: []DraftResponse{ - {ID: "draft-1", Subject: "Draft 1"}, - {ID: "draft-2", Subject: "Draft 2"}, - }, - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded DraftsResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if len(decoded.Drafts) != 2 { - t.Errorf("expected 2 drafts, got %d", len(decoded.Drafts)) - } -} - -func TestDraftActionResponseMap(t *testing.T) { - t.Parallel() - - resp := map[string]any{ - "success": true, - "message": "Draft created", - "draft": map[string]any{ - "id": "draft-new", - "subject": "New Draft", - }, - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded map[string]any - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded["success"] != true { - t.Error("expected success to be true") - } - - if decoded["draft"] == nil { - t.Error("expected draft to be present") - } -} - -func TestDraftActionResponseErrorMap(t *testing.T) { - t.Parallel() - - resp := map[string]any{ - "success": false, - "error": "Failed to create draft", - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded map[string]any - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded["success"] != false { - t.Error("expected success to be false") - } - - if decoded["error"] != "Failed to create draft" { - t.Errorf("expected error message, got %v", decoded["error"]) - } -} diff --git a/internal/air/handlers_email.go b/internal/air/handlers_email.go deleted file mode 100644 index 18311fc..0000000 --- a/internal/air/handlers_email.go +++ /dev/null @@ -1,560 +0,0 @@ -package air - -import ( - "log/slog" - "net/http" - "strings" - - "github.com/nylas/cli/internal/air/cache" - "github.com/nylas/cli/internal/domain" -) - -// handleListEmails returns emails with optional filtering. -func (s *Server) handleListEmails(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodGet) { - return - } - - // Parse query parameters - query := NewQueryParams(r.URL.Query()) - - // Demo mode: filter the demo dataset by folder/unread/starred so users - // can exercise the sidebar (Sent/Drafts/Archive/Trash) without a real - // account. Without this, every folder showed the same Inbox set. - if s.demoMode { - filtered := filterDemoEmails(demoEmails(), - query.Get("folder"), - query.GetBool("unread"), - query.GetBool("starred"), - ) - writeJSON(w, http.StatusOK, EmailsResponse{Emails: filtered, HasMore: false}) - return - } - - grantID := s.withAuthGrant(w, nil) - if grantID == "" { - return - } - - params := &domain.MessageQueryParams{ - Limit: query.GetLimit(50), - } - - // Filter by folder - folderID := query.Get("folder") - if folderID != "" { - params.In = []string{folderID} - } - - // Filter by unread - if query.GetBool("unread") { - unreadBool := true - params.Unread = &unreadBool - } - - // Filter by starred - if query.GetBool("starred") { - starredBool := true - params.Starred = &starredBool - } - - // Search by sender email (from) - fromFilter := query.Get("from") - if fromFilter != "" { - params.From = fromFilter - } - - // Full-text search query - searchQuery := query.Get("search") - if searchQuery != "" { - params.SearchQuery = searchQuery - } - - // Cursor for pagination - cursor := query.Get("cursor") - if cursor != "" { - params.PageToken = cursor - } - - // Get account email for cache lookup - accountEmail := s.getAccountEmail(grantID) - - // Try cache first (only for first page without complex filters). - // Folder-filter caveat: background sync fetches the top-N messages with - // no folder filter, so on busy inboxes the cache barely covers - // Sent/Drafts/Archive. We short-circuit on a folder-filter hit only when - // the cache returned at least a full page — otherwise the user sees a - // stub of 1–2 messages instead of the real folder. from/search filters - // operate on the full cached dataset, so they short-circuit as before. - 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 { - if folderID == "" || len(cached) >= params.Limit { - writeJSON(w, http.StatusOK, cachedEmailsToResponse(cached, params.Limit)) - return - } - } - } - - // Fetch messages from Nylas API - ctx, cancel := s.withTimeout(r) - defer cancel() - - result, err := s.nylasClient.GetMessagesWithCursor(ctx, grantID, params) - if err != nil { - // If offline and cache available, try cache as fallback - 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 - } - } - writeUpstreamError(w, http.StatusInternalServerError, - "Failed to fetch emails — please try again", err, - "account", redactEmail(accountEmail)) - return - } - - // Cache the results. Cache write failures must not fail the request - // (the user already has the data), but a silently-wedged cache will - // drift further from server state on every refresh, so we log the - // first put error per request to keep the failure debuggable. - if s.cacheAvailable() { - if cacheErr := s.withEmailStore(accountEmail, func(store *cache.EmailStore) error { - var firstErr error - for i := range result.Data { - if putErr := store.Put(domainMessageToCached(&result.Data[i])); putErr != nil && firstErr == nil { - firstErr = putErr - } - } - return firstErr - }); cacheErr != nil { - slog.Warn("email list cache fill failed", "account", redactEmail(accountEmail), "err", cacheErr) - } - } - - // Convert to response format - resp := EmailsResponse{ - Emails: make([]EmailResponse, 0, len(result.Data)), - NextCursor: result.Pagination.NextCursor, - HasMore: result.Pagination.HasMore, - } - for _, m := range result.Data { - resp.Emails = append(resp.Emails, emailToResponse(m, false)) - } - - writeJSON(w, http.StatusOK, resp) -} - -// handleEmailByID handles single email operations: GET, PUT, DELETE. -func (s *Server) handleEmailByID(w http.ResponseWriter, r *http.Request) { - // Parse email ID from path: /api/emails/{id}[/{action}] - path := strings.TrimPrefix(r.URL.Path, "/api/emails/") - parts := strings.Split(path, "/") - if len(parts) == 0 || parts[0] == "" { - writeError(w, http.StatusBadRequest, "Email ID required") - return - } - emailID := parts[0] - - // Sub-resource: /api/emails/{id}/invite returns parsed iCalendar - // invite data so the email preview can show a Gmail-style RSVP card. - if len(parts) > 1 && parts[1] == "invite" { - s.handleEmailInvite(w, r, emailID) - return - } - // Sub-resource: /api/emails/{id}/rsvp accepts {status: yes|no|maybe} - // and forwards to the Nylas send-rsvp endpoint after resolving the - // invite's iCalendar UID to a Nylas event. - if len(parts) > 1 && parts[1] == "rsvp" { - s.handleEmailRSVP(w, r, emailID) - return - } - - switch r.Method { - case http.MethodGet: - s.handleGetEmail(w, r, emailID) - case http.MethodPut: - s.handleUpdateEmail(w, r, emailID) - case http.MethodDelete: - s.handleDeleteEmail(w, r, emailID) - default: - writeError(w, http.StatusMethodNotAllowed, "Method not allowed") - } -} - -// handleGetEmail retrieves a single email with full body. -func (s *Server) handleGetEmail(w http.ResponseWriter, r *http.Request, emailID string) { - // Special demo mode: return specific email or 404 - if s.demoMode { - for _, e := range demoEmails() { - if e.ID == emailID { - writeJSON(w, http.StatusOK, e) - return - } - } - writeError(w, http.StatusNotFound, "Email not found") - return - } - grantID := s.withAuthGrant(w, nil) // Demo mode already handled above - if grantID == "" { - return - } - - // Get account email for cache lookup - accountEmail := s.getAccountEmail(grantID) - - // Try cache first - 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 - } - } - - // Fetch message from Nylas API - ctx, cancel := s.withTimeout(r) - defer cancel() - - msg, err := s.nylasClient.GetMessage(ctx, grantID, emailID) - if err != nil { - // Try cache as fallback on error - 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 - } - } - writeUpstreamError(w, http.StatusInternalServerError, - "Failed to fetch email — please try again", err, - "emailID", emailID, "account", redactEmail(accountEmail)) - return - } - - // Cache the result. A wedged single-message cache silently drifts - // from server state on every fetch otherwise; mirror the - // handleListEmails:Cache fill failed log so support can diagnose - // from production logs without changing the user-facing 200. - if s.cacheAvailable() { - if err := s.withEmailStore(accountEmail, func(store *cache.EmailStore) error { - return store.Put(domainMessageToCached(msg)) - }); err != nil { - slog.Warn("get-email cache fill failed", - "emailID", emailID, - "account", redactEmail(accountEmail), - "err", err) - } - } - - writeJSON(w, http.StatusOK, emailToResponse(*msg, true)) -} - -// handleUpdateEmail updates an email (mark read/unread, star/unstar). -func (s *Server) handleUpdateEmail(w http.ResponseWriter, r *http.Request, emailID string) { - grantID := s.withAuthGrant(w, UpdateEmailResponse{Success: true, Message: "Email updated (demo mode)"}) - if grantID == "" { - return - } - - var req UpdateEmailRequest - if !parseJSONBody(w, r, &req) { - return - } - - accountEmail := s.getAccountEmail(grantID) - - ctx, cancel := s.withTimeout(r) - defer cancel() - - updateReq := &domain.UpdateMessageRequest{ - Unread: req.Unread, - Starred: req.Starred, - 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 - } else { - // Offline AND the queue is broken. Falling through to the live - // API call is a best-effort retry (IsOnline() can be stale), - // but the queue failure itself must not be invisible — a - // silently-wedged queue will drop user actions repeatedly. - slog.Warn("offline enqueue failed, attempting live API call", - "emailID", emailID, - "account", redactEmail(accountEmail), - "err", err, - ) - } - } - - _, err := s.nylasClient.UpdateMessage(ctx, grantID, emailID, updateReq) - if err != nil { - if s.shouldQueueEmailAction(err) { - queueErr := s.enqueueMessageUpdate(grantID, accountEmail, emailID, updateReq) - if 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 - } - // Queue write failed under a known-transient upstream error — - // the user is about to see a 500, but they also lost the - // fallback path that would have stashed their action. Log so - // the queue health regression is debuggable. - slog.Error("queue enqueue after transient API error failed", - "emailID", emailID, - "account", redactEmail(accountEmail), - "apiErr", err, - "queueErr", queueErr, - ) - } - // UpdateEmailResponse envelope — frontend reads `error`. Raw err - // is logged, generic message goes to the user. - slog.Error("Failed to update email", - "err", err, - "emailID", emailID, - "account", redactEmail(accountEmail), - ) - writeJSON(w, http.StatusInternalServerError, UpdateEmailResponse{ - Success: false, - Error: "Failed to update email — please try again", - }) - return - } - - s.updateCachedEmail(accountEmail, emailID, req.Unread, req.Starred, req.Folders) - - writeJSON(w, http.StatusOK, UpdateEmailResponse{ - Success: true, - Message: "Email updated", - }) -} - -// handleDeleteEmail moves an email to trash. -func (s *Server) handleDeleteEmail(w http.ResponseWriter, r *http.Request, emailID string) { - grantID := s.withAuthGrant(w, UpdateEmailResponse{Success: true, Message: "Email deleted (demo mode)"}) - if grantID == "" { - 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 - } else { - // Offline AND the queue is broken. Falling through to the live - // API call is a best-effort retry (IsOnline() can be stale), - // but the queue failure itself must not be invisible — a - // silently-wedged queue will drop user actions repeatedly. - // Mirrors handleUpdateEmail's offline-enqueue log. - slog.Warn("offline enqueue failed, attempting live API call", - "emailID", emailID, - "account", redactEmail(accountEmail), - "err", err, - ) - } - } - - 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 - } else { - // Queue write failed under a known-transient upstream - // error. The user is about to see a 500 AND lost the - // fallback path. Co-log apiErr + queueErr so the - // double-failure is debuggable. Mirrors handleUpdateEmail. - slog.Error("queue enqueue after transient API error failed", - "emailID", emailID, - "account", redactEmail(accountEmail), - "apiErr", err, - "queueErr", queueErr, - ) - } - } - slog.Error("Failed to delete email", - "err", err, - "emailID", emailID, - "account", redactEmail(accountEmail), - ) - writeJSON(w, http.StatusInternalServerError, UpdateEmailResponse{ - Success: false, - Error: "Failed to delete email — please try again", - }) - 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 -} - -// emailToResponse converts a domain message to an API response. -func emailToResponse(m domain.Message, includeBody bool) EmailResponse { - resp := EmailResponse{ - ID: m.ID, - ThreadID: m.ThreadID, - Subject: m.Subject, - Snippet: m.Snippet, - Date: m.Date.Unix(), - Unread: m.Unread, - Starred: m.Starred, - Folders: m.Folders, - } - - if includeBody { - resp.Body = m.Body - } - - // Convert participants with pre-allocated slices - if len(m.From) > 0 { - resp.From = make([]EmailParticipantResponse, 0, len(m.From)) - for _, p := range m.From { - resp.From = append(resp.From, EmailParticipantResponse{ - Name: p.Name, - Email: p.Email, - }) - } - } - if len(m.To) > 0 { - resp.To = make([]EmailParticipantResponse, 0, len(m.To)) - for _, p := range m.To { - resp.To = append(resp.To, EmailParticipantResponse{ - Name: p.Name, - Email: p.Email, - }) - } - } - if len(m.Cc) > 0 { - resp.Cc = make([]EmailParticipantResponse, 0, len(m.Cc)) - for _, p := range m.Cc { - resp.Cc = append(resp.Cc, EmailParticipantResponse{ - Name: p.Name, - Email: p.Email, - }) - } - } - - // Convert attachments with pre-allocated slice - if len(m.Attachments) > 0 { - resp.Attachments = make([]AttachmentResponse, 0, len(m.Attachments)) - for _, a := range m.Attachments { - resp.Attachments = append(resp.Attachments, AttachmentResponse{ - ID: a.ID, - Filename: a.Filename, - ContentType: a.ContentType, - Size: a.Size, - }) - } - } - - return resp -} - -// cachedEmailToResponse converts a cached email to response format. -func cachedEmailToResponse(e *cache.CachedEmail) EmailResponse { - return EmailResponse{ - ID: e.ID, - ThreadID: e.ThreadID, - Subject: e.Subject, - Snippet: e.Snippet, - From: []EmailParticipantResponse{ - {Name: e.FromName, Email: e.FromEmail}, - }, - Date: e.Date.Unix(), - Unread: e.Unread, - Starred: e.Starred, - Folders: []string{e.FolderID}, - } -} diff --git a/internal/air/handlers_email_advanced_test.go b/internal/air/handlers_email_advanced_test.go deleted file mode 100644 index 9d1bd24..0000000 --- a/internal/air/handlers_email_advanced_test.go +++ /dev/null @@ -1,409 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/nylas/cli/internal/air/cache" - "github.com/nylas/cli/internal/domain" -) - -func TestHandleGetEmail_DemoMode_AllDemoEmails(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - // These IDs match demoEmails() function in handlers.go - demoIDs := []string{"demo-email-001", "demo-email-002", "demo-email-003", "demo-email-004", "demo-email-005"} - - for _, id := range demoIDs { - t.Run(id, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/emails/"+id, nil) - w := httptest.NewRecorder() - - server.handleGetEmail(w, req, id) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200 for %s, got %d", id, w.Code) - } - }) - } -} - -func TestHandleListEmails_FolderFilter(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/emails?folder=INBOX", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleListEmails_UnreadFilter(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/emails?unread=true", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleListEmails_StarredFilter(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/emails?starred=true", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleListEmails_LimitParam(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/emails?limit=10", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleEmailByID_GET(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - // Get an email from demo data - emails := demoEmails() - if len(emails) == 0 { - t.Skip("no demo emails available") - } - - emailID := emails[0].ID - req := httptest.NewRequest(http.MethodGet, "/api/emails/"+emailID, nil) - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleEmailByID_PUT(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - update := map[string]any{ - "unread": false, - "starred": true, - } - body, _ := json.Marshal(update) - req := httptest.NewRequest(http.MethodPut, "/api/emails/email-1", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleEmailByID_DELETE(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodDelete, "/api/emails/email-1", nil) - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -// ================================ -// EMAIL RESPONSE CONVERTER TESTS -// ================================ - -func TestEmailToResponse_Basic(t *testing.T) { - t.Parallel() - - msg := domain.Message{ - ID: "msg-123", - ThreadID: "thread-456", - Subject: "Test Subject", - Snippet: "This is a snippet...", - Body: "Full body content
", - Unread: true, - Starred: false, - Folders: []string{"INBOX"}, - } - - resp := emailToResponse(msg, false) - - if resp.ID != "msg-123" { - t.Errorf("expected ID 'msg-123', got %s", resp.ID) - } - if resp.ThreadID != "thread-456" { - t.Errorf("expected ThreadID 'thread-456', got %s", resp.ThreadID) - } - if resp.Subject != "Test Subject" { - t.Errorf("expected Subject 'Test Subject', got %s", resp.Subject) - } - if resp.Snippet != "This is a snippet..." { - t.Errorf("expected Snippet to match, got %s", resp.Snippet) - } - if resp.Body != "" { - t.Error("expected Body to be empty when includeBody=false") - } - if !resp.Unread { - t.Error("expected Unread to be true") - } - if resp.Starred { - t.Error("expected Starred to be false") - } -} - -func TestEmailToResponse_WithBody(t *testing.T) { - t.Parallel() - - msg := domain.Message{ - ID: "msg-123", - Body: "Full body content
", - } - - resp := emailToResponse(msg, true) - - if resp.Body != "Full body content
" { - t.Errorf("expected Body to be included, got %s", resp.Body) - } -} - -func TestEmailToResponse_WithParticipants(t *testing.T) { - t.Parallel() - - msg := domain.Message{ - ID: "msg-123", - From: []domain.EmailParticipant{ - {Name: "Sender Name", Email: "sender@example.com"}, - }, - To: []domain.EmailParticipant{ - {Name: "Recipient One", Email: "recipient1@example.com"}, - {Name: "Recipient Two", Email: "recipient2@example.com"}, - }, - Cc: []domain.EmailParticipant{ - {Name: "CC Person", Email: "cc@example.com"}, - }, - } - - resp := emailToResponse(msg, false) - - if len(resp.From) != 1 { - t.Errorf("expected 1 From participant, got %d", len(resp.From)) - } - if resp.From[0].Email != "sender@example.com" { - t.Errorf("expected From email 'sender@example.com', got %s", resp.From[0].Email) - } - - if len(resp.To) != 2 { - t.Errorf("expected 2 To participants, got %d", len(resp.To)) - } - - if len(resp.Cc) != 1 { - t.Errorf("expected 1 Cc participant, got %d", len(resp.Cc)) - } -} - -func TestEmailToResponse_WithAttachments(t *testing.T) { - t.Parallel() - - msg := domain.Message{ - ID: "msg-123", - Attachments: []domain.Attachment{ - {ID: "att-1", Filename: "document.pdf", ContentType: "application/pdf", Size: 1024}, - {ID: "att-2", Filename: "image.png", ContentType: "image/png", Size: 2048}, - }, - } - - resp := emailToResponse(msg, false) - - if len(resp.Attachments) != 2 { - t.Errorf("expected 2 attachments, got %d", len(resp.Attachments)) - } - - if resp.Attachments[0].Filename != "document.pdf" { - t.Errorf("expected first attachment filename 'document.pdf', got %s", resp.Attachments[0].Filename) - } - if resp.Attachments[1].Size != 2048 { - t.Errorf("expected second attachment size 2048, got %d", resp.Attachments[1].Size) - } -} - -func TestCachedEmailToResponse(t *testing.T) { - t.Parallel() - - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) - cachedEmail := &cache.CachedEmail{ - ID: "email-123", - ThreadID: "thread-456", - FolderID: "inbox", - Subject: "Test Email Subject", - Snippet: "This is a test snippet...", - FromName: "John Doe", - FromEmail: "john@example.com", - To: []string{"recipient@example.com"}, - Date: testTime, - Unread: true, - Starred: false, - HasAttachments: true, - } - - resp := cachedEmailToResponse(cachedEmail) - - if resp.ID != "email-123" { - t.Errorf("ID = %q, want %q", resp.ID, "email-123") - } - if resp.ThreadID != "thread-456" { - t.Errorf("ThreadID = %q, want %q", resp.ThreadID, "thread-456") - } - if resp.Subject != "Test Email Subject" { - t.Errorf("Subject = %q, want %q", resp.Subject, "Test Email Subject") - } - if resp.Snippet != "This is a test snippet..." { - t.Errorf("Snippet = %q, want %q", resp.Snippet, "This is a test snippet...") - } - if len(resp.From) != 1 || resp.From[0].Name != "John Doe" || resp.From[0].Email != "john@example.com" { - t.Errorf("From = %+v, want [{John Doe john@example.com}]", resp.From) - } - if resp.Date != testTime.Unix() { - t.Errorf("Date = %d, want %d", resp.Date, testTime.Unix()) - } - if !resp.Unread { - t.Error("Unread should be true") - } - if resp.Starred { - t.Error("Starred should be false") - } - if len(resp.Folders) != 1 || resp.Folders[0] != "inbox" { - t.Errorf("Folders = %v, want [inbox]", resp.Folders) - } -} - -func TestCachedEmailToResponse_EmptyFields(t *testing.T) { - t.Parallel() - - cachedEmail := &cache.CachedEmail{ - ID: "email-empty", - Date: time.Time{}, - } - - resp := cachedEmailToResponse(cachedEmail) - - if resp.ID != "email-empty" { - t.Errorf("ID = %q, want %q", resp.ID, "email-empty") - } - if resp.ThreadID != "" { - t.Errorf("ThreadID should be empty, got %q", resp.ThreadID) - } - if len(resp.From) != 1 { - t.Error("From should have one entry even with empty values") - } -} - -func TestHandleSendMessage_ValidRequest(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - msg := map[string]any{ - "to": []map[string]string{ - {"email": "recipient@example.com", "name": "Recipient"}, - }, - "subject": "Test Subject", - "body": "Test body content", - } - body, _ := json.Marshal(msg) - req := httptest.NewRequest(http.MethodPost, "/api/messages/send", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleSendMessage(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestEmailResponseSerialization(t *testing.T) { - t.Parallel() - - resp := EmailResponse{ - ID: "email-123", - Subject: "Test Subject", - Body: "Test Body", - From: []EmailParticipantResponse{ - {Email: "sender@example.com", Name: "Sender"}, - }, - To: []EmailParticipantResponse{ - {Email: "recipient@example.com", Name: "Recipient"}, - }, - Unread: true, - Starred: false, - } - - data, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal: %v", err) - } - - var decoded EmailResponse - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if decoded.ID != resp.ID { - t.Errorf("expected ID %s, got %s", resp.ID, decoded.ID) - } - - if decoded.Subject != resp.Subject { - t.Errorf("expected Subject %s, got %s", resp.Subject, decoded.Subject) - } - - if len(decoded.From) != 1 { - t.Errorf("expected 1 From, got %d", len(decoded.From)) - } - - if len(decoded.To) != 1 { - t.Errorf("expected 1 To, got %d", len(decoded.To)) - } -} diff --git a/internal/air/handlers_email_basic_test.go b/internal/air/handlers_email_basic_test.go deleted file mode 100644 index f584db3..0000000 --- a/internal/air/handlers_email_basic_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// ================================ -// EMAILS HANDLER TESTS -// ================================ - -func TestHandleListEmails_DemoMode(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/emails", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp EmailsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if len(resp.Emails) == 0 { - t.Error("expected non-empty emails") - } - - // Check first email has expected fields - if resp.Emails[0].ID == "" { - t.Error("expected email to have ID") - } - - if resp.Emails[0].Subject == "" { - t.Error("expected email to have Subject") - } - - if len(resp.Emails[0].From) == 0 { - t.Error("expected email to have From") - } -} - -func TestHandleGetEmail_DemoMode(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/emails/demo-email-001", nil) - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp EmailResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.ID != "demo-email-001" { - t.Errorf("expected ID 'demo-email-001', got %s", resp.ID) - } -} - -func TestHandleGetEmail_NotFound(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/emails/nonexistent", nil) - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected status 404, got %d", w.Code) - } -} - -func TestHandleUpdateEmail_DemoMode(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - body := `{"unread": false, "starred": true}` - req := httptest.NewRequest(http.MethodPut, "/api/emails/demo-email-001", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp UpdateEmailResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Error("expected Success to be true") - } -} - -func TestHandleDeleteEmail_DemoMode(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodDelete, "/api/emails/demo-email-001", nil) - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp UpdateEmailResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Error("expected Success to be true") - } -} - -func TestHandleListEmails_WithQueryParams(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - // Test with folder filter - req := httptest.NewRequest(http.MethodGet, "/api/emails?folder=inbox&limit=10", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp EmailsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if len(resp.Emails) == 0 { - t.Error("expected emails in response") - } -} - -func TestHandleListEmails_WithPagination(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - // Test with page token - req := httptest.NewRequest(http.MethodGet, "/api/emails?page_token=test", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -func TestHandleListEmails_MethodNotAllowed(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPost, "/api/emails", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleEmailByID_WithInvalidMethod(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodOptions, "/api/emails/demo-email-001", nil) - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleEmailByID_MissingID(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/emails/", nil) - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } -} - -func TestHandleUpdateEmail_InvalidJSON(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodPut, "/api/emails/demo-email-001", strings.NewReader("not valid json")) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleUpdateEmail(w, req, "demo-email-001") - - // Demo mode might still succeed or return bad request - // Either is acceptable for invalid JSON - if w.Code != http.StatusOK && w.Code != http.StatusBadRequest { - t.Errorf("expected status 200 or 400, got %d", w.Code) - } -} - -func TestHandleDeleteEmail_DemoMode_Additional(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodDelete, "/api/emails/demo-email-001", nil) - w := httptest.NewRecorder() - - server.handleDeleteEmail(w, req, "demo-email-001") - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if success, ok := resp["success"].(bool); !ok || !success { - t.Error("expected success to be true") - } -} - -func TestHandleGetEmail_DemoMode_Found(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - // Get a known demo email ID (matches demoEmails() function) - req := httptest.NewRequest(http.MethodGet, "/api/emails/demo-email-001", nil) - w := httptest.NewRecorder() - - server.handleGetEmail(w, req, "demo-email-001") - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp EmailResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.ID != "demo-email-001" { - t.Errorf("expected ID 'demo-email-001', got %s", resp.ID) - } -} - -func TestHandleGetEmail_DemoMode_NotFound(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/emails/nonexistent-id", nil) - w := httptest.NewRecorder() - - server.handleGetEmail(w, req, "nonexistent-id") - - if w.Code != http.StatusNotFound { - t.Errorf("expected status 404, got %d", w.Code) - } -} diff --git a/internal/air/handlers_email_cache_runtime_test.go b/internal/air/handlers_email_cache_runtime_test.go deleted file mode 100644 index 7ed2553..0000000 --- a/internal/air/handlers_email_cache_runtime_test.go +++ /dev/null @@ -1,703 +0,0 @@ -package air - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "path/filepath" - "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) ReplaceGrants(grants []domain.GrantInfo) error { - s.grants = append([]domain.GrantInfo(nil), grants...) - 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, err := cache.LoadSettings(tmpDir) - if err != nil { - t.Fatalf("load cache settings: %v", err) - } - 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 TestNewCachedTestServerSettingsUseTempCachePath(t *testing.T) { - server, _, accountEmail := newCachedTestServer(t) - - want := filepath.Dir(server.cacheManager.DBPath(accountEmail)) - if got := server.cacheSettings.BasePath(); got != want { - t.Fatalf("cache settings base path = %q, want cache manager base path %q", got, want) - } -} - -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) - } - }) - } -} - -// Pins the fix for the Sent-folder coverage bug: when the cache holds a -// partial view of a folder (typical for Sent/Drafts/etc. on busy accounts — -// background sync fetches top-N unfiltered, dominated by Inbox), the handler -// must fall through to the API. Otherwise the user sees a 1–2 message stub -// when their real Sent folder has hundreds. -func TestHandleListEmails_FolderPartialCache_FallsThroughToAPI(t *testing.T) { - t.Parallel() - - server, client, accountEmail := newCachedTestServer(t) - - // Single sent message in cache (the "1 email" symptom from the bug - // report). - putCachedEmail(t, server, accountEmail, &cache.CachedEmail{ - ID: "cached-sent-1", - FolderID: "sent", - Subject: "Hello From Nylas", - FromName: "Qasim", - FromEmail: "qasim@example.com", - Date: time.Now(), - CachedAt: time.Now(), - }) - - apiCalled := false - client.GetMessagesWithParamsFunc = func(_ context.Context, _ string, _ *domain.MessageQueryParams) ([]domain.Message, error) { - apiCalled = true - msgs := make([]domain.Message, 0, 3) - for i := range 3 { - msgs = append(msgs, domain.Message{ - ID: "api-sent-" + string(rune('a'+i)), - Subject: "Real sent message", - Folders: []string{"sent"}, - Date: time.Now(), - }) - } - return msgs, nil - } - - req := httptest.NewRequest(http.MethodGet, "/api/emails?folder=sent", nil) - w := httptest.NewRecorder() - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) - } - if !apiCalled { - t.Fatal("expected API call when cache has partial folder coverage; got cache short-circuit") - } -} - -// Pins the inverse: a fully covered cache page must short-circuit so that -// Inbox loads stay fast. -func TestHandleListEmails_FolderFullCache_ShortCircuits(t *testing.T) { - t.Parallel() - - server, client, accountEmail := newCachedTestServer(t) - - // 50 cached inbox messages = full default page (handleListEmails uses - // query.GetLimit(50)). - for i := range 50 { - putCachedEmail(t, server, accountEmail, &cache.CachedEmail{ - ID: "inbox-" + string(rune('A'+i%26)) + string(rune('A'+i/26)), - FolderID: "inbox", - Subject: "Cached message", - FromEmail: "sender@example.com", - Date: time.Now().Add(-time.Duration(i) * time.Minute), - CachedAt: time.Now(), - }) - } - - client.GetMessagesWithParamsFunc = func(_ context.Context, _ string, _ *domain.MessageQueryParams) ([]domain.Message, error) { - t.Fatal("expected cache short-circuit when cache holds a full page") - return nil, nil - } - - req := httptest.NewRequest(http.MethodGet, "/api/emails?folder=inbox", nil) - w := httptest.NewRecorder() - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) - } -} - -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() { - // nil folders triggers the unfiltered top-N fallback path, which is - // the behavior this lock-contention test was written against. - server.syncEmails(context.Background(), accountEmail, "grant-123", nil) - 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(90 * 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 - folders := []string{"archive"} - if err := server.enqueueMessageUpdate("grant-123", accountEmail, "email-2", &domain.UpdateMessageRequest{ - Unread: &unread, - Starred: &starred, - Folders: folders, - }); 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_email_demo.go b/internal/air/handlers_email_demo.go deleted file mode 100644 index b4d2720..0000000 --- a/internal/air/handlers_email_demo.go +++ /dev/null @@ -1,237 +0,0 @@ -package air - -import ( - "strings" - "time" -) - -// demoEmails returns demo email data spread across multiple folders so the -// sidebar (Inbox / Sent / Drafts / Archive / Trash) actually shows different -// content per folder. Includes one calendar-invite (.ics) email so the -// calendar-invite card UI has something to render. -func demoEmails() []EmailResponse { - now := time.Now() - return []EmailResponse{ - // Inbox - { - ID: "demo-email-001", - Subject: "Q4 Product Roadmap Review", - Snippet: "Hi team, I've attached the updated roadmap for Q4...", - Body: "Hi team,
I've attached the updated roadmap for Q4. Please review the timeline changes and let me know if you have any concerns.
", - From: []EmailParticipantResponse{{Name: "Sarah Chen", Email: "sarah.chen@company.com"}}, - To: []EmailParticipantResponse{{Name: "Team", Email: "team@company.com"}}, - Date: now.Add(-2 * time.Minute).Unix(), - Unread: true, - Starred: true, - Folders: []string{"inbox"}, - Attachments: []AttachmentResponse{ - {ID: "att-001", Filename: "Q4_Roadmap_v2.pdf", ContentType: "application/pdf", Size: 2516582}, - }, - }, - { - ID: "demo-email-002", - Subject: "[nylas/cli] PR #142: Add focus time feature", - Snippet: "mergify[bot] merged 1 commit into main...", - From: []EmailParticipantResponse{{Name: "GitHub", Email: "notifications@github.com"}}, - To: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - Date: now.Add(-15 * time.Minute).Unix(), - Unread: true, - Starred: false, - Folders: []string{"inbox"}, - }, - { - ID: "demo-email-003", - Subject: "Re: Meeting Tomorrow", - Snippet: "That works for me. I'll send a calendar invite...", - From: []EmailParticipantResponse{{Name: "Alex Johnson", Email: "demo@example.com"}}, - To: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - Date: now.Add(-1 * time.Hour).Unix(), - Unread: false, - Starred: false, - Folders: []string{"inbox"}, - }, - { - ID: "demo-email-004", - Subject: "Your December invoice is ready", - Snippet: "Your invoice for December 2024 is now available...", - From: []EmailParticipantResponse{{Name: "Stripe", Email: "billing@stripe.com"}}, - To: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - Date: now.Add(-3 * time.Hour).Unix(), - Unread: false, - Starred: true, - Folders: []string{"inbox"}, - }, - { - ID: "demo-email-005", - Subject: "This week in design: AI tools reshaping...", - Snippet: "The latest trends, tools, and inspiration...", - From: []EmailParticipantResponse{{Name: "Design Weekly", Email: "newsletter@designweekly.com"}}, - To: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - Date: now.Add(-5 * time.Hour).Unix(), - Unread: false, - Starred: false, - Folders: []string{"inbox"}, - }, - // Calendar invite (with .ics attachment) - { - ID: "demo-email-invite-001", - Subject: "Event Invitation: Quarterly Sync", - Snippet: "You have received a calendar invitation: Quarterly Sync", - Body: "You have received a calendar invitation: Quarterly Sync
Please let me know if this time works.
", - From: []EmailParticipantResponse{{Name: "Priya Patel", Email: "priya@partner.example"}}, - To: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - Date: now.Add(-30 * time.Minute).Unix(), - Unread: true, - Starred: false, - Folders: []string{"inbox"}, - Attachments: []AttachmentResponse{ - { - ID: "att-invite-001", - Filename: "invite.ics", - ContentType: "text/calendar", - Size: 1024, - }, - }, - }, - // Sent — explicitly more than one so we can prove the filter works. - { - ID: "demo-email-sent-001", - Subject: "Re: Q4 Product Roadmap Review", - Snippet: "Thanks Sarah, here are my comments on the roadmap...", - Body: "Thanks Sarah,
Here are my comments on the roadmap. Looks good overall — happy to discuss the Q4 priorities live.
", - From: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - To: []EmailParticipantResponse{{Name: "Sarah Chen", Email: "sarah.chen@company.com"}}, - Date: now.Add(-1 * time.Hour).Unix(), - Folders: []string{"sent"}, - }, - { - ID: "demo-email-sent-002", - Subject: "Pricing follow-up", - Snippet: "Hi Mike, sending the updated pricing sheet...", - Body: "Hi Mike,
Sending the updated pricing sheet as discussed. Let me know if you need any changes.
", - From: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - To: []EmailParticipantResponse{{Name: "Mike Johnson", Email: "mike@customer.example"}}, - Date: now.Add(-3 * time.Hour).Unix(), - Folders: []string{"sent"}, - }, - { - ID: "demo-email-sent-003", - Subject: "Welcome to the team!", - Snippet: "Excited to have you joining us next Monday...", - Body: "Excited to have you joining us next Monday! Here's the on-boarding checklist.
", - From: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - To: []EmailParticipantResponse{{Name: "Jamie Lee", Email: "jamie@newhire.example"}}, - Date: now.Add(-1 * 24 * time.Hour).Unix(), - Folders: []string{"sent"}, - }, - // Drafts - { - ID: "demo-email-draft-001", - Subject: "Draft: Proposal for Acme", - Snippet: "Hi Acme team, here's the rough proposal...", - Body: "Hi Acme team,
Here's the rough proposal — still working through the timeline section.
", - From: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - To: []EmailParticipantResponse{{Name: "Acme Procurement", Email: "buyers@acme.example"}}, - Date: now.Add(-4 * time.Hour).Unix(), - Folders: []string{"drafts"}, - }, - // Archive - { - ID: "demo-email-archive-001", - Subject: "Confirmation: Subscription renewed", - Snippet: "Your annual subscription has been renewed...", - Body: "Your annual subscription has been renewed for another year.
", - From: []EmailParticipantResponse{{Name: "Acme Billing", Email: "billing@acme.example"}}, - To: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - Date: now.Add(-30 * 24 * time.Hour).Unix(), - Folders: []string{"archive"}, - }, - // Trash - { - ID: "demo-email-trash-001", - Subject: "URGENT: Winning offer (don't miss out)", - Snippet: "You've been pre-selected for an exclusive offer...", - Body: "You've been pre-selected.
", - From: []EmailParticipantResponse{{Name: "Promo Bot", Email: "deals@spammy.example"}}, - To: []EmailParticipantResponse{{Name: "You", Email: "you@example.com"}}, - Date: now.Add(-7 * 24 * time.Hour).Unix(), - Folders: []string{"trash"}, - }, - } -} - -// filterDemoEmails applies folder/unread/starred filters to a demo email -// list. Folder matching is case-insensitive against email.Folders entries -// and against well-known aliases (e.g., "SENT" → "sent", "Sent Items" -// → "sent"). Empty folder string means "no folder filter." -func filterDemoEmails(emails []EmailResponse, folder string, onlyUnread, onlyStarred bool) []EmailResponse { - target := normalizeDemoFolder(folder) - out := make([]EmailResponse, 0, len(emails)) - for _, e := range emails { - if onlyUnread && !e.Unread { - continue - } - if onlyStarred && !e.Starred { - continue - } - if target != "" && !demoEmailIsInFolder(e, target) { - continue - } - out = append(out, e) - } - return out -} - -// normalizeDemoFolder turns a UI-supplied folder identifier (e.g., the -// system-folder ID "SENT", the Microsoft display name "Sent Items", or the -// canonical "sent") into the lowercase canonical name used in demoEmails. -// -// "all" and "all mail" route to the dedicated "all" target rather than -// collapsing into "archive": Gmail's "All Mail" view shows every -// message regardless of folder, and demoEmailIsInFolder's `target == -// "all"` branch matches that semantic. Aliasing them to "archive" -// instead would surface only the single demo email tagged with the -// archive folder, which is wrong end-user behavior. -func normalizeDemoFolder(folder string) string { - f := strings.ToLower(strings.TrimSpace(folder)) - switch f { - case "": - return "" - case "inbox": - return "inbox" - case "sent", "sent items", "sent mail": - return "sent" - case "drafts", "draft": - return "drafts" - case "archive": - return "archive" - case "all", "all mail": - return "all" - case "trash", "deleted items", "deleted": - return "trash" - case "spam", "junk", "junk email": - return "spam" - case "starred": - return "starred" - default: - return f - } -} - -// demoEmailIsInFolder reports whether the email is in `target` (already -// canonicalised). Special-cases "starred" since starring is a flag, not a -// folder. "all" never filters anything out. -func demoEmailIsInFolder(e EmailResponse, target string) bool { - if target == "starred" { - return e.Starred - } - if target == "all" { - return true - } - for _, f := range e.Folders { - if strings.EqualFold(f, target) { - return true - } - } - return false -} diff --git a/internal/air/handlers_email_invite.go b/internal/air/handlers_email_invite.go deleted file mode 100644 index 7e056c1..0000000 --- a/internal/air/handlers_email_invite.go +++ /dev/null @@ -1,298 +0,0 @@ -package air - -import ( - "context" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/nylas/cli/internal/domain" -) - -// maxICSBytes caps a single iCalendar payload. 1 MB is far above any -// real invitation and keeps memory predictable when an attacker stitches -// a large fake attachment. -const maxICSBytes = 1 << 20 - -// CalendarInviteResponse is the JSON returned by /api/emails/{id}/invite. -// It is intentionally a small subset of iCalendar VEVENT — just what the -// Air UI needs to render a Gmail-style "you have an invite" card. -type CalendarInviteResponse struct { - HasInvite bool `json:"has_invite"` - AttachmentID string `json:"attachment_id,omitempty"` - Filename string `json:"filename,omitempty"` - ICalUID string `json:"ical_uid,omitempty"` // VEVENT UID; lets the RSVP endpoint resolve a Nylas event ID - Title string `json:"title,omitempty"` - Location string `json:"location,omitempty"` - Description string `json:"description,omitempty"` - StartTime int64 `json:"start_time,omitempty"` - EndTime int64 `json:"end_time,omitempty"` - IsAllDay bool `json:"is_all_day,omitempty"` - OrganizerName string `json:"organizer_name,omitempty"` - OrganizerEmail string `json:"organizer_email,omitempty"` - ConferencingURL string `json:"conferencing_url,omitempty"` - Status string `json:"status,omitempty"` // CONFIRMED / TENTATIVE / CANCELLED - Method string `json:"method,omitempty"` // REQUEST / CANCEL / REPLY - Attendees []InviteAttendee `json:"attendees,omitempty"` - RecurrenceRule string `json:"recurrence_rule,omitempty"` // raw RRULE for callers that want to summarise -} - -// InviteAttendee is a participant on the VEVENT — surfaced so the Air -// invite card can render the same "Alice (accepted), Bob (declined)" -// list that Gmail shows. Distinct from the demo-data Attendee in -// data.go which is a UI-only avatar. -type InviteAttendee struct { - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - Status string `json:"status,omitempty"` // ACCEPTED / DECLINED / TENTATIVE / NEEDS-ACTION - Role string `json:"role,omitempty"` - IsOrganizer bool `json:"is_organizer,omitempty"` -} - -// errInviteFetchFailed flags a transient upstream failure on the second -// raw_mime fetch (the fallback after attachment download fails). Callers -// distinguish it from "the email simply has no invite" so they can choose -// between silent degrade (preview card) and 502 (RSVP — the user is -// actively trying to do something and deserves an actionable error). -var errInviteFetchFailed = errors.New("invite: failed to fetch raw_mime") - -// handleEmailInvite returns parsed iCalendar invite data for an email. -// Returns has_invite=false when neither attachments[] nor raw_mime yields -// a calendar payload — the frontend silently degrades to the regular -// email render in that case. Transient raw_mime fetch failures are also -// silently degraded here so a flaky upstream doesn't break the inbox -// view; the RSVP endpoint surfaces the same condition as 502 because the -// user is actively trying to act on the invite. -func (s *Server) handleEmailInvite(w http.ResponseWriter, r *http.Request, emailID string) { - if !requireMethod(w, r, http.MethodGet) { - return - } - - if s.demoMode { - writeJSON(w, http.StatusOK, demoInviteFor(emailID)) - return - } - - grantID := s.withAuthGrant(w, nil) - if grantID == "" { - return - } - - ctx, cancel := s.withTimeout(r) - defer cancel() - - resp, err := s.resolveEmailInvite(ctx, grantID, emailID) - if err != nil { - // Preserve the legacy "preview card silently disappears" UX even - // when raw_mime can't be fetched. Only the initial GetMessage - // failure (a hard error) reaches here. - if errors.Is(err, errInviteFetchFailed) { - writeJSON(w, http.StatusOK, CalendarInviteResponse{HasInvite: false}) - return - } - writeUpstreamError(w, http.StatusInternalServerError, - "Failed to fetch email — please try again", err, - "emailID", emailID) - return - } - writeJSON(w, http.StatusOK, resp) -} - -// resolveEmailInvite parses a message's iCalendar invite, if any. -// Resolution order: -// 1. attachments[] with text/calendar or .ics (Microsoft/custom senders). -// 2. inline text/calendar in raw_mime (Gmail's multipart/alternative shape). -// -// Returns HasInvite=false on no-invite. Non-nil error only on initial -// GetMessage failure — attachment-download and raw_mime errors are swallowed. -func (s *Server) resolveEmailInvite(ctx context.Context, grantID, emailID string) (CalendarInviteResponse, error) { - msg, err := s.nylasClient.GetMessage(ctx, grantID, emailID) - if err != nil { - return CalendarInviteResponse{}, err - } - - att := findCalendarAttachment(msg.Attachments) - if att != nil { - if parsed, ok := s.tryParseAttachmentInvite(ctx, grantID, emailID, att); ok { - return parsed, nil - } - // Download or parse failed. Don't surface as an error — Nylas - // frequently returns synthetic attachment IDs (v0:base64(...):...) - // that look like real attachments but cannot be downloaded. - // Falling through to the raw_mime walker recovers the calendar - // payload directly from the MIME tree. - } - - // Fetch raw MIME and look for a text/calendar part — both Gmail's - // inline-multipart shape AND Nylas's "synthetic attachment that - // can't be downloaded" case land here. A network error on this call - // is *transient* and distinguishable from "email has no invite", so - // we surface it as errInviteFetchFailed: callers in actionable paths - // (RSVP) can return 502, while the preview path silently degrades. - full, err := s.nylasClient.GetMessageWithFields(ctx, grantID, emailID, "raw_mime") - if err != nil { - // Double-wrap so callers can errors.Is against errInviteFetchFailed - // (the sentinel) AND unwrap the underlying transport error for - // logging. Go 1.20+ supports multiple %w in a single Errorf. - return CalendarInviteResponse{}, fmt.Errorf("%w: %w", errInviteFetchFailed, err) - } - if full == nil || full.RawMIME == "" { - return CalendarInviteResponse{HasInvite: false}, nil - } - parts := findInlineCalendarParts(full.RawMIME) - if len(parts) == 0 { - return CalendarInviteResponse{HasInvite: false}, nil - } - - parsed, err := parseICS(parts[0].Body) - if err != nil { - return CalendarInviteResponse{HasInvite: false}, nil - } - parsed.HasInvite = true - // If we had a Nylas attachment entry (even an undownloadable - // synthetic one), keep its ID so the frontend's existing - // attachment-row check can match by name; otherwise mint a stable - // inline marker so the row still renders. - if att != nil { - parsed.AttachmentID = att.ID - parsed.Filename = att.Filename - } else { - parsed.AttachmentID = inlineCalendarAttachmentID(parts[0].ContentID) - if parts[0].Filename != "" { - parsed.Filename = parts[0].Filename - } else { - parsed.Filename = "invite.ics" - } - } - if parsed.Method == "" && parts[0].Method != "" { - parsed.Method = parts[0].Method - } - return parsed, nil -} - -// tryParseAttachmentInvite downloads and parses an ICS attachment. -// Returns ok=false on any failure so the caller falls back to raw_mime; -// each failure logs at slog.Debug for diagnosability. -func (s *Server) tryParseAttachmentInvite(ctx context.Context, grantID, emailID string, att *domain.Attachment) (CalendarInviteResponse, bool) { - body, err := s.nylasClient.DownloadAttachment(ctx, grantID, emailID, att.ID) - if err != nil { - slog.Debug("invite attachment download failed", - "attachment_id", att.ID, "filename", att.Filename, "err", err) - return CalendarInviteResponse{}, false - } - defer func() { _ = body.Close() }() - - raw, err := io.ReadAll(io.LimitReader(body, maxICSBytes+1)) - if err != nil || len(raw) > maxICSBytes { - slog.Debug("invite attachment read failed or oversized", - "attachment_id", att.ID, "filename", att.Filename, - "size", len(raw), "max", maxICSBytes, "err", err) - return CalendarInviteResponse{}, false - } - - parsed, err := parseICS(string(raw)) - if err != nil { - slog.Debug("invite attachment ICS parse failed", - "attachment_id", att.ID, "filename", att.Filename, "err", err) - return CalendarInviteResponse{}, false - } - parsed.HasInvite = true - parsed.AttachmentID = att.ID - parsed.Filename = att.Filename - return parsed, true -} - -// inlineCalendarPrefix prefixes synthetic attachment IDs that point at -// a text/calendar MIME part rather than a real Nylas attachment. The -// download endpoint recognises this prefix and serves the part directly -// from raw_mime instead of forwarding to Nylas. -const inlineCalendarPrefix = "inline-calendar:" - -// inlineCalendarAttachmentID builds a stable synthetic attachment ID for -// a calendar MIME part. Falls back to a fixed marker when the source -// part has no Content-ID — Gmail invitations often omit it. -func inlineCalendarAttachmentID(contentID string) string { - if contentID == "" { - return inlineCalendarPrefix + "default" - } - return inlineCalendarPrefix + contentID -} - -// isInlineCalendarAttachmentID reports whether an attachment ID came -// from a synthesized calendar part rather than a real Nylas attachment. -func isInlineCalendarAttachmentID(id string) bool { - return strings.HasPrefix(id, inlineCalendarPrefix) -} - -// findCalendarAttachment locates the first attachment that looks like an -// iCalendar invite — either by content type or by filename suffix. -func findCalendarAttachment(atts []domain.Attachment) *domain.Attachment { - for i := range atts { - if isCalendarAttachment(atts[i].ContentType, atts[i].Filename) { - return &atts[i] - } - } - return nil -} - -// isCalendarAttachment is the shared "looks like an invite" predicate so -// frontend and tests can reuse the same rule. -func isCalendarAttachment(contentType, filename string) bool { - ct := strings.ToLower(strings.TrimSpace(contentType)) - fn := strings.ToLower(strings.TrimSpace(filename)) - return strings.HasPrefix(ct, "text/calendar") || - strings.HasPrefix(ct, "application/ics") || - strings.HasSuffix(fn, ".ics") -} - -// errNoUsableEvent is returned by parseICS when the iCalendar payload is -// either malformed, has no VEVENT, or whose first VEVENT carries no -// fields the UI can render. Callers translate this into a "no invite" -// response rather than a 5xx — clients should silently degrade. -var errNoUsableEvent = errors.New("ics: no usable event") - -// demoInviteFor returns canned parsed-event data so the calendar-invite -// card has something to render in demo mode without round-tripping -// through the parser. -func demoInviteFor(emailID string) CalendarInviteResponse { - if emailID != "demo-email-invite-001" { - return CalendarInviteResponse{HasInvite: false} - } - - start := demoInviteStart() - end := start.Add(time.Hour) - - return CalendarInviteResponse{ - HasInvite: true, - AttachmentID: "att-invite-001", - Filename: "invite.ics", - ICalUID: "demo-invite-001@nylas.example", - Title: "Quarterly Sync", - Location: "Conference Room A / Online", - Description: "Quarterly review with the partner team.", - StartTime: start.Unix(), - EndTime: end.Unix(), - IsAllDay: false, - OrganizerName: "Priya Patel", - OrganizerEmail: "priya@partner.example", - ConferencingURL: "https://meet.example.com/q-sync", - Status: "CONFIRMED", - Method: "REQUEST", - Attendees: []InviteAttendee{ - {Name: "Priya Patel", Email: "priya@partner.example", Status: "ACCEPTED", Role: "CHAIR", IsOrganizer: true}, - {Name: "Alex Reed", Email: "alex@example.com", Status: "ACCEPTED", Role: "REQ-PARTICIPANT"}, - {Name: "Jamie Chen", Email: "jamie@example.com", Status: "TENTATIVE", Role: "REQ-PARTICIPANT"}, - }, - } -} - -// demoInviteStart returns a stable demo start time — 24h from now, -// truncated to the hour. Extracted so tests can stub via build tag if -// the rounding behaviour ever needs pinning. -func demoInviteStart() time.Time { - return time.Now().Add(24 * time.Hour).Truncate(time.Hour) -} diff --git a/internal/air/handlers_email_invite_handler_test.go b/internal/air/handlers_email_invite_handler_test.go deleted file mode 100644 index 33d3f76..0000000 --- a/internal/air/handlers_email_invite_handler_test.go +++ /dev/null @@ -1,333 +0,0 @@ -package air - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/nylas/cli/internal/domain" -) - -// TestHandleEmailInvite_RawMIMEFallback drives the Gmail-style invitation -// path: attachments[] is empty, so the handler must fetch raw_mime and -// pull the calendar payload from the inline body part. Pins the fix for -// "Air email viewer fails to render Gmail invitations" — Nylas doesn't -// surface inline parts as attachments, and the previous handler simply -// reported has_invite=false in that case. -func TestHandleEmailInvite_RawMIMEFallback(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - - client.GetMessageFunc = func(_ context.Context, _ string, messageID string) (*domain.Message, error) { - return &domain.Message{ - ID: messageID, - Subject: "Event Invitation: Meeting", - Attachments: nil, // The whole point: no calendar attachment surfaced. - }, nil - } - client.GetMessageWithFieldsFunc = func(_ context.Context, _ string, messageID, fields string) (*domain.Message, error) { - if fields != "raw_mime" { - t.Fatalf("handler asked for fields=%q, want raw_mime", fields) - } - return &domain.Message{ - ID: messageID, - Subject: "Event Invitation: Meeting", - RawMIME: gmailStyleInviteMIME, - }, nil - } - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/api/emails/email-1/invite", http.NoBody) - server.handleEmailInvite(w, r, "email-1") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - - var got CalendarInviteResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode response: %v", err) - } - - if !got.HasInvite { - t.Errorf("HasInvite=false, want true (raw_mime fallback should detect inline calendar)") - } - if got.Title != "Meeting" { - t.Errorf("Title=%q, want Meeting", got.Title) - } - if got.Method != "REQUEST" { - t.Errorf("Method=%q, want REQUEST", got.Method) - } - if !strings.HasPrefix(got.AttachmentID, inlineCalendarPrefix) { - t.Errorf("AttachmentID=%q, want prefix %q (inline calendar marker)", got.AttachmentID, inlineCalendarPrefix) - } - if got.Filename == "" { - t.Errorf("Filename should be populated for inline calendar parts (defaulted to invite.ics)") - } -} - -// TestHandleEmailInvite_NoCalendarAtAll exercises the silent-degrade -// path: neither attachments[] nor raw_mime contains a calendar part. -// The handler must return has_invite=false rather than 5xx so the -// frontend can keep the regular email render. -func TestHandleEmailInvite_NoCalendarAtAll(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - client.GetMessageFunc = func(_ context.Context, _ string, messageID string) (*domain.Message, error) { - return &domain.Message{ID: messageID, Subject: "Just a regular email"}, nil - } - client.GetMessageWithFieldsFunc = func(_ context.Context, _ string, messageID, _ string) (*domain.Message, error) { - return &domain.Message{ - ID: messageID, - RawMIME: "From: a@example.com\r\nSubject: Hi\r\nContent-Type: text/plain\r\n\r\nNo calendar here.\r\n", - }, nil - } - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/api/emails/email-2/invite", http.NoBody) - server.handleEmailInvite(w, r, "email-2") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - var got CalendarInviteResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.HasInvite { - t.Errorf("expected HasInvite=false for non-invite email, got %+v", got) - } -} - -// TestHandleEmailInvite_SyntheticAttachmentFallsBackToRawMIME pins the -// regression where Nylas's single-message API returns a synthetic -// attachment entry (id like "v0:base64(name):base64(ct):size") that -// looks downloadable but actually 404s on the attachments endpoint. -// The pre-fix handler returned 5xx; the fix falls through to the -// raw_mime walker so the user still gets the invite card. -func TestHandleEmailInvite_SyntheticAttachmentFallsBackToRawMIME(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - - // Synthetic attachment ID in the v0:... format Nylas v3 uses for - // inline parts surfaced via the single-message endpoint. - syntheticID := "v0:aW52aXRlLmljcw==:dGV4dC9jYWxlbmRhcjsgY2hhcnNldD11dGYtOA==:443" - client.GetMessageFunc = func(_ context.Context, _ string, messageID string) (*domain.Message, error) { - return &domain.Message{ - ID: messageID, - Subject: "Event Invitation: Meeting", - Attachments: []domain.Attachment{ - {ID: syntheticID, Filename: "invite.ics", ContentType: "text/calendar; charset=utf-8", Size: 443}, - }, - }, nil - } - client.DownloadAttachmentFunc = func(_ context.Context, _, _, attID string) (io.ReadCloser, error) { - if attID != syntheticID { - t.Fatalf("expected download for synthetic id, got %q", attID) - } - return nil, errors.New("nylas API error: attachment not found") // mirrors the real prod failure - } - client.GetMessageWithFieldsFunc = func(_ context.Context, _ string, messageID, _ string) (*domain.Message, error) { - return &domain.Message{ID: messageID, RawMIME: gmailStyleInviteMIME}, nil - } - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/api/emails/email-99/invite", http.NoBody) - server.handleEmailInvite(w, r, "email-99") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - var got CalendarInviteResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if !got.HasInvite { - t.Errorf("HasInvite=false; want true (raw_mime fallback should recover from undownloadable attachment)") - } - // Keep the synthetic ID so the frontend's name-based attachment-row - // dedup still matches the existing entry. - if got.AttachmentID != syntheticID { - t.Errorf("AttachmentID=%q, want synthetic id %q (preserved through fallback)", got.AttachmentID, syntheticID) - } - if got.Filename != "invite.ics" { - t.Errorf("Filename=%q", got.Filename) - } -} - -// TestHandleEmailInvite_RealAttachmentTakesPriority pins the existing -// happy path: when attachments[] already has an ICS, the handler uses -// it directly without paying for a raw_mime round-trip. Microsoft and -// custom senders typically arrive this way. -func TestHandleEmailInvite_RealAttachmentTakesPriority(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - client.GetMessageFunc = func(_ context.Context, _ string, messageID string) (*domain.Message, error) { - return &domain.Message{ - ID: messageID, - Subject: "Meeting Invitation", - Attachments: []domain.Attachment{ - {ID: "att-1", Filename: "invite.ics", ContentType: "text/calendar"}, - }, - }, nil - } - client.DownloadAttachmentFunc = func(_ context.Context, _, _, _ string) (io.ReadCloser, error) { - body := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\nMETHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\nUID:real-1\r\nSUMMARY:Attached invite\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T150000Z\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" - return io.NopCloser(strings.NewReader(body)), nil - } - rawMIMEHit := false - client.GetMessageWithFieldsFunc = func(_ context.Context, _ string, _, _ string) (*domain.Message, error) { - rawMIMEHit = true - return nil, errors.New("should not be called when attachments[] has the calendar") - } - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/api/emails/email-3/invite", http.NoBody) - server.handleEmailInvite(w, r, "email-3") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - if rawMIMEHit { - t.Errorf("raw_mime fallback was called even though attachments[] had the calendar") - } - var got CalendarInviteResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.AttachmentID != "att-1" { - t.Errorf("AttachmentID=%q, want att-1 (real attachment)", got.AttachmentID) - } - if got.Title != "Attached invite" { - t.Errorf("Title=%q", got.Title) - } -} - -// Sanity-check the inline-calendar attachment-ID helpers — these gate -// the frontend's "is this a synthetic part?" check, so a typo on the -// prefix would break inline rendering. -func TestInlineCalendarAttachmentID(t *testing.T) { - cases := []struct { - name string - input string - want string - wantPrefix bool - }{ - {"with content-id", "abc@example.com", inlineCalendarPrefix + "abc@example.com", true}, - {"empty content-id", "", inlineCalendarPrefix + "default", true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := inlineCalendarAttachmentID(tc.input) - if got != tc.want { - t.Errorf("got %q, want %q", got, tc.want) - } - if isInlineCalendarAttachmentID(got) != tc.wantPrefix { - t.Errorf("isInlineCalendarAttachmentID(%q) wrong", got) - } - }) - } - - if isInlineCalendarAttachmentID("regular-attachment-id") { - t.Errorf("real attachment ID should not match inline prefix") - } -} - -// TestParseICS_AttendeesAndMethod pins the new fields surfaced by the -// golang-ical-backed parser. Without these the UI can't show "3 going, -// 1 declined" or the cancellation banner. -func TestParseICS_AttendeesAndMethod(t *testing.T) { - body := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\n" + - "METHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\nUID:e1\r\nSUMMARY:Standup\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T143000Z\r\n" + - "ORGANIZER;CN=Priya Patel:mailto:priya@partner.example\r\n" + - "ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:alice@example.com\r\n" + - "ATTENDEE;CN=Bob;PARTSTAT=DECLINED;ROLE=REQ-PARTICIPANT:mailto:bob@example.com\r\n" + - "ATTENDEE;CN=Carol;PARTSTAT=TENTATIVE:mailto:carol@example.com\r\n" + - "ATTENDEE;CN=Priya Patel;PARTSTAT=ACCEPTED:mailto:priya@partner.example\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" - - resp, err := parseICS(body) - if err != nil { - t.Fatalf("parseICS: %v", err) - } - if resp.Method != "REQUEST" { - t.Errorf("Method=%q, want REQUEST", resp.Method) - } - if len(resp.Attendees) != 4 { - t.Fatalf("Attendees count=%d, want 4", len(resp.Attendees)) - } - - // Build a map by email for stable lookups regardless of ordering. - byEmail := map[string]InviteAttendee{} - for _, a := range resp.Attendees { - byEmail[a.Email] = a - } - - if a, ok := byEmail["alice@example.com"]; !ok || a.Status != "ACCEPTED" { - t.Errorf("Alice attendee wrong: %+v", a) - } - if a, ok := byEmail["bob@example.com"]; !ok || a.Status != "DECLINED" { - t.Errorf("Bob attendee wrong: %+v", a) - } - if a, ok := byEmail["priya@partner.example"]; !ok || !a.IsOrganizer { - t.Errorf("Priya should be flagged as organizer: %+v", a) - } -} - -// TestParseICS_CancelMethod pins that METHOD=CANCEL is preserved so the -// UI can render the cancellation banner instead of the RSVP buttons. -func TestParseICS_CancelMethod(t *testing.T) { - body := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\nMETHOD:CANCEL\r\n" + - "BEGIN:VEVENT\r\nUID:e1\r\nSUMMARY:Cancelled meeting\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T150000Z\r\n" + - "STATUS:CANCELLED\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" - - resp, err := parseICS(body) - if err != nil { - t.Fatalf("parseICS: %v", err) - } - if resp.Method != "CANCEL" { - t.Errorf("Method=%q, want CANCEL", resp.Method) - } - if resp.Status != "CANCELLED" { - t.Errorf("Status=%q, want CANCELLED", resp.Status) - } -} - -// TestParseICS_ClampsOversizedUID pins the UID-length cap. A hostile -// inviter could craft a multi-MB UID; without the clamp we'd round-trip -// it into the Nylas ical_uid query parameter and form a giant URL. The -// prefix is kept (so the lookup can still succeed for legitimate long -// UIDs that happen to share a prefix) but the byte budget is bounded. -func TestParseICS_ClampsOversizedUID(t *testing.T) { - huge := strings.Repeat("a", 64*1024) // 64KB UID - body := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\nMETHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\nUID:" + huge + "\r\nSUMMARY:OversizedUID\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T150000Z\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" - - resp, err := parseICS(body) - if err != nil { - t.Fatalf("parseICS: %v", err) - } - if got := len(resp.ICalUID); got > 1024 { - t.Errorf("ICalUID len=%d, want <=1024 after clamp", got) - } - if !strings.HasPrefix(huge, resp.ICalUID) { - t.Errorf("ICalUID=%q does not look like a prefix of the input", resp.ICalUID) - } -} diff --git a/internal/air/handlers_email_invite_mime.go b/internal/air/handlers_email_invite_mime.go deleted file mode 100644 index 6523d44..0000000 --- a/internal/air/handlers_email_invite_mime.go +++ /dev/null @@ -1,148 +0,0 @@ -package air - -import ( - "bytes" - "encoding/base64" - "io" - "mime" - "mime/multipart" - "mime/quotedprintable" - "net/mail" - "net/textproto" - "strings" -) - -// inlineCalendarPart is an iCalendar (text/calendar) MIME part extracted -// from a raw RFC822 message. Gmail invites ship the ICS as an inline -// part of multipart/alternative; Nylas does not surface those in the -// attachments[] list, so we have to walk raw_mime ourselves. -type inlineCalendarPart struct { - Body string - Filename string - ContentID string - Method string // upper-case METHOD parameter (REQUEST / CANCEL / REPLY) -} - -// maxRawMIMEBytes caps how much of a raw MIME blob we'll walk. Nylas -// returns base64-decoded MIME, so 5 MB covers any normal invitation -// while keeping memory predictable when an attacker stitches a giant -// message. -const maxRawMIMEBytes = 5 * 1024 * 1024 - -// maxMIMEDepth caps multipart recursion. RFC 5322 imposes no limit; -// real invitations nest at most 2 levels (mixed → alternative → calendar). -const maxMIMEDepth = 8 - -// findInlineCalendarParts walks the raw RFC822/MIME message and returns -// every text/calendar part it finds, decoded. Returns nil on any parse -// failure — callers treat "can't parse" the same as "no invite present". -func findInlineCalendarParts(rawMIME string) []inlineCalendarPart { - if rawMIME == "" || len(rawMIME) > maxRawMIMEBytes { - return nil - } - msg, err := mail.ReadMessage(strings.NewReader(rawMIME)) - if err != nil { - return nil - } - var out []inlineCalendarPart - walkMIMEForCalendar(textproto.MIMEHeader(msg.Header), msg.Body, &out, 0) - return out -} - -// walkMIMEForCalendar recursively descends multipart bodies and appends -// any text/calendar leaf to out. Depth-capped to defend against -// pathological MIME nesting. -func walkMIMEForCalendar(header textproto.MIMEHeader, body io.Reader, out *[]inlineCalendarPart, depth int) { - if depth > maxMIMEDepth { - return - } - mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type")) - if err != nil { - return - } - - if strings.HasPrefix(mediaType, "multipart/") { - boundary := params["boundary"] - if boundary == "" { - return - } - mr := multipart.NewReader(body, boundary) - for { - part, err := mr.NextPart() - if err != nil { - return - } - walkMIMEForCalendar(part.Header, part, out, depth+1) - } - } - - if !strings.EqualFold(mediaType, "text/calendar") { - return - } - - raw, err := io.ReadAll(io.LimitReader(body, maxRawMIMEBytes+1)) - if err != nil || len(raw) > maxRawMIMEBytes { - return - } - decoded := decodePartBody(raw, header.Get("Content-Transfer-Encoding")) - - filename := params["name"] - if filename == "" { - if cd := header.Get("Content-Disposition"); cd != "" { - if _, dParams, err := mime.ParseMediaType(cd); err == nil { - filename = dParams["filename"] - } - } - } - cid := strings.TrimSuffix(strings.TrimPrefix(header.Get("Content-ID"), "<"), ">") - method := strings.ToUpper(strings.TrimSpace(params["method"])) - - *out = append(*out, inlineCalendarPart{ - Body: string(decoded), - Filename: filename, - ContentID: cid, - Method: method, - }) -} - -// decodePartBody applies the Content-Transfer-Encoding to a part body. -// 7bit/8bit/binary pass through unchanged. base64 and quoted-printable -// failures fall back to the raw bytes so we still attempt parsing — the -// iCalendar parser will reject garbage cleanly downstream. -func decodePartBody(raw []byte, cte string) []byte { - switch strings.ToLower(strings.TrimSpace(cte)) { - case "base64": - // MIME base64 may include CR/LF runs; strip whitespace so the - // permissive decoders accept it. Try standard then raw to handle - // senders that omit padding. - clean := stripBase64Whitespace(raw) - if dec, err := base64.StdEncoding.DecodeString(string(clean)); err == nil { - return dec - } - if dec, err := base64.RawStdEncoding.DecodeString(string(clean)); err == nil { - return dec - } - return raw - case "quoted-printable": - dec, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(raw))) - if err != nil { - return raw - } - return dec - default: - return raw - } -} - -// stripBase64Whitespace removes CR, LF, tabs, and spaces from a base64 -// blob so the decoders don't trip on standard MIME line wrapping. -func stripBase64Whitespace(b []byte) []byte { - out := make([]byte, 0, len(b)) - for _, c := range b { - if c == '\r' || c == '\n' || c == '\t' || c == ' ' { - continue - } - out = append(out, c) - } - return out -} diff --git a/internal/air/handlers_email_invite_mime_test.go b/internal/air/handlers_email_invite_mime_test.go deleted file mode 100644 index e0ba4b3..0000000 --- a/internal/air/handlers_email_invite_mime_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package air - -import ( - "strings" - "testing" -) - -// gmailStyleInviteMIME is a minimal multipart/alternative message with -// an inline text/calendar leaf. It mirrors what Gmail ships when the -// sender declines to attach the ICS as a separate file — the very case -// that hides the invite from Nylas's attachments[] list. -const gmailStyleInviteMIME = "From: organizer@example.com\r\n" + - "To: user@example.com\r\n" + - "Subject: Event Invitation: Meeting\r\n" + - "Content-Type: multipart/alternative; boundary=\"BOUNDARY1\"\r\n" + - "\r\n" + - "--BOUNDARY1\r\n" + - "Content-Type: text/plain; charset=UTF-8\r\n" + - "\r\n" + - "You have received a calendar invitation: Meeting\r\n" + - "--BOUNDARY1\r\n" + - "Content-Type: text/calendar; charset=UTF-8; method=REQUEST; name=invite.ics\r\n" + - "Content-Disposition: inline; filename=invite.ics\r\n" + - "Content-Transfer-Encoding: 7bit\r\n" + - "\r\n" + - "BEGIN:VCALENDAR\r\n" + - "VERSION:2.0\r\n" + - "PRODID:-//Test//EN\r\n" + - "METHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\n" + - "UID:gmail-1@example.com\r\n" + - "SUMMARY:Meeting\r\n" + - "DTSTART:20260501T140000Z\r\n" + - "DTEND:20260501T150000Z\r\n" + - "ORGANIZER;CN=Priya Patel:mailto:priya@partner.example\r\n" + - "END:VEVENT\r\n" + - "END:VCALENDAR\r\n" + - "--BOUNDARY1--\r\n" - -// nestedInviteMIME wraps the calendar inside multipart/mixed → -// multipart/alternative → text/calendar. Outlook produces nesting like -// this when the body has both an HTML rendering and the attached ICS. -const nestedInviteMIME = "From: a@example.com\r\n" + - "Subject: Meeting Invitation\r\n" + - "Content-Type: multipart/mixed; boundary=\"OUTER\"\r\n" + - "\r\n" + - "--OUTER\r\n" + - "Content-Type: multipart/alternative; boundary=\"INNER\"\r\n" + - "\r\n" + - "--INNER\r\n" + - "Content-Type: text/html; charset=UTF-8\r\n" + - "\r\n" + - "Calendar invite
\r\n" + - "--INNER\r\n" + - "Content-Type: text/calendar; charset=UTF-8; method=REQUEST\r\n" + - "Content-Transfer-Encoding: 7bit\r\n" + - "\r\n" + - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\nMETHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\nUID:nested-1\r\nSUMMARY:Nested\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T150000Z\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" + - "--INNER--\r\n" + - "--OUTER--\r\n" - -func TestFindInlineCalendarParts_GmailShape(t *testing.T) { - parts := findInlineCalendarParts(gmailStyleInviteMIME) - if len(parts) != 1 { - t.Fatalf("want 1 part, got %d", len(parts)) - } - p := parts[0] - if !strings.Contains(p.Body, "BEGIN:VEVENT") { - t.Errorf("Body should be the VCALENDAR payload, got: %q", p.Body[:min(80, len(p.Body))]) - } - if p.Filename != "invite.ics" { - t.Errorf("Filename: want invite.ics, got %q", p.Filename) - } - if p.Method != "REQUEST" { - t.Errorf("Method: want REQUEST, got %q", p.Method) - } -} - -func TestFindInlineCalendarParts_NestedAlternative(t *testing.T) { - parts := findInlineCalendarParts(nestedInviteMIME) - if len(parts) != 1 { - t.Fatalf("want 1 part, got %d", len(parts)) - } - if !strings.Contains(parts[0].Body, "SUMMARY:Nested") { - t.Errorf("Body did not contain expected SUMMARY") - } -} - -func TestFindInlineCalendarParts_NoCalendar(t *testing.T) { - plain := "From: a@example.com\r\n" + - "Subject: Hi\r\n" + - "Content-Type: text/plain; charset=UTF-8\r\n" + - "\r\n" + - "Just a regular email.\r\n" - if got := findInlineCalendarParts(plain); len(got) != 0 { - t.Errorf("plain mail should produce no parts, got %d", len(got)) - } -} - -func TestFindInlineCalendarParts_EmptyAndOversize(t *testing.T) { - if got := findInlineCalendarParts(""); got != nil { - t.Errorf("empty input should return nil, got %d parts", len(got)) - } - huge := strings.Repeat("x", maxRawMIMEBytes+1) - if got := findInlineCalendarParts(huge); got != nil { - t.Errorf("oversize input should be rejected, got %d parts", len(got)) - } -} - -func TestFindInlineCalendarParts_QuotedPrintableBody(t *testing.T) { - // Realistic case: Outlook frequently encodes calendar bodies with - // quoted-printable so soft line breaks survive 998-octet MIME limits. - mime := "Content-Type: multipart/alternative; boundary=B\r\n" + - "\r\n" + - "--B\r\n" + - "Content-Type: text/calendar; charset=UTF-8\r\n" + - "Content-Transfer-Encoding: quoted-printable\r\n" + - "\r\n" + - "BEGIN:VCALENDAR=0D=0A" + - "BEGIN:VEVENT=0D=0A" + - "SUMMARY:Encoded=0D=0A" + - "DTSTART:20260501T140000Z=0D=0A" + - "END:VEVENT=0D=0A" + - "END:VCALENDAR=0D=0A" + - "\r\n--B--\r\n" - parts := findInlineCalendarParts(mime) - if len(parts) != 1 { - t.Fatalf("want 1 part, got %d", len(parts)) - } - if !strings.Contains(parts[0].Body, "SUMMARY:Encoded") { - t.Errorf("quoted-printable body wasn't decoded: %q", parts[0].Body) - } -} - -// Round-trip the Gmail-style MIME through the parser to prove the full -// "raw_mime → walk → ICS → parse" pipeline produces a card-ready event. -func TestParseICS_FromGmailInlinePart(t *testing.T) { - parts := findInlineCalendarParts(gmailStyleInviteMIME) - if len(parts) == 0 { - t.Fatal("expected an inline calendar part") - } - resp, err := parseICS(parts[0].Body) - if err != nil { - t.Fatalf("parseICS: %v", err) - } - if resp.Title != "Meeting" { - t.Errorf("Title: %q", resp.Title) - } - if resp.Method != "REQUEST" { - t.Errorf("Method: %q", resp.Method) - } - if resp.OrganizerEmail != "priya@partner.example" { - t.Errorf("OrganizerEmail: %q", resp.OrganizerEmail) - } -} diff --git a/internal/air/handlers_email_invite_parse.go b/internal/air/handlers_email_invite_parse.go deleted file mode 100644 index bca7386..0000000 --- a/internal/air/handlers_email_invite_parse.go +++ /dev/null @@ -1,211 +0,0 @@ -package air - -import ( - "strings" - - ical "github.com/arran4/golang-ical" -) - -// maxICalUIDBytes caps the iCalendar UID we will round-trip into Nylas -// query parameters. RFC 5545 doesn't define a maximum, but a hostile -// inviter could craft a multi-MB UID — URL-encoded that becomes a giant -// query string. 1KB comfortably covers Outlook's 200-char defaults and -// Google's 100-byte UIDs while clamping the worst case. -const maxICalUIDBytes = 1024 - -// parseICS parses an iCalendar payload and returns the first VEVENT in a -// shape the Air invite card understands. Backed by golang-ical so we -// inherit RFC 5545 line-folding, TZID resolution, ATTENDEE parsing, and -// VALUE=DATE all-day handling. -// -// Returns errNoUsableEvent for input that doesn't yield a renderable -// event — empty calendars, malformed bodies, VEVENTs without title or -// start time. Callers should map this to a "no invite" response, not a -// 5xx, since clients silently degrade. -func parseICS(raw string) (CalendarInviteResponse, error) { - cal, err := ical.ParseCalendar(strings.NewReader(raw)) - if err != nil { - return CalendarInviteResponse{}, err - } - events := cal.Events() - if len(events) == 0 { - return CalendarInviteResponse{}, errNoUsableEvent - } - - method := calendarMethod(cal) - first := events[0] - resp := mapVEvent(first) - if resp.Method == "" { - resp.Method = method - } - - if resp.Title == "" && resp.StartTime == 0 { - return CalendarInviteResponse{}, errNoUsableEvent - } - return resp, nil -} - -// calendarMethod returns the calendar-level METHOD property, upper-cased. -// REQUEST is the most common (a fresh invitation); CANCEL needs a banner -// in the UI; REPLY arrives when an attendee responds and we surface it -// for completeness. -func calendarMethod(cal *ical.Calendar) string { - for _, p := range cal.CalendarProperties { - if strings.EqualFold(p.IANAToken, string(ical.PropertyMethod)) { - return strings.ToUpper(strings.TrimSpace(p.Value)) - } - } - return "" -} - -// mapVEvent flattens a VEVENT into the response shape. Each property is -// optional — Outlook and Google produce subtly different invitations, -// and we want the card to render whatever is available rather than -// failing closed. -func mapVEvent(ev *ical.VEvent) CalendarInviteResponse { - var resp CalendarInviteResponse - - if p := ev.GetProperty(ical.ComponentPropertyUniqueId); p != nil { - uid := strings.TrimSpace(p.Value) - // Clamp pathological UIDs. A UID we cannot trust is preferable to - // dropping the invite, so keep the prefix and let the downstream - // ical_uid filter still find the event. - if len(uid) > maxICalUIDBytes { - uid = uid[:maxICalUIDBytes] - } - resp.ICalUID = uid - } - if p := ev.GetProperty(ical.ComponentPropertySummary); p != nil { - resp.Title = p.Value - } - if p := ev.GetProperty(ical.ComponentPropertyLocation); p != nil { - resp.Location = p.Value - } - if p := ev.GetProperty(ical.ComponentPropertyDescription); p != nil { - resp.Description = p.Value - } - if p := ev.GetProperty(ical.ComponentPropertyStatus); p != nil { - resp.Status = strings.ToUpper(strings.TrimSpace(p.Value)) - } - if rrule := ev.GetProperty(ical.ComponentPropertyRrule); rrule != nil { - resp.RecurrenceRule = rrule.Value - } - resp.ConferencingURL = firstConferenceURL(ev) - - resp.StartTime, resp.EndTime, resp.IsAllDay = eventTimes(ev) - - if org := ev.GetProperty(ical.ComponentPropertyOrganizer); org != nil { - resp.OrganizerEmail, resp.OrganizerName = parseOrganizerProp(org.Value, org.ICalParameters) - } - - resp.Attendees = mapAttendees(ev, resp.OrganizerEmail) - return resp -} - -// eventTimes returns DTSTART, DTEND, and whether the event is all-day. -// All-day is indicated by VALUE=DATE on DTSTART per RFC 5545 §3.6.1. -// We check the param explicitly because golang-ical's GetStartAt -// happily parses both DATE-TIME ("20260501T140000Z") and DATE -// ("20260704") formats — so a successful return tells us nothing about -// which form was used. -func eventTimes(ev *ical.VEvent) (start, end int64, allDay bool) { - allDay = isAllDayProp(ev.GetProperty(ical.ComponentPropertyDtStart)) || - isAllDayProp(ev.GetProperty(ical.ComponentPropertyDtEnd)) - - if allDay { - if t, err := ev.GetAllDayStartAt(); err == nil { - start = t.Unix() - } - if t, err := ev.GetAllDayEndAt(); err == nil { - end = t.Unix() - } - return - } - if t, err := ev.GetStartAt(); err == nil { - start = t.Unix() - } - if t, err := ev.GetEndAt(); err == nil { - end = t.Unix() - } - return -} - -// isAllDayProp reports whether a DTSTART/DTEND property carries -// VALUE=DATE — the iCalendar marker for an all-day event. -func isAllDayProp(p *ical.IANAProperty) bool { - if p == nil { - return false - } - return strings.EqualFold(firstParam(p.ICalParameters, "VALUE"), "DATE") -} - -// firstConferenceURL prefers an explicit conferencing extension (Google -// uses X-GOOGLE-CONFERENCE) over the more generic URL property — URL is -// often the calendar's own canonical link rather than the meeting room. -func firstConferenceURL(ev *ical.VEvent) string { - candidates := []ical.ComponentProperty{ - ical.ComponentPropertyExtended("X-GOOGLE-CONFERENCE"), - ical.ComponentPropertyExtended("X-CONFERENCE-URL"), - ical.ComponentPropertyUrl, - } - for _, c := range candidates { - if p := ev.GetProperty(c); p != nil && p.Value != "" { - return strings.TrimSpace(p.Value) - } - } - return "" -} - -// parseOrganizerProp pulls the email + display name from an ORGANIZER -// property such as `ORGANIZER;CN=Priya Patel:mailto:priya@partner.example`. -// Falls back to the raw value when the mailto: prefix is missing. -func parseOrganizerProp(value string, params map[string][]string) (email, name string) { - v := strings.TrimSpace(value) - if lower := strings.ToLower(v); strings.HasPrefix(lower, "mailto:") { - v = v[len("mailto:"):] - } - email = strings.TrimSpace(v) - if cn := firstParam(params, "CN"); cn != "" { - name = strings.TrimSpace(cn) - } - return email, name -} - -// mapAttendees flattens ATTENDEE properties to the response shape. -// Marks the organizer's own attendee row so the UI can show the -// organizer once rather than twice. -func mapAttendees(ev *ical.VEvent, organizerEmail string) []InviteAttendee { - props := ev.GetProperties(ical.ComponentPropertyAttendee) - if len(props) == 0 { - return nil - } - out := make([]InviteAttendee, 0, len(props)) - for _, p := range props { - email, name := parseOrganizerProp(p.Value, p.ICalParameters) - if email == "" && name == "" { - continue - } - a := InviteAttendee{ - Name: name, - Email: email, - Status: strings.ToUpper(firstParam(p.ICalParameters, "PARTSTAT")), - Role: strings.ToUpper(firstParam(p.ICalParameters, "ROLE")), - } - if organizerEmail != "" && strings.EqualFold(email, organizerEmail) { - a.IsOrganizer = true - } - out = append(out, a) - } - return out -} - -// firstParam returns the first value for a key in an ICalParameter map, -// or empty when missing. golang-ical stores parameters as []string to -// support multi-valued ones (e.g. CATEGORIES) — most properties of -// interest here are single-valued. -func firstParam(params map[string][]string, key string) string { - if vs, ok := params[key]; ok && len(vs) > 0 { - return vs[0] - } - return "" -} diff --git a/internal/air/handlers_email_invite_silent_failure_test.go b/internal/air/handlers_email_invite_silent_failure_test.go deleted file mode 100644 index 1e3fd1e..0000000 --- a/internal/air/handlers_email_invite_silent_failure_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package air - -import ( - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/nylas/cli/internal/domain" - "github.com/stretchr/testify/assert" -) - -// TestHandleEmailInvite_RealAttachmentDownloadFailure_LogsFailure pins -// the silent-failure gap in tryParseAttachmentInvite -// (handlers_email_invite.go:188-191). When DownloadAttachment fails on -// a real (non-synthetic) attachment ID — e.g., a transient Nylas 5xx, -// disk-full while streaming, decryption error — the function returns -// (CalendarInviteResponse{}, false) with no log. The handler then -// falls through to raw_mime, which is the right behavior for the user -// (the invite card might still render via the inline path) but means -// support cannot diagnose "RSVP card never appears for X.ics" without -// knowing which leg of the parser failed. -// -// EXPECTED FAILURE today: handlers_email_invite.go:189-191 returns -// silently. After the fix an slog Debug or Warn entry should record -// the attachment ID and the underlying download error so a wedged -// attachments endpoint shows up in production logs. -func TestHandleEmailInvite_RealAttachmentDownloadFailure_LogsFailure(t *testing.T) { - // No t.Parallel — captureSlog mutates process-global slog default. - server, client, _ := newCachedTestServer(t) - - const realAttID = "real-att-canary-DOWNLOAD-XYZ" - const downloadErrSentinel = "nylas-503-download-canary-7777" - client.GetMessageFunc = func(_ context.Context, _, msgID string) (*domain.Message, error) { - return &domain.Message{ - ID: msgID, - Subject: "Calendar invite", - Attachments: []domain.Attachment{ - {ID: realAttID, Filename: "invite.ics", ContentType: "text/calendar"}, - }, - }, nil - } - client.DownloadAttachmentFunc = func(context.Context, string, string, string) (io.ReadCloser, error) { - return nil, errors.New(downloadErrSentinel) - } - // raw_mime fallback exists but does not contain a calendar part — - // confirms the handler completes (200 has_invite=false) while still - // expecting the download leg to have left a log breadcrumb. - client.GetMessageWithFieldsFunc = func(_ context.Context, _ string, msgID, _ string) (*domain.Message, error) { - return &domain.Message{ID: msgID, RawMIME: ""}, nil - } - - logs := captureSlog(t) - - r := httptest.NewRequest(http.MethodGet, "/api/emails/email-1/invite", http.NoBody) - w := httptest.NewRecorder() - server.handleEmailInvite(w, r, "email-1") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s — silent fall-through must still produce a 200", - w.Code, w.Body.String()) - } - - got := logs.String() - assert.Contains(t, got, realAttID, - "slog must record the failed attachment ID for diagnosability; got %q", got) - assert.Contains(t, got, downloadErrSentinel, - "slog must record the underlying download error so transient Nylas "+ - "failures are diagnosable; got %q", got) -} - -// TestHandleEmailInvite_OversizedAttachment_LogsFailure pins the silent -// drop at handlers_email_invite.go:194-197 — when the streamed body -// exceeds maxICSBytes (or io.ReadAll returns an error), the function -// silently returns false. An attacker-controlled multi-MB ICS would -// land here with no record at all. Also covers the legitimate case -// where Nylas (rarely) ships a misencoded attachment that streams much -// larger than reported. -// -// EXPECTED FAILURE today: silent return. After the fix an slog Warn -// should record the attID + filename so oversize-DOS attempts and -// runaway attachments are visible. -func TestHandleEmailInvite_OversizedAttachment_LogsFailure(t *testing.T) { - // No t.Parallel — captureSlog mutates process-global slog default. - server, client, _ := newCachedTestServer(t) - - const fatAttID = "fat-att-canary-OVERSIZE-XYZ" - client.GetMessageFunc = func(_ context.Context, _, msgID string) (*domain.Message, error) { - return &domain.Message{ - ID: msgID, - Attachments: []domain.Attachment{ - {ID: fatAttID, Filename: "huge-invite.ics", ContentType: "text/calendar", Size: 1}, - }, - }, nil - } - // 1MB+1 of 'A' — exceeds maxICSBytes (1<<20 in handlers_email_invite.go), - // so the io.LimitReader read returns len(raw) == maxICSBytes+1, which - // trips the oversized-payload swallow at line ~194-197. - huge := strings.Repeat("A", (1<<20)+1) - client.DownloadAttachmentFunc = func(context.Context, string, string, string) (io.ReadCloser, error) { - return io.NopCloser(strings.NewReader(huge)), nil - } - client.GetMessageWithFieldsFunc = func(_ context.Context, _ string, msgID, _ string) (*domain.Message, error) { - return &domain.Message{ID: msgID, RawMIME: ""}, nil - } - - logs := captureSlog(t) - - r := httptest.NewRequest(http.MethodGet, "/api/emails/email-1/invite", http.NoBody) - w := httptest.NewRecorder() - server.handleEmailInvite(w, r, "email-1") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s — silent fall-through must still produce a 200", - w.Code, w.Body.String()) - } - - got := logs.String() - assert.Contains(t, got, fatAttID, - "slog must record the oversize attachment ID — currently silent at "+ - "handlers_email_invite.go:194-197; got %q", got) -} - -// TestHandleEmailInvite_MalformedICS_LogsFailure pins the third silent -// drop in tryParseAttachmentInvite (handlers_email_invite.go:199-202): -// parseICS errors are swallowed entirely. A malformed calendar payload -// should be visible to support, even if the user-facing path falls -// through to raw_mime. -// -// EXPECTED FAILURE today: parseICS error returned without log. After -// the fix an slog Debug entry should fire with the attachment ID and -// the parse error so misencoded ICS payloads are diagnosable. -func TestHandleEmailInvite_MalformedICS_LogsFailure(t *testing.T) { - // No t.Parallel — captureSlog mutates process-global slog default. - server, client, _ := newCachedTestServer(t) - - const badICSAttID = "bad-ics-att-canary-PARSE-XYZ" - client.GetMessageFunc = func(_ context.Context, _, msgID string) (*domain.Message, error) { - return &domain.Message{ - ID: msgID, - Attachments: []domain.Attachment{ - {ID: badICSAttID, Filename: "broken.ics", ContentType: "text/calendar"}, - }, - }, nil - } - client.DownloadAttachmentFunc = func(context.Context, string, string, string) (io.ReadCloser, error) { - // Not a valid VCALENDAR — parseICS will error. - return io.NopCloser(strings.NewReader("THIS IS NOT VALID ICS PAYLOAD")), nil - } - client.GetMessageWithFieldsFunc = func(_ context.Context, _ string, msgID, _ string) (*domain.Message, error) { - return &domain.Message{ID: msgID, RawMIME: ""}, nil - } - - logs := captureSlog(t) - - r := httptest.NewRequest(http.MethodGet, "/api/emails/email-1/invite", http.NoBody) - w := httptest.NewRecorder() - server.handleEmailInvite(w, r, "email-1") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s — silent fall-through must still produce a 200", - w.Code, w.Body.String()) - } - - got := logs.String() - assert.Contains(t, got, badICSAttID, - "slog must record the malformed-ICS attachment ID — currently silent at "+ - "handlers_email_invite.go:199-202; got %q", got) -} diff --git a/internal/air/handlers_email_invite_test.go b/internal/air/handlers_email_invite_test.go deleted file mode 100644 index 35080a7..0000000 --- a/internal/air/handlers_email_invite_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package air - -import ( - "strings" - "testing" - - "github.com/nylas/cli/internal/domain" -) - -const sampleICS = "BEGIN:VCALENDAR\r\n" + - "VERSION:2.0\r\n" + - "PRODID:-//Test//EN\r\n" + - "METHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\n" + - "UID:demo-uid-1@example.com\r\n" + - "SUMMARY:Quarterly Sync\r\n" + - "DESCRIPTION:Quarterly review with the partner team.\\nBring notes.\r\n" + - "LOCATION:Conference Room A\\, HQ\r\n" + - "DTSTART:20260501T140000Z\r\n" + - "DTEND:20260501T150000Z\r\n" + - "ORGANIZER;CN=Priya Patel:mailto:priya@partner.example\r\n" + - "STATUS:CONFIRMED\r\n" + - "URL:https://meet.example.com/q-sync\r\n" + - "END:VEVENT\r\n" + - "END:VCALENDAR\r\n" - -const sampleAllDayICS = "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" + - "SUMMARY:Holiday\n" + - "DTSTART;VALUE=DATE:20260704\n" + - "DTEND;VALUE=DATE:20260705\n" + - "END:VEVENT\nEND:VCALENDAR\n" - -func TestParseICS_BasicVEvent(t *testing.T) { - ev, err := parseICS(sampleICS) - if err != nil { - t.Fatalf("parseICS: %v", err) - } - - if ev.Title != "Quarterly Sync" { - t.Errorf("Title: want %q, got %q", "Quarterly Sync", ev.Title) - } - if ev.Location != "Conference Room A, HQ" { - t.Errorf("Location: want unescaped, got %q", ev.Location) - } - if !strings.Contains(ev.Description, "Bring notes.") { - t.Errorf("Description: want unescaped \\n, got %q", ev.Description) - } - // 2026-05-01T14:00:00Z = 1777644000 - if ev.StartTime != 1777644000 { - t.Errorf("StartTime: want 1777644000, got %d", ev.StartTime) - } - // 2026-05-01T15:00:00Z = 1777647600 - if ev.EndTime != 1777647600 { - t.Errorf("EndTime: want 1777647600, got %d", ev.EndTime) - } - if ev.IsAllDay { - t.Errorf("IsAllDay should be false for DATE-TIME") - } - if ev.OrganizerEmail != "priya@partner.example" { - t.Errorf("OrganizerEmail: %q", ev.OrganizerEmail) - } - if ev.OrganizerName != "Priya Patel" { - t.Errorf("OrganizerName: %q", ev.OrganizerName) - } - if ev.Status != "CONFIRMED" { - t.Errorf("Status: %q", ev.Status) - } - if ev.ConferencingURL != "https://meet.example.com/q-sync" { - t.Errorf("ConferencingURL: %q", ev.ConferencingURL) - } -} - -func TestParseICS_AllDayDate(t *testing.T) { - ev, err := parseICS(sampleAllDayICS) - if err != nil { - t.Fatalf("parseICS: %v", err) - } - if ev.Title != "Holiday" { - t.Errorf("Title: %q", ev.Title) - } - if !ev.IsAllDay { - t.Error("IsAllDay should be true for VALUE=DATE") - } - // 2026-07-04T00:00:00Z = 1814400000... let's just sanity-check >0 - if ev.StartTime <= 0 { - t.Errorf("StartTime should be positive Unix seconds, got %d", ev.StartTime) - } -} - -func TestParseICS_LineFolding(t *testing.T) { - folded := "BEGIN:VCALENDAR\r\n" + - "VERSION:2.0\r\n" + - "PRODID:-//T//EN\r\n" + - "BEGIN:VEVENT\r\n" + - "UID:fold-1\r\n" + - "SUMMARY:Long\r\n" + - " Title Continued\r\n" + - "DTSTART:20260501T140000Z\r\n" + - "DTEND:20260501T150000Z\r\n" + - "END:VEVENT\r\n" + - "END:VCALENDAR\r\n" - - ev, err := parseICS(folded) - if err != nil { - t.Fatalf("parseICS: %v", err) - } - if ev.Title != "LongTitle Continued" { - t.Errorf("expected unfolded title, got %q", ev.Title) - } -} - -func TestParseICS_MissingVEvent(t *testing.T) { - // A calendar with zero events should not produce a renderable invite. - _, err := parseICS("BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//T//EN\nEND:VCALENDAR\n") - if err == nil { - t.Fatal("expected error when no VEVENT block is present") - } -} - -func TestParseICS_EmptyEvent(t *testing.T) { - // VEVENT block with no usable fields → error rather than empty noise. - body := "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//T//EN\nBEGIN:VEVENT\nUID:e\nEND:VEVENT\nEND:VCALENDAR\n" - _, err := parseICS(body) - if err == nil { - t.Fatal("expected error for VEVENT with no fields") - } -} - -func TestIsCalendarAttachment(t *testing.T) { - tests := []struct { - name string - ct string - fn string - want bool - }{ - {"text/calendar", "text/calendar; charset=utf-8", "invite.ics", true}, - {"application/ics", "application/ics", "x", true}, - {".ics filename only", "application/octet-stream", "MEETING.ICS", true}, - {"pdf attachment", "application/pdf", "agenda.pdf", false}, - {"empty", "", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := isCalendarAttachment(tt.ct, tt.fn); got != tt.want { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - -func TestFindCalendarAttachment(t *testing.T) { - atts := []domain.Attachment{ - {ID: "a1", Filename: "spec.pdf", ContentType: "application/pdf"}, - {ID: "a2", Filename: "invite.ics", ContentType: "text/calendar"}, - {ID: "a3", Filename: "logo.png", ContentType: "image/png"}, - } - got := findCalendarAttachment(atts) - if got == nil { - t.Fatal("expected to find ICS attachment") - } - if got.ID != "a2" { - t.Errorf("expected a2, got %s", got.ID) - } -} - -func TestFilterDemoEmails_FolderFilter(t *testing.T) { - all := demoEmails() - - inbox := filterDemoEmails(all, "inbox", false, false) - sent := filterDemoEmails(all, "sent", false, false) - drafts := filterDemoEmails(all, "drafts", false, false) - trash := filterDemoEmails(all, "trash", false, false) - archive := filterDemoEmails(all, "archive", false, false) - none := filterDemoEmails(all, "", false, false) - - cases := []struct { - name string - got []EmailResponse - min int - }{ - {"inbox", inbox, 5}, - {"sent (>1 — proves filter works)", sent, 2}, - {"drafts", drafts, 1}, - {"trash", trash, 1}, - {"archive", archive, 1}, - {"none (empty filter returns all)", none, len(all)}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if len(tc.got) < tc.min { - t.Errorf("%s: got %d, want >= %d", tc.name, len(tc.got), tc.min) - } - }) - } - - // Sent emails should NOT be in the inbox response. - for _, e := range inbox { - for _, f := range e.Folders { - if strings.EqualFold(f, "sent") { - t.Errorf("sent email %s leaked into inbox", e.ID) - } - } - } -} - -func TestFilterDemoEmails_AliasMapping(t *testing.T) { - all := demoEmails() - - // "SENT", "Sent Items" should all map to canonical "sent". - for _, alias := range []string{"SENT", "Sent Items", "sent mail"} { - got := filterDemoEmails(all, alias, false, false) - if len(got) < 2 { - t.Errorf("alias %q: expected >=2 sent emails, got %d", alias, len(got)) - } - } - - // "Deleted Items" → trash - got := filterDemoEmails(all, "Deleted Items", false, false) - if len(got) < 1 { - t.Errorf("Deleted Items alias: expected >=1, got %d", len(got)) - } -} - -func TestFilterDemoEmails_UnreadStarredFlags(t *testing.T) { - all := demoEmails() - - unread := filterDemoEmails(all, "", true, false) - for _, e := range unread { - if !e.Unread { - t.Errorf("unread filter returned read email %s", e.ID) - } - } - - starred := filterDemoEmails(all, "", false, true) - for _, e := range starred { - if !e.Starred { - t.Errorf("starred filter returned unstarred email %s", e.ID) - } - } -} - -func TestDemoInviteFor_KnownAndUnknownIDs(t *testing.T) { - known := demoInviteFor("demo-email-invite-001") - if !known.HasInvite { - t.Fatal("known invite ID should return HasInvite=true") - } - if known.Title != "Quarterly Sync" { - t.Errorf("Title: %q", known.Title) - } - if known.OrganizerEmail == "" || known.StartTime == 0 || known.EndTime == 0 { - t.Errorf("expected populated fields, got %+v", known) - } - - unknown := demoInviteFor("demo-email-001") - if unknown.HasInvite { - t.Error("non-invite email should return HasInvite=false") - } -} diff --git a/internal/air/handlers_email_offline.go b/internal/air/handlers_email_offline.go deleted file mode 100644 index e012de7..0000000 --- a/internal/air/handlers_email_offline.go +++ /dev/null @@ -1,83 +0,0 @@ -package air - -import ( - "context" - "errors" - "log/slog" - "net" - - "github.com/nylas/cli/internal/air/cache" - "github.com/nylas/cli/internal/domain" -) - -// shouldQueueEmailAction reports whether an upstream API failure should -// route through the offline queue. The queue must be enabled (cache is -// configured AND queueing is opted in), and the failure has to look like -// a transient network/timeout problem — application errors (4xx) flow -// straight back to the caller. -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) 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, - }) - }) -} - -// updateCachedEmail mirrors a remote update into the local cache. -// Cache write failures are logged but never bubbled — the live update -// already succeeded. folders nil = leave alone; non-nil = set. -func (s *Server) updateCachedEmail(accountEmail, emailID string, unread, starred *bool, folders []string) { - if accountEmail == "" || !s.cacheAvailable() { - return - } - - if err := s.withEmailStore(accountEmail, func(store *cache.EmailStore) error { - return store.UpdateMessage(emailID, unread, starred, folders) - }); err != nil { - slog.Warn("cache update failed", "emailID", emailID, "account", redactEmail(accountEmail), "err", err) - } -} - -func (s *Server) deleteCachedEmail(accountEmail, emailID string) { - if accountEmail == "" || !s.cacheAvailable() { - return - } - - if err := s.withEmailStore(accountEmail, func(store *cache.EmailStore) error { - return store.Delete(emailID) - }); err != nil { - slog.Warn("cache delete failed", "emailID", emailID, "account", redactEmail(accountEmail), "err", err) - } -} diff --git a/internal/air/handlers_email_offline_replay_test.go b/internal/air/handlers_email_offline_replay_test.go deleted file mode 100644 index edaf0a1..0000000 --- a/internal/air/handlers_email_offline_replay_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package air - -import ( - "context" - "testing" - - "github.com/nylas/cli/internal/domain" -) - -// TestProcessOfflineQueues_PreservesEmptyFoldersIntent locks down the -// payload-shape contract that motivates the nil-vs-empty distinction on -// `[]string` Folders. The Gmail-archive intent is encoded as a non-nil -// empty slice ([]string{}) — distinct from "leave folders alone" (nil). -// A regression that re-introduced `omitempty` on cache.UpdateMessagePayload -// would silently drop the empty slice on the queue's JSON round-trip, -// replaying as nil and reverting every offline-archived message: UI says -// archived, server unchanged. -// -// This test enqueues an empty-Folders update, drains the queue, and -// asserts the captured request still has Folders as a non-nil empty -// slice. Should pass today — the find that motivated it is the absence -// of coverage, not a live bug. -func TestProcessOfflineQueues_PreservesEmptyFoldersIntent(t *testing.T) { - t.Parallel() - - server, client, accountEmail := newCachedTestServer(t) - server.SetOnline(false) - - var captured *domain.UpdateMessageRequest - client.UpdateMessageFunc = func(_ context.Context, _, _ string, req *domain.UpdateMessageRequest) (*domain.Message, error) { - // Snapshot the request so SetOnline(true)'s replay write is - // observable after the fact. Mock writes to LastGrantID etc. - // are not enough — those don't capture Folders. - captured = req - return &domain.Message{ID: "email-archive"}, nil - } - - if err := server.enqueueMessageUpdate("grant-123", accountEmail, "email-archive", &domain.UpdateMessageRequest{ - Folders: []string{}, - }); err != nil { - t.Fatalf("enqueue update: %v", err) - } - - server.SetOnline(true) // triggers processOfflineQueues synchronously - - if !client.UpdateMessageCalled { - t.Fatal("expected UpdateMessage to be replayed when going back online") - } - if captured == nil { - t.Fatal("UpdateMessage was called but request was not captured") - } - if captured.Folders == nil { - t.Fatal("replayed Folders is nil; want non-nil empty slice (Gmail archive intent)") - } - if len(captured.Folders) != 0 { - t.Errorf("replayed Folders = %v, want empty slice (Gmail archive intent)", - captured.Folders) - } -} - -// TestProcessOfflineQueues_PreservesNilFoldersIntent is the symmetric -// lock-down: nil Folders ("leave folders alone", e.g. mark-as-read -// without touching folders) must replay as nil, not as []string{}. -// Confusing the two on either side of the queue corrupts the user's -// intent: a "mark unread" enqueued offline must not also clear the -// folder list when it eventually replays. -func TestProcessOfflineQueues_PreservesNilFoldersIntent(t *testing.T) { - t.Parallel() - - server, client, accountEmail := newCachedTestServer(t) - server.SetOnline(false) - - var captured *domain.UpdateMessageRequest - client.UpdateMessageFunc = func(_ context.Context, _, _ string, req *domain.UpdateMessageRequest) (*domain.Message, error) { - captured = req - return &domain.Message{ID: "email-mark-read"}, nil - } - - unread := false - if err := server.enqueueMessageUpdate("grant-123", accountEmail, "email-mark-read", &domain.UpdateMessageRequest{ - Unread: &unread, - // Folders intentionally omitted — caller's intent is "do not - // touch folders," and the queue must not invent one on replay. - }); err != nil { - t.Fatalf("enqueue update: %v", err) - } - - server.SetOnline(true) - - if !client.UpdateMessageCalled { - t.Fatal("expected UpdateMessage to be replayed when going back online") - } - if captured == nil { - t.Fatal("UpdateMessage was called but request was not captured") - } - if captured.Folders != nil { - t.Errorf("replayed Folders = %v (non-nil), want nil (leave-alone intent)", - captured.Folders) - } -} diff --git a/internal/air/handlers_email_offline_test.go b/internal/air/handlers_email_offline_test.go deleted file mode 100644 index c5c23b9..0000000 --- a/internal/air/handlers_email_offline_test.go +++ /dev/null @@ -1,493 +0,0 @@ -package air - -import ( - "context" - "encoding/json" - "errors" - "net" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/nylas/cli/internal/air/cache" - "github.com/nylas/cli/internal/domain" -) - -// TestHandleListEmails_APIErrorFallsBackToCache pins the server-online, -// upstream-failed cache fallback. The handler is expected to return -// cached results with HasMore:false (so the UI doesn't try to paginate -// past the snapshot) instead of surfacing a 500. This was an untested -// branch — without coverage a future refactor of the cache fallback -// loop could silently leave the UI dead in the water on transient -// Nylas outages even though the cache is healthy. -func TestHandleListEmails_APIErrorFallsBackToCache(t *testing.T) { - t.Parallel() - - server, client, accountEmail := newCachedTestServer(t) - putCachedEmail(t, server, accountEmail, &cache.CachedEmail{ - ID: "cached-1", - FolderID: "inbox", - Subject: "Cached when API is down", - FromName: "Cache", - FromEmail: "cache@example.com", - Date: time.Now(), - CachedAt: time.Now(), - }) - - // Single-message cache won't satisfy the "full page" short-circuit - // when a folder filter is applied (the threshold is len >= limit), - // so we hit the API path. Force the API to fail with a transient - // error — handler must serve the stale cache rather than 500. - apiCalled := false - client.GetMessagesWithParamsFunc = func(_ context.Context, _ string, _ *domain.MessageQueryParams) ([]domain.Message, error) { - apiCalled = true - return nil, errors.New("nylas 503 service unavailable") - } - - req := httptest.NewRequest(http.MethodGet, "/api/emails?folder=inbox", nil) - w := httptest.NewRecorder() - server.handleListEmails(w, req) - - if !apiCalled { - t.Fatal("expected the API to be called once before falling back to cache") - } - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s, want 200 (cache fallback)", w.Code, w.Body.String()) - } - var resp EmailsResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if len(resp.Emails) != 1 || resp.Emails[0].ID != "cached-1" { - t.Errorf("response emails=%+v, want single cached-1", resp.Emails) - } - // HasMore must be false — paginating past the cache snapshot would - // hit the same broken upstream and confuse the user. - if resp.HasMore { - t.Errorf("HasMore=true on cache fallback; expected false to prevent retry-paginate") - } -} - -// TestHandleUpdateEmail_OnlineTransientErrorQueuesAction pins the -// transient-error branch in handleUpdateEmail. When the API call -// returns a network-shaped error AND the offline queue is configured, -// the handler must enqueue the action, flip server state to offline, -// and return a 200 "queued" envelope — not a 500. -func TestHandleUpdateEmail_OnlineTransientErrorQueuesAction(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - // Force a transient error type that shouldQueueEmailAction recognises. - client.UpdateMessageFunc = func(context.Context, string, string, *domain.UpdateMessageRequest) (*domain.Message, error) { - return nil, &transientNetErr{} - } - - req := httptest.NewRequest(http.MethodPut, "/api/emails/email-1", - strings.NewReader(`{"unread":false}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - server.handleUpdateEmail(w, req, "email-1") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s, want 200 (transient error must queue, not 500)", w.Code, w.Body.String()) - } - var resp UpdateEmailResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if !resp.Success || !strings.Contains(resp.Message, "queued") { - t.Errorf("response=%+v, want Success:true with 'queued' in message", resp) - } - // Server should now be marked offline so subsequent calls take the - // fast offline-first path instead of round-tripping the broken API. - if server.IsOnline() { - t.Error("expected SetOnline(false) after transient API error, got isOnline=true") - } -} - -// TestHandleUpdateEmail_OfflineButQueueFails_FallsThroughToAPI pins -// the rare case where the server is offline AND the offline queue -// itself is broken. The handler should still attempt the live API -// call (it might succeed — IsOnline can be stale) rather than dropping -// the user's action silently. -func TestHandleUpdateEmail_OfflineButQueueFails_FallsThroughToAPI(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - server.SetOnline(false) - // Disable the offline queue so enqueueMessageUpdate fails. With no - // account-email lookup possible the helper short-circuits with an - // "offline queue unavailable" error. - server.offlineQueues = nil - server.cacheSettings.OfflineQueueEnabled = false - - apiCalled := false - client.UpdateMessageFunc = func(context.Context, string, string, *domain.UpdateMessageRequest) (*domain.Message, error) { - apiCalled = true - return &domain.Message{ID: "email-1"}, nil - } - - req := httptest.NewRequest(http.MethodPut, "/api/emails/email-1", - strings.NewReader(`{"unread":true}`)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - server.handleUpdateEmail(w, req, "email-1") - - if !apiCalled { - t.Error("expected handler to attempt live API call when queue is unavailable") - } - if w.Code != http.StatusOK { - t.Errorf("status=%d body=%s, want 200 (live retry succeeded)", w.Code, w.Body.String()) - } -} - -// TestShouldQueueEmailAction pins the predicate that gates whether -// upstream errors enter the offline queue. Without direct coverage, -// regressions in the net.Error / context.DeadlineExceeded matching -// are invisible — the only signal would be "archives queue properly -// most of the time." -func TestShouldQueueEmailAction(t *testing.T) { - t.Parallel() - - server, _, _ := newCachedTestServer(t) - - cases := []struct { - name string - err error - online bool - enabled bool - want bool - }{ - { - name: "offline + queue enabled → queue", - err: errors.New("any error"), - online: false, - enabled: true, - want: true, - }, - { - name: "online + transient net.Error → queue", - err: &transientNetErr{}, - online: true, - enabled: true, - want: true, - }, - { - name: "online + context deadline exceeded → queue", - err: context.DeadlineExceeded, - online: true, - enabled: true, - want: true, - }, - { - name: "online + plain error (4xx-shaped) → don't queue", - err: errors.New("nylas 401 unauthorized"), - online: true, - enabled: true, - want: false, - }, - { - name: "queue disabled → never queue", - err: &transientNetErr{}, - online: false, - enabled: false, - want: false, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - // shouldQueueEmailAction is a method on *Server, not pure — - // reset state between cases. Each subtest is sequential so - // the shared server is safe. - server.SetOnline(tc.online) - server.cacheSettings.OfflineQueueEnabled = tc.enabled - got := server.shouldQueueEmailAction(tc.err) - if got != tc.want { - t.Errorf("shouldQueueEmailAction(%v) [online=%v enabled=%v] = %v, want %v", - tc.err, tc.online, tc.enabled, got, tc.want) - } - }) - } -} - -// TestNormalizeDemoFolder pins the alias map for demo-mode folder -// filtering. Without coverage, "Sent Items" → "sent" silently breaks -// when a future refactor inlines the switch into demoEmailIsInFolder. -func TestNormalizeDemoFolder(t *testing.T) { - t.Parallel() - - cases := []struct { - input string - want string - }{ - {"", ""}, - {"inbox", "inbox"}, - {"INBOX", "inbox"}, - {" inbox ", "inbox"}, - {"sent", "sent"}, - {"Sent Items", "sent"}, // Microsoft display name - {"Sent Mail", "sent"}, // Gmail display name - {"drafts", "drafts"}, - {"Draft", "drafts"}, - {"archive", "archive"}, - {"All Mail", "all"}, // Gmail "All Mail" routes to the "show everything" target - {"all", "all"}, - {"trash", "trash"}, - {"Deleted Items", "trash"}, // Microsoft - {"Deleted", "trash"}, - {"spam", "spam"}, - {"Junk", "spam"}, - {"Junk Email", "spam"}, // Outlook - {"starred", "starred"}, - {"unknown-folder", "unknown-folder"}, // pass-through, lowercased - } - for _, tc := range cases { - t.Run(tc.input, func(t *testing.T) { - got := normalizeDemoFolder(tc.input) - if got != tc.want { - t.Errorf("normalizeDemoFolder(%q) = %q, want %q", tc.input, got, tc.want) - } - }) - } -} - -// TestDemoEmailIsInFolder_AllAliasMatchesEverything pins the special -// "all" branch — a UI-supplied "all" target should match every demo -// email regardless of its folders[]. -func TestDemoEmailIsInFolder_AllAliasMatchesEverything(t *testing.T) { - t.Parallel() - - cases := []EmailResponse{ - {Folders: []string{"inbox"}}, - {Folders: []string{"sent"}}, - {Folders: []string{"trash"}}, - {Folders: nil}, - {Folders: []string{}}, - } - for i, e := range cases { - if !demoEmailIsInFolder(e, "all") { - t.Errorf("case %d: demoEmailIsInFolder(%+v, \"all\") = false, want true", i, e) - } - } -} - -// TestFilterDemoEmails_FolderAllReturnsEverything exercises the same -// "all means everything" intent end-to-end through the call chain a UI -// request actually takes: -// -// filterDemoEmails(emails, "all", false, false) -// → normalizeDemoFolder("all") // canonicalises folder string -// → demoEmailIsInFolder(e, target) // matches against e.Folders -// -// `demoEmailIsInFolder` has a dedicated `if target == "all" { return -// true }` branch (handlers_email_demo.go:219), but -// `normalizeDemoFolder` collapses "all" into "archive" via the alias -// case `"archive", "all", "all mail"`. The branch is therefore -// unreachable from the UI path, and `filterDemoEmails(emails, "all", -// ...)` returns only emails whose Folders[] contains "archive" — one -// email — instead of all 13. -// -// EXPECTED FAILURE today: assertion expects len(filtered) == -// len(demoEmails()), got 1. After the fix (drop "all"/"all mail" from -// the archive aliases — or remove the dead branch and update -// TestNormalizeDemoFolder) this test passes. -func TestFilterDemoEmails_FolderAllReturnsEverything(t *testing.T) { - t.Parallel() - - all := demoEmails() - got := filterDemoEmails(all, "all", false, false) - - if len(got) != len(all) { - t.Errorf("filterDemoEmails(_, \"all\") returned %d email(s), want %d (every demo email)", - len(got), len(all)) - } -} - -// TestHandleGetEmail_CachedBodyServedWhenAPIWouldFail pins the user- -// visible promise: "when Nylas is down but the cache holds the email, -// I can still read it." The handler's cache-first short-circuit -// returns the cached body before ever calling GetMessage, so the API -// stub here exists as a fail-loud guard — if a refactor were to -// reorder the lookups so the API call fires first AND fails, the -// stub would record it AND the response would still need to deliver -// the body (via the fallback at handlers_email.go ~241). -func TestHandleGetEmail_CachedBodyServedWhenAPIWouldFail(t *testing.T) { - t.Parallel() - - server, client, accountEmail := newCachedTestServer(t) - putCachedEmail(t, server, accountEmail, &cache.CachedEmail{ - ID: "email-1", - FolderID: "inbox", - Subject: "Cached body survives outage", - FromName: "Cache", - FromEmail: "cache@example.com", - BodyHTML: "cached body
", - Date: time.Now(), - CachedAt: time.Now(), - }) - // API failure stub: in the documented flow this never fires (the - // cache hit short-circuits first), but we leave it wired so any - // refactor that bypasses the cache-first lookup will land in a - // 500 instead of silently masking the regression. - client.GetMessageFunc = func(context.Context, string, string) (*domain.Message, error) { - return nil, errors.New("nylas 503 service unavailable") - } - - req := httptest.NewRequest(http.MethodGet, "/api/emails/email-1", nil) - w := httptest.NewRecorder() - server.handleGetEmail(w, req, "email-1") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s, want 200 (cached body must be served)", w.Code, w.Body.String()) - } - var resp EmailResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.ID != "email-1" { - t.Errorf("response id=%q, want email-1", resp.ID) - } - if resp.Body != "cached body
" { - t.Errorf("response body=%q, want full cached BodyHTML — the cached-email response must include Body, not just metadata", resp.Body) - } -} - -// TestHandleGetEmail_APIErrorWhenNotCached_Returns500 covers the -// other end of the same branch: cache empty, API fails. The user -// sees a generic 500 (no upstream-error leakage) rather than a stuck -// loading state. Pins both the user-visible code AND the privacy -// contract that we don't echo Nylas's raw error string back. -func TestHandleGetEmail_APIErrorWhenNotCached_Returns500(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - client.GetMessageFunc = func(context.Context, string, string) (*domain.Message, error) { - // Include identifying noise in the upstream error so we can - // assert the handler doesn't echo it back. - return nil, errors.New("nylas 503: grant_id=secret-grant-12345 endpoint=/messages/email-1") - } - - req := httptest.NewRequest(http.MethodGet, "/api/emails/uncached-id", nil) - w := httptest.NewRecorder() - server.handleGetEmail(w, req, "uncached-id") - - if w.Code != http.StatusInternalServerError { - t.Fatalf("status=%d body=%s, want 500", w.Code, w.Body.String()) - } - body := w.Body.String() - if strings.Contains(body, "secret-grant-12345") || strings.Contains(body, "/messages/") { - t.Errorf("response leaked upstream error details: %s", body) - } -} - -// TestHandleDeleteEmail_OfflineButQueueFails_FallsThroughToAPI pins -// that an offline server with a broken queue still attempts the live -// API call. Without this fallthrough a misconfigured cache silently -// swallows every delete the user issues — invisible data loss. -func TestHandleDeleteEmail_OfflineButQueueFails_FallsThroughToAPI(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - server.SetOnline(false) - // Disable queueing so enqueueMessageDelete fails closed. - server.cacheSettings.OfflineQueueEnabled = false - - apiCalled := false - client.DeleteMessageFunc = func(context.Context, string, string) error { - apiCalled = true - return nil - } - - req := httptest.NewRequest(http.MethodDelete, "/api/emails/email-1", nil) - w := httptest.NewRecorder() - server.handleDeleteEmail(w, req, "email-1") - - if !apiCalled { - t.Error("expected handler to attempt live DeleteMessage when queue is unavailable") - } - if w.Code != http.StatusOK { - t.Errorf("status=%d body=%s, want 200 (live retry succeeded)", w.Code, w.Body.String()) - } -} - -// TestHandleDeleteEmail_OnlineTransientErrorQueuesAction pins the -// online-transient-error path for delete: API errors with a -// queue-eligible error AND the queue is healthy → enqueue and return -// 200. Mirrors the update-side test so a future refactor can't -// accidentally diverge the two flows. -func TestHandleDeleteEmail_OnlineTransientErrorQueuesAction(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - client.DeleteMessageFunc = func(context.Context, string, string) error { - return &transientNetErr{} - } - - 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("status=%d body=%s, want 200 (transient error must queue)", w.Code, w.Body.String()) - } - var resp UpdateEmailResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if !resp.Success || !strings.Contains(resp.Message, "queued") { - t.Errorf("response=%+v, want Success:true with 'queued' in message", resp) - } - if server.IsOnline() { - t.Error("expected SetOnline(false) after transient API error, got isOnline=true") - } -} - -// TestHandleDeleteEmail_OnlineTransientErrorQueueFails_Returns500 -// pins the worst-case for delete: API errors with a transient AND the -// queue write itself fails. The user must see a 500 rather than a -// silently-dropped delete. Delete is irreversible — silent loss of -// the user's intent here is the most damaging branch in the package. -func TestHandleDeleteEmail_OnlineTransientErrorQueueFails_Returns500(t *testing.T) { - t.Parallel() - - server, client, _ := newCachedTestServer(t) - client.DeleteMessageFunc = func(context.Context, string, string) error { - return &transientNetErr{} - } - // Disable the queue so enqueueMessageDelete fails inside the - // shouldQueueEmailAction branch. - server.cacheSettings.OfflineQueueEnabled = false - - req := httptest.NewRequest(http.MethodDelete, "/api/emails/email-1", nil) - w := httptest.NewRecorder() - server.handleDeleteEmail(w, req, "email-1") - - if w.Code != http.StatusInternalServerError { - t.Fatalf("status=%d body=%s, want 500 (queue-write-fails branch)", w.Code, w.Body.String()) - } - var resp UpdateEmailResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.Success { - t.Errorf("response Success=true on double-failure, expected false") - } - if resp.Error == "" { - t.Error("response Error should describe the failure, got empty") - } -} - -// transientNetErr implements net.Error with Timeout()=true so the -// handler classifies it as "queue this and try again later" rather -// than a permanent 4xx. -type transientNetErr struct{} - -func (transientNetErr) Error() string { return "simulated transient network error" } -func (transientNetErr) Timeout() bool { return true } -func (transientNetErr) Temporary() bool { return true } - -// Compile-time assertion: transientNetErr satisfies net.Error. -var _ net.Error = transientNetErr{} diff --git a/internal/air/handlers_email_rsvp.go b/internal/air/handlers_email_rsvp.go deleted file mode 100644 index dcc76b4..0000000 --- a/internal/air/handlers_email_rsvp.go +++ /dev/null @@ -1,238 +0,0 @@ -package air - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "net/http" - "strings" - - "github.com/nylas/cli/internal/domain" -) - -// rsvpCommentMaxBytes caps the free-form RSVP comment forwarded to Nylas. -// 1MB JSON body limits already protect against DoS, but a UI-meaningful -// cap surfaces a friendlier error and prevents accidentally pasting an -// entire email body into the comment field. -const rsvpCommentMaxBytes = 1024 - -// rsvpRequest is the JSON body accepted by POST /api/emails/{id}/rsvp. -// Mirrors the shape the CLI's `nylas calendar events rsvp` command uses -// so frontend and CLI converge on the same vocabulary. -type rsvpRequest struct { - Status string `json:"status"` - Comment string `json:"comment,omitempty"` -} - -// rsvpResponse is the success body — small on purpose so the frontend -// can update local state (button highlight, attendee count) without a -// follow-up fetch. -type rsvpResponse struct { - Status string `json:"status"` - EventID string `json:"event_id"` - CalendarID string `json:"calendar_id"` -} - -// validRSVPStatuses pins the Nylas v3 send-rsvp vocabulary. -// "noreply" exists in the API but isn't a meaningful UI choice — the -// user just doesn't click anything — so we don't accept it here. -var validRSVPStatuses = map[string]struct{}{ - "yes": {}, - "no": {}, - "maybe": {}, -} - -// errEventNotImported: the invite UID hasn't been ingested by the Nylas -// calendar importer yet — surface as 404 with a "try again" hint. -var errEventNotImported = errors.New("invite has not been imported into your calendar yet") - -// errNoWritableCalendar: grant has only read-only calendars (e.g. a -// "Holidays" subscription) — surface as 422, retry won't help. -var errNoWritableCalendar = errors.New("no writable calendar available") - -// handleEmailRSVP forwards the user's RSVP choice to Nylas. -// -// Security invariant: never trust a client-supplied event ID — always -// re-resolve via the email's VEVENT UID. Otherwise a forged frontend -// could RSVP to arbitrary events on the user's behalf. -func (s *Server) handleEmailRSVP(w http.ResponseWriter, r *http.Request, emailID string) { - if !requireMethod(w, r, http.MethodPost) { - return - } - - var body rsvpRequest - if err := json.NewDecoder(limitedBody(w, r)).Decode(&body); err != nil { - slog.Warn("RSVP request body decode failed", "emailID", emailID, "err", err) - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - writeError(w, http.StatusRequestEntityTooLarge, "Request body too large") - return - } - writeError(w, http.StatusBadRequest, "Invalid request body") - return - } - status := strings.ToLower(strings.TrimSpace(body.Status)) - if _, ok := validRSVPStatuses[status]; !ok { - writeError(w, http.StatusBadRequest, "status must be one of: yes, no, maybe") - return - } - body.Comment = strings.TrimSpace(body.Comment) - if len(body.Comment) > rsvpCommentMaxBytes { - writeError(w, http.StatusBadRequest, fmt.Sprintf("comment must be %d bytes or fewer", rsvpCommentMaxBytes)) - return - } - - if s.demoMode { - writeJSON(w, http.StatusOK, rsvpResponse{ - Status: status, - EventID: "demo-event-001", - CalendarID: "primary", - }) - return - } - - grantID := s.withAuthGrant(w, nil) - if grantID == "" { - return - } - - ctx, cancel := s.withTimeout(r) - defer cancel() - - invite, err := s.resolveEmailInvite(ctx, grantID, emailID) - if err != nil { - // Both initial GetMessage failure and the raw_mime fallback land - // here — both are transient. 502 lets the frontend offer a retry. - slog.Error("RSVP failed to fetch email", "emailID", emailID, "err", err) - writeError(w, http.StatusBadGateway, "Failed to fetch email — please try again") - return - } - if !invite.HasInvite { - writeError(w, http.StatusNotFound, "This email does not contain a calendar invitation") - return - } - if strings.EqualFold(invite.Method, "CANCEL") || strings.EqualFold(invite.Status, "CANCELLED") { - writeError(w, http.StatusConflict, "This event has been cancelled — RSVP is no longer accepted") - return - } - if invite.ICalUID == "" { - // Some Microsoft senders ship invites without a UID. - writeError(w, http.StatusUnprocessableEntity, "Invite has no UID — open the event in your calendar to RSVP") - return - } - - // Search ALL writable calendars: invites often land in non-primary ones - // (work + personal under one Google account, shared team calendars). - calendarID, eventID, err := s.findInviteEventAcrossCalendars(ctx, grantID, invite.ICalUID) - if err != nil { - if errors.Is(err, errEventNotImported) { - writeError(w, http.StatusNotFound, err.Error()) - return - } - // "No writable calendar" is a config-shaped failure (only - // read-only subscriptions on this grant). Retrying won't help — - // surface a 422 with a clear message so the user knows where to - // look instead of being told to retry forever. - if errors.Is(err, errNoWritableCalendar) { - writeError(w, http.StatusUnprocessableEntity, - "No writable calendar on this account — RSVP requires a calendar you can edit") - return - } - slog.Error("RSVP calendar lookup failed", "emailID", emailID, "icalUID", invite.ICalUID, "err", err) - writeError(w, http.StatusBadGateway, "Failed to look up event — please try again") - return - } - - rsvpReq := &domain.SendRSVPRequest{Status: status, Comment: body.Comment} - if err := s.nylasClient.SendRSVP(ctx, grantID, calendarID, eventID, rsvpReq); err != nil { - slog.Error("RSVP send failed", - "emailID", emailID, - "calendarID", calendarID, - "eventID", eventID, - "status", status, - "err", err, - ) - writeError(w, http.StatusBadGateway, "Failed to send RSVP — please try again") - return - } - - writeJSON(w, http.StatusOK, rsvpResponse{ - Status: status, - EventID: eventID, - CalendarID: calendarID, - }) -} - -// findInviteEventAcrossCalendars searches every writable calendar -// (primary first) for an event matching icalUID. Returns -// errEventNotImported when no calendar contains the UID. A single -// calendar erroring doesn't kill the search — the next might hold it. -func (s *Server) findInviteEventAcrossCalendars(ctx context.Context, grantID, icalUID string) (string, string, error) { - calendars, err := s.nylasClient.GetCalendars(ctx, grantID) - if err != nil { - return "", "", fmt.Errorf("failed to list calendars: %w", err) - } - if len(calendars) == 0 { - return "", "", errors.New("no calendars found for this account") - } - - writable := writableCalendars(calendars) - if len(writable) == 0 { - return "", "", errNoWritableCalendar - } - - var lastLookupErr error - for _, c := range writable { - eventID, err := s.findEventByICalUID(ctx, grantID, c.ID, icalUID) - if err == nil { - return c.ID, eventID, nil - } - if !errors.Is(err, errEventNotImported) { - slog.Warn("RSVP per-calendar lookup failed", - "calendarID", c.ID, - "icalUID", icalUID, - "err", err, - ) - lastLookupErr = err - } - } - - if lastLookupErr != nil { - return "", "", fmt.Errorf("failed to look up event: %w", lastLookupErr) - } - return "", "", errEventNotImported -} - -// writableCalendars returns writable calendars, primary first. -func writableCalendars(calendars []domain.Calendar) []domain.Calendar { - out := make([]domain.Calendar, 0, len(calendars)) - for _, c := range calendars { - if c.IsPrimary && !c.ReadOnly { - out = append(out, c) - } - } - for _, c := range calendars { - if !c.IsPrimary && !c.ReadOnly { - out = append(out, c) - } - } - return out -} - -// findEventByICalUID resolves a UID via Nylas v3's `ical_uid` filter. -// Returns errEventNotImported when no event matches. -func (s *Server) findEventByICalUID(ctx context.Context, grantID, calendarID, icalUID string) (string, error) { - resp, err := s.nylasClient.GetEventsWithCursor(ctx, grantID, calendarID, &domain.EventQueryParams{ - ICalUID: icalUID, - Limit: 1, - }) - if err != nil { - return "", err - } - if resp == nil || len(resp.Data) == 0 { - return "", errEventNotImported - } - return resp.Data[0].ID, nil -} diff --git a/internal/air/handlers_email_rsvp_edge_cases_test.go b/internal/air/handlers_email_rsvp_edge_cases_test.go deleted file mode 100644 index 6fa2e6b..0000000 --- a/internal/air/handlers_email_rsvp_edge_cases_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package air - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/nylas/cli/internal/domain" -) - -// statusCancelledOnlyMIME pins the case where METHOD is REQUEST (so the -// invite is "live") but the VEVENT carries STATUS:CANCELLED. The handler -// must still 409 — accepting an RSVP on a cancelled event would create -// a confusing diverging UI state. -const statusCancelledOnlyMIME = "Content-Type: multipart/alternative; boundary=\"B\"\r\n" + - "\r\n--B\r\n" + - "Content-Type: text/calendar; charset=UTF-8; method=REQUEST\r\n\r\n" + - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\nMETHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\nUID:status-cancelled-uid@example.com\r\nSUMMARY:CancelledStatusOnly\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T150000Z\r\n" + - "STATUS:CANCELLED\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" + - "--B--\r\n" - -// TestHandleEmailRSVP_StatusCancelledOnly_Rejected covers the case where -// only STATUS:CANCELLED (no METHOD:CANCEL) marks the event as dead. -// Without this branch the 409 guard would only fire on Outlook-style -// cancellations and silently accept Google's status-only path. -func TestHandleEmailRSVP_StatusCancelledOnly_Rejected(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, statusCancelledOnlyMIME) - - calledRSVP := false - mock.SendRSVPFunc = func(context.Context, string, string, string, *domain.SendRSVPRequest) error { - calledRSVP = true - return nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusConflict { - t.Errorf("status=%d body=%s, want 409 (cancelled)", w.Code, w.Body.String()) - } - if calledRSVP { - t.Error("SendRSVP was called for STATUS:CANCELLED event — guard is missing") - } -} - -// TestHandleEmailRSVP_GetCalendarsError pins the upstream-failure path -// for the calendars listing. The handler should NOT report "no calendars -// found" (which would mislead the user) — it must surface 502 so the -// frontend offers a retry. -func TestHandleEmailRSVP_GetCalendarsError(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - return nil, errors.New("nylas listing failed: 503") - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusBadGateway { - t.Errorf("status=%d body=%s, want 502 on calendar listing failure", w.Code, w.Body.String()) - } -} - -// TestHandleEmailRSVP_NoCalendars covers the zero-calendar branch. -// If Nylas returns an empty calendars list (no error), the handler -// should not silently RSVP to an empty calendarID — it must 502. -func TestHandleEmailRSVP_NoCalendars(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - return []domain.Calendar{}, nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusBadGateway { - t.Errorf("status=%d body=%s, want 502 (no calendars)", w.Code, w.Body.String()) - } -} - -// TestHandleEmailRSVP_MissingDefaultGrant exercises the auth path: when -// no default grant is configured, withAuthGrant writes a 400 envelope -// and the handler returns without calling Nylas. Without this test, a -// future refactor could break the "select an account first" UX. -func TestHandleEmailRSVP_MissingDefaultGrant(t *testing.T) { - t.Parallel() - server, mock, _ := newCachedTestServer(t) - - // Strip the default grant so requireDefaultGrant fails closed. - if err := server.grantStore.ClearGrants(); err != nil { - t.Fatalf("clear grants: %v", err) - } - - calledNylas := false - mock.SendRSVPFunc = func(context.Context, string, string, string, *domain.SendRSVPRequest) error { - calledNylas = true - return nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusBadRequest { - t.Errorf("status=%d body=%s, want 400 (no default grant)", w.Code, w.Body.String()) - } - if calledNylas { - t.Error("SendRSVP called without a default grant — auth gate failed") - } -} - -// TestHandleEmailRSVP_DemoMode_RejectsInvalidStatus pins that body -// validation runs BEFORE the demo-mode short-circuit, so a hostile or -// stale frontend can't get a green-path RSVP response with garbage data. -func TestHandleEmailRSVP_DemoMode_RejectsInvalidStatus(t *testing.T) { - t.Parallel() - server, _, _ := newCachedTestServer(t) - server.demoMode = true - - w := postRSVP(t, server, "any-email", `{"status":"bogus"}`) - if w.Code != http.StatusBadRequest { - t.Errorf("status=%d body=%s, want 400 even in demo mode", w.Code, w.Body.String()) - } -} - -// TestHandleEmailRSVP_DemoMode_PinsLiteralEventID pins the demo response -// shape. The frontend reads event_id back to highlight the active button, -// so a regression in the demo literal would silently break the demo UX. -func TestHandleEmailRSVP_DemoMode_PinsLiteralEventID(t *testing.T) { - t.Parallel() - server, _, _ := newCachedTestServer(t) - server.demoMode = true - - w := postRSVP(t, server, "any-email", `{"status":"maybe"}`) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - var got rsvpResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.EventID != "demo-event-001" || got.CalendarID != "primary" || got.Status != "maybe" { - t.Errorf("demo response=%+v, want {Status:maybe, EventID:demo-event-001, CalendarID:primary}", got) - } -} - -// TestHandleEmailRSVP_OversizedComment pins the comment-length cap. -// Without this guard a single user could submit megabytes of comment -// text against an organiser's RSVP — the LimitedBody (1MB) bound is too -// permissive, so we cap at the UI-meaningful rsvpCommentMaxBytes. -// -// Boundary cases are also pinned: exactly rsvpCommentMaxBytes is OK, -// rsvpCommentMaxBytes+1 is rejected. This keeps the cap interpretation -// frozen against an off-by-one refactor. -func TestHandleEmailRSVP_OversizedComment(t *testing.T) { - t.Parallel() - cases := []struct { - name string - size int - wantCode int - wantCalls bool - }{ - {name: "at cap", size: rsvpCommentMaxBytes, wantCode: http.StatusOK, wantCalls: true}, - {name: "one over cap", size: rsvpCommentMaxBytes + 1, wantCode: http.StatusBadRequest, wantCalls: false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - calledRSVP := false - mock.SendRSVPFunc = func(context.Context, string, string, string, *domain.SendRSVPRequest) error { - calledRSVP = true - return nil - } - body := `{"status":"yes","comment":"` + strings.Repeat("x", tc.size) + `"}` - w := postRSVP(t, server, "email-1", body) - if w.Code != tc.wantCode { - t.Errorf("status=%d body=%s, want %d", w.Code, w.Body.String(), tc.wantCode) - } - if calledRSVP != tc.wantCalls { - t.Errorf("SendRSVP called=%v, want %v", calledRSVP, tc.wantCalls) - } - }) - } -} - -// TestHandleEmailRSVP_TrimsCommentBeforeLengthCheck pins that surrounding -// whitespace is stripped before the comment cap is applied. Without this, -// a user pasting from a WYSIWYG editor (which often includes trailing -// newline/space) could trip the limit on a message they perceive as -// short. Also pins that the trimmed form is what gets forwarded — the -// Nylas organiser shouldn't see " yes please " in their notification. -func TestHandleEmailRSVP_TrimsCommentBeforeLengthCheck(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - var got string - mock.SendRSVPFunc = func(_ context.Context, _, _, _ string, req *domain.SendRSVPRequest) error { - got = req.Comment - return nil - } - body := `{"status":"yes","comment":" see you there \n"}` - w := postRSVP(t, server, "email-1", body) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - if got != "see you there" { - t.Errorf("forwarded comment=%q, want %q (trimmed)", got, "see you there") - } -} - -// TestHandleEmailRSVP_OversizedBodyReturns413 pins that a request body -// exceeding the 1MB MaxRequestBodySize cap returns 413 (RequestEntityTooLarge), -// not 400 (BadRequest). Without this distinction, clients can't tell -// "your JSON is malformed" (caller bug) from "your payload is too big" -// (caller can shrink and retry). -func TestHandleEmailRSVP_OversizedBodyReturns413(t *testing.T) { - t.Parallel() - server, _ := rsvpHappyPathMock(t, rsvpInviteMIME) - - // 2MB body — well past the 1MB MaxRequestBodySize cap. - huge := strings.Repeat("a", 2<<20) - body := `{"status":"yes","comment":"` + huge + `"}` - w := postRSVP(t, server, "email-1", body) - if w.Code != http.StatusRequestEntityTooLarge { - t.Errorf("status=%d body=%s, want 413 on >1MB body", w.Code, w.Body.String()) - } -} - -// TestHandleEmailRSVP_BodyParseErrorIsGeneric pins the privacy contract: -// the JSON-decode error must NOT echo the raw bytes back to the client. -// (Localhost-only mitigates blast radius, but echoing the input weakens -// defense-in-depth and complicates anti-XSS reasoning.) -func TestHandleEmailRSVP_BodyParseErrorIsGeneric(t *testing.T) { - t.Parallel() - server, _ := rsvpHappyPathMock(t, rsvpInviteMIME) - - probe := "" - w := postRSVP(t, server, "email-1", probe) - if w.Code != http.StatusBadRequest { - t.Fatalf("status=%d, want 400", w.Code) - } - if strings.Contains(w.Body.String(), probe) { - t.Errorf("error response echoed raw client bytes: %s", w.Body.String()) - } -} - -// TestHandleEmailRSVP_RouteDispatch wires the test through handleEmailByID -// (the actual entry point bound to /api/emails/{id}/rsvp) instead of -// calling handleEmailRSVP directly. This pins the path-splitting logic -// in handlers_email.go so a future refactor of `parts[1] == "rsvp"` -// can't silently break the route while every direct-call test still -// passes. -func TestHandleEmailRSVP_RouteDispatch(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - hit := false - mock.SendRSVPFunc = func(context.Context, string, string, string, *domain.SendRSVPRequest) error { - hit = true - return nil - } - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/api/emails/email-1/rsvp", strings.NewReader(`{"status":"yes"}`)) - r.Header.Set("Content-Type", "application/json") - server.handleEmailByID(w, r) - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s — route dispatch is broken", w.Code, w.Body.String()) - } - if !hit { - t.Error("SendRSVP not called via /rsvp route — handleEmailByID failed to dispatch") - } -} - -// (TestHandleEmailRSVP_LooksUpAcrossCalendars removed — duplicate of -// TestHandleEmailRSVP_FindsEventInSecondaryCalendar in -// handlers_email_rsvp_test.go, which has stricter assertions on the -// ical_uid filter and uses reflect.DeepEqual for the walk order.) - -// TestHandleEmailRSVP_TransientCalendarLookupFailureSurfacedWhenAllMiss -// pins that a non-errEventNotImported error from a per-calendar lookup -// (e.g. Nylas 5xx) is surfaced as 502 when no other calendar resolves -// the event. Without this, repeated "calendar not imported" 404s would -// hide a real Nylas outage. -func TestHandleEmailRSVP_TransientCalendarLookupFailureSurfacedWhenAllMiss(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - return []domain.Calendar{ - {ID: "cal-primary", IsPrimary: true}, - }, nil - } - mock.GetEventsWithCursorFunc = func(context.Context, string, string, *domain.EventQueryParams) (*domain.EventListResponse, error) { - return nil, errors.New("nylas events listing returned 503") - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusBadGateway { - t.Errorf("status=%d body=%s, want 502 (transient lookup error)", w.Code, w.Body.String()) - } - if strings.Contains(w.Body.String(), "503") { - t.Errorf("response leaked upstream status code: %s", w.Body.String()) - } -} diff --git a/internal/air/handlers_email_rsvp_fixtures_test.go b/internal/air/handlers_email_rsvp_fixtures_test.go deleted file mode 100644 index 0d65dbc..0000000 --- a/internal/air/handlers_email_rsvp_fixtures_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package air - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - - nylasmock "github.com/nylas/cli/internal/adapters/nylas" - "github.com/nylas/cli/internal/domain" -) - -// Shared MIME fixtures and helpers for the RSVP handler test suites. -// Lifted out of handlers_email_rsvp_test.go so the main test file stays -// under the 600-line cap. - -// rsvpInviteMIME is a Gmail-style invite shaped like the production -// payload the RSVP path needs to handle: inline text/calendar with a -// stable UID we can resolve to a Nylas event. -const rsvpInviteMIME = "From: organizer@example.com\r\n" + - "To: user@example.com\r\n" + - "Subject: Event Invitation: Standup\r\n" + - "Content-Type: multipart/alternative; boundary=\"BOUNDARY1\"\r\n" + - "\r\n" + - "--BOUNDARY1\r\n" + - "Content-Type: text/calendar; charset=UTF-8; method=REQUEST\r\n" + - "\r\n" + - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n" + - "METHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\nUID:rsvp-event-uid@example.com\r\nSUMMARY:Standup\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T143000Z\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" + - "--BOUNDARY1--\r\n" - -// cancelledInviteMIME exercises the METHOD:CANCEL guard. -const cancelledInviteMIME = "Content-Type: multipart/alternative; boundary=\"B\"\r\n" + - "\r\n--B\r\n" + - "Content-Type: text/calendar; charset=UTF-8; method=CANCEL\r\n\r\n" + - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\nMETHOD:CANCEL\r\n" + - "BEGIN:VEVENT\r\nUID:cancelled-uid@example.com\r\nSUMMARY:Killed\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T150000Z\r\n" + - "STATUS:CANCELLED\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" + - "--B--\r\n" - -// noUIDInviteMIME pins the "Microsoft sent us an invite without a UID" -// edge case — handler should fail loudly, not silently RSVP to nothing. -const noUIDInviteMIME = "Content-Type: multipart/alternative; boundary=\"B\"\r\n" + - "\r\n--B\r\n" + - "Content-Type: text/calendar; charset=UTF-8; method=REQUEST\r\n\r\n" + - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\nMETHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\nSUMMARY:UID-less\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T150000Z\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" + - "--B--\r\n" - -// rsvpHappyPathMock returns a server + mock pre-wired for the standard -// "everything works" path: the email exposes mime, the user has a primary -// writable calendar, and the iCal UID resolves to a Nylas event. -// Individual tests override fields on the mock to exercise failure modes. -func rsvpHappyPathMock(t *testing.T, mime string) (*Server, *nylasmock.MockClient) { - t.Helper() - server, mock, _ := newCachedTestServer(t) - - mock.GetMessageFunc = func(_ context.Context, _, messageID string) (*domain.Message, error) { - return &domain.Message{ID: messageID, Subject: "Event Invitation: Standup"}, nil - } - mock.GetMessageWithFieldsFunc = func(_ context.Context, _, messageID, _ string) (*domain.Message, error) { - return &domain.Message{ID: messageID, RawMIME: mime}, nil - } - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - return []domain.Calendar{ - {ID: "cal-primary", Name: "Primary", IsPrimary: true, ReadOnly: false}, - }, nil - } - mock.GetEventsWithCursorFunc = func(_ context.Context, _, _ string, params *domain.EventQueryParams) (*domain.EventListResponse, error) { - // Pin the contract: handler must filter by ical_uid, not scan - // the whole calendar — without this the lookup could return the - // first random event and we'd RSVP to the wrong meeting. - if params == nil || params.ICalUID == "" { - t.Errorf("GetEventsWithCursor called without ICalUID filter; params=%+v", params) - } - return &domain.EventListResponse{Data: []domain.Event{{ID: "evt-resolved-1"}}}, nil - } - return server, mock -} - -// postRSVP sends a POST to the RSVP endpoint and returns the recorder. -func postRSVP(t *testing.T, server *Server, emailID, body string) *httptest.ResponseRecorder { - t.Helper() - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodPost, "/api/emails/"+emailID+"/rsvp", strings.NewReader(body)) - r.Header.Set("Content-Type", "application/json") - server.handleEmailRSVP(w, r, emailID) - return w -} diff --git a/internal/air/handlers_email_rsvp_test.go b/internal/air/handlers_email_rsvp_test.go deleted file mode 100644 index dc6b2e1..0000000 --- a/internal/air/handlers_email_rsvp_test.go +++ /dev/null @@ -1,542 +0,0 @@ -package air - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "reflect" - "strings" - "testing" - - "github.com/nylas/cli/internal/domain" -) - -// MIME fixtures, rsvpHappyPathMock, and postRSVP live in -// handlers_email_rsvp_fixtures_test.go (extracted to keep this file -// under the 600-line ceiling). - -func TestHandleEmailRSVP_HappyPath(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - var sentReq *domain.SendRSVPRequest - var sentEventID, sentCalendarID string - mock.SendRSVPFunc = func(_ context.Context, grantID, calendarID, eventID string, req *domain.SendRSVPRequest) error { - if grantID != "grant-123" { - // Use Fatalf — the rest of this assertion block (event ID, - // calendar ID, request body) is meaningless if we ended up on - // the wrong grant. Loud-fail at the source so debugging - // points at the auth gate, not a downstream symptom. - t.Fatalf("grantID=%q, want grant-123", grantID) - } - sentEventID = eventID - sentCalendarID = calendarID - sentReq = req - return nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes","comment":"see you there"}`) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - if sentEventID != "evt-resolved-1" { - t.Errorf("eventID=%q, want evt-resolved-1 (resolved by ical_uid)", sentEventID) - } - if sentCalendarID != "cal-primary" { - t.Errorf("calendarID=%q, want cal-primary", sentCalendarID) - } - if sentReq == nil || sentReq.Status != "yes" || sentReq.Comment != "see you there" { - t.Errorf("request=%+v, want {Status:yes, Comment:see you there}", sentReq) - } - - var got rsvpResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Status != "yes" || got.EventID != "evt-resolved-1" || got.CalendarID != "cal-primary" { - t.Errorf("response=%+v", got) - } -} - -func TestHandleEmailRSVP_MethodNotAllowed(t *testing.T) { - t.Parallel() - server, _ := rsvpHappyPathMock(t, rsvpInviteMIME) - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/api/emails/email-1/rsvp", http.NoBody) - server.handleEmailRSVP(w, r, "email-1") - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("status=%d, want 405", w.Code) - } -} - -func TestHandleEmailRSVP_InvalidStatus(t *testing.T) { - t.Parallel() - cases := []struct { - name string - body string - }{ - {"empty", `{"status":""}`}, - {"unknown", `{"status":"sure"}`}, - {"yes-with-typo", `{"status":"yess"}`}, - {"noreply rejected", `{"status":"noreply"}`}, // valid in Nylas API but no UI affordance - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - server, _ := rsvpHappyPathMock(t, rsvpInviteMIME) - w := postRSVP(t, server, "email-1", tc.body) - if w.Code != http.StatusBadRequest { - t.Errorf("status=%d, want 400, body=%s", w.Code, w.Body.String()) - } - }) - } -} - -// TestHandleEmailRSVP_StatusCaseInsensitive pins that yes/no/maybe in -// any case (including with surrounding whitespace) all normalize to the -// lowercase Nylas vocabulary before being forwarded. Without exercising -// every branch a future refactor of the `strings.ToLower` chain could -// silently regress one variant. -func TestHandleEmailRSVP_StatusCaseInsensitive(t *testing.T) { - t.Parallel() - cases := []struct { - name string - input string - want string - }{ - {name: "uppercase yes", input: "YES", want: "yes"}, - {name: "titlecase maybe", input: "Maybe", want: "maybe"}, - {name: "uppercase no", input: "NO", want: "no"}, - {name: "padded yes", input: " yes ", want: "yes"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - var got string - mock.SendRSVPFunc = func(_ context.Context, _, _, _ string, req *domain.SendRSVPRequest) error { - got = req.Status - return nil - } - w := postRSVP(t, server, "email-1", `{"status":"`+tc.input+`"}`) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - if got != tc.want { - t.Errorf("forwarded status=%q, want %q", got, tc.want) - } - }) - } -} - -func TestHandleEmailRSVP_InvalidJSONBody(t *testing.T) { - t.Parallel() - server, _ := rsvpHappyPathMock(t, rsvpInviteMIME) - - w := postRSVP(t, server, "email-1", `not json at all`) - if w.Code != http.StatusBadRequest { - t.Errorf("status=%d, want 400", w.Code) - } -} - -func TestHandleEmailRSVP_NoInviteOnEmail(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - mock.GetMessageWithFieldsFunc = func(context.Context, string, string, string) (*domain.Message, error) { - return &domain.Message{RawMIME: "From: a@b.example\r\nSubject: Hi\r\n\r\nplain"}, nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusNotFound { - t.Errorf("status=%d body=%s, want 404 (no calendar invitation)", w.Code, w.Body.String()) - } -} - -func TestHandleEmailRSVP_CancelledInviteRejected(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, cancelledInviteMIME) - - calledRSVP := false - mock.SendRSVPFunc = func(context.Context, string, string, string, *domain.SendRSVPRequest) error { - calledRSVP = true - return nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusConflict { - t.Errorf("status=%d body=%s, want 409 (cancelled)", w.Code, w.Body.String()) - } - if calledRSVP { - t.Error("SendRSVP was called for a cancelled event — guard is missing") - } -} - -func TestHandleEmailRSVP_MissingUIDRejected(t *testing.T) { - t.Parallel() - server, _ := rsvpHappyPathMock(t, noUIDInviteMIME) - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusUnprocessableEntity { - t.Errorf("status=%d body=%s, want 422 (no UID)", w.Code, w.Body.String()) - } -} - -func TestHandleEmailRSVP_NoMatchingEvent(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - mock.GetEventsWithCursorFunc = func(context.Context, string, string, *domain.EventQueryParams) (*domain.EventListResponse, error) { - return &domain.EventListResponse{Data: nil}, nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusNotFound { - t.Errorf("status=%d body=%s, want 404 (event not imported)", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "imported") { - t.Errorf("error message should mention import lag, got %s", w.Body.String()) - } -} - -// TestHandleEmailRSVP_NoWritableCalendar pins the user-facing surface -// for "this account has only read-only calendars". This is a -// config-shaped failure — not transient — so the response must be 422 -// with a descriptive message, not a generic 502 that would invite a -// useless retry. Asserting the message body too keeps a future refactor -// from silently broadening the user-visible string. -func TestHandleEmailRSVP_NoWritableCalendar(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - return []domain.Calendar{ - {ID: "subscribed", Name: "US Holidays", IsPrimary: true, ReadOnly: true}, - }, nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusUnprocessableEntity { - t.Errorf("status=%d body=%s, want 422 (no writable calendar — config issue, not transient)", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "writable") { - t.Errorf("response should mention 'writable' so users know what to fix; got %s", w.Body.String()) - } -} - -// TestHandleEmailRSVP_FindsEventInSecondaryCalendar pins the multi-calendar -// search: when the invite landed in a writable calendar that ISN'T the -// primary (work + personal account, shared team calendar, etc.), the -// handler must keep looking instead of returning "not imported" off the -// first calendar's empty result. Also pins the lookup ORDER — primary -// must be queried first so we don't surprise users with a slower or -// less-likely-to-match calendar getting priority. -func TestHandleEmailRSVP_FindsEventInSecondaryCalendar(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - return []domain.Calendar{ - {ID: "primary-cal", IsPrimary: true, ReadOnly: false}, - {ID: "team-cal", IsPrimary: false, ReadOnly: false}, - }, nil - } - // Record query order so we can pin "primary first, then secondaries" - // — the docstring on findInviteEventAcrossCalendars promises this and - // without an order assertion a future refactor could quietly invert it. - var queryOrder []string - mock.GetEventsWithCursorFunc = func(_ context.Context, _, calendarID string, params *domain.EventQueryParams) (*domain.EventListResponse, error) { - if params == nil || params.ICalUID == "" { - t.Errorf("ical_uid filter missing on calendar %q", calendarID) - } - queryOrder = append(queryOrder, calendarID) - if calendarID == "team-cal" { - return &domain.EventListResponse{Data: []domain.Event{{ID: "evt-team-1"}}}, nil - } - return &domain.EventListResponse{Data: nil}, nil - } - - var sentCal, sentEvent string - mock.SendRSVPFunc = func(_ context.Context, _, calendarID, eventID string, _ *domain.SendRSVPRequest) error { - sentCal = calendarID - sentEvent = eventID - return nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s, want 200 (event must resolve via secondary calendar)", w.Code, w.Body.String()) - } - if sentCal != "team-cal" || sentEvent != "evt-team-1" { - t.Errorf("RSVP sent to (%q, %q), want (team-cal, evt-team-1)", sentCal, sentEvent) - } - wantOrder := []string{"primary-cal", "team-cal"} - if !reflect.DeepEqual(queryOrder, wantOrder) { - t.Errorf("calendar lookup order=%v, want %v (primary must be queried first)", queryOrder, wantOrder) - } -} - -// TestHandleEmailRSVP_TransientErrorOnPrimaryFallsThroughToSecondary -// pins the partial-failure walk: a flaky primary should NOT abort the -// search if a later calendar holds the event. Without this test, a -// future refactor could decide "tracked errors abort the loop" without -// breaking any existing assertion. -func TestHandleEmailRSVP_TransientErrorOnPrimaryFallsThroughToSecondary(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - return []domain.Calendar{ - {ID: "primary-cal", IsPrimary: true, ReadOnly: false}, - {ID: "team-cal", IsPrimary: false, ReadOnly: false}, - }, nil - } - mock.GetEventsWithCursorFunc = func(_ context.Context, _, calendarID string, _ *domain.EventQueryParams) (*domain.EventListResponse, error) { - if calendarID == "primary-cal" { - // Transient blip — must NOT short-circuit the walk. - return nil, errors.New("upstream 503 service unavailable") - } - return &domain.EventListResponse{Data: []domain.Event{{ID: "evt-team-2"}}}, nil - } - - var sentCal, sentEvent string - mock.SendRSVPFunc = func(_ context.Context, _, calendarID, eventID string, _ *domain.SendRSVPRequest) error { - sentCal = calendarID - sentEvent = eventID - return nil - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s, want 200 (transient err on primary must fall through)", w.Code, w.Body.String()) - } - if sentCal != "team-cal" || sentEvent != "evt-team-2" { - t.Errorf("RSVP sent to (%q, %q), want (team-cal, evt-team-2)", sentCal, sentEvent) - } -} - -// TestHandleEmailRSVP_TransientLookupErrorSurfacedWhenAllFail pins that -// when every writable calendar errors on the lookup, the handler returns -// 502 (so the user can retry) — NOT 404. A transient Nylas blip on a -// secondary calendar shouldn't be reported as "this invite doesn't -// exist." -func TestHandleEmailRSVP_TransientLookupErrorSurfacedWhenAllFail(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - return []domain.Calendar{ - {ID: "cal-a", IsPrimary: true, ReadOnly: false}, - {ID: "cal-b", IsPrimary: false, ReadOnly: false}, - }, nil - } - mock.GetEventsWithCursorFunc = func(context.Context, string, string, *domain.EventQueryParams) (*domain.EventListResponse, error) { - return nil, errors.New("upstream 502") - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusBadGateway { - t.Errorf("status=%d body=%s, want 502 (transient lookup error must not be 404)", w.Code, w.Body.String()) - } -} - -func TestHandleEmailRSVP_FallsBackToFirstWritableCalendar(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - // Primary is read-only (e.g. a "holidays" subscription marked - // primary by mistake) — handler should fall through to the next - // writable calendar instead of failing. - return []domain.Calendar{ - {ID: "ro", IsPrimary: true, ReadOnly: true}, - {ID: "writable", IsPrimary: false, ReadOnly: false}, - }, nil - } - - var seenCal string - mock.SendRSVPFunc = func(_ context.Context, _, calendarID, _ string, _ *domain.SendRSVPRequest) error { - seenCal = calendarID - return nil - } - - w := postRSVP(t, server, "email-1", `{"status":"maybe"}`) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - if seenCal != "writable" { - t.Errorf("calendarID=%q, want fallback to writable", seenCal) - } -} - -func TestHandleEmailRSVP_UpstreamSendRSVPError(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - mock.SendRSVPFunc = func(context.Context, string, string, string, *domain.SendRSVPRequest) error { - return errors.New("nylas API error: 503 service unavailable") - } - - w := postRSVP(t, server, "email-1", `{"status":"no"}`) - if w.Code != http.StatusBadGateway { - t.Errorf("status=%d body=%s, want 502", w.Code, w.Body.String()) - } -} - -func TestHandleEmailRSVP_UpstreamGetMessageError(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - mock.GetMessageFunc = func(context.Context, string, string) (*domain.Message, error) { - return nil, errors.New("nylas API error: 500") - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusBadGateway { - t.Errorf("status=%d body=%s, want 502", w.Code, w.Body.String()) - } -} - -// TestHandleEmailRSVP_RawMimeFetchFails pins the regression where a -// transient raw_mime fetch failure was misclassified as "no invite" and -// returned 404. Now it must propagate as 502 so the frontend can offer -// a retry rather than telling the user the invite doesn't exist. -func TestHandleEmailRSVP_RawMimeFetchFails(t *testing.T) { - t.Parallel() - server, mock := rsvpHappyPathMock(t, rsvpInviteMIME) - - // GetMessage succeeds but exposes no parseable attachment, so the - // resolver MUST fall through to GetMessageWithFields. Make that fail - // with a transient-looking error. - mock.GetMessageFunc = func(_ context.Context, _, messageID string) (*domain.Message, error) { - return &domain.Message{ID: messageID, Subject: "Event Invitation"}, nil - } - mock.GetMessageWithFieldsFunc = func(context.Context, string, string, string) (*domain.Message, error) { - return nil, errors.New("upstream timeout: 504") - } - - w := postRSVP(t, server, "email-1", `{"status":"yes"}`) - if w.Code != http.StatusBadGateway { - t.Errorf("status=%d body=%s, want 502 (raw_mime fetch failure should not be 404)", w.Code, w.Body.String()) - } - if strings.Contains(w.Body.String(), "does not contain a calendar invitation") { - t.Errorf("raw_mime fetch failure must not be misreported as 'no invite'; body=%s", w.Body.String()) - } -} - -// TestHandleEmailInvite_RawMimeFetchSilentlyDegrades pins the inverse -// for the preview endpoint: the same upstream failure should silently -// return HasInvite:false (so the email view doesn't error out) rather -// than crash the page with 500. Behavioural pair to the test above. -func TestHandleEmailInvite_RawMimeFetchSilentlyDegrades(t *testing.T) { - t.Parallel() - server, mock, _ := newCachedTestServer(t) - - mock.GetMessageFunc = func(_ context.Context, _, messageID string) (*domain.Message, error) { - return &domain.Message{ID: messageID, Subject: "Event Invitation"}, nil - } - mock.GetMessageWithFieldsFunc = func(context.Context, string, string, string) (*domain.Message, error) { - return nil, errors.New("upstream timeout: 504") - } - - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/api/emails/email-1/invite", http.NoBody) - server.handleEmailInvite(w, r, "email-1") - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s, want 200 (silent degrade)", w.Code, w.Body.String()) - } - var got CalendarInviteResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.HasInvite { - t.Errorf("HasInvite=true on raw_mime fetch failure, want false; body=%s", w.Body.String()) - } -} - -func TestHandleEmailRSVP_DemoMode(t *testing.T) { - t.Parallel() - server, _, _ := newCachedTestServer(t) - server.demoMode = true - - w := postRSVP(t, server, "any-email", `{"status":"yes"}`) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - var got rsvpResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.Status != "yes" || got.EventID == "" { - t.Errorf("demo response=%+v", got) - } -} - -func TestHandleEmailRSVP_AttachmentPathHasUID(t *testing.T) { - // When the email carries the ICS as a real attachment (Microsoft's - // shape), the parser should still surface the UID so RSVP works. - t.Parallel() - server, mock, _ := newCachedTestServer(t) - - icsBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\nMETHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\nUID:attached-uid@example.com\r\nSUMMARY:Attached\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T150000Z\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" - - mock.GetMessageFunc = func(context.Context, string, string) (*domain.Message, error) { - return &domain.Message{ - ID: "email-x", - Attachments: []domain.Attachment{ - {ID: "att-1", Filename: "invite.ics", ContentType: "text/calendar"}, - }, - }, nil - } - mock.DownloadAttachmentFunc = func(context.Context, string, string, string) (io.ReadCloser, error) { - return io.NopCloser(strings.NewReader(icsBody)), nil - } - mock.GetCalendarsFunc = func(context.Context, string) ([]domain.Calendar, error) { - return []domain.Calendar{{ID: "primary", IsPrimary: true}}, nil - } - var seenUID string - mock.GetEventsWithCursorFunc = func(_ context.Context, _, _ string, params *domain.EventQueryParams) (*domain.EventListResponse, error) { - if params != nil { - seenUID = params.ICalUID - } - return &domain.EventListResponse{Data: []domain.Event{{ID: "evt-attached-1"}}}, nil - } - mock.SendRSVPFunc = func(context.Context, string, string, string, *domain.SendRSVPRequest) error { - return nil - } - - w := postRSVP(t, server, "email-x", `{"status":"yes"}`) - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - if seenUID != "attached-uid@example.com" { - t.Errorf("ical_uid passed to events lookup=%q, want attached-uid@example.com", seenUID) - } -} - -// TestParseICS_SurfacesUID covers the parser change for the RSVP feature. -// Without the UID the RSVP handler can't resolve a Nylas event ID. -func TestParseICS_SurfacesUID(t *testing.T) { - body := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//T//EN\r\nMETHOD:REQUEST\r\n" + - "BEGIN:VEVENT\r\nUID:my-uid@example.com\r\nSUMMARY:Standup\r\n" + - "DTSTART:20260501T140000Z\r\nDTEND:20260501T150000Z\r\n" + - "END:VEVENT\r\nEND:VCALENDAR\r\n" - got, err := parseICS(body) - if err != nil { - t.Fatalf("parseICS: %v", err) - } - if got.ICalUID != "my-uid@example.com" { - t.Errorf("ICalUID=%q, want my-uid@example.com", got.ICalUID) - } -} - -// (postRSVP moved to handlers_email_rsvp_fixtures_test.go) diff --git a/internal/air/handlers_email_silent_failure_test.go b/internal/air/handlers_email_silent_failure_test.go deleted file mode 100644 index e485760..0000000 --- a/internal/air/handlers_email_silent_failure_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package air - -import ( - "bytes" - "context" - "log/slog" - "net/http" - "net/http/httptest" - "testing" - - "github.com/nylas/cli/internal/air/cache" - "github.com/nylas/cli/internal/domain" - "github.com/stretchr/testify/assert" -) - -// captureSlog redirects slog.Default to a buffer for the duration of t. -// Tests using it must not call t.Parallel() — slog.Default is process-global. -func captureSlog(t *testing.T) *bytes.Buffer { - t.Helper() - buf := &bytes.Buffer{} - handler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) - prev := slog.Default() - slog.SetDefault(slog.New(handler)) - t.Cleanup(func() { slog.SetDefault(prev) }) - return buf -} - -// TestHandleDeleteEmail_OfflineQueueFails_LogsFailure exposes the -// observability divergence between the near-twin handlers -// handleUpdateEmail and handleDeleteEmail. Both follow the same -// shape: -// -// if !s.IsOnline() { -// if err := enqueue(...); err == nil { return queued-200 } -// // err != nil: queue is broken, falling through to API -// } -// ... live API call ... -// -// handleUpdateEmail logs the offline-enqueue failure at -// handlers_email.go:305 ("offline enqueue failed, attempting live API -// call"). handleDeleteEmail does not — the err return from -// enqueueMessageDelete on line 372 is silently dropped (no else -// clause). A queue health regression that affects both code paths is -// debuggable for update and invisible for delete. -// -// EXPECTED FAILURE today: the slog buffer for the delete path is -// empty (no warning emitted). After the fix it contains a warning -// referencing the emailID. The fail-first message is the bug receipt. -func TestHandleDeleteEmail_OfflineQueueFails_LogsFailure(t *testing.T) { - // No t.Parallel — captureSlog mutates the process-global default. - server, client, _ := newCachedTestServer(t) - server.SetOnline(false) - // Disable the offline queue so enqueueMessageDelete returns - // "offline queue unavailable" (handlers_email_offline.go:51). - server.cacheSettings.OfflineQueueEnabled = false - - apiCalled := false - client.DeleteMessageFunc = func(context.Context, string, string) error { - apiCalled = true - return nil - } - - const sentinelEmailID = "deletefail-canary-offline-XYZ" - logs := captureSlog(t) - - req := httptest.NewRequest(http.MethodDelete, "/api/emails/"+sentinelEmailID, nil) - w := httptest.NewRecorder() - server.handleDeleteEmail(w, req, sentinelEmailID) - - if !apiCalled { - t.Fatal("expected fall-through to live DeleteMessage when queue fails") - } - got := logs.String() - assert.Contains(t, got, sentinelEmailID, - "slog should record the offline-queue failure for emailID %q (parity with handleUpdateEmail line 305); got %q", - sentinelEmailID, got) -} - -// TestHandleDeleteEmail_QueueWriteAfterTransientError_LogsQueueFailure -// pins the second observability gap. When the live API errors with a -// transient/queue-eligible error AND the offline queue write itself -// fails, handleUpdateEmail logs both error contexts together at -// handlers_email.go:330-335: -// -// slog.Error("queue enqueue after transient API error failed", -// "emailID", emailID, "apiErr", err, "queueErr", queueErr) -// -// handleDeleteEmail's mirror branch (handlers_email.go:384-393) has no -// such log — only the catch-all "Failed to delete email" log fires -// with `err = apiErr`, dropping the `queueErr` context entirely. The -// double-failure mode is exactly when a queue health alert matters -// most (the user is also about to see a 500), and it's exactly when -// the divergence makes it invisible. -// -// EXPECTED FAILURE today: the slog buffer captures "Failed to delete -// email" but does not record the queueErr substring "offline queue -// unavailable". After the fix the queueErr is co-logged with apiErr. -func TestHandleDeleteEmail_QueueWriteAfterTransientError_LogsQueueFailure(t *testing.T) { - // No t.Parallel — captureSlog mutates the process-global default. - server, client, _ := newCachedTestServer(t) - client.DeleteMessageFunc = func(context.Context, string, string) error { - return &transientNetErr{} - } - // To reach the inner queueErr branch we need shouldQueueEmailAction - // to say YES (queue is configured AND error looks transient) but - // the actual enqueue to fail. Achieved by replacing the grant - // store's grant with one whose Email is empty: withAuthGrant still - // resolves the default grant (ID-based), but - // `getAccountEmail(grantID)` returns "" because the grant's Email - // is blank, which trips enqueueMessageDelete's first guard: - // `if accountEmail == "" { return errors.New("offline queue unavailable") }`. - grantStore := server.grantStore.(*testGrantStore) - grantStore.grants = []domain.GrantInfo{{ - ID: "grant-123", - Email: "", // ← the load-bearing empty - Provider: domain.ProviderGoogle, - }} - grantStore.defaultGrant = "grant-123" - - const sentinelEmailID = "deletefail-canary-double-XYZ" - logs := captureSlog(t) - - req := httptest.NewRequest(http.MethodDelete, "/api/emails/"+sentinelEmailID, nil) - w := httptest.NewRecorder() - server.handleDeleteEmail(w, req, sentinelEmailID) - - if w.Code != http.StatusInternalServerError { - t.Fatalf("status=%d body=%s, want 500 (double-failure path)", - w.Code, w.Body.String()) - } - got := logs.String() - // Sentinel from the queue-error message in handlers_email_offline.go:51. - const queueErrSentinel = "offline queue unavailable" - assert.Contains(t, got, queueErrSentinel, - "slog should record the queueErr context (parity with handleUpdateEmail lines 330-335); got %q", - got) - // Also assert the test's emailID appears so the log is correlatable - // to the failing request — same correlation handleUpdateEmail offers. - assert.Contains(t, got, sentinelEmailID, - "slog should record emailID for correlation; got %q", got) -} - -// TestHandleGetEmail_CacheFillFailure_LogsFailure pins the parity gap -// between handleListEmails (handlers_email.go:144 logs cache-fill -// failures) and handleGetEmail (line 261-264 silently does -// `_ = s.withEmailStore(...)`). The user-facing behavior is correct — -// cache write failures must not break the response — but the silent -// drop means a wedged single-message cache drifts further from server -// state on every request, with no log to debug from. -// -// We force the write to fail by dropping the underlying SQLite emails -// table after lazy-init: the lookup call returns "no such table" (no -// cache hit, falls through), the live GetMessage succeeds, the -// post-fetch Put then fails for the same reason. Today no log fires. -// After the fix, slog should record the failure with the sentinel -// emailID for support diagnosability. -// -// EXPECTED FAILURE today: the slog buffer for the cache-fill path is -// silent. After the fix it contains a warning referencing the emailID -// (parity with handleListEmails). -func TestHandleGetEmail_CacheFillFailure_LogsFailure(t *testing.T) { - // No t.Parallel — captureSlog mutates the process-global default. - server, client, accountEmail := newCachedTestServer(t) - - const sentinelEmailID = "cachefill-canary-XYZ" - client.GetMessageFunc = func(_ context.Context, _, msgID string) (*domain.Message, error) { - return &domain.Message{ - ID: msgID, - Subject: "Test message", - From: []domain.EmailParticipant{{Email: "x@example.com"}}, - }, nil - } - - // Lazy-init the emails table by acquiring the store once, then - // drop it so the post-GetMessage Put fails with "no such table: - // emails". The handler-side `_ = s.withEmailStore(...)` swallows - // that error today. - if err := server.withEmailStore(accountEmail, func(_ *cache.EmailStore) error { return nil }); err != nil { - t.Fatalf("lazy-init emails store: %v", err) - } - db, err := server.cacheManager.GetDB(accountEmail) - if err != nil { - t.Fatalf("get db: %v", err) - } - if _, err := db.Exec("DROP TABLE IF EXISTS emails"); err != nil { - t.Fatalf("drop table: %v", err) - } - - logs := captureSlog(t) - - req := httptest.NewRequest(http.MethodGet, "/api/emails/"+sentinelEmailID, nil) - w := httptest.NewRecorder() - server.handleGetEmail(w, req, sentinelEmailID) - - if w.Code != http.StatusOK { - t.Fatalf("status=%d body=%s, want 200 — user-facing response must not break on cache failure", - w.Code, w.Body.String()) - } - - got := logs.String() - assert.Contains(t, got, sentinelEmailID, - "slog should record the emailID for cache-fill failures (parity with handleListEmails:144); got %q", - got) -} diff --git a/internal/air/handlers_events.go b/internal/air/handlers_events.go deleted file mode 100644 index b684c70..0000000 --- a/internal/air/handlers_events.go +++ /dev/null @@ -1,385 +0,0 @@ -package air - -import ( - "net/http" - "strings" - "time" - - "github.com/nylas/cli/internal/air/cache" - "github.com/nylas/cli/internal/domain" -) - -// handleListEvents returns events for a calendar with optional date filtering. -func (s *Server) handleListEvents(w http.ResponseWriter, r *http.Request) { - grantID := s.withAuthGrant(w, EventsResponse{Events: demoEvents(), HasMore: false}) - if grantID == "" { - return - } - - // Parse query parameters - query := NewQueryParams(r.URL.Query()) - - // Calendar ID is required - calendarID := query.GetString("calendar_id", "primary") - - // Build query params - params := &domain.EventQueryParams{ - Limit: query.GetLimit(50), - ExpandRecurring: true, - Start: query.GetInt64("start", 0), - End: query.GetInt64("end", 0), - } - - // Default to current week if no date range specified - if params.Start == 0 && params.End == 0 { - now := time.Now() - // Start of week (Sunday) - weekday := int(now.Weekday()) - startOfWeek := now.AddDate(0, 0, -weekday).Truncate(24 * time.Hour) - // End of week (Saturday) - endOfWeek := startOfWeek.AddDate(0, 0, 7).Add(-time.Second) - params.Start = startOfWeek.Unix() - params.End = endOfWeek.Unix() - } - - // Cursor for pagination - cursor := query.Get("cursor") - if cursor != "" { - params.PageToken = cursor - } - - // Get account email for cache lookup - accountEmail := s.getAccountEmail(grantID) - - // Try cache first (only for first page) - 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, - } - for _, e := range cached { - resp.Events = append(resp.Events, cachedEventToResponse(e)) - } - writeJSON(w, http.StatusOK, resp) - return - } - } - - // Fetch events from Nylas API - ctx, cancel := s.withTimeout(r) - defer cancel() - - result, err := s.nylasClient.GetEventsWithCursor(ctx, grantID, calendarID, params) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "Failed to fetch events: " + err.Error(), - }) - return - } - - // Convert to response format - resp := EventsResponse{ - Events: make([]EventResponse, 0, len(result.Data)), - NextCursor: result.Pagination.NextCursor, - HasMore: result.Pagination.HasMore, - } - for _, e := range result.Data { - resp.Events = append(resp.Events, eventToResponse(e)) - } - - writeJSON(w, http.StatusOK, resp) -} - -// handleEventsRoute handles /api/events: GET (list) and POST (create). -func (s *Server) handleEventsRoute(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.handleListEvents(w, r) - case http.MethodPost: - s.handleCreateEvent(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleEventByID handles single event operations: GET, PUT, DELETE. -func (s *Server) handleEventByID(w http.ResponseWriter, r *http.Request) { - // Parse event ID and calendar ID from path: /api/events/{id}?calendar_id=xxx - path := strings.TrimPrefix(r.URL.Path, "/api/events/") - parts := strings.Split(path, "/") - if len(parts) == 0 || parts[0] == "" { - http.Error(w, "Event ID required", http.StatusBadRequest) - return - } - eventID := parts[0] - - switch r.Method { - case http.MethodGet: - s.handleGetEvent(w, r, eventID) - case http.MethodPut: - s.handleUpdateEvent(w, r, eventID) - case http.MethodDelete: - s.handleDeleteEvent(w, r, eventID) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleCreateEvent creates a new event. -func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { - // Special demo mode: generate dynamic response with timestamp - if s.demoMode { - now := time.Now() - writeJSON(w, http.StatusOK, EventActionResponse{ - Success: true, - Event: &EventResponse{ - ID: "demo-event-new-" + now.Format("20060102150405"), - CalendarID: "primary", - Title: "New Event", - StartTime: now.Add(1 * time.Hour).Unix(), - EndTime: now.Add(2 * time.Hour).Unix(), - Status: "confirmed", - Busy: true, - }, - Message: "Event created (demo mode)", - }) - return - } - grantID := s.withAuthGrant(w, nil) // Demo mode already handled above - if grantID == "" { - return - } - - var req CreateEventRequest - if !parseJSONBody(w, r, &req) { - return - } - - // Validate required fields - if req.Title == "" { - writeJSON(w, http.StatusBadRequest, EventActionResponse{ - Success: false, - Error: "Title is required", - }) - return - } - - calendarID := req.CalendarID - if calendarID == "" { - calendarID = "primary" - } - - // Build domain request - createReq := &domain.CreateEventRequest{ - Title: req.Title, - Description: req.Description, - Location: req.Location, - Busy: req.Busy, - } - - // Set event time - if req.IsAllDay { - // All-day event: use date format - startDate := time.Unix(req.StartTime, 0).Format("2006-01-02") - endDate := time.Unix(req.EndTime, 0).Format("2006-01-02") - createReq.When = domain.EventWhen{ - StartDate: startDate, - EndDate: endDate, - Object: "datespan", - } - } else { - // Timed event - createReq.When = domain.EventWhen{ - StartTime: req.StartTime, - EndTime: req.EndTime, - StartTimezone: req.Timezone, - EndTimezone: req.Timezone, - Object: "timespan", - } - } - - // Convert participants - for _, p := range req.Participants { - createReq.Participants = append(createReq.Participants, domain.Participant{ - Person: domain.Person{Name: p.Name, Email: p.Email}, - }) - } - - // Create event via Nylas API - ctx, cancel := s.withTimeout(r) - defer cancel() - - event, err := s.nylasClient.CreateEvent(ctx, grantID, calendarID, createReq) - if err != nil { - writeJSON(w, http.StatusInternalServerError, EventActionResponse{ - Success: false, - Error: "Failed to create event: " + err.Error(), - }) - return - } - - eventResp := eventToResponse(*event) - writeJSON(w, http.StatusOK, EventActionResponse{ - Success: true, - Event: &eventResp, - Message: "Event created successfully", - }) -} - -// handleGetEvent retrieves a single event. -func (s *Server) handleGetEvent(w http.ResponseWriter, r *http.Request, eventID string) { - calendarID := r.URL.Query().Get("calendar_id") - if calendarID == "" { - calendarID = "primary" - } - // Special demo mode: return specific event or 404 - if s.demoMode { - for _, e := range demoEvents() { - if e.ID == eventID { - writeJSON(w, http.StatusOK, e) - return - } - } - writeError(w, http.StatusNotFound, "Event not found") - return - } - grantID := s.withAuthGrant(w, nil) // Demo mode already handled above - if grantID == "" { - return - } - - ctx, cancel := s.withTimeout(r) - defer cancel() - - event, err := s.nylasClient.GetEvent(ctx, grantID, calendarID, eventID) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "Failed to fetch event: " + err.Error(), - }) - return - } - - writeJSON(w, http.StatusOK, eventToResponse(*event)) -} - -// handleUpdateEvent updates an existing event. -func (s *Server) handleUpdateEvent(w http.ResponseWriter, r *http.Request, eventID string) { - calendarID := r.URL.Query().Get("calendar_id") - if calendarID == "" { - calendarID = "primary" - } - grantID := s.withAuthGrant(w, EventActionResponse{ - Success: true, - Event: &EventResponse{ID: eventID, CalendarID: calendarID, Title: "Updated Event", Status: "confirmed"}, - Message: "Event updated (demo mode)", - }) - if grantID == "" { - return - } - - var req UpdateEventRequest - if !parseJSONBody(w, r, &req) { - return - } - - // Build domain update request - updateReq := &domain.UpdateEventRequest{ - Title: req.Title, - Description: req.Description, - Location: req.Location, - Busy: req.Busy, - } - - // Set event time if provided - if req.StartTime != nil && req.EndTime != nil { - when := &domain.EventWhen{} - if req.IsAllDay != nil && *req.IsAllDay { - // All-day event - startDate := time.Unix(*req.StartTime, 0).Format("2006-01-02") - endDate := time.Unix(*req.EndTime, 0).Format("2006-01-02") - when.StartDate = startDate - when.EndDate = endDate - when.Object = "datespan" - } else { - // Timed event - when.StartTime = *req.StartTime - when.EndTime = *req.EndTime - if req.Timezone != nil { - when.StartTimezone = *req.Timezone - when.EndTimezone = *req.Timezone - } - when.Object = "timespan" - } - updateReq.When = when - } - - // Convert participants if provided - if len(req.Participants) > 0 { - for _, p := range req.Participants { - updateReq.Participants = append(updateReq.Participants, domain.Participant{ - Person: domain.Person{Name: p.Name, Email: p.Email}, - }) - } - } - - // Update event via Nylas API - ctx, cancel := s.withTimeout(r) - defer cancel() - - event, err := s.nylasClient.UpdateEvent(ctx, grantID, calendarID, eventID, updateReq) - if err != nil { - writeJSON(w, http.StatusInternalServerError, EventActionResponse{ - Success: false, - Error: "Failed to update event: " + err.Error(), - }) - return - } - - eventResp := eventToResponse(*event) - writeJSON(w, http.StatusOK, EventActionResponse{ - Success: true, - Event: &eventResp, - Message: "Event updated successfully", - }) -} - -// handleDeleteEvent deletes an event. -func (s *Server) handleDeleteEvent(w http.ResponseWriter, r *http.Request, eventID string) { - calendarID := r.URL.Query().Get("calendar_id") - if calendarID == "" { - calendarID = "primary" - } - grantID := s.withAuthGrant(w, EventActionResponse{Success: true, Message: "Event deleted (demo mode)"}) - if grantID == "" { - return - } - - ctx, cancel := s.withTimeout(r) - defer cancel() - - err := s.nylasClient.DeleteEvent(ctx, grantID, calendarID, eventID) - if err != nil { - writeJSON(w, http.StatusInternalServerError, EventActionResponse{ - Success: false, - Error: "Failed to delete event: " + err.Error(), - }) - return - } - - writeJSON(w, http.StatusOK, EventActionResponse{ - Success: true, - Message: "Event deleted successfully", - }) -} diff --git a/internal/air/handlers_events_test.go b/internal/air/handlers_events_test.go deleted file mode 100644 index 2d65f4b..0000000 --- a/internal/air/handlers_events_test.go +++ /dev/null @@ -1,352 +0,0 @@ -//go:build !integration -// +build !integration - -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -// ============================================================================= -// Events Handler Additional Tests -// ============================================================================= - -// TestHandleEventByID_MissingID tests missing event ID error. -func TestHandleEventByID_MissingID(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/events/", nil) - w := httptest.NewRecorder() - - server.handleEventByID(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } -} - -// TestHandleEventByID_DELETE_DemoMode tests event deletion in demo mode. -func TestHandleEventByID_DELETE_DemoMode(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodDelete, "/api/events/demo-event-001?calendar_id=primary", nil) - w := httptest.NewRecorder() - - server.handleEventByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp EventActionResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Error("expected success to be true") - } - - if resp.Message == "" { - t.Error("expected non-empty message") - } -} - -// TestHandleGetEvent_DemoMode_Found tests retrieving existing event. -func TestHandleGetEvent_DemoMode_Found(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - events := demoEvents() - if len(events) == 0 { - t.Skip("no demo events available") - } - - eventID := events[0].ID - req := httptest.NewRequest(http.MethodGet, "/api/events/"+eventID+"?calendar_id=primary", nil) - w := httptest.NewRecorder() - - server.handleEventByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp EventResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.ID != eventID { - t.Errorf("expected ID %s, got %s", eventID, resp.ID) - } -} - -// TestHandleGetEvent_DemoMode_NotFound tests non-existent event. -func TestHandleGetEvent_DemoMode_NotFound(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/events/nonexistent-event-id", nil) - w := httptest.NewRecorder() - - server.handleEventByID(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("expected status 404, got %d", w.Code) - } -} - -// TestHandleUpdateEvent_DemoMode_Success tests event update in demo mode. -func TestHandleUpdateEvent_DemoMode_Success(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - update := map[string]any{ - "title": "Updated Event Title", - "description": "Updated description", - } - body, _ := json.Marshal(update) - - req := httptest.NewRequest(http.MethodPut, "/api/events/demo-event-001?calendar_id=primary", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleEventByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp EventActionResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Error("expected success to be true") - } - - if resp.Event == nil { - t.Error("expected event in response") - } -} - -// TestHandleCreateEvent_DemoMode_Success tests event creation in demo mode. -func TestHandleCreateEvent_DemoMode_Success(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - now := time.Now() - event := CreateEventRequest{ - CalendarID: "primary", - Title: "New Test Event", - Description: "Test event description", - Location: "Test Location", - StartTime: now.Add(1 * time.Hour).Unix(), - EndTime: now.Add(2 * time.Hour).Unix(), - Timezone: "America/New_York", - IsAllDay: false, - Busy: true, - } - body, _ := json.Marshal(event) - - req := httptest.NewRequest(http.MethodPost, "/api/events", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleEventsRoute(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp EventActionResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Errorf("expected success, got error: %s", resp.Error) - } - - if resp.Event == nil { - t.Error("expected event in response") - } -} - -// TestHandleCreateEvent_AllDayEvent tests all-day event creation. -func TestHandleCreateEvent_AllDayEvent(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - now := time.Now() - tomorrow := now.Add(24 * time.Hour) - event := CreateEventRequest{ - CalendarID: "primary", - Title: "All Day Event", - StartTime: tomorrow.Unix(), - EndTime: tomorrow.Add(24 * time.Hour).Unix(), - IsAllDay: true, - } - body, _ := json.Marshal(event) - - req := httptest.NewRequest(http.MethodPost, "/api/events", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleEventsRoute(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -// TestHandleListEvents_WithPagination tests events list with cursor. -func TestHandleListEvents_WithPagination(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/events?cursor=abc123&limit=10", nil) - w := httptest.NewRecorder() - - server.handleListEvents(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -// TestHandleListEvents_StructuredResponse tests events response structure. -func TestHandleListEvents_StructuredResponse(t *testing.T) { - t.Parallel() - - server := newTestDemoServer() - - req := httptest.NewRequest(http.MethodGet, "/api/events", nil) - w := httptest.NewRecorder() - - server.handleListEvents(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp EventsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Verify response structure - for _, event := range resp.Events { - if event.ID == "" { - t.Error("expected event to have ID") - } - if event.Title == "" { - t.Error("expected event to have Title") - } - } -} - -// TestDemoEvents_Coverage tests demo events data. -func TestDemoEvents_Coverage(t *testing.T) { - t.Parallel() - - events := demoEvents() - - if len(events) == 0 { - t.Error("expected non-empty demo events") - } - - // Check for variety of event types - hasAllDay := false - hasConferencing := false - hasParticipants := false - hasLocation := false - - for _, e := range events { - if e.IsAllDay { - hasAllDay = true - } - if e.Conferencing != nil { - hasConferencing = true - } - if len(e.Participants) > 0 { - hasParticipants = true - } - if e.Location != "" { - hasLocation = true - } - } - - if !hasAllDay { - t.Error("expected at least one all-day event") - } - if !hasConferencing { - t.Error("expected at least one event with conferencing") - } - if !hasParticipants { - t.Error("expected at least one event with participants") - } - if !hasLocation { - t.Error("expected at least one event with location") - } -} - -// TestDemoCalendars_Coverage tests demo calendars data. -func TestDemoCalendars_Coverage(t *testing.T) { - t.Parallel() - - calendars := demoCalendars() - - if len(calendars) == 0 { - t.Error("expected non-empty demo calendars") - } - - hasPrimary := false - hasReadOnly := false - hasColor := false - - for _, c := range calendars { - if c.ID == "" { - t.Error("expected calendar to have ID") - } - if c.Name == "" { - t.Error("expected calendar to have Name") - } - if c.IsPrimary { - hasPrimary = true - } - if c.ReadOnly { - hasReadOnly = true - } - if c.HexColor != "" { - hasColor = true - } - } - - if !hasPrimary { - t.Error("expected at least one primary calendar") - } - if !hasReadOnly { - t.Error("expected at least one read-only calendar") - } - if !hasColor { - t.Error("expected at least one calendar with color") - } -} diff --git a/internal/air/handlers_filter_workflow_test.go b/internal/air/handlers_filter_workflow_test.go deleted file mode 100644 index 6f9a29e..0000000 --- a/internal/air/handlers_filter_workflow_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestFilterWorkflow_VIPFilter(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - // Step 1: Frontend loads VIP senders list on init (GET /api/inbox/vip) - req := httptest.NewRequest(http.MethodGet, "/api/inbox/vip", nil) - w := httptest.NewRecorder() - server.handleVIPSenders(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("GET /api/inbox/vip failed: status %d", w.Code) - } - - var vipResp map[string]any - if err := json.NewDecoder(w.Body).Decode(&vipResp); err != nil { - t.Fatalf("failed to decode VIP response: %v", err) - } - - // Verify response has vip_senders array (may be empty initially) - vipSenders, ok := vipResp["vip_senders"].([]any) - if !ok { - t.Fatal("vip_senders field missing or not an array") - } - t.Logf("Initial VIP senders count: %d", len(vipSenders)) - - // Step 2: Add a VIP sender (POST /api/inbox/vip) - addBody, _ := json.Marshal(map[string]string{"email": "boss@company.com"}) - req = httptest.NewRequest(http.MethodPost, "/api/inbox/vip", bytes.NewReader(addBody)) - w = httptest.NewRecorder() - server.handleVIPSenders(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("POST /api/inbox/vip failed: status %d, body: %s", w.Code, w.Body.String()) - } - - // Step 3: Verify VIP sender was added - req = httptest.NewRequest(http.MethodGet, "/api/inbox/vip", nil) - w = httptest.NewRecorder() - server.handleVIPSenders(w, req) - - if err := json.NewDecoder(w.Body).Decode(&vipResp); err != nil { - t.Fatalf("failed to decode VIP response: %v", err) - } - - vipSenders = vipResp["vip_senders"].([]any) - found := false - for _, v := range vipSenders { - if v.(string) == "boss@company.com" { - found = true - break - } - } - if !found { - t.Error("VIP sender 'boss@company.com' not found in list after adding") - } - - // Step 4: Frontend would filter emails client-side using the VIP list - // The categorization endpoint should also recognize VIP senders - catBody, _ := json.Marshal(map[string]string{ - "email_id": "email-1", - "from": "boss@company.com", - "subject": "Important meeting", - }) - req = httptest.NewRequest(http.MethodPost, "/api/inbox/categorize", bytes.NewReader(catBody)) - w = httptest.NewRecorder() - server.handleCategorizeEmail(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("POST /api/inbox/categorize failed: status %d", w.Code) - } - - var catResp CategorizedEmail - if err := json.NewDecoder(w.Body).Decode(&catResp); err != nil { - t.Fatalf("failed to decode categorize response: %v", err) - } - - if catResp.Category != CategoryVIP { - t.Errorf("expected VIP category for VIP sender, got %s", catResp.Category) - } - - t.Logf("VIP filter workflow test passed: VIP sender correctly identified") -} - -// TestFilterWorkflow_UnreadFilter tests the unread filter concept -// Note: Actual unread filtering happens client-side based on email.unread field -func TestFilterWorkflow_UnreadFilter(t *testing.T) { - t.Parallel() - - // The unread filter works by filtering the email list client-side - // based on the "unread" field in each email object. - // This test verifies the email API returns the unread field. - - // Create a mock email list that the frontend would receive - mockEmails := []map[string]any{ - {"id": "1", "subject": "Read email", "unread": false}, - {"id": "2", "subject": "Unread email 1", "unread": true}, - {"id": "3", "subject": "Unread email 2", "unread": true}, - {"id": "4", "subject": "Another read", "unread": false}, - } - - // Simulate frontend filter logic - var unreadEmails []map[string]any - for _, email := range mockEmails { - if unread, ok := email["unread"].(bool); ok && unread { - unreadEmails = append(unreadEmails, email) - } - } - - if len(unreadEmails) != 2 { - t.Errorf("expected 2 unread emails, got %d", len(unreadEmails)) - } - - for _, email := range unreadEmails { - if !email["unread"].(bool) { - t.Errorf("filtered email %s should be unread", email["id"]) - } - } - - t.Logf("Unread filter workflow test passed: %d unread emails filtered correctly", len(unreadEmails)) -} - -// TestFilterWorkflow_AllFilter tests the "all" filter shows everything -func TestFilterWorkflow_AllFilter(t *testing.T) { - t.Parallel() - - mockEmails := []map[string]any{ - {"id": "1", "subject": "Email 1", "unread": false}, - {"id": "2", "subject": "Email 2", "unread": true}, - {"id": "3", "subject": "Email 3", "unread": true}, - } - - // "All" filter should return all emails unchanged - allEmails := mockEmails - - if len(allEmails) != 3 { - t.Errorf("expected 3 emails for 'all' filter, got %d", len(allEmails)) - } - - t.Logf("All filter workflow test passed: %d emails shown", len(allEmails)) -} - -// TestFilterWorkflow_VIPFilterClientSide tests the client-side VIP filtering logic -func TestFilterWorkflow_VIPFilterClientSide(t *testing.T) { - t.Parallel() - - // VIP senders list (as returned by GET /api/inbox/vip) - vipSenders := []string{"boss@company.com", "ceo@corp.com", "important@vip.org"} - - // Mock emails with sender info - mockEmails := []struct { - id string - fromEmail string - subject string - shouldBeVIP bool - }{ - {"1", "boss@company.com", "Meeting tomorrow", true}, - {"2", "random@example.com", "Newsletter", false}, - {"3", "ceo@corp.com", "Q4 Results", true}, - {"4", "spam@junk.com", "You won!", false}, - {"5", "important@vip.org", "Urgent matter", true}, - } - - // Simulate frontend VIP filter logic (same as in email.js) - var vipEmails []string - for _, email := range mockEmails { - isVIP := false - for _, vip := range vipSenders { - if email.fromEmail == vip { - isVIP = true - break - } - } - if isVIP { - vipEmails = append(vipEmails, email.id) - } - if isVIP != email.shouldBeVIP { - t.Errorf("email %s (from %s): expected VIP=%v, got %v", - email.id, email.fromEmail, email.shouldBeVIP, isVIP) - } - } - - if len(vipEmails) != 3 { - t.Errorf("expected 3 VIP emails, got %d", len(vipEmails)) - } - - t.Logf("VIP client-side filter test passed: %d VIP emails identified", len(vipEmails)) -} - -// TestFilterWorkflow_EmptyVIPList tests behavior when no VIP senders configured -func TestFilterWorkflow_EmptyVIPList(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - // Don't add any VIP senders, just get the default list - req := httptest.NewRequest(http.MethodGet, "/api/inbox/vip", nil) - w := httptest.NewRecorder() - server.handleVIPSenders(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("GET /api/inbox/vip failed: status %d", w.Code) - } - - var vipResp map[string]any - if err := json.NewDecoder(w.Body).Decode(&vipResp); err != nil { - t.Fatalf("failed to decode VIP response: %v", err) - } - - vipSenders, ok := vipResp["vip_senders"].([]any) - if !ok { - t.Fatal("vip_senders field missing or not an array") - } - - // With empty VIP list, filtering should return empty results - mockEmails := []map[string]any{ - {"id": "1", "from": "test@example.com"}, - {"id": "2", "from": "another@example.com"}, - } - - var vipFiltered []map[string]any - for _, email := range mockEmails { - fromEmail := email["from"].(string) - isVIP := false - for _, vip := range vipSenders { - if fromEmail == vip.(string) { - isVIP = true - break - } - } - if isVIP { - vipFiltered = append(vipFiltered, email) - } - } - - if len(vipFiltered) != 0 { - t.Errorf("expected 0 VIP emails with empty VIP list, got %d", len(vipFiltered)) - } - - t.Logf("Empty VIP list test passed: correctly returns 0 VIP emails") -} - -// TestFilterWorkflow_SwitchBetweenFilters tests rapid filter switching -func TestFilterWorkflow_SwitchBetweenFilters(t *testing.T) { - t.Parallel() - - vipSenders := []string{"boss@company.com"} - - mockEmails := []struct { - id string - fromEmail string - unread bool - }{ - {"1", "boss@company.com", true}, // VIP + Unread - {"2", "boss@company.com", false}, // VIP only - {"3", "other@example.com", true}, // Unread only - {"4", "other@example.com", false}, // Neither - } - - // Test "all" filter - allCount := len(mockEmails) - if allCount != 4 { - t.Errorf("all filter: expected 4, got %d", allCount) - } - - // Test "vip" filter - vipCount := 0 - for _, e := range mockEmails { - for _, vip := range vipSenders { - if e.fromEmail == vip { - vipCount++ - break - } - } - } - if vipCount != 2 { - t.Errorf("vip filter: expected 2, got %d", vipCount) - } - - // Test "unread" filter - unreadCount := 0 - for _, e := range mockEmails { - if e.unread { - unreadCount++ - } - } - if unreadCount != 2 { - t.Errorf("unread filter: expected 2, got %d", unreadCount) - } - - // Verify switching back to "all" restores full list - if allCount != 4 { - t.Error("switching back to 'all' should show all emails") - } - - t.Logf("Filter switching test passed: all=%d, vip=%d, unread=%d", allCount, vipCount, unreadCount) -} diff --git a/internal/air/handlers_focus_mode.go b/internal/air/handlers_focus_mode.go deleted file mode 100644 index 654fcd4..0000000 --- a/internal/air/handlers_focus_mode.go +++ /dev/null @@ -1,263 +0,0 @@ -package air - -import ( - "encoding/json" - "net/http" - "slices" - "sync" - "time" - - "github.com/nylas/cli/internal/httputil" -) - -// FocusModeState represents the current focus mode state -type FocusModeState struct { - IsActive bool `json:"isActive"` - StartedAt time.Time `json:"startedAt,omitzero"` - EndsAt time.Time `json:"endsAt,omitzero"` - Duration int `json:"duration"` // minutes - PomodoroMode bool `json:"pomodoroMode"` - SessionCount int `json:"sessionCount"` - BreakDuration int `json:"breakDuration"` // minutes - InBreak bool `json:"inBreak"` -} - -// FocusModeSettings represents focus mode preferences -type FocusModeSettings struct { - DefaultDuration int `json:"defaultDuration"` // minutes - PomodoroWork int `json:"pomodoroWork"` // minutes - PomodoroBreak int `json:"pomodoroBreak"` // minutes - PomodoroLongBreak int `json:"pomodoroLongBreak"` // minutes - SessionsBeforeLong int `json:"sessionsBeforeLong"` // sessions before long break - HideNotifications bool `json:"hideNotifications"` - HideEmailList bool `json:"hideEmailList"` - MuteSound bool `json:"muteSound"` - AllowedSenders []string `json:"allowedSenders"` // VIP list that can interrupt - AutoReplyEnabled bool `json:"autoReplyEnabled"` - AutoReplyMessage string `json:"autoReplyMessage"` -} - -// focusModeStore holds focus mode state -type focusModeStore struct { - state *FocusModeState - settings *FocusModeSettings - mu sync.RWMutex -} - -var fmStore = &focusModeStore{ - state: &FocusModeState{ - IsActive: false, - Duration: 25, - PomodoroMode: false, - }, - settings: &FocusModeSettings{ - DefaultDuration: 25, - PomodoroWork: 25, - PomodoroBreak: 5, - PomodoroLongBreak: 15, - SessionsBeforeLong: 4, - HideNotifications: true, - HideEmailList: true, - MuteSound: false, - AllowedSenders: []string{}, - AutoReplyEnabled: false, - AutoReplyMessage: "I'm currently in focus mode and will respond later.", - }, -} - -// handleFocusModeRoute dispatches focus mode requests by method -func (s *Server) handleFocusModeRoute(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.handleGetFocusModeState(w, r) - case http.MethodPost: - s.handleStartFocusMode(w, r) - case http.MethodDelete: - s.handleStopFocusMode(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleFocusModeSettings dispatches focus mode settings requests by method -func (s *Server) handleFocusModeSettings(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.handleGetFocusModeSettings(w, r) - case http.MethodPut, http.MethodPost: - s.handleUpdateFocusModeSettings(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleGetFocusModeState returns current focus mode state -func (s *Server) handleGetFocusModeState(w http.ResponseWriter, r *http.Request) { - fmStore.mu.RLock() - defer fmStore.mu.RUnlock() - - // Check if session has ended - state := *fmStore.state - if state.IsActive && !state.EndsAt.IsZero() && time.Now().After(state.EndsAt) { - state.IsActive = false - } - - // Calculate remaining time - response := map[string]any{ - "state": state, - } - - if state.IsActive && !state.EndsAt.IsZero() { - remaining := time.Until(state.EndsAt) - if remaining > 0 { - response["remainingMinutes"] = int(remaining.Minutes()) - response["remainingSeconds"] = int(remaining.Seconds()) % 60 - } - } - - httputil.WriteJSON(w, http.StatusOK, response) -} - -// handleStartFocusMode starts a focus mode session -func (s *Server) handleStartFocusMode(w http.ResponseWriter, r *http.Request) { - var req struct { - Duration int `json:"duration,omitempty"` - PomodoroMode bool `json:"pomodoroMode"` - } - - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - fmStore.mu.Lock() - defer fmStore.mu.Unlock() - - duration := req.Duration - if duration <= 0 { - if req.PomodoroMode { - duration = fmStore.settings.PomodoroWork - } else { - duration = fmStore.settings.DefaultDuration - } - } - - now := time.Now() - fmStore.state = &FocusModeState{ - IsActive: true, - StartedAt: now, - EndsAt: now.Add(time.Duration(duration) * time.Minute), - Duration: duration, - PomodoroMode: req.PomodoroMode, - SessionCount: fmStore.state.SessionCount, - BreakDuration: fmStore.settings.PomodoroBreak, - InBreak: false, - } - - httputil.WriteJSON(w, http.StatusOK, fmStore.state) -} - -// handleStopFocusMode stops the current focus mode session -func (s *Server) handleStopFocusMode(w http.ResponseWriter, r *http.Request) { - fmStore.mu.Lock() - defer fmStore.mu.Unlock() - - if fmStore.state.IsActive && !fmStore.state.InBreak { - fmStore.state.SessionCount++ - } - fmStore.state.IsActive = false - fmStore.state.InBreak = false - - httputil.WriteJSON(w, http.StatusOK, map[string]any{ - "status": "stopped", - "sessionCount": fmStore.state.SessionCount, - }) -} - -// handleStartBreak starts a break in pomodoro mode -func (s *Server) handleStartBreak(w http.ResponseWriter, r *http.Request) { - fmStore.mu.Lock() - defer fmStore.mu.Unlock() - - if !fmStore.state.PomodoroMode { - http.Error(w, "Not in pomodoro mode", http.StatusBadRequest) - return - } - - // Determine break duration - breakDuration := fmStore.settings.PomodoroBreak - if fmStore.state.SessionCount > 0 && fmStore.state.SessionCount%fmStore.settings.SessionsBeforeLong == 0 { - breakDuration = fmStore.settings.PomodoroLongBreak - } - - now := time.Now() - fmStore.state.InBreak = true - fmStore.state.StartedAt = now - fmStore.state.EndsAt = now.Add(time.Duration(breakDuration) * time.Minute) - fmStore.state.BreakDuration = breakDuration - - httputil.WriteJSON(w, http.StatusOK, fmStore.state) -} - -// handleGetFocusModeSettings returns focus mode settings -func (s *Server) handleGetFocusModeSettings(w http.ResponseWriter, r *http.Request) { - fmStore.mu.RLock() - defer fmStore.mu.RUnlock() - - httputil.WriteJSON(w, http.StatusOK, fmStore.settings) -} - -// handleUpdateFocusModeSettings updates focus mode settings -func (s *Server) handleUpdateFocusModeSettings(w http.ResponseWriter, r *http.Request) { - var settings FocusModeSettings - if err := json.NewDecoder(limitedBody(w, r)).Decode(&settings); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - func() { - fmStore.mu.Lock() - defer fmStore.mu.Unlock() - fmStore.settings = &settings - }() - - httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "updated"}) -} - -// IsFocusModeActive reports whether a focus session is currently running. -// -// A session is "active" when IsActive=true AND we haven't passed EndsAt. -// The handler that started the session never schedules a goroutine to flip -// IsActive when the timer elapses, so callers (notification gating, auto- -// reply) have to perform the expiry check at call time. Without this the -// session would appear to last forever after the user closes the tab. -func IsFocusModeActive() bool { - fmStore.mu.RLock() - defer fmStore.mu.RUnlock() - if !fmStore.state.IsActive { - return false - } - if !fmStore.state.EndsAt.IsZero() && !time.Now().Before(fmStore.state.EndsAt) { - return false - } - return true -} - -// ShouldAllowNotification reports whether a notification should be surfaced. -// -// Honours the active expiry window — once EndsAt has passed, focus mode is -// effectively over even if no client GET refreshed the persisted state. -func ShouldAllowNotification(senderEmail string) bool { - fmStore.mu.RLock() - defer fmStore.mu.RUnlock() - - active := fmStore.state.IsActive - if active && !fmStore.state.EndsAt.IsZero() && !time.Now().Before(fmStore.state.EndsAt) { - active = false - } - if !active || !fmStore.settings.HideNotifications { - return true - } - - return slices.Contains(fmStore.settings.AllowedSenders, senderEmail) -} diff --git a/internal/air/handlers_focus_mode_test.go b/internal/air/handlers_focus_mode_test.go deleted file mode 100644 index 98f120a..0000000 --- a/internal/air/handlers_focus_mode_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package air - -import ( - "testing" - "time" -) - -// TestIsFocusModeActive_ExpiresAtEndsAt is a regression test for the bug -// where ShouldAllowNotification kept returning false long after the focus -// session window had elapsed because the read handler only updated a local -// copy of the state. Now both IsFocusModeActive and ShouldAllowNotification -// honour EndsAt at call time. -func TestIsFocusModeActive_ExpiresAtEndsAt(t *testing.T) { - original := fmStore - t.Cleanup(func() { fmStore = original }) - - now := time.Now() - - t.Run("active before EndsAt", func(t *testing.T) { - fmStore = &focusModeStore{ - state: &FocusModeState{ - IsActive: true, - StartedAt: now.Add(-10 * time.Minute), - EndsAt: now.Add(15 * time.Minute), - }, - settings: original.settings, - } - if !IsFocusModeActive() { - t.Error("expected active session to report true") - } - }) - - t.Run("expired after EndsAt", func(t *testing.T) { - fmStore = &focusModeStore{ - state: &FocusModeState{ - IsActive: true, - StartedAt: now.Add(-2 * time.Hour), - EndsAt: now.Add(-1 * time.Hour), - }, - settings: original.settings, - } - if IsFocusModeActive() { - t.Error("expected expired session to report false") - } - }) - - t.Run("not started", func(t *testing.T) { - fmStore = &focusModeStore{ - state: &FocusModeState{IsActive: false}, - settings: original.settings, - } - if IsFocusModeActive() { - t.Error("expected inactive session to report false") - } - }) -} - -func TestShouldAllowNotification_HonoursExpiry(t *testing.T) { - original := fmStore - t.Cleanup(func() { fmStore = original }) - - now := time.Now() - fmStore = &focusModeStore{ - state: &FocusModeState{ - IsActive: true, - StartedAt: now.Add(-2 * time.Hour), - EndsAt: now.Add(-1 * time.Hour), - }, - settings: &FocusModeSettings{ - HideNotifications: true, - AllowedSenders: []string{"vip@example.com"}, - }, - } - - // Once the session is past EndsAt, *every* sender should pass through - // — the user is no longer in focus mode. - if !ShouldAllowNotification("random@example.com") { - t.Error("expired session must allow notifications") - } -} - -func TestShouldAllowNotification_VIPDuringActiveSession(t *testing.T) { - original := fmStore - t.Cleanup(func() { fmStore = original }) - - now := time.Now() - fmStore = &focusModeStore{ - state: &FocusModeState{ - IsActive: true, - StartedAt: now, - EndsAt: now.Add(15 * time.Minute), - }, - settings: &FocusModeSettings{ - HideNotifications: true, - AllowedSenders: []string{"vip@example.com"}, - }, - } - - if !ShouldAllowNotification("vip@example.com") { - t.Error("VIP sender must always pass during active focus") - } - if ShouldAllowNotification("noise@example.com") { - t.Error("non-VIP sender must be blocked during active focus") - } -} diff --git a/internal/air/handlers_helpers.go b/internal/air/handlers_helpers.go deleted file mode 100644 index 1ae84e1..0000000 --- a/internal/air/handlers_helpers.go +++ /dev/null @@ -1,131 +0,0 @@ -package air - -import ( - "context" - "encoding/json" - "log/slog" - "net/http" - "strings" - "time" -) - -// defaultTimeout is the default API request timeout. -const defaultTimeout = 30 * time.Second - -// withTimeout creates a context with the default timeout. -// Returns the context and a cancel function that must be deferred. -func (s *Server) withTimeout(r *http.Request) (context.Context, context.CancelFunc) { - return context.WithTimeout(r.Context(), defaultTimeout) -} - -// requireConfig checks if the Nylas client is configured. -// Returns true if configured, false if not (error response already written). -// Callers should return immediately when this returns false. -func (s *Server) requireConfig(w http.ResponseWriter) bool { - if s.nylasClient == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "Not configured. Run 'nylas auth login' first.", - }) - return false - } - return true -} - -// parseJSONBody decodes the request body into dest. Returns false on -// error after writing a generic 400; the raw decoder error stays in slog -// (it can quote request bytes — PII or attacker input). -func parseJSONBody[T any](w http.ResponseWriter, r *http.Request, dest *T) bool { - if err := json.NewDecoder(limitedBody(w, r)).Decode(dest); err != nil { - slog.Warn("invalid JSON request body", - "err", err, - "path", r.URL.Path, - "method", r.Method, - ) - writeError(w, http.StatusBadRequest, "Invalid request body") - return false - } - return true -} - -// writeBadParamError writes a generic 400 ("invalidTest body
", - "send_at": futureTime, - } - bodyJSON, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/scheduled", bytes.NewBuffer(bodyJSON)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleScheduledSend(w, req) - - if w.Code != http.StatusOK && w.Code != http.StatusCreated { - t.Errorf("expected status 200 or 201, got %d: %s", w.Code, w.Body.String()) - } -} - -// TestHandleTemplates_GET tests listing email templates. -func TestHandleTemplates_GET(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - emailTemplates: make(map[string]EmailTemplate), - } - - // Add test templates - server.emailTemplates["tmpl-001"] = EmailTemplate{ - ID: "tmpl-001", - Name: "Test Template", - Subject: "Test Subject", - Body: "Test body
", - CreatedAt: time.Now().Unix(), - } - - req := httptest.NewRequest(http.MethodGet, "/api/templates", nil) - w := httptest.NewRecorder() - - server.handleTemplates(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -// TestHandleTemplates_POST_Create tests creating a template. -func TestHandleTemplates_POST_Create(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - emailTemplates: make(map[string]EmailTemplate), - } - - template := map[string]string{ - "name": "New Template", - "subject": "Template Subject", - "body": "Template body content
", - } - body, _ := json.Marshal(template) - - req := httptest.NewRequest(http.MethodPost, "/api/templates", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleTemplates(w, req) - - if w.Code != http.StatusOK && w.Code != http.StatusCreated { - t.Errorf("expected status 200 or 201, got %d: %s", w.Code, w.Body.String()) - } -} - -// TestHandleTemplateByID_GET tests retrieving a template by ID. -func TestHandleTemplateByID_GET(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - emailTemplates: make(map[string]EmailTemplate), - } - - server.emailTemplates["tmpl-001"] = EmailTemplate{ - ID: "tmpl-001", - Name: "Test", - Subject: "Subject", - Body: "Body", - } - - req := httptest.NewRequest(http.MethodGet, "/api/templates/tmpl-001", nil) - w := httptest.NewRecorder() - - server.handleTemplateByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -// TestHandleTemplateByID_DELETE tests deleting a template. -func TestHandleTemplateByID_DELETE(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - emailTemplates: make(map[string]EmailTemplate), - } - - server.emailTemplates["tmpl-001"] = EmailTemplate{ - ID: "tmpl-001", - Name: "To Delete", - } - - req := httptest.NewRequest(http.MethodDelete, "/api/templates/tmpl-001", nil) - w := httptest.NewRecorder() - - server.handleTemplateByID(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - // Verify deletion - if _, exists := server.emailTemplates["tmpl-001"]; exists { - t.Error("expected template to be deleted") - } -} - -// TestHandleUndoSend_GET tests getting undo send config. -func TestHandleUndoSend_GET(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - undoSendConfig: &UndoSendConfig{ - Enabled: true, - GracePeriodSec: 10, - }, - } - - req := httptest.NewRequest(http.MethodGet, "/api/undo-send", nil) - w := httptest.NewRecorder() - - server.handleUndoSend(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp["enabled"] != true { - t.Error("expected enabled to be true") - } -} - -// TestHandleUndoSend_PUT tests updating undo send config. -func TestHandleUndoSend_PUT(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - undoSendConfig: &UndoSendConfig{ - Enabled: false, - GracePeriodSec: 5, - }, - } - - update := map[string]any{ - "enabled": true, - "grace_period": 15, - } - body, _ := json.Marshal(update) - - req := httptest.NewRequest(http.MethodPut, "/api/undo-send", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleUndoSend(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } -} - -// TestHandlePendingSends tests listing pending sends. -func TestHandlePendingSends(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - pendingSends: make(map[string]PendingSend), - } - - // Add a pending send - server.pendingSends["send-001"] = PendingSend{ - ID: "send-001", - SendAt: time.Now().Add(10 * time.Second).Unix(), - } - - req := httptest.NewRequest(http.MethodGet, "/api/pending-sends", nil) - w := httptest.NewRecorder() - - server.handlePendingSends(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -// TestSplitInboxConfig tests split inbox configuration. -func TestSplitInboxConfig(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - splitInboxConfig: &SplitInboxConfig{ - Enabled: true, - VIPSenders: []string{ - "ceo@company.com", - "important@example.com", - }, - }, - } - - req := httptest.NewRequest(http.MethodGet, "/api/inbox/split", nil) - w := httptest.NewRecorder() - - server.handleSplitInbox(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - config, ok := resp["config"].(map[string]any) - if !ok { - t.Fatal("expected config in response") - } - if config["enabled"] != true { - t.Error("expected enabled to be true") - } -} - -// TestHandleVIPSenders_GET tests getting VIP senders. -func TestHandleVIPSenders_GET(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - splitInboxConfig: &SplitInboxConfig{ - Enabled: true, - VIPSenders: []string{ - "vip1@example.com", - "vip2@example.com", - }, - }, - } - - req := httptest.NewRequest(http.MethodGet, "/api/inbox/vip", nil) - w := httptest.NewRecorder() - - server.handleVIPSenders(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } -} - -// TestHandleVIPSenders_POST tests adding a VIP sender. -func TestHandleVIPSenders_POST(t *testing.T) { - t.Parallel() - - server := &Server{ - demoMode: true, - splitInboxConfig: &SplitInboxConfig{ - Enabled: true, - VIPSenders: []string{}, - }, - } - - body := map[string]string{ - "email": "newvip@example.com", - } - bodyJSON, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/inbox/vip", bytes.NewBuffer(bodyJSON)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleVIPSenders(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } -} - -// TestSnoozedEmail_Types tests snoozed email type structure. -func TestSnoozedEmail_Types(t *testing.T) { - t.Parallel() - - now := time.Now() - snooze := SnoozedEmail{ - EmailID: "email-001", - SnoozeUntil: now.Add(2 * time.Hour).Unix(), - CreatedAt: now.Unix(), - } - - if snooze.EmailID != "email-001" { - t.Errorf("expected EmailID email-001, got %s", snooze.EmailID) - } - - if snooze.SnoozeUntil <= now.Unix() { - t.Error("expected SnoozeUntil to be in the future") - } -} - -// TestPendingSend_Types tests pending send type structure. -func TestPendingSend_Types(t *testing.T) { - t.Parallel() - - now := time.Now() - pending := PendingSend{ - ID: "pending-001", - SendAt: now.Add(10 * time.Second).Unix(), - } - - if pending.ID != "pending-001" { - t.Errorf("expected ID pending-001, got %s", pending.ID) - } - - if pending.SendAt <= now.Unix() { - t.Error("expected SendAt to be in the future") - } -} - -// TestEmailTemplate_Types tests email template type structure. -func TestEmailTemplate_Types(t *testing.T) { - t.Parallel() - - now := time.Now() - template := EmailTemplate{ - ID: "tmpl-001", - Name: "Welcome Email", - Subject: "Welcome to our platform", - Body: "Thank you for joining.
", - CreatedAt: now.Unix(), - UpdatedAt: now.Unix(), - } - - if template.ID != "tmpl-001" { - t.Errorf("expected ID tmpl-001, got %s", template.ID) - } - - if template.Name != "Welcome Email" { - t.Errorf("expected Name 'Welcome Email', got %s", template.Name) - } - - if template.Subject != "Welcome to our platform" { - t.Error("expected correct subject") - } -} - -// TestUndoSendConfig_Types tests undo send config type structure. -func TestUndoSendConfig_Types(t *testing.T) { - t.Parallel() - - config := UndoSendConfig{ - Enabled: true, - GracePeriodSec: 10, - } - - if !config.Enabled { - t.Error("expected Enabled to be true") - } - - if config.GracePeriodSec != 10 { - t.Errorf("expected GracePeriodSec 10, got %d", config.GracePeriodSec) - } -} - -// TestSplitInboxConfig_Types tests split inbox config type structure. -func TestSplitInboxConfig_Types(t *testing.T) { - t.Parallel() - - config := SplitInboxConfig{ - Enabled: true, - VIPSenders: []string{ - "ceo@company.com", - "manager@company.com", - }, - } - - if !config.Enabled { - t.Error("expected Enabled to be true") - } - - if len(config.VIPSenders) != 2 { - t.Errorf("expected 2 VIP senders, got %d", len(config.VIPSenders)) - } -} diff --git a/internal/air/handlers_read_receipts.go b/internal/air/handlers_read_receipts.go deleted file mode 100644 index e613cf7..0000000 --- a/internal/air/handlers_read_receipts.go +++ /dev/null @@ -1,180 +0,0 @@ -package air - -import ( - "encoding/json" - "net/http" - "net/url" - "sync" - "time" - - "github.com/nylas/cli/internal/httputil" -) - -// ReadReceipt represents a read receipt for a sent email -type ReadReceipt struct { - EmailID string `json:"emailId"` - Recipient string `json:"recipient"` - OpenedAt time.Time `json:"openedAt,omitzero"` - OpenCount int `json:"openCount"` - Device string `json:"device,omitempty"` - Location string `json:"location,omitempty"` - UserAgent string `json:"userAgent,omitempty"` - IsOpened bool `json:"isOpened"` -} - -// ReadReceiptSettings represents user settings for read receipts -type ReadReceiptSettings struct { - Enabled bool `json:"enabled"` - TrackOpens bool `json:"trackOpens"` - TrackClicks bool `json:"trackClicks"` - ShowNotification bool `json:"showNotification"` - BlockTracking bool `json:"blockTracking"` // Block tracking pixels in received emails -} - -// readReceiptStore holds read receipts -type readReceiptStore struct { - receipts map[string]*ReadReceipt // emailID -> receipt - settings *ReadReceiptSettings - mu sync.RWMutex -} - -var rrStore = &readReceiptStore{ - receipts: make(map[string]*ReadReceipt), - settings: &ReadReceiptSettings{ - Enabled: true, - TrackOpens: true, - TrackClicks: false, - ShowNotification: true, - BlockTracking: true, - }, -} - -// handleGetReadReceipts returns read receipts for sent emails -func (s *Server) handleGetReadReceipts(w http.ResponseWriter, r *http.Request) { - emailID := r.URL.Query().Get("emailId") - - rrStore.mu.RLock() - defer rrStore.mu.RUnlock() - - if emailID != "" { - if receipt, ok := rrStore.receipts[emailID]; ok { - httputil.WriteJSON(w, http.StatusOK, receipt) - return - } - http.Error(w, "Receipt not found", http.StatusNotFound) - return - } - - receipts := make([]*ReadReceipt, 0, len(rrStore.receipts)) - for _, r := range rrStore.receipts { - receipts = append(receipts, r) - } - - httputil.WriteJSON(w, http.StatusOK, receipts) -} - -// handleTrackOpen records an email open (tracking pixel endpoint) -func (s *Server) handleTrackOpen(w http.ResponseWriter, r *http.Request) { - emailID := r.URL.Query().Get("id") - if emailID == "" { - // Return transparent pixel anyway - w.Header().Set("Content-Type", "image/gif") - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - _, _ = w.Write(transparentPixel) - return - } - - // Cap the User-Agent we persist. Browsers and bots sometimes send - // multi-kilobyte UA strings, and tracking pixels can be hit - // indefinitely; without a cap a single recipient could grow our - // in-memory store unboundedly. - const maxUA = 256 - ua := r.UserAgent() - if len(ua) > maxUA { - ua = ua[:maxUA] - } - - func() { - rrStore.mu.Lock() - defer rrStore.mu.Unlock() - if receipt, ok := rrStore.receipts[emailID]; ok { - receipt.OpenCount++ - if !receipt.IsOpened { - receipt.IsOpened = true - receipt.OpenedAt = time.Now() - } - receipt.UserAgent = ua - } - }() - - // Return transparent 1x1 GIF - w.Header().Set("Content-Type", "image/gif") - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - _, _ = w.Write(transparentPixel) -} - -// transparentPixel is a 1x1 transparent GIF -var transparentPixel = []byte{ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, - 0x80, 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x21, - 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, - 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, - 0x01, 0x00, 0x3b, -} - -// handleReadReceiptSettings dispatches settings requests by method -func (s *Server) handleReadReceiptSettings(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.handleGetReadReceiptSettings(w, r) - case http.MethodPut, http.MethodPost: - s.handleUpdateReadReceiptSettings(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleGetReadReceiptSettings returns settings -func (s *Server) handleGetReadReceiptSettings(w http.ResponseWriter, r *http.Request) { - rrStore.mu.RLock() - defer rrStore.mu.RUnlock() - - httputil.WriteJSON(w, http.StatusOK, rrStore.settings) -} - -// handleUpdateReadReceiptSettings updates settings -func (s *Server) handleUpdateReadReceiptSettings(w http.ResponseWriter, r *http.Request) { - var settings ReadReceiptSettings - if err := json.NewDecoder(limitedBody(w, r)).Decode(&settings); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - func() { - rrStore.mu.Lock() - defer rrStore.mu.Unlock() - rrStore.settings = &settings - }() - - httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "updated"}) -} - -// RegisterEmailForTracking registers an email for read tracking -func RegisterEmailForTracking(emailID, recipient string) { - rrStore.mu.Lock() - defer rrStore.mu.Unlock() - - rrStore.receipts[emailID] = &ReadReceipt{ - EmailID: emailID, - Recipient: recipient, - OpenCount: 0, - IsOpened: false, - } -} - -// GetTrackingPixelURL returns the tracking pixel URL for an email. The -// emailID is URL-escaped so callers that compose the result into HTML -// markup don't accidentally produce a broken or attribute-escaping URL. -func GetTrackingPixelURL(emailID string) string { - return "/api/track/open?id=" + url.QueryEscape(emailID) -} diff --git a/internal/air/handlers_reply_later.go b/internal/air/handlers_reply_later.go deleted file mode 100644 index 33bf681..0000000 --- a/internal/air/handlers_reply_later.go +++ /dev/null @@ -1,194 +0,0 @@ -package air - -import ( - "encoding/json" - "net/http" - "sync" - "time" - - "github.com/nylas/cli/internal/httputil" -) - -// ReplyLaterItem represents an email in the reply later queue -type ReplyLaterItem struct { - EmailID string `json:"emailId"` - Subject string `json:"subject"` - From string `json:"from"` - AddedAt time.Time `json:"addedAt"` - RemindAt time.Time `json:"remindAt,omitzero"` - DraftID string `json:"draftId,omitempty"` - Notes string `json:"notes,omitempty"` - Priority int `json:"priority"` // 1=high, 2=medium, 3=low - IsCompleted bool `json:"isCompleted"` -} - -// replyLaterStore holds reply later items -type replyLaterStore struct { - items map[string]*ReplyLaterItem // emailID -> item - mu sync.RWMutex -} - -var rlStore = &replyLaterStore{ - items: make(map[string]*ReplyLaterItem), -} - -// handleReplyLaterRoute dispatches reply later requests by method -func (s *Server) handleReplyLaterRoute(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.handleGetReplyLaterItems(w, r) - case http.MethodPost: - s.handleAddToReplyLater(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleGetReplyLaterItems returns all reply later items -func (s *Server) handleGetReplyLaterItems(w http.ResponseWriter, r *http.Request) { - rlStore.mu.RLock() - defer rlStore.mu.RUnlock() - - showCompleted := NewQueryParams(r.URL.Query()).GetBool("completed") - - items := make([]*ReplyLaterItem, 0) - for _, item := range rlStore.items { - if showCompleted || !item.IsCompleted { - items = append(items, item) - } - } - - httputil.WriteJSON(w, http.StatusOK, items) -} - -// handleAddToReplyLater adds an email to reply later queue -func (s *Server) handleAddToReplyLater(w http.ResponseWriter, r *http.Request) { - var req struct { - EmailID string `json:"emailId"` - Subject string `json:"subject"` - From string `json:"from"` - RemindIn string `json:"remindIn,omitempty"` // "1h", "1d", "1w" - Priority int `json:"priority,omitempty"` - Notes string `json:"notes,omitempty"` - } - - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.EmailID == "" { - http.Error(w, "emailId is required", http.StatusBadRequest) - return - } - - item := &ReplyLaterItem{ - EmailID: req.EmailID, - Subject: req.Subject, - From: req.From, - AddedAt: time.Now(), - Notes: req.Notes, - Priority: req.Priority, - IsCompleted: false, - } - - if item.Priority == 0 { - item.Priority = 2 // Default medium - } - - // Parse remind time - if req.RemindIn != "" { - switch req.RemindIn { - case "1h": - item.RemindAt = time.Now().Add(1 * time.Hour) - case "4h": - item.RemindAt = time.Now().Add(4 * time.Hour) - case "1d": - item.RemindAt = time.Now().Add(24 * time.Hour) - case "3d": - item.RemindAt = time.Now().Add(72 * time.Hour) - case "1w": - item.RemindAt = time.Now().Add(168 * time.Hour) - } - } - - func() { - rlStore.mu.Lock() - defer rlStore.mu.Unlock() - rlStore.items[req.EmailID] = item - }() - - httputil.WriteJSON(w, http.StatusOK, item) -} - -// handleUpdateReplyLater updates a reply later item -func (s *Server) handleUpdateReplyLater(w http.ResponseWriter, r *http.Request) { - var req struct { - EmailID string `json:"emailId"` - DraftID string `json:"draftId,omitempty"` - Notes string `json:"notes,omitempty"` - Priority int `json:"priority,omitempty"` - IsCompleted bool `json:"isCompleted"` - } - - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - rlStore.mu.Lock() - defer rlStore.mu.Unlock() - - item, ok := rlStore.items[req.EmailID] - if !ok { - http.Error(w, "Item not found", http.StatusNotFound) - return - } - - if req.DraftID != "" { - item.DraftID = req.DraftID - } - if req.Notes != "" { - item.Notes = req.Notes - } - if req.Priority > 0 { - item.Priority = req.Priority - } - item.IsCompleted = req.IsCompleted - - httputil.WriteJSON(w, http.StatusOK, item) -} - -// handleRemoveFromReplyLater removes an email from reply later queue -func (s *Server) handleRemoveFromReplyLater(w http.ResponseWriter, r *http.Request) { - emailID := r.URL.Query().Get("emailId") - if emailID == "" { - http.Error(w, "emailId is required", http.StatusBadRequest) - return - } - - func() { - rlStore.mu.Lock() - defer rlStore.mu.Unlock() - delete(rlStore.items, emailID) - }() - - w.WriteHeader(http.StatusNoContent) -} - -// GetPendingReminders returns items with reminders due -func GetPendingReminders() []*ReplyLaterItem { - rlStore.mu.RLock() - defer rlStore.mu.RUnlock() - - now := time.Now() - pending := make([]*ReplyLaterItem, 0) - - for _, item := range rlStore.items { - if !item.IsCompleted && !item.RemindAt.IsZero() && item.RemindAt.Before(now) { - pending = append(pending, item) - } - } - - return pending -} diff --git a/internal/air/handlers_response_conversion_contacts_test.go b/internal/air/handlers_response_conversion_contacts_test.go deleted file mode 100644 index ffe646f..0000000 --- a/internal/air/handlers_response_conversion_contacts_test.go +++ /dev/null @@ -1,253 +0,0 @@ -//go:build !integration -// +build !integration - -package air - -import ( - "testing" - - "github.com/nylas/cli/internal/air/cache" - "github.com/nylas/cli/internal/domain" -) - -// TestContactToResponse tests contact domain to response conversion. -func TestContactToResponse(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - contact domain.Contact - checkFunc func(resp ContactResponse) bool - desc string - }{ - { - name: "basic contact", - contact: domain.Contact{ - ID: "contact-001", - GivenName: "John", - Surname: "Doe", - Nickname: "Johnny", - CompanyName: "Acme Corp", - JobTitle: "Engineer", - Birthday: "1990-01-15", - Notes: "Test notes", - PictureURL: "https://example.com/photo.jpg", - Source: "google", - }, - checkFunc: func(resp ContactResponse) bool { - return resp.ID == "contact-001" && - resp.GivenName == "John" && - resp.Surname == "Doe" && - resp.Nickname == "Johnny" && - resp.CompanyName == "Acme Corp" && - resp.JobTitle == "Engineer" && - resp.Birthday == "1990-01-15" && - resp.Notes == "Test notes" && - resp.PictureURL == "https://example.com/photo.jpg" && - resp.Source == "google" - }, - desc: "all basic fields should be converted", - }, - { - name: "contact with emails", - contact: domain.Contact{ - ID: "contact-002", - GivenName: "Jane", - Emails: []domain.ContactEmail{ - {Email: "jane@work.com", Type: "work"}, - {Email: "jane@home.com", Type: "home"}, - }, - }, - checkFunc: func(resp ContactResponse) bool { - return len(resp.Emails) == 2 && - resp.Emails[0].Email == "jane@work.com" && - resp.Emails[0].Type == "work" && - resp.Emails[1].Email == "jane@home.com" - }, - desc: "emails should be converted", - }, - { - name: "contact with phone numbers", - contact: domain.Contact{ - ID: "contact-003", - GivenName: "Bob", - PhoneNumbers: []domain.ContactPhone{ - {Number: "+1-555-1234", Type: "mobile"}, - {Number: "+1-555-5678", Type: "work"}, - }, - }, - checkFunc: func(resp ContactResponse) bool { - return len(resp.PhoneNumbers) == 2 && - resp.PhoneNumbers[0].Number == "+1-555-1234" && - resp.PhoneNumbers[0].Type == "mobile" - }, - desc: "phone numbers should be converted", - }, - { - name: "contact with addresses", - contact: domain.Contact{ - ID: "contact-004", - GivenName: "Alice", - PhysicalAddresses: []domain.ContactAddress{ - { - Type: "home", - StreetAddress: "123 Main St", - City: "San Francisco", - State: "CA", - PostalCode: "94105", - Country: "USA", - }, - }, - }, - checkFunc: func(resp ContactResponse) bool { - return len(resp.Addresses) == 1 && - resp.Addresses[0].Type == "home" && - resp.Addresses[0].StreetAddress == "123 Main St" && - resp.Addresses[0].City == "San Francisco" && - resp.Addresses[0].State == "CA" - }, - desc: "addresses should be converted", - }, - { - name: "contact with all details", - contact: domain.Contact{ - ID: "contact-005", - GivenName: "Complete", - Surname: "Contact", - CompanyName: "Full Corp", - Emails: []domain.ContactEmail{ - {Email: "complete@example.com", Type: "work"}, - }, - PhoneNumbers: []domain.ContactPhone{ - {Number: "+1-555-0000", Type: "mobile"}, - }, - PhysicalAddresses: []domain.ContactAddress{ - {Type: "work", City: "NYC"}, - }, - }, - checkFunc: func(resp ContactResponse) bool { - return resp.GivenName == "Complete" && - resp.Surname == "Contact" && - len(resp.Emails) == 1 && - len(resp.PhoneNumbers) == 1 && - len(resp.Addresses) == 1 - }, - desc: "all nested fields should be converted", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp := contactToResponse(tt.contact) - - if !tt.checkFunc(resp) { - t.Errorf("contactToResponse() %s: got %+v", tt.desc, resp) - } - }) - } -} - -// TestCachedContactToResponse_Extended tests cached contact conversion. -func TestCachedContactToResponse_Extended(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cached *cache.CachedContact - checkFunc func(resp ContactResponse) bool - desc string - }{ - { - name: "basic cached contact", - cached: &cache.CachedContact{ - ID: "cached-contact-001", - GivenName: "Cached", - Surname: "User", - DisplayName: "Cached User", - Email: "cached@example.com", - Phone: "+1-555-1234", - Company: "Cache Corp", - JobTitle: "Developer", - Notes: "Cached notes", - }, - checkFunc: func(resp ContactResponse) bool { - return resp.ID == "cached-contact-001" && - resp.DisplayName == "Cached User" && - resp.CompanyName == "Cache Corp" && - len(resp.Emails) == 1 && - resp.Emails[0].Email == "cached@example.com" && - len(resp.PhoneNumbers) == 1 && - resp.PhoneNumbers[0].Number == "+1-555-1234" - }, - desc: "all fields should be converted correctly", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp := cachedContactToResponse(tt.cached) - - if !tt.checkFunc(resp) { - t.Errorf("cachedContactToResponse() %s: got %+v", tt.desc, resp) - } - }) - } -} - -// TestCalendarToResponse tests calendar domain to response conversion. -func TestCalendarToResponse(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - calendar domain.Calendar - checkFunc func(resp CalendarResponse) bool - desc string - }{ - { - name: "primary calendar", - calendar: domain.Calendar{ - ID: "cal-primary", - Name: "Personal", - Description: "My personal calendar", - Timezone: "America/New_York", - IsPrimary: true, - ReadOnly: false, - HexColor: "#4285f4", - }, - checkFunc: func(resp CalendarResponse) bool { - return resp.ID == "cal-primary" && - resp.Name == "Personal" && - resp.Description == "My personal calendar" && - resp.Timezone == "America/New_York" && - resp.IsPrimary == true && - resp.ReadOnly == false && - resp.HexColor == "#4285f4" - }, - desc: "all fields should be converted", - }, - { - name: "read-only calendar", - calendar: domain.Calendar{ - ID: "cal-holidays", - Name: "Holidays", - ReadOnly: true, - IsPrimary: false, - }, - checkFunc: func(resp CalendarResponse) bool { - return resp.ReadOnly == true && resp.IsPrimary == false - }, - desc: "read-only flag should be preserved", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp := calendarToResponse(tt.calendar) - - if !tt.checkFunc(resp) { - t.Errorf("calendarToResponse() %s: got %+v", tt.desc, resp) - } - }) - } -} diff --git a/internal/air/handlers_response_conversion_test.go b/internal/air/handlers_response_conversion_test.go deleted file mode 100644 index 5086c75..0000000 --- a/internal/air/handlers_response_conversion_test.go +++ /dev/null @@ -1,423 +0,0 @@ -//go:build !integration -// +build !integration - -package air - -import ( - "testing" - "time" - - "github.com/nylas/cli/internal/air/cache" - "github.com/nylas/cli/internal/domain" -) - -// ============================================================================= -// Response Conversion Tests -// ============================================================================= - -// TestEmailToResponse_Extended tests email domain to response conversion. -func TestEmailToResponse_Extended(t *testing.T) { - t.Parallel() - - now := time.Now() - - tests := []struct { - name string - message domain.Message - includeBody bool - checkFunc func(resp EmailResponse) bool - desc string - }{ - { - name: "basic message", - message: domain.Message{ - ID: "msg-001", - ThreadID: "thread-001", - Subject: "Test Subject", - Snippet: "Test snippet...", - Date: now, - Unread: true, - Starred: false, - Folders: []string{"inbox"}, - }, - includeBody: false, - checkFunc: func(resp EmailResponse) bool { - return resp.ID == "msg-001" && - resp.ThreadID == "thread-001" && - resp.Subject == "Test Subject" && - resp.Snippet == "Test snippet..." && - resp.Unread == true && - resp.Starred == false && - resp.Body == "" - }, - desc: "basic fields should match", - }, - { - name: "with body", - message: domain.Message{ - ID: "msg-002", - Subject: "Subject", - Body: "Email body content
", - }, - includeBody: true, - checkFunc: func(resp EmailResponse) bool { - return resp.Body == "Email body content
" - }, - desc: "body should be included when requested", - }, - { - name: "body excluded", - message: domain.Message{ - ID: "msg-003", - Subject: "Subject", - Body: "Email body content
", - }, - includeBody: false, - checkFunc: func(resp EmailResponse) bool { - return resp.Body == "" - }, - desc: "body should be empty when not requested", - }, - { - name: "with participants", - message: domain.Message{ - ID: "msg-004", - Subject: "Subject", - From: []domain.EmailParticipant{ - {Name: "Sender", Email: "sender@example.com"}, - }, - To: []domain.EmailParticipant{ - {Name: "Recipient", Email: "recipient@example.com"}, - }, - Cc: []domain.EmailParticipant{ - {Name: "CC User", Email: "cc@example.com"}, - }, - }, - includeBody: false, - checkFunc: func(resp EmailResponse) bool { - return len(resp.From) == 1 && resp.From[0].Email == "sender@example.com" && - len(resp.To) == 1 && resp.To[0].Email == "recipient@example.com" && - len(resp.Cc) == 1 && resp.Cc[0].Email == "cc@example.com" - }, - desc: "participants should be converted", - }, - { - name: "with attachments", - message: domain.Message{ - ID: "msg-005", - Subject: "Subject", - Attachments: []domain.Attachment{ - {ID: "att-001", Filename: "doc.pdf", ContentType: "application/pdf", Size: 1024}, - {ID: "att-002", Filename: "image.png", ContentType: "image/png", Size: 2048}, - }, - }, - includeBody: false, - checkFunc: func(resp EmailResponse) bool { - return len(resp.Attachments) == 2 && - resp.Attachments[0].Filename == "doc.pdf" && - resp.Attachments[1].Filename == "image.png" - }, - desc: "attachments should be converted", - }, - { - name: "starred message", - message: domain.Message{ - ID: "msg-006", - Subject: "Important", - Starred: true, - }, - includeBody: false, - checkFunc: func(resp EmailResponse) bool { - return resp.Starred == true - }, - desc: "starred flag should be preserved", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp := emailToResponse(tt.message, tt.includeBody) - - if !tt.checkFunc(resp) { - t.Errorf("emailToResponse() %s: got %+v", tt.desc, resp) - } - }) - } -} - -// TestCachedEmailToResponse_Extended tests cached email conversion. -func TestCachedEmailToResponse_Extended(t *testing.T) { - t.Parallel() - - now := time.Now() - - tests := []struct { - name string - cached *cache.CachedEmail - checkFunc func(resp EmailResponse) bool - desc string - }{ - { - name: "basic cached email", - cached: &cache.CachedEmail{ - ID: "cached-001", - ThreadID: "thread-001", - Subject: "Cached Subject", - Snippet: "Cached snippet...", - FromName: "Sender Name", - FromEmail: "sender@example.com", - Date: now, - Unread: true, - Starred: false, - FolderID: "inbox", - }, - checkFunc: func(resp EmailResponse) bool { - return resp.ID == "cached-001" && - resp.ThreadID == "thread-001" && - resp.Subject == "Cached Subject" && - len(resp.From) == 1 && - resp.From[0].Name == "Sender Name" && - resp.From[0].Email == "sender@example.com" && - resp.Unread == true && - len(resp.Folders) == 1 && - resp.Folders[0] == "inbox" - }, - desc: "all fields should be converted correctly", - }, - { - name: "starred cached email", - cached: &cache.CachedEmail{ - ID: "cached-002", - Subject: "Starred", - Starred: true, - FolderID: "starred", - }, - checkFunc: func(resp EmailResponse) bool { - return resp.Starred == true - }, - desc: "starred should be preserved", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp := cachedEmailToResponse(tt.cached) - - if !tt.checkFunc(resp) { - t.Errorf("cachedEmailToResponse() %s: got %+v", tt.desc, resp) - } - }) - } -} - -// TestEventToResponse tests event domain to response conversion. -func TestEventToResponse(t *testing.T) { - t.Parallel() - - now := time.Now() - - tests := []struct { - name string - event domain.Event - checkFunc func(resp EventResponse) bool - desc string - }{ - { - name: "basic timed event", - event: domain.Event{ - ID: "evt-001", - CalendarID: "cal-001", - Title: "Meeting", - Description: "Team sync", - Location: "Room A", - When: domain.EventWhen{ - StartTime: now.Unix(), - EndTime: now.Add(1 * time.Hour).Unix(), - Object: "timespan", - }, - Status: "confirmed", - Busy: true, - }, - checkFunc: func(resp EventResponse) bool { - return resp.ID == "evt-001" && - resp.CalendarID == "cal-001" && - resp.Title == "Meeting" && - resp.Description == "Team sync" && - resp.Location == "Room A" && - resp.Status == "confirmed" && - resp.Busy == true && - resp.IsAllDay == false - }, - desc: "basic event fields should match", - }, - { - name: "all-day event with date", - event: domain.Event{ - ID: "evt-002", - CalendarID: "cal-001", - Title: "Holiday", - When: domain.EventWhen{ - Date: "2025-12-25", - Object: "date", - }, - }, - checkFunc: func(resp EventResponse) bool { - return resp.IsAllDay == true && resp.StartTime > 0 - }, - desc: "all-day event should have IsAllDay=true", - }, - { - name: "all-day event with date range", - event: domain.Event{ - ID: "evt-003", - CalendarID: "cal-001", - Title: "Vacation", - When: domain.EventWhen{ - StartDate: "2025-12-20", - EndDate: "2025-12-27", - Object: "datespan", - }, - }, - checkFunc: func(resp EventResponse) bool { - return resp.IsAllDay == true && - resp.StartTime > 0 && - resp.EndTime > resp.StartTime - }, - desc: "date range event should have proper start/end times", - }, - { - name: "event with participants", - event: domain.Event{ - ID: "evt-004", - Title: "Team Meeting", - Participants: []domain.Participant{ - {Person: domain.Person{Name: "Alice", Email: "alice@example.com"}, Status: "yes"}, - {Person: domain.Person{Name: "Bob", Email: "bob@example.com"}, Status: "maybe"}, - }, - }, - checkFunc: func(resp EventResponse) bool { - return len(resp.Participants) == 2 && - resp.Participants[0].Name == "Alice" && - resp.Participants[0].Status == "yes" && - resp.Participants[1].Name == "Bob" - }, - desc: "participants should be converted", - }, - { - name: "event with conferencing", - event: domain.Event{ - ID: "evt-005", - Title: "Video Call", - Conferencing: &domain.Conferencing{ - Provider: "Zoom", - Details: &domain.ConferencingDetails{ - URL: "https://zoom.us/j/123456", - }, - }, - }, - checkFunc: func(resp EventResponse) bool { - return resp.Conferencing != nil && - resp.Conferencing.Provider == "Zoom" && - resp.Conferencing.URL == "https://zoom.us/j/123456" - }, - desc: "conferencing should be converted", - }, - { - name: "event without conferencing", - event: domain.Event{ - ID: "evt-006", - Title: "In-person Meeting", - Conferencing: nil, - }, - checkFunc: func(resp EventResponse) bool { - return resp.Conferencing == nil - }, - desc: "nil conferencing should remain nil", - }, - { - name: "event with html link", - event: domain.Event{ - ID: "evt-007", - Title: "External Event", - HtmlLink: "https://calendar.google.com/event/abc123", - }, - checkFunc: func(resp EventResponse) bool { - return resp.HtmlLink == "https://calendar.google.com/event/abc123" - }, - desc: "html link should be preserved", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp := eventToResponse(tt.event) - - if !tt.checkFunc(resp) { - t.Errorf("eventToResponse() %s: got %+v", tt.desc, resp) - } - }) - } -} - -// TestCachedEventToResponse_Extended tests cached event conversion. -func TestCachedEventToResponse_Extended(t *testing.T) { - t.Parallel() - - now := time.Now() - - tests := []struct { - name string - cached *cache.CachedEvent - checkFunc func(resp EventResponse) bool - desc string - }{ - { - name: "basic cached event", - cached: &cache.CachedEvent{ - ID: "cached-evt-001", - CalendarID: "cal-001", - Title: "Cached Meeting", - Description: "Description", - Location: "Room B", - StartTime: now, - EndTime: now.Add(1 * time.Hour), - AllDay: false, - Status: "confirmed", - Busy: true, - }, - checkFunc: func(resp EventResponse) bool { - return resp.ID == "cached-evt-001" && - resp.CalendarID == "cal-001" && - resp.Title == "Cached Meeting" && - resp.Location == "Room B" && - resp.IsAllDay == false && - resp.Busy == true - }, - desc: "all fields should be converted correctly", - }, - { - name: "all-day cached event", - cached: &cache.CachedEvent{ - ID: "cached-evt-002", - Title: "All Day Event", - AllDay: true, - StartTime: now, - EndTime: now.Add(24 * time.Hour), - }, - checkFunc: func(resp EventResponse) bool { - return resp.IsAllDay == true - }, - desc: "all-day flag should be preserved", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resp := cachedEventToResponse(tt.cached) - - if !tt.checkFunc(resp) { - t.Errorf("cachedEventToResponse() %s: got %+v", tt.desc, resp) - } - }) - } -} diff --git a/internal/air/handlers_rules_policy.go b/internal/air/handlers_rules_policy.go deleted file mode 100644 index bcae73e..0000000 --- a/internal/air/handlers_rules_policy.go +++ /dev/null @@ -1,317 +0,0 @@ -package air - -import ( - "context" - "errors" - "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 { - writeUpstreamError(w, http.StatusInternalServerError, "Failed to fetch default agent account", err) - return - } - - policyID, err := s.resolveAccountPolicyID(ctx, account) - if err != nil { - writeUpstreamError(w, http.StatusInternalServerError, "Failed to resolve workspace policy", err) - return - } - if policyID == "" { - writeJSON(w, http.StatusOK, PoliciesResponse{Policies: []domain.Policy{}}) - return - } - - policy, err := s.nylasClient.GetPolicy(ctx, policyID) - if err != nil { - // A workspace can reference a policy that has since been deleted; - // render that as "no policies" rather than an error. - if errors.Is(err, domain.ErrPolicyNotFound) { - writeJSON(w, http.StatusOK, PoliciesResponse{Policies: []domain.Policy{}}) - return - } - writeUpstreamError(w, http.StatusInternalServerError, "Failed to fetch policy", err) - 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 { - writeUpstreamError(w, http.StatusInternalServerError, "Failed to fetch default agent account", err) - return - } - - ruleIDs, err := s.resolveAccountRuleIDs(ctx, account) - if err != nil { - writeUpstreamError(w, http.StatusInternalServerError, "Failed to resolve workspace rules", err) - return - } - if len(ruleIDs) == 0 { - writeJSON(w, http.StatusOK, RulesResponse{Rules: []domain.Rule{}}) - return - } - - allRules, err := s.nylasClient.ListRules(ctx) - if err != nil { - writeUpstreamError(w, http.StatusInternalServerError, "Failed to fetch rules", err) - return - } - - ruleSet := make(map[string]struct{}, len(ruleIDs)) - for _, id := range ruleIDs { - ruleSet[id] = struct{}{} - } - - rules := make([]domain.Rule, 0, len(ruleIDs)) - for _, rule := range allRules { - if _, ok := ruleSet[rule.ID]; !ok { - continue - } - rules = append(rules, rule) - } - - writeJSON(w, http.StatusOK, RulesResponse{Rules: rules}) -} - -func (s *Server) handleAgentWorkspace(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodGet) { - return - } - if s.handleDemoMode(w, WorkspaceResponse{Workspace: demoWorkspace()}) { - 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 { - writeUpstreamError(w, http.StatusInternalServerError, "Failed to fetch default agent account", err) - return - } - - wsID := strings.TrimSpace(account.WorkspaceID) - if wsID == "" { - writeJSON(w, http.StatusOK, WorkspaceResponse{}) - return - } - - workspace, err := s.nylasClient.GetWorkspace(ctx, wsID) - if err != nil { - if errors.Is(err, domain.ErrWorkspaceNotFound) { - writeJSON(w, http.StatusOK, WorkspaceResponse{}) - return - } - writeUpstreamError(w, http.StatusInternalServerError, "Failed to fetch workspace", err) - return - } - - writeJSON(w, http.StatusOK, WorkspaceResponse{Workspace: workspace}) -} - -func (s *Server) handleAgentLists(w http.ResponseWriter, r *http.Request) { - if !requireMethod(w, r, http.MethodGet) { - return - } - if s.handleDemoMode(w, AgentListsResponse{Lists: demoAgentLists()}) { - 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() - - lists, err := s.nylasClient.ListLists(ctx) - if err != nil { - writeUpstreamError(w, http.StatusInternalServerError, "Failed to fetch lists", err) - return - } - if lists == nil { - lists = []domain.AgentList{} - } - - writeJSON(w, http.StatusOK, AgentListsResponse{Lists: lists}) -} - -func (s *Server) resolveAccountPolicyID(ctx context.Context, account *domain.AgentAccount) (string, error) { - if wsID := strings.TrimSpace(account.WorkspaceID); wsID != "" { - ws, err := s.nylasClient.GetWorkspace(ctx, wsID) - switch { - case errors.Is(err, domain.ErrWorkspaceNotFound): - // A deleted workspace must not break the endpoint; fall back to - // the policy reference stored on the account itself. - case err != nil: - return "", err - case ws != nil: - return strings.TrimSpace(ws.PolicyID), nil - } - } - return strings.TrimSpace(account.Settings.PolicyID), nil -} - -func (s *Server) resolveAccountRuleIDs(ctx context.Context, account *domain.AgentAccount) ([]string, error) { - if wsID := strings.TrimSpace(account.WorkspaceID); wsID != "" { - ws, err := s.nylasClient.GetWorkspace(ctx, wsID) - switch { - case errors.Is(err, domain.ErrWorkspaceNotFound): - // Deleted workspace: fall back to the account's policy rules. - case err != nil: - return nil, err - case ws != nil: - var ids []string - for _, id := range ws.RulesIDs { - if id = strings.TrimSpace(id); id != "" { - ids = append(ids, id) - } - } - return ids, nil - } - } - if policyID := strings.TrimSpace(account.Settings.PolicyID); policyID != "" { - policy, err := s.nylasClient.GetPolicy(ctx, policyID) - if err != nil { - if errors.Is(err, domain.ErrPolicyNotFound) { - return nil, nil - } - return nil, err - } - return policy.Rules, nil - } - return nil, nil -} - -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 demoWorkspace() *domain.Workspace { - return &domain.Workspace{ - ID: "workspace-demo", - Name: "Demo Tenant Workspace", - AutoGroup: true, - Default: true, - PolicyID: "policy-demo-default", - RulesIDs: []string{"rule-demo-inbound"}, - } -} - -func demoAgentLists() []domain.AgentList { - return []domain.AgentList{ - { - ID: "list-demo-domains", - Name: "Blocked domains", - Description: "Domains flagged by the demo inbound rule.", - Type: "domain", - ItemsCount: 2, - }, - } -} - -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 deleted file mode 100644 index 8e0c3e7..0000000 --- a/internal/air/handlers_rules_policy_test.go +++ /dev/null @@ -1,352 +0,0 @@ -package air - -import ( - "context" - "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") - } -} - -// policyNotFoundClient simulates a workspace whose policy_id references a -// deleted policy: GetPolicy returns ErrPolicyNotFound. -type policyNotFoundClient struct { - *nylasmock.MockClient -} - -func (c *policyNotFoundClient) GetPolicy(ctx context.Context, policyID string) (*domain.Policy, error) { - return nil, domain.ErrPolicyNotFound -} - -func TestHandleListPolicies_DanglingPolicyReturnsEmpty(t *testing.T) { - t.Parallel() - - server := newRulesPolicyTestServer(domain.ProviderNylas) - server.nylasClient = &policyNotFoundClient{MockClient: nylasmock.NewMockClient()} - - req := httptest.NewRequest(http.MethodGet, "/api/policies", nil) - w := httptest.NewRecorder() - - server.handleListPolicies(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("dangling policy_id should render as empty state, not an error: expected status 200, got %d (body: %s)", w.Code, w.Body.String()) - } - - 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.Fatalf("expected no policies for a dangling policy reference, got %d", len(resp.Policies)) - } -} - -// workspaceNotFoundClient simulates an agent account whose workspace was -// deleted: GetWorkspace returns ErrWorkspaceNotFound. -type workspaceNotFoundClient struct { - *nylasmock.MockClient -} - -func (c *workspaceNotFoundClient) GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) { - return nil, domain.ErrWorkspaceNotFound -} - -func TestHandleRulesPolicy_DanglingWorkspaceDegradesGracefully(t *testing.T) { - t.Parallel() - - server := newRulesPolicyTestServer(domain.ProviderNylas) - server.nylasClient = &workspaceNotFoundClient{MockClient: nylasmock.NewMockClient()} - - t.Run("policies fall back to empty state", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/policies", nil) - w := httptest.NewRecorder() - - server.handleListPolicies(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("a deleted workspace must not break the policies endpoint: expected status 200, got %d (body: %s)", w.Code, w.Body.String()) - } - }) - - t.Run("rules fall back to empty state", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/rules", nil) - w := httptest.NewRecorder() - - server.handleListRules(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("a deleted workspace must not break the rules endpoint: expected status 200, got %d (body: %s)", w.Code, w.Body.String()) - } - }) -} - -// policyErrorClient simulates a transient failure fetching the policy. -type policyErrorClient struct { - *nylasmock.MockClient -} - -func (c *policyErrorClient) GetPolicy(ctx context.Context, policyID string) (*domain.Policy, error) { - return nil, context.DeadlineExceeded -} - -func TestHandleListPolicies_PolicyFetchErrorStays500(t *testing.T) { - t.Parallel() - - server := newRulesPolicyTestServer(domain.ProviderNylas) - server.nylasClient = &policyErrorClient{MockClient: nylasmock.NewMockClient()} - - req := httptest.NewRequest(http.MethodGet, "/api/policies", nil) - w := httptest.NewRecorder() - - server.handleListPolicies(w, req) - - if w.Code != http.StatusInternalServerError { - t.Fatalf("only ErrPolicyNotFound may degrade to an empty state; other errors must surface: expected status 500, got %d (body: %s)", w.Code, w.Body.String()) - } -} - -func TestHandleAgentWorkspace_NylasProvider(t *testing.T) { - t.Parallel() - - server := newRulesPolicyTestServer(domain.ProviderNylas) - - req := httptest.NewRequest(http.MethodGet, "/api/workspace", nil) - w := httptest.NewRecorder() - - server.handleAgentWorkspace(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d (body: %s)", w.Code, w.Body.String()) - } - - var resp WorkspaceResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if resp.Workspace == nil { - t.Fatal("expected workspace to be populated") - } - if resp.Workspace.ID != "workspace-1" { - t.Fatalf("expected workspace-1, got %q", resp.Workspace.ID) - } - if resp.Workspace.PolicyID == "" { - t.Fatal("expected workspace policy ID to be populated") - } - if len(resp.Workspace.RulesIDs) == 0 { - t.Fatal("expected workspace rule IDs to be populated") - } -} - -func TestHandleAgentLists_NylasProvider(t *testing.T) { - t.Parallel() - - server := newRulesPolicyTestServer(domain.ProviderNylas) - - req := httptest.NewRequest(http.MethodGet, "/api/lists", nil) - w := httptest.NewRecorder() - - server.handleAgentLists(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d (body: %s)", w.Code, w.Body.String()) - } - - var resp AgentListsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode response: %v", err) - } - if len(resp.Lists) != 1 { - t.Fatalf("expected exactly one list, got %d", len(resp.Lists)) - } - if resp.Lists[0].ID != "list-1" { - t.Fatalf("expected list-1, got %q", resp.Lists[0].ID) - } - if resp.Lists[0].Type == "" { - t.Fatal("expected list type 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"}, - {name: "workspace", handler: server.handleAgentWorkspace, path: "/api/workspace"}, - {name: "lists", handler: server.handleAgentLists, path: "/api/lists"}, - } - - 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_scheduled_send.go b/internal/air/handlers_scheduled_send.go deleted file mode 100644 index b85d924..0000000 --- a/internal/air/handlers_scheduled_send.go +++ /dev/null @@ -1,174 +0,0 @@ -package air - -import ( - "net/http" - "strconv" - "time" -) - -// ============================================================================= -// Send Later / Scheduled Send Handlers -// ============================================================================= - -// handleScheduledSend handles scheduled message operations. -func (s *Server) handleScheduledSend(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.listScheduledMessages(w, r) - case http.MethodPost: - s.createScheduledMessage(w, r) - case http.MethodDelete: - s.cancelScheduledMessage(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// listScheduledMessages returns all scheduled messages. -func (s *Server) listScheduledMessages(w http.ResponseWriter, r *http.Request) { - // Special demo mode: return sample scheduled messages - if s.demoMode { - now := time.Now() - writeJSON(w, http.StatusOK, map[string]any{ - "scheduled": []map[string]any{ - { - "schedule_id": "demo-sched-1", - "status": "scheduled", - "send_at": now.Add(2 * time.Hour).Unix(), - "subject": "Follow-up on our meeting", - "to": []string{"colleague@example.com"}, - }, - { - "schedule_id": "demo-sched-2", - "status": "scheduled", - "send_at": now.Add(24 * time.Hour).Unix(), - "subject": "Weekly report", - "to": []string{"team@example.com"}, - }, - }, - }) - return - } - grantID := s.withAuthGrant(w, nil) // Demo mode already handled above - if grantID == "" { - return - } - - ctx, cancel := s.withTimeout(r) - defer cancel() - - scheduled, err := s.nylasClient.ListScheduledMessages(ctx, grantID) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "Failed to list scheduled messages: " + err.Error(), - }) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "scheduled": scheduled, - }) -} - -// createScheduledMessage schedules a message for later sending. -func (s *Server) createScheduledMessage(w http.ResponseWriter, r *http.Request) { - var req ScheduledSendRequest - if !parseJSONBody(w, r, &req) { - return - } - - // Determine send time - var sendAt int64 - if req.SendAt > 0 { - sendAt = req.SendAt - } else if req.SendAtNatural != "" { - parsed, err := parseNaturalDuration(req.SendAtNatural) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "Invalid send time: " + err.Error(), - }) - return - } - sendAt = parsed - } else { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "Send time required (send_at or send_at_natural)", - }) - return - } - - // Validate send time is in the future (at least 1 minute) and not so - // far in the future that we'd accept obvious garbage (year 9999) and - // queue infinitely. One year out is the documented Nylas API ceiling - // and matches the upstream send_at limit; anything beyond that is - // almost certainly a client bug or a hostile request. - now := time.Now() - if sendAt <= now.Add(time.Minute).Unix() { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "Send time must be at least 1 minute in the future", - }) - return - } - if sendAt > now.Add(366*24*time.Hour).Unix() { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "Send time must be within one year", - }) - return - } - - if s.demoMode { - writeJSON(w, http.StatusOK, ScheduledSendResponse{ - Success: true, - ScheduleID: "demo-" + strconv.FormatInt(time.Now().UnixNano(), 36), - SendAt: sendAt, - Message: "Demo mode: Message scheduled for " + time.Unix(sendAt, 0).Format("Mon Jan 2 3:04 PM"), - }) - return - } - - // For real implementation, use Nylas send with SendAt - writeJSON(w, http.StatusOK, ScheduledSendResponse{ - Success: true, - ScheduleID: "sched-" + strconv.FormatInt(time.Now().UnixNano(), 36), - SendAt: sendAt, - Message: "Message scheduled for " + time.Unix(sendAt, 0).Format("Mon Jan 2 3:04 PM"), - }) -} - -// cancelScheduledMessage cancels a scheduled message. -func (s *Server) cancelScheduledMessage(w http.ResponseWriter, r *http.Request) { - scheduleID := r.URL.Query().Get("schedule_id") - if scheduleID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Schedule ID required"}) - return - } - - // Special demo mode: return success response - if s.demoMode { - writeJSON(w, http.StatusOK, map[string]any{ - "success": true, - "schedule_id": scheduleID, - "message": "Demo mode: Scheduled message cancelled", - }) - return - } - grantID := s.withAuthGrant(w, nil) // Demo mode already handled above - if grantID == "" { - return - } - - ctx, cancel := s.withTimeout(r) - defer cancel() - - if err := s.nylasClient.CancelScheduledMessage(ctx, grantID, scheduleID); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "Failed to cancel: " + err.Error(), - }) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "success": true, - "schedule_id": scheduleID, - }) -} diff --git a/internal/air/handlers_scheduled_send_test.go b/internal/air/handlers_scheduled_send_test.go deleted file mode 100644 index 9b9b543..0000000 --- a/internal/air/handlers_scheduled_send_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -// TestCreateScheduledMessage_RejectsFarFuture pins the post-fix invariant: -// a send_at more than ~1 year in the future is refused. Without it, a -// client bug or hostile request could keep a message in the queue -// indefinitely (or trip Nylas API limits silently). -func TestCreateScheduledMessage_RejectsFarFuture(t *testing.T) { - server := &Server{demoMode: true} - - body, _ := json.Marshal(ScheduledSendRequest{ - SendAt: time.Now().Add(10 * 365 * 24 * time.Hour).Unix(), - To: []EmailParticipantResponse{{Email: "a@example.com"}}, - Subject: "ping", - Body: "hi", - }) - r := httptest.NewRequest(http.MethodPost, "/api/scheduled-send", bytes.NewReader(body)) - r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.createScheduledMessage(w, r) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } - if !bytes.Contains(w.Body.Bytes(), []byte("within one year")) { - t.Fatalf("expected 'within one year' message, got %s", w.Body.String()) - } -} - -func TestCreateScheduledMessage_AcceptsNearFuture(t *testing.T) { - server := &Server{demoMode: true} - - body, _ := json.Marshal(ScheduledSendRequest{ - SendAt: time.Now().Add(2 * time.Hour).Unix(), - To: []EmailParticipantResponse{{Email: "a@example.com"}}, - Subject: "ping", - Body: "hi", - }) - r := httptest.NewRequest(http.MethodPost, "/api/scheduled-send", bytes.NewReader(body)) - r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.createScheduledMessage(w, r) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) - } -} - -func TestCreateScheduledMessage_RejectsTooSoon(t *testing.T) { - server := &Server{demoMode: true} - - body, _ := json.Marshal(ScheduledSendRequest{ - SendAt: time.Now().Unix(), - To: []EmailParticipantResponse{{Email: "a@example.com"}}, - Subject: "ping", - Body: "hi", - }) - r := httptest.NewRequest(http.MethodPost, "/api/scheduled-send", bytes.NewReader(body)) - r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.createScheduledMessage(w, r) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for past time, got %d", w.Code) - } -} diff --git a/internal/air/handlers_screener.go b/internal/air/handlers_screener.go deleted file mode 100644 index d9c4722..0000000 --- a/internal/air/handlers_screener.go +++ /dev/null @@ -1,170 +0,0 @@ -package air - -import ( - "encoding/json" - "net/http" - "sync" - "time" - - "github.com/nylas/cli/internal/httputil" -) - -// ScreenedSender represents a sender pending approval -type ScreenedSender struct { - Email string `json:"email"` - Name string `json:"name,omitempty"` - Domain string `json:"domain"` - FirstSeen time.Time `json:"firstSeen"` - EmailCount int `json:"emailCount"` - SampleSubj string `json:"sampleSubject,omitempty"` - Status string `json:"status"` // pending, allowed, blocked - Destination string `json:"destination,omitempty"` // inbox, feed, paper_trail -} - -// ScreenerStore manages screened senders -type ScreenerStore struct { - senders map[string]*ScreenedSender - mu sync.RWMutex -} - -var screenerStore = &ScreenerStore{ - senders: make(map[string]*ScreenedSender), -} - -// handleGetScreenedSenders returns pending senders -func (s *Server) handleGetScreenedSenders(w http.ResponseWriter, r *http.Request) { - screenerStore.mu.RLock() - defer screenerStore.mu.RUnlock() - - status := r.URL.Query().Get("status") - if status == "" { - status = "pending" - } - - senders := make([]*ScreenedSender, 0) - for _, sender := range screenerStore.senders { - if sender.Status == status { - senders = append(senders, sender) - } - } - - httputil.WriteJSON(w, http.StatusOK, senders) -} - -// handleScreenerAllow allows a sender -func (s *Server) handleScreenerAllow(w http.ResponseWriter, r *http.Request) { - var req struct { - Email string `json:"email"` - Destination string `json:"destination"` // inbox, feed, paper_trail - } - - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.Destination == "" { - req.Destination = "inbox" - } - - screenerStore.mu.Lock() - defer screenerStore.mu.Unlock() - - if sender, ok := screenerStore.senders[req.Email]; ok { - sender.Status = "allowed" - sender.Destination = req.Destination - } else { - screenerStore.senders[req.Email] = &ScreenedSender{ - Email: req.Email, - Status: "allowed", - Destination: req.Destination, - FirstSeen: time.Now(), - } - } - - httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "allowed", "destination": req.Destination}) -} - -// handleScreenerBlock blocks a sender -func (s *Server) handleScreenerBlock(w http.ResponseWriter, r *http.Request) { - var req struct { - Email string `json:"email"` - } - - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - screenerStore.mu.Lock() - defer screenerStore.mu.Unlock() - - if sender, ok := screenerStore.senders[req.Email]; ok { - sender.Status = "blocked" - } else { - screenerStore.senders[req.Email] = &ScreenedSender{ - Email: req.Email, - Status: "blocked", - FirstSeen: time.Now(), - } - } - - httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "blocked"}) -} - -// handleAddToScreener adds a new sender for screening -func (s *Server) handleAddToScreener(w http.ResponseWriter, r *http.Request) { - var req struct { - Email string `json:"email"` - Name string `json:"name,omitempty"` - Subject string `json:"subject,omitempty"` - } - - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - domain := extractDomain(req.Email) - - screenerStore.mu.Lock() - defer screenerStore.mu.Unlock() - - if sender, ok := screenerStore.senders[req.Email]; ok { - sender.EmailCount++ - if req.Subject != "" { - sender.SampleSubj = req.Subject - } - } else { - screenerStore.senders[req.Email] = &ScreenedSender{ - Email: req.Email, - Name: req.Name, - Domain: domain, - FirstSeen: time.Now(), - EmailCount: 1, - SampleSubj: req.Subject, - Status: "pending", - } - } - - httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "pending"}) -} - -// IsSenderAllowed reports whether a sender has been explicitly allowed and, -// if so, the routing destination ("inbox", "feed", "paper_trail"). -// -// Only "allowed" senders pass — pending senders need screening, and blocked -// senders are rejected. Unknown senders default to needing screening too. -func IsSenderAllowed(email string) (bool, string) { - screenerStore.mu.RLock() - defer screenerStore.mu.RUnlock() - - sender, ok := screenerStore.senders[email] - if !ok { - return false, "" - } - if sender.Status == "allowed" { - return true, sender.Destination - } - return false, "" -} diff --git a/internal/air/handlers_screener_test.go b/internal/air/handlers_screener_test.go deleted file mode 100644 index 01d5bc8..0000000 --- a/internal/air/handlers_screener_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package air - -import ( - "sync" - "testing" - "time" -) - -// TestIsSenderAllowed_OnlyAllowedReturnsTrue is a regression test for the -// pre-fix logic where pending senders accidentally returned (true, "") — -// the screener would let unscreened mail leak into the inbox before the -// user had decided to allow them. -func TestIsSenderAllowed_OnlyAllowedReturnsTrue(t *testing.T) { - // Replace the package-level store so the test cannot leak across runs. - original := screenerStore - screenerStore = &ScreenerStore{senders: map[string]*ScreenedSender{ - "allowed@example.com": { - Email: "allowed@example.com", - Status: "allowed", - Destination: "inbox", - FirstSeen: time.Now(), - }, - "pending@example.com": { - Email: "pending@example.com", - Status: "pending", - FirstSeen: time.Now(), - }, - "blocked@example.com": { - Email: "blocked@example.com", - Status: "blocked", - FirstSeen: time.Now(), - }, - "feed@example.com": { - Email: "feed@example.com", - Status: "allowed", - Destination: "feed", - FirstSeen: time.Now(), - }, - }} - t.Cleanup(func() { screenerStore = original }) - - cases := []struct { - email string - wantAllowed bool - wantDestination string - }{ - {"allowed@example.com", true, "inbox"}, - {"feed@example.com", true, "feed"}, - {"pending@example.com", false, ""}, - {"blocked@example.com", false, ""}, - {"unknown@example.com", false, ""}, - } - - for _, tc := range cases { - t.Run(tc.email, func(t *testing.T) { - gotAllowed, gotDest := IsSenderAllowed(tc.email) - if gotAllowed != tc.wantAllowed { - t.Errorf("allowed: got %v, want %v", gotAllowed, tc.wantAllowed) - } - if gotDest != tc.wantDestination { - t.Errorf("destination: got %q, want %q", gotDest, tc.wantDestination) - } - }) - } -} - -// TestIsSenderAllowed_ConcurrentReads catches any future regression where the -// read path stops holding RLock — the data race detector will flag a writer -// running alongside the readers. -func TestIsSenderAllowed_ConcurrentReads(t *testing.T) { - original := screenerStore - screenerStore = &ScreenerStore{senders: map[string]*ScreenedSender{ - "a@example.com": {Email: "a@example.com", Status: "allowed", Destination: "inbox"}, - }} - t.Cleanup(func() { screenerStore = original }) - - var wg sync.WaitGroup - for range 32 { - wg.Go(func() { - for range 200 { - _, _ = IsSenderAllowed("a@example.com") - } - }) - } - wg.Wait() -} diff --git a/internal/air/handlers_snooze_handlers.go b/internal/air/handlers_snooze_handlers.go deleted file mode 100644 index 2386ff2..0000000 --- a/internal/air/handlers_snooze_handlers.go +++ /dev/null @@ -1,129 +0,0 @@ -package air - -import ( - "cmp" - "net/http" - "slices" - "time" -) - -// ============================================================================= -// Snooze HTTP Handlers -// ============================================================================= - -// handleSnooze handles snooze operations. -func (s *Server) handleSnooze(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.listSnoozedEmails(w, r) - case http.MethodPost: - s.snoozeEmail(w, r) - case http.MethodDelete: - s.unsnoozeEmail(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// listSnoozedEmails returns all snoozed emails. -func (s *Server) listSnoozedEmails(w http.ResponseWriter, _ *http.Request) { - s.snoozeMu.RLock() - snoozed := make([]SnoozedEmail, 0, len(s.snoozedEmails)) - now := time.Now().Unix() - for _, se := range s.snoozedEmails { - if se.SnoozeUntil > now { - snoozed = append(snoozed, se) - } - } - s.snoozeMu.RUnlock() - - // Sort by snooze time (soonest first) - slices.SortFunc(snoozed, func(a, b SnoozedEmail) int { - return cmp.Compare(a.SnoozeUntil, b.SnoozeUntil) - }) - - writeJSON(w, http.StatusOK, map[string]any{ - "snoozed": snoozed, - "count": len(snoozed), - }) -} - -// snoozeEmail snoozes an email until a specific time. -func (s *Server) snoozeEmail(w http.ResponseWriter, r *http.Request) { - var req SnoozeRequest - if !parseJSONBody(w, r, &req) { - return - } - - if req.EmailID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Email ID required"}) - return - } - - var snoozeUntil int64 - if req.SnoozeUntil > 0 { - snoozeUntil = req.SnoozeUntil - } else if req.Duration != "" { - parsed, err := parseNaturalDuration(req.Duration) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "Invalid duration: " + err.Error(), - }) - return - } - snoozeUntil = parsed - } else { - // Default: snooze for 1 hour - snoozeUntil = time.Now().Add(time.Hour).Unix() - } - - // Validate snooze time is in the future - if snoozeUntil <= time.Now().Unix() { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "Snooze time must be in the future", - }) - return - } - - snoozed := SnoozedEmail{ - EmailID: req.EmailID, - SnoozeUntil: snoozeUntil, - CreatedAt: time.Now().Unix(), - } - - func() { - s.snoozeMu.Lock() - defer s.snoozeMu.Unlock() - if s.snoozedEmails == nil { - s.snoozedEmails = make(map[string]SnoozedEmail) - } - s.snoozedEmails[req.EmailID] = snoozed - }() - - writeJSON(w, http.StatusOK, SnoozeResponse{ - Success: true, - EmailID: req.EmailID, - SnoozeUntil: snoozeUntil, - Message: "Email snoozed until " + time.Unix(snoozeUntil, 0).Format("Mon Jan 2 3:04 PM"), - }) -} - -// unsnoozeEmail removes the snooze from an email. -func (s *Server) unsnoozeEmail(w http.ResponseWriter, r *http.Request) { - emailID := r.URL.Query().Get("email_id") - if emailID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Email ID required"}) - return - } - - func() { - s.snoozeMu.Lock() - defer s.snoozeMu.Unlock() - delete(s.snoozedEmails, emailID) - }() - - writeJSON(w, http.StatusOK, map[string]any{ - "success": true, - "email_id": emailID, - }) -} diff --git a/internal/air/handlers_snooze_parser.go b/internal/air/handlers_snooze_parser.go deleted file mode 100644 index 981ebfe..0000000 --- a/internal/air/handlers_snooze_parser.go +++ /dev/null @@ -1,142 +0,0 @@ -package air - -import ( - "regexp" - "strconv" - "strings" - "time" -) - -// ============================================================================= -// Natural Language Duration Parser -// ============================================================================= - -// parseNaturalDuration parses natural language duration into Unix timestamp. -func parseNaturalDuration(input string) (int64, error) { - now := time.Now() - input = strings.ToLower(strings.TrimSpace(input)) - - // Handle relative durations: "1h", "2d", "30m", "1w" - if matched, _ := regexp.MatchString(`^\d+[hdwm]$`, input); matched { - num, _ := strconv.Atoi(input[:len(input)-1]) - unit := input[len(input)-1] - switch unit { - case 'h': - return now.Add(time.Duration(num) * time.Hour).Unix(), nil - case 'd': - return now.Add(time.Duration(num) * 24 * time.Hour).Unix(), nil - case 'w': - return now.Add(time.Duration(num) * 7 * 24 * time.Hour).Unix(), nil - case 'm': - return now.Add(time.Duration(num) * time.Minute).Unix(), nil - } - } - - // Handle "later today" (4 hours or 5 PM, whichever is first) - if input == "later" || input == "later today" { - later := now.Add(4 * time.Hour) - fivePM := time.Date(now.Year(), now.Month(), now.Day(), 17, 0, 0, 0, now.Location()) - if fivePM.After(now) && fivePM.Before(later) { - return fivePM.Unix(), nil - } - return later.Unix(), nil - } - - // Handle "tonight" (8 PM today) - if input == "tonight" { - tonight := time.Date(now.Year(), now.Month(), now.Day(), 20, 0, 0, 0, now.Location()) - if tonight.Before(now) { - tonight = tonight.Add(24 * time.Hour) - } - return tonight.Unix(), nil - } - - // Handle "tomorrow" (9 AM tomorrow) - if strings.HasPrefix(input, "tomorrow") { - tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 9, 0, 0, 0, now.Location()) - - // Check for time specification: "tomorrow 2pm", "tomorrow at 3:30" - parts := strings.Fields(input) - if len(parts) > 1 { - timeStr := parts[len(parts)-1] - if strings.HasPrefix(parts[1], "at") && len(parts) > 2 { - timeStr = parts[2] - } - if hour, min, ok := parseTimeString(timeStr); ok { - tomorrow = time.Date(now.Year(), now.Month(), now.Day()+1, hour, min, 0, 0, now.Location()) - } - } - return tomorrow.Unix(), nil - } - - // Handle "next week" (Monday 9 AM) - if input == "next week" || input == "monday" { - daysUntilMonday := (8 - int(now.Weekday())) % 7 - if daysUntilMonday == 0 { - daysUntilMonday = 7 - } - nextMonday := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 9, 0, 0, 0, now.Location()) - return nextMonday.Unix(), nil - } - - // Handle "this weekend" (Saturday 10 AM) - if input == "weekend" || input == "this weekend" || input == "saturday" { - daysUntilSaturday := (6 - int(now.Weekday()) + 7) % 7 - if daysUntilSaturday == 0 { - daysUntilSaturday = 7 - } - saturday := time.Date(now.Year(), now.Month(), now.Day()+daysUntilSaturday, 10, 0, 0, 0, now.Location()) - return saturday.Unix(), nil - } - - // Handle specific times: "9am", "14:30", "3:30pm" - if hour, min, ok := parseTimeString(input); ok { - target := time.Date(now.Year(), now.Month(), now.Day(), hour, min, 0, 0, now.Location()) - if target.Before(now) { - target = target.Add(24 * time.Hour) - } - return target.Unix(), nil - } - - return 0, &parseError{input: input} -} - -// parseTimeString parses time strings like "9am", "14:30", "3:30pm". -func parseTimeString(s string) (hour, min int, ok bool) { - s = strings.ToLower(strings.TrimSpace(s)) - - isPM := strings.HasSuffix(s, "pm") - isAM := strings.HasSuffix(s, "am") - s = strings.TrimSuffix(strings.TrimSuffix(s, "pm"), "am") - - parts := strings.Split(s, ":") - if len(parts) == 1 { - // Just hour: "9", "14" - h, err := strconv.Atoi(parts[0]) - if err != nil || h < 0 || h > 23 { - return 0, 0, false - } - hour = h - min = 0 - } else if len(parts) == 2 { - // Hour:min: "9:30", "14:00" - h, err1 := strconv.Atoi(parts[0]) - m, err2 := strconv.Atoi(parts[1]) - if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 { - return 0, 0, false - } - hour = h - min = m - } else { - return 0, 0, false - } - - // Handle AM/PM - if isPM && hour < 12 { - hour += 12 - } else if isAM && hour == 12 { - hour = 0 - } - - return hour, min, true -} diff --git a/internal/air/handlers_snooze_parser_test.go b/internal/air/handlers_snooze_parser_test.go deleted file mode 100644 index 1a5a82e..0000000 --- a/internal/air/handlers_snooze_parser_test.go +++ /dev/null @@ -1,468 +0,0 @@ -//go:build !integration - -package air - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseTimeString_Extended(t *testing.T) { - tests := []struct { - name string - input string - wantHour int - wantMin int - wantOK bool - }{ - // Simple hours - { - name: "simple hour 9", - input: "9", - wantHour: 9, - wantMin: 0, - wantOK: true, - }, - { - name: "24-hour format 14", - input: "14", - wantHour: 14, - wantMin: 0, - wantOK: true, - }, - { - name: "hour with am", - input: "9am", - wantHour: 9, - wantMin: 0, - wantOK: true, - }, - { - name: "hour with pm", - input: "3pm", - wantHour: 15, - wantMin: 0, - wantOK: true, - }, - { - name: "12pm is noon", - input: "12pm", - wantHour: 12, - wantMin: 0, - wantOK: true, - }, - { - name: "12am is midnight", - input: "12am", - wantHour: 0, - wantMin: 0, - wantOK: true, - }, - - // Hour:minute formats - { - name: "hour:minute 24h", - input: "14:30", - wantHour: 14, - wantMin: 30, - wantOK: true, - }, - { - name: "hour:minute with pm", - input: "3:30pm", - wantHour: 15, - wantMin: 30, - wantOK: true, - }, - { - name: "hour:minute with am", - input: "9:15am", - wantHour: 9, - wantMin: 15, - wantOK: true, - }, - { - name: "midnight with colon", - input: "0:00", - wantHour: 0, - wantMin: 0, - wantOK: true, - }, - { - name: "end of day", - input: "23:59", - wantHour: 23, - wantMin: 59, - wantOK: true, - }, - - // Edge cases - uppercase - { - name: "uppercase AM", - input: "9AM", - wantHour: 9, - wantMin: 0, - wantOK: true, - }, - { - name: "uppercase PM", - input: "3PM", - wantHour: 15, - wantMin: 0, - wantOK: true, - }, - - // Invalid inputs - { - name: "invalid - negative hour", - input: "-1", - wantOK: false, - }, - { - name: "invalid - hour too high", - input: "25", - wantOK: false, - }, - { - name: "invalid - minute too high", - input: "10:60", - wantOK: false, - }, - { - name: "invalid - not a number", - input: "abc", - wantOK: false, - }, - { - name: "invalid - too many colons", - input: "10:30:45", - wantOK: false, - }, - { - name: "invalid - empty string", - input: "", - wantOK: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - hour, min, ok := parseTimeString(tt.input) - - assert.Equal(t, tt.wantOK, ok, "ok mismatch") - if tt.wantOK { - assert.Equal(t, tt.wantHour, hour, "hour mismatch") - assert.Equal(t, tt.wantMin, min, "minute mismatch") - } - }) - } -} - -func TestParseNaturalDuration_RelativeDurations_Extended(t *testing.T) { - now := time.Now() - - tests := []struct { - name string - input string - expectedDelta time.Duration - tolerance time.Duration - }{ - { - name: "1 hour", - input: "1h", - expectedDelta: 1 * time.Hour, - tolerance: time.Second, - }, - { - name: "5 hours", - input: "5h", - expectedDelta: 5 * time.Hour, - tolerance: time.Second, - }, - { - name: "1 day", - input: "1d", - expectedDelta: 24 * time.Hour, - tolerance: time.Second, - }, - { - name: "3 days", - input: "3d", - expectedDelta: 72 * time.Hour, - tolerance: time.Second, - }, - { - name: "1 week", - input: "1w", - expectedDelta: 7 * 24 * time.Hour, - tolerance: time.Second, - }, - { - name: "30 minutes", - input: "30m", - expectedDelta: 30 * time.Minute, - tolerance: time.Second, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseNaturalDuration(tt.input) - - require.NoError(t, err) - - expected := now.Add(tt.expectedDelta).Unix() - diff := result - expected - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", expected, result, diff) - }) - } -} - -func TestParseNaturalDuration_SpecialKeywords_Extended(t *testing.T) { - now := time.Now() - - t.Run("later today", func(t *testing.T) { - result, err := parseNaturalDuration("later today") - require.NoError(t, err) - - // Should be 4 hours from now or 5 PM, whichever is first - fourHoursLater := now.Add(4 * time.Hour).Unix() - fivePM := time.Date(now.Year(), now.Month(), now.Day(), 17, 0, 0, 0, now.Location()).Unix() - - // Result should be one of these two - assert.True(t, result == fourHoursLater || result == fivePM || (result >= fivePM-1 && result <= fivePM+1), - "Expected around %d or %d, got %d", fourHoursLater, fivePM, result) - }) - - t.Run("later", func(t *testing.T) { - result, err := parseNaturalDuration("later") - require.NoError(t, err) - - // Same as "later today" - fourHoursLater := now.Add(4 * time.Hour).Unix() - fivePM := time.Date(now.Year(), now.Month(), now.Day(), 17, 0, 0, 0, now.Location()).Unix() - - assert.True(t, result == fourHoursLater || result == fivePM || (result >= fivePM-1 && result <= fivePM+1), - "Expected around %d or %d, got %d", fourHoursLater, fivePM, result) - }) - - t.Run("tonight", func(t *testing.T) { - result, err := parseNaturalDuration("tonight") - require.NoError(t, err) - - // Should be 8 PM today or tomorrow if past 8 PM - tonight := time.Date(now.Year(), now.Month(), now.Day(), 20, 0, 0, 0, now.Location()) - if tonight.Before(now) { - tonight = tonight.Add(24 * time.Hour) - } - - diff := result - tonight.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", tonight.Unix(), result, diff) - }) - - t.Run("tomorrow basic", func(t *testing.T) { - result, err := parseNaturalDuration("tomorrow") - require.NoError(t, err) - - // Should be 9 AM tomorrow - tomorrow9AM := time.Date(now.Year(), now.Month(), now.Day()+1, 9, 0, 0, 0, now.Location()) - - diff := result - tomorrow9AM.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", tomorrow9AM.Unix(), result, diff) - }) - - t.Run("tomorrow with time", func(t *testing.T) { - result, err := parseNaturalDuration("tomorrow 2pm") - require.NoError(t, err) - - // Should be 2 PM tomorrow - tomorrow2PM := time.Date(now.Year(), now.Month(), now.Day()+1, 14, 0, 0, 0, now.Location()) - - diff := result - tomorrow2PM.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", tomorrow2PM.Unix(), result, diff) - }) - - t.Run("tomorrow at time", func(t *testing.T) { - result, err := parseNaturalDuration("tomorrow at 3:30pm") - require.NoError(t, err) - - // Should be 3:30 PM tomorrow - tomorrowTime := time.Date(now.Year(), now.Month(), now.Day()+1, 15, 30, 0, 0, now.Location()) - - diff := result - tomorrowTime.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", tomorrowTime.Unix(), result, diff) - }) - - t.Run("next week", func(t *testing.T) { - result, err := parseNaturalDuration("next week") - require.NoError(t, err) - - // Should be Monday 9 AM - daysUntilMonday := (8 - int(now.Weekday())) % 7 - if daysUntilMonday == 0 { - daysUntilMonday = 7 - } - nextMonday := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 9, 0, 0, 0, now.Location()) - - diff := result - nextMonday.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", nextMonday.Unix(), result, diff) - }) - - t.Run("monday", func(t *testing.T) { - result, err := parseNaturalDuration("monday") - require.NoError(t, err) - - // Should be Monday 9 AM (same as next week) - daysUntilMonday := (8 - int(now.Weekday())) % 7 - if daysUntilMonday == 0 { - daysUntilMonday = 7 - } - nextMonday := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 9, 0, 0, 0, now.Location()) - - diff := result - nextMonday.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", nextMonday.Unix(), result, diff) - }) - - t.Run("weekend", func(t *testing.T) { - result, err := parseNaturalDuration("weekend") - require.NoError(t, err) - - // Should be Saturday 10 AM - daysUntilSaturday := (6 - int(now.Weekday()) + 7) % 7 - if daysUntilSaturday == 0 { - daysUntilSaturday = 7 - } - saturday := time.Date(now.Year(), now.Month(), now.Day()+daysUntilSaturday, 10, 0, 0, 0, now.Location()) - - diff := result - saturday.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", saturday.Unix(), result, diff) - }) - - t.Run("this weekend", func(t *testing.T) { - result, err := parseNaturalDuration("this weekend") - require.NoError(t, err) - - // Should be Saturday 10 AM - daysUntilSaturday := (6 - int(now.Weekday()) + 7) % 7 - if daysUntilSaturday == 0 { - daysUntilSaturday = 7 - } - saturday := time.Date(now.Year(), now.Month(), now.Day()+daysUntilSaturday, 10, 0, 0, 0, now.Location()) - - diff := result - saturday.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", saturday.Unix(), result, diff) - }) - - t.Run("saturday", func(t *testing.T) { - result, err := parseNaturalDuration("saturday") - require.NoError(t, err) - - // Should be Saturday 10 AM - daysUntilSaturday := (6 - int(now.Weekday()) + 7) % 7 - if daysUntilSaturday == 0 { - daysUntilSaturday = 7 - } - saturday := time.Date(now.Year(), now.Month(), now.Day()+daysUntilSaturday, 10, 0, 0, 0, now.Location()) - - diff := result - saturday.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", saturday.Unix(), result, diff) - }) -} - -func TestParseNaturalDuration_SpecificTimes_Extended(t *testing.T) { - now := time.Now() - - t.Run("specific time 9am", func(t *testing.T) { - result, err := parseNaturalDuration("9am") - require.NoError(t, err) - - // Should be 9 AM today or tomorrow - target := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, now.Location()) - if target.Before(now) { - target = target.Add(24 * time.Hour) - } - - diff := result - target.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", target.Unix(), result, diff) - }) - - t.Run("specific time 14:30", func(t *testing.T) { - result, err := parseNaturalDuration("14:30") - require.NoError(t, err) - - // Should be 2:30 PM today or tomorrow - target := time.Date(now.Year(), now.Month(), now.Day(), 14, 30, 0, 0, now.Location()) - if target.Before(now) { - target = target.Add(24 * time.Hour) - } - - diff := result - target.Unix() - assert.True(t, diff >= -1 && diff <= 1, - "Expected around %d, got %d (diff: %d)", target.Unix(), result, diff) - }) -} - -func TestParseNaturalDuration_Errors_Extended(t *testing.T) { - tests := []struct { - name string - input string - }{ - { - name: "invalid input", - input: "invalid", - }, - { - name: "random text", - input: "some random text", - }, - { - name: "partial match", - input: "in 5", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := parseNaturalDuration(tt.input) - assert.Error(t, err) - }) - } -} - -func TestParseNaturalDuration_CaseInsensitive_Extended(t *testing.T) { - now := time.Now() - - tests := []struct { - name string - input string - }{ - {"uppercase TOMORROW", "TOMORROW"}, - {"mixed case Tomorrow", "Tomorrow"}, - {"uppercase LATER", "LATER"}, - {"uppercase TONIGHT", "TONIGHT"}, - {"uppercase 1H", "1H"}, - {"uppercase 1D", "1D"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseNaturalDuration(tt.input) - require.NoError(t, err) - assert.True(t, result > now.Unix(), "Expected future timestamp") - }) - } -} diff --git a/internal/air/handlers_snooze_test.go b/internal/air/handlers_snooze_test.go deleted file mode 100644 index 96ad1c7..0000000 --- a/internal/air/handlers_snooze_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -func TestHandleSnooze_List(t *testing.T) { - t.Parallel() - server := &Server{ - demoMode: true, - snoozedEmails: make(map[string]SnoozedEmail), - } - - // Add a snoozed email - server.snoozedEmails["test-123"] = SnoozedEmail{ - EmailID: "test-123", - SnoozeUntil: time.Now().Add(time.Hour).Unix(), - CreatedAt: time.Now().Unix(), - } - - req := httptest.NewRequest(http.MethodGet, "/api/snooze", nil) - w := httptest.NewRecorder() - - server.handleSnooze(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - count := int(resp["count"].(float64)) - if count != 1 { - t.Errorf("expected 1 snoozed email, got %d", count) - } -} - -func TestHandleSnooze_Create(t *testing.T) { - t.Parallel() - server := &Server{ - demoMode: true, - snoozedEmails: make(map[string]SnoozedEmail), - } - - body, _ := json.Marshal(SnoozeRequest{ - EmailID: "test-456", - Duration: "2h", - }) - req := httptest.NewRequest(http.MethodPost, "/api/snooze", bytes.NewReader(body)) - w := httptest.NewRecorder() - - server.handleSnooze(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp SnoozeResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Error("expected success to be true") - } - if resp.EmailID != "test-456" { - t.Errorf("expected email ID test-456, got %s", resp.EmailID) - } - - // Verify snooze was stored - if _, exists := server.snoozedEmails["test-456"]; !exists { - t.Error("expected email to be in snoozed list") - } -} - -func TestHandleSnooze_CreateWithTimestamp(t *testing.T) { - t.Parallel() - server := &Server{ - demoMode: true, - snoozedEmails: make(map[string]SnoozedEmail), - } - - futureTime := time.Now().Add(3 * time.Hour).Unix() - body, _ := json.Marshal(SnoozeRequest{ - EmailID: "test-789", - SnoozeUntil: futureTime, - }) - req := httptest.NewRequest(http.MethodPost, "/api/snooze", bytes.NewReader(body)) - w := httptest.NewRecorder() - - server.handleSnooze(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp SnoozeResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.SnoozeUntil != futureTime { - t.Errorf("expected snooze until %d, got %d", futureTime, resp.SnoozeUntil) - } -} - -func TestHandleSnooze_Delete(t *testing.T) { - t.Parallel() - server := &Server{ - demoMode: true, - snoozedEmails: make(map[string]SnoozedEmail), - } - - // Add a snoozed email - server.snoozedEmails["test-123"] = SnoozedEmail{ - EmailID: "test-123", - SnoozeUntil: time.Now().Add(time.Hour).Unix(), - } - - req := httptest.NewRequest(http.MethodDelete, "/api/snooze?email_id=test-123", nil) - w := httptest.NewRecorder() - - server.handleSnooze(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - if _, exists := server.snoozedEmails["test-123"]; exists { - t.Error("expected email to be removed from snoozed list") - } -} - -func TestHandleSnooze_PastTime(t *testing.T) { - t.Parallel() - server := &Server{ - demoMode: true, - snoozedEmails: make(map[string]SnoozedEmail), - } - - pastTime := time.Now().Add(-time.Hour).Unix() - body, _ := json.Marshal(SnoozeRequest{ - EmailID: "test-123", - SnoozeUntil: pastTime, - }) - req := httptest.NewRequest(http.MethodPost, "/api/snooze", bytes.NewReader(body)) - w := httptest.NewRecorder() - - server.handleSnooze(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400 for past snooze time, got %d", w.Code) - } -} - -func TestParseNaturalDuration(t *testing.T) { - t.Parallel() - - now := time.Now() - - tests := []struct { - input string - wantErr bool - checkFn func(int64) bool - }{ - {"1h", false, func(ts int64) bool { return ts > now.Unix() && ts <= now.Add(2*time.Hour).Unix() }}, - {"2d", false, func(ts int64) bool { return ts > now.Add(24*time.Hour).Unix() }}, - {"30m", false, func(ts int64) bool { return ts > now.Unix() && ts <= now.Add(time.Hour).Unix() }}, - {"tomorrow", false, func(ts int64) bool { return ts > now.Unix() }}, - // "next week" returns next Monday 9 AM - could be < 24h away on Sunday - {"next week", false, func(ts int64) bool { - result := time.Unix(ts, 0) - // Should be a Monday at 9 AM, in the future - return result.After(now) && result.Weekday() == time.Monday && result.Hour() == 9 - }}, - {"weekend", false, func(ts int64) bool { return ts > now.Unix() }}, - {"later", false, func(ts int64) bool { return ts > now.Unix() }}, - {"9am", false, func(ts int64) bool { return ts > now.Unix() }}, - {"14:30", false, func(ts int64) bool { return ts > now.Unix() }}, - {"invalid_duration", true, nil}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result, err := parseNaturalDuration(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("parseNaturalDuration(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) - return - } - if !tt.wantErr && tt.checkFn != nil && !tt.checkFn(result) { - t.Errorf("parseNaturalDuration(%q) = %d, time check failed", tt.input, result) - } - }) - } -} - -func TestParseTimeString(t *testing.T) { - t.Parallel() - - tests := []struct { - input string - wantHour int - wantMin int - wantOK bool - }{ - {"9am", 9, 0, true}, - {"9pm", 21, 0, true}, - {"12pm", 12, 0, true}, - {"12am", 0, 0, true}, - {"14:30", 14, 30, true}, - {"2:30pm", 14, 30, true}, - {"9:00", 9, 0, true}, - {"25:00", 0, 0, false}, // Invalid hour - {"12:60", 0, 0, false}, // Invalid minute - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - hour, min, ok := parseTimeString(tt.input) - if ok != tt.wantOK { - t.Errorf("parseTimeString(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) - return - } - if ok && (hour != tt.wantHour || min != tt.wantMin) { - t.Errorf("parseTimeString(%q) = %d:%02d, want %d:%02d", tt.input, hour, min, tt.wantHour, tt.wantMin) - } - }) - } -} - -func TestHandleSnooze_MethodNotAllowed(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodPut, "/api/snooze", nil) - w := httptest.NewRecorder() - - server.handleSnooze(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -// ============================================================================= -// Frontend Filter Workflow Tests -// These tests simulate what the frontend JavaScript does to verify the API -// contracts are correct and the filter functionality works end-to-end. -// ============================================================================= - -// TestFilterWorkflow_VIPFilter tests the complete VIP filter workflow as the frontend uses it diff --git a/internal/air/handlers_snooze_types.go b/internal/air/handlers_snooze_types.go deleted file mode 100644 index 29d90aa..0000000 --- a/internal/air/handlers_snooze_types.go +++ /dev/null @@ -1,37 +0,0 @@ -package air - -// ============================================================================= -// Snooze Types -// ============================================================================= - -// SnoozedEmail represents a snoozed email. -type SnoozedEmail struct { - EmailID string `json:"email_id"` - SnoozeUntil int64 `json:"snooze_until"` // Unix timestamp - OriginalFolder string `json:"original_folder,omitempty"` - CreatedAt int64 `json:"created_at"` -} - -// SnoozeRequest represents a request to snooze an email. -type SnoozeRequest struct { - EmailID string `json:"email_id"` - SnoozeUntil int64 `json:"snooze_until,omitempty"` // Explicit Unix timestamp - Duration string `json:"duration,omitempty"` // Natural language: "1h", "2d", "tomorrow 9am" -} - -// SnoozeResponse represents a snooze operation response. -type SnoozeResponse struct { - Success bool `json:"success"` - EmailID string `json:"email_id"` - SnoozeUntil int64 `json:"snooze_until"` - Message string `json:"message,omitempty"` -} - -// parseError represents a parsing error for duration strings. -type parseError struct { - input string -} - -func (e *parseError) Error() string { - return "cannot parse duration: " + e.input -} diff --git a/internal/air/handlers_splitinbox_categorize.go b/internal/air/handlers_splitinbox_categorize.go deleted file mode 100644 index d508b86..0000000 --- a/internal/air/handlers_splitinbox_categorize.go +++ /dev/null @@ -1,149 +0,0 @@ -package air - -import ( - "cmp" - "encoding/json" - "net/http" - "regexp" - "slices" - "strings" - "time" -) - -// ============================================================================= -// Email Categorization -// ============================================================================= - -// handleCategorizeEmail categorizes a single email. -func (s *Server) handleCategorizeEmail(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - EmailID string `json:"email_id"` - From string `json:"from"` - Subject string `json:"subject"` - Headers map[string]string `json:"headers,omitempty"` - } - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request"}) - return - } - - category, rule := s.categorizeEmail(req.From, req.Subject, req.Headers) - - writeJSON(w, http.StatusOK, CategorizedEmail{ - EmailID: req.EmailID, - Category: category, - MatchedRule: rule, - CategorizedAt: time.Now().Unix(), - }) -} - -// categorizeEmail determines the category for an email. -func (s *Server) categorizeEmail(from, subject string, headers map[string]string) (InboxCategory, string) { - config := s.getOrCreateSplitInboxConfig() - fromLower := strings.ToLower(from) - subjectLower := strings.ToLower(subject) - - // Check VIP first (highest priority) - for _, vip := range config.VIPSenders { - if strings.Contains(fromLower, strings.ToLower(vip)) { - return CategoryVIP, "vip:" + vip - } - } - - // Check custom rules (sorted by priority descending) - rules := make([]CategoryRule, len(config.Rules)) - copy(rules, config.Rules) - slices.SortFunc(rules, func(a, b CategoryRule) int { - return cmp.Compare(b.Priority, a.Priority) // descending - }) - - for _, rule := range rules { - if s.matchesRule(rule, fromLower, subjectLower, headers) { - return rule.Category, rule.ID - } - } - - // Check default patterns - if category, rule := s.matchDefaultPatterns(fromLower, subjectLower, headers); category != "" { - return category, rule - } - - return CategoryPrimary, "default" -} - -// matchesRule checks if an email matches a category rule. -func (s *Server) matchesRule(rule CategoryRule, from, subject string, headers map[string]string) bool { - var target string - switch rule.Type { - case "sender": - target = from - case "subject": - target = subject - case "domain": - // Extract domain from email - if idx := strings.Index(from, "@"); idx >= 0 { - target = from[idx:] - } - case "header": - // Check specific header - for k, v := range headers { - if strings.EqualFold(k, rule.Pattern) { - target = v - break - } - } - default: - target = from + " " + subject - } - - if rule.IsRegex { - if re, err := regexp.Compile(rule.Pattern); err == nil { - return re.MatchString(target) - } - return false - } - return strings.Contains(target, strings.ToLower(rule.Pattern)) -} - -// matchDefaultPatterns checks against default category patterns. -func (s *Server) matchDefaultPatterns(from, subject string, headers map[string]string) (InboxCategory, string) { - // Check social FIRST (specific domains take priority over generic patterns) - for _, pattern := range defaultSocialPatterns { - if strings.Contains(from, pattern) { - return CategorySocial, "pattern:social:" + pattern - } - } - - // Check for list-unsubscribe header (strong newsletter signal) - if _, ok := headers["List-Unsubscribe"]; ok { - return CategoryNewsletters, "header:list-unsubscribe" - } - - // Check newsletters - for _, pattern := range defaultNewsletterPatterns { - if strings.Contains(from, pattern) || strings.Contains(subject, pattern) { - return CategoryNewsletters, "pattern:newsletter:" + pattern - } - } - - // Check promotions - for _, pattern := range defaultPromotionPatterns { - if strings.Contains(from, pattern) { - return CategoryPromotions, "pattern:promo:" + pattern - } - } - - // Check updates (transactional) - for _, pattern := range defaultUpdatePatterns { - if strings.Contains(from, pattern) { - return CategoryUpdates, "pattern:update:" + pattern - } - } - - return "", "" -} diff --git a/internal/air/handlers_splitinbox_config.go b/internal/air/handlers_splitinbox_config.go deleted file mode 100644 index 18f3bf2..0000000 --- a/internal/air/handlers_splitinbox_config.go +++ /dev/null @@ -1,166 +0,0 @@ -package air - -import ( - "encoding/json" - "net/http" - "strings" -) - -// ============================================================================= -// Split Inbox Config & VIP Management -// ============================================================================= - -// handleSplitInbox handles split inbox configuration and retrieval. -func (s *Server) handleSplitInbox(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.getSplitInboxConfig(w, r) - case http.MethodPut: - s.updateSplitInboxConfig(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// getSplitInboxConfig returns the split inbox configuration. -func (s *Server) getSplitInboxConfig(w http.ResponseWriter, _ *http.Request) { - config := s.getOrCreateSplitInboxConfig() - - // Count emails per category (demo mode or from cache) - counts := make(map[InboxCategory]int) - counts[CategoryPrimary] = 50 - counts[CategoryVIP] = 5 - counts[CategoryNewsletters] = 20 - counts[CategoryUpdates] = 15 - counts[CategorySocial] = 8 - counts[CategoryPromotions] = 12 - - writeJSON(w, http.StatusOK, SplitInboxResponse{ - Config: config, - Categories: counts, - }) -} - -// updateSplitInboxConfig updates the split inbox configuration. -func (s *Server) updateSplitInboxConfig(w http.ResponseWriter, r *http.Request) { - var config SplitInboxConfig - if err := json.NewDecoder(limitedBody(w, r)).Decode(&config); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "Invalid request body", - }) - return - } - - func() { - s.splitInboxMu.Lock() - defer s.splitInboxMu.Unlock() - s.splitInboxConfig = &config - }() - - writeJSON(w, http.StatusOK, map[string]any{ - "success": true, - "config": config, - }) -} - -// getOrCreateSplitInboxConfig returns the current split inbox config. -func (s *Server) getOrCreateSplitInboxConfig() SplitInboxConfig { - s.splitInboxMu.RLock() - if s.splitInboxConfig != nil { - config := *s.splitInboxConfig - s.splitInboxMu.RUnlock() - return config - } - s.splitInboxMu.RUnlock() - - // Create default config - return SplitInboxConfig{ - Enabled: true, - Categories: []InboxCategory{ - CategoryPrimary, CategoryVIP, CategoryNewsletters, - CategoryUpdates, CategorySocial, CategoryPromotions, - }, - VIPSenders: []string{}, - Rules: []CategoryRule{}, - } -} - -// ============================================================================= -// VIP Sender Management -// ============================================================================= - -// handleVIPSenders manages VIP sender list. -func (s *Server) handleVIPSenders(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - config := s.getOrCreateSplitInboxConfig() - writeJSON(w, http.StatusOK, map[string]any{ - "vip_senders": config.VIPSenders, - }) - case http.MethodPost: - var req struct { - Email string `json:"email"` - } - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request"}) - return - } - s.addVIPSender(req.Email) - writeJSON(w, http.StatusOK, map[string]any{"success": true, "email": req.Email}) - case http.MethodDelete: - email := r.URL.Query().Get("email") - if email == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Email required"}) - return - } - s.removeVIPSender(email) - writeJSON(w, http.StatusOK, map[string]any{"success": true, "email": email}) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// addVIPSender adds an email to the VIP list. -func (s *Server) addVIPSender(email string) { - s.splitInboxMu.Lock() - defer s.splitInboxMu.Unlock() - - if s.splitInboxConfig == nil { - // Create default config inline to avoid deadlock - s.splitInboxConfig = &SplitInboxConfig{ - Enabled: true, - Categories: []InboxCategory{ - CategoryPrimary, CategoryVIP, CategoryNewsletters, - CategoryUpdates, CategorySocial, CategoryPromotions, - }, - VIPSenders: []string{}, - Rules: []CategoryRule{}, - } - } - - // Check if already exists - for _, vip := range s.splitInboxConfig.VIPSenders { - if strings.EqualFold(vip, email) { - return - } - } - s.splitInboxConfig.VIPSenders = append(s.splitInboxConfig.VIPSenders, email) -} - -// removeVIPSender removes an email from the VIP list. -func (s *Server) removeVIPSender(email string) { - s.splitInboxMu.Lock() - defer s.splitInboxMu.Unlock() - - if s.splitInboxConfig == nil { - return - } - - filtered := make([]string, 0, len(s.splitInboxConfig.VIPSenders)) - for _, vip := range s.splitInboxConfig.VIPSenders { - if !strings.EqualFold(vip, email) { - filtered = append(filtered, vip) - } - } - s.splitInboxConfig.VIPSenders = filtered -} diff --git a/internal/air/handlers_splitinbox_test.go b/internal/air/handlers_splitinbox_test.go deleted file mode 100644 index 63297e4..0000000 --- a/internal/air/handlers_splitinbox_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestHandleSplitInbox_Get(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodGet, "/api/inbox/split", nil) - w := httptest.NewRecorder() - - server.handleSplitInbox(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp SplitInboxResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Config.Enabled { - t.Error("expected split inbox to be enabled by default") - } - if len(resp.Config.Categories) == 0 { - t.Error("expected default categories to be set") - } - if len(resp.Categories) == 0 { - t.Error("expected category counts to be returned") - } -} - -func TestHandleSplitInbox_Put(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - config := SplitInboxConfig{ - Enabled: true, - Categories: []InboxCategory{CategoryPrimary, CategoryVIP}, - VIPSenders: []string{"boss@company.com"}, - } - body, _ := json.Marshal(config) - - req := httptest.NewRequest(http.MethodPut, "/api/inbox/split", bytes.NewReader(body)) - w := httptest.NewRecorder() - - server.handleSplitInbox(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp["success"] != true { - t.Error("expected success to be true") - } -} - -func TestHandleSplitInbox_MethodNotAllowed(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodDelete, "/api/inbox/split", nil) - w := httptest.NewRecorder() - - server.handleSplitInbox(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestHandleCategorizeEmail(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - testCases := []struct { - name string - from string - subject string - wantCat InboxCategory - }{ - {"newsletter", "newsletter@example.com", "Weekly Update", CategoryNewsletters}, - {"social", "notifications@linkedin.com", "New connection", CategorySocial}, - {"updates", "receipt@stripe.com", "Payment received", CategoryUpdates}, - {"promotions", "deals@store.com", "50% off sale", CategoryPromotions}, - {"primary", "friend@gmail.com", "Hey there!", CategoryPrimary}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - body, _ := json.Marshal(map[string]string{ - "email_id": "test-123", - "from": tc.from, - "subject": tc.subject, - }) - req := httptest.NewRequest(http.MethodPost, "/api/inbox/categorize", bytes.NewReader(body)) - w := httptest.NewRecorder() - - server.handleCategorizeEmail(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp CategorizedEmail - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Category != tc.wantCat { - t.Errorf("expected category %s, got %s", tc.wantCat, resp.Category) - } - }) - } -} - -func TestHandleCategorizeEmail_MethodNotAllowed(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodGet, "/api/inbox/categorize", nil) - w := httptest.NewRecorder() - - server.handleCategorizeEmail(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} diff --git a/internal/air/handlers_splitinbox_types.go b/internal/air/handlers_splitinbox_types.go deleted file mode 100644 index 3f37f9c..0000000 --- a/internal/air/handlers_splitinbox_types.go +++ /dev/null @@ -1,84 +0,0 @@ -package air - -// ============================================================================= -// Split Inbox Types & Constants -// ============================================================================= - -// InboxCategory represents an email category for split inbox. -type InboxCategory string - -const ( - CategoryPrimary InboxCategory = "primary" - CategoryVIP InboxCategory = "vip" - CategoryNewsletters InboxCategory = "newsletters" - CategoryUpdates InboxCategory = "updates" - CategorySocial InboxCategory = "social" - CategoryPromotions InboxCategory = "promotions" - CategoryForums InboxCategory = "forums" -) - -// CategoryRule defines a rule for categorizing emails. -type CategoryRule struct { - ID string `json:"id"` - Category InboxCategory `json:"category"` - Type string `json:"type"` // "sender", "domain", "subject", "header" - Pattern string `json:"pattern"` - IsRegex bool `json:"is_regex"` - Priority int `json:"priority"` // Higher priority rules are checked first - Description string `json:"description,omitempty"` - CreatedAt int64 `json:"created_at"` -} - -// CategorizedEmail represents an email with its category. -type CategorizedEmail struct { - EmailID string `json:"email_id"` - Category InboxCategory `json:"category"` - MatchedRule string `json:"matched_rule,omitempty"` - CategorizedAt int64 `json:"categorized_at"` -} - -// SplitInboxConfig holds the split inbox configuration. -type SplitInboxConfig struct { - Enabled bool `json:"enabled"` - Categories []InboxCategory `json:"categories"` - VIPSenders []string `json:"vip_senders"` // Email addresses marked as VIP - Rules []CategoryRule `json:"rules"` -} - -// SplitInboxResponse represents the split inbox API response. -type SplitInboxResponse struct { - Config SplitInboxConfig `json:"config"` - Categories map[InboxCategory]int `json:"category_counts"` - Recent map[InboxCategory][]EmailResponse `json:"recent,omitempty"` -} - -// ============================================================================= -// Default Categorization Patterns -// ============================================================================= - -// Default newsletter patterns. -var defaultNewsletterPatterns = []string{ - "noreply@", "newsletter@", "updates@", "digest@", "news@", - "notifications@", "mailer@", "info@", "no-reply@", - "unsubscribe", "list-unsubscribe", -} - -// Default social patterns. -var defaultSocialPatterns = []string{ - "@facebook.com", "@twitter.com", "@x.com", "@linkedin.com", - "@instagram.com", "@tiktok.com", "@pinterest.com", - "facebookmail.com", "linkedin.com", -} - -// Default promotion patterns. -var defaultPromotionPatterns = []string{ - "deals@", "offers@", "promo@", "sale@", "discount@", - "marketing@", "promotions@", "special@", -} - -// Default update patterns (transactional). -var defaultUpdatePatterns = []string{ - "order@", "receipt@", "shipping@", "delivery@", "tracking@", - "confirmation@", "booking@", "reservation@", "invoice@", - "payment@", "billing@", "account@", "security@", "alert@", -} diff --git a/internal/air/handlers_templates.go b/internal/air/handlers_templates.go deleted file mode 100644 index c7a6165..0000000 --- a/internal/air/handlers_templates.go +++ /dev/null @@ -1,266 +0,0 @@ -package air - -import ( - "cmp" - "encoding/json" - "net/http" - "slices" - "strconv" - "strings" - "time" -) - -// ============================================================================= -// Email Templates Handlers -// ============================================================================= - -// handleTemplates handles template CRUD operations. -func (s *Server) handleTemplates(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.listTemplates(w, r) - case http.MethodPost: - s.createTemplate(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleTemplateByID handles single template operations. -func (s *Server) handleTemplateByID(w http.ResponseWriter, r *http.Request) { - // Parse template ID from path: /api/templates/{id} - path := strings.TrimPrefix(r.URL.Path, "/api/templates/") - parts := strings.Split(path, "/") - if len(parts) == 0 || parts[0] == "" { - http.Error(w, "Template ID required", http.StatusBadRequest) - return - } - templateID := parts[0] - - // Handle /api/templates/{id}/expand - if len(parts) > 1 && parts[1] == "expand" { - s.expandTemplate(w, r, templateID) - return - } - - switch r.Method { - case http.MethodGet: - s.getTemplate(w, r, templateID) - case http.MethodPut: - s.updateTemplate(w, r, templateID) - case http.MethodDelete: - s.deleteTemplate(w, r, templateID) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// listTemplates returns all templates. -func (s *Server) listTemplates(w http.ResponseWriter, r *http.Request) { - category := r.URL.Query().Get("category") - - s.templatesMu.RLock() - templates := make([]EmailTemplate, 0, len(s.emailTemplates)) - for _, t := range s.emailTemplates { - if category == "" || t.Category == category { - templates = append(templates, t) - } - } - s.templatesMu.RUnlock() - - // Sort by usage count (most used first), then by name - slices.SortFunc(templates, func(a, b EmailTemplate) int { - if a.UsageCount != b.UsageCount { - return cmp.Compare(b.UsageCount, a.UsageCount) // descending - } - return cmp.Compare(a.Name, b.Name) // ascending - }) - - // Add default templates if none exist - if len(templates) == 0 { - templates = defaultTemplates() - } - - writeJSON(w, http.StatusOK, TemplateListResponse{ - Templates: templates, - Total: len(templates), - }) -} - -// createTemplate creates a new template. -func (s *Server) createTemplate(w http.ResponseWriter, r *http.Request) { - var template EmailTemplate - if err := json.NewDecoder(limitedBody(w, r)).Decode(&template); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request"}) - return - } - - if template.Name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Template name required"}) - return - } - if template.Body == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Template body required"}) - return - } - - // Generate ID - template.ID = "tmpl-" + strconv.FormatInt(time.Now().UnixNano(), 36) - template.CreatedAt = time.Now().Unix() - template.UpdatedAt = template.CreatedAt - template.UsageCount = 0 - - // Extract variables from both body and subject, then deduplicate - allVars := extractTemplateVariables(template.Body) - if template.Subject != "" { - allVars = append(allVars, extractTemplateVariables(template.Subject)...) - } - template.Variables = deduplicateStrings(allVars) - - func() { - s.templatesMu.Lock() - defer s.templatesMu.Unlock() - if s.emailTemplates == nil { - s.emailTemplates = make(map[string]EmailTemplate) - } - s.emailTemplates[template.ID] = template - }() - - writeJSON(w, http.StatusCreated, template) -} - -// getTemplate returns a single template. -func (s *Server) getTemplate(w http.ResponseWriter, _ *http.Request, templateID string) { - s.templatesMu.RLock() - template, exists := s.emailTemplates[templateID] - s.templatesMu.RUnlock() - - if !exists { - // Check default templates - for _, t := range defaultTemplates() { - if t.ID == templateID { - writeJSON(w, http.StatusOK, t) - return - } - } - writeJSON(w, http.StatusNotFound, map[string]string{"error": "Template not found"}) - return - } - - writeJSON(w, http.StatusOK, template) -} - -// updateTemplate updates an existing template. -func (s *Server) updateTemplate(w http.ResponseWriter, r *http.Request, templateID string) { - var update EmailTemplate - if err := json.NewDecoder(limitedBody(w, r)).Decode(&update); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request"}) - return - } - - s.templatesMu.Lock() - defer s.templatesMu.Unlock() - - template, exists := s.emailTemplates[templateID] - if !exists { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "Template not found"}) - return - } - - // Update fields - if update.Name != "" { - template.Name = update.Name - } - if update.Subject != "" { - template.Subject = update.Subject - } - if update.Body != "" { - template.Body = update.Body - template.Variables = extractTemplateVariables(template.Body) - } - if update.Shortcut != "" { - template.Shortcut = update.Shortcut - } - if update.Category != "" { - template.Category = update.Category - } - template.UpdatedAt = time.Now().Unix() - - s.emailTemplates[templateID] = template - writeJSON(w, http.StatusOK, template) -} - -// deleteTemplate deletes a template. -func (s *Server) deleteTemplate(w http.ResponseWriter, _ *http.Request, templateID string) { - func() { - s.templatesMu.Lock() - defer s.templatesMu.Unlock() - delete(s.emailTemplates, templateID) - }() - - writeJSON(w, http.StatusOK, map[string]any{ - "success": true, - "id": templateID, - }) -} - -// expandTemplate expands a template with variables. -func (s *Server) expandTemplate(w http.ResponseWriter, r *http.Request, templateID string) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - Variables map[string]string `json:"variables"` - } - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request"}) - return - } - - // Find template - s.templatesMu.RLock() - template, exists := s.emailTemplates[templateID] - s.templatesMu.RUnlock() - - if !exists { - // Check default templates - for _, t := range defaultTemplates() { - if t.ID == templateID { - template = t - exists = true - break - } - } - } - - if !exists { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "Template not found"}) - return - } - - // Expand variables - body := template.Body - subject := template.Subject - for key, value := range req.Variables { - placeholder := "{{" + key + "}}" - body = strings.ReplaceAll(body, placeholder, value) - subject = strings.ReplaceAll(subject, placeholder, value) - } - - // Increment usage count - func() { - s.templatesMu.Lock() - defer s.templatesMu.Unlock() - if t, ok := s.emailTemplates[templateID]; ok { - t.UsageCount++ - s.emailTemplates[templateID] = t - } - }() - - writeJSON(w, http.StatusOK, map[string]any{ - "subject": subject, - "body": body, - }) -} diff --git a/internal/air/handlers_templates_handlers_test.go b/internal/air/handlers_templates_handlers_test.go deleted file mode 100644 index 30ae265..0000000 --- a/internal/air/handlers_templates_handlers_test.go +++ /dev/null @@ -1,415 +0,0 @@ -//go:build !integration - -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "sync" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func createTestServerWithTemplates() *Server { - s := &Server{ - templatesMu: sync.RWMutex{}, - emailTemplates: make(map[string]EmailTemplate), - } - return s -} - -func TestHandleTemplates_List(t *testing.T) { - s := createTestServerWithTemplates() - - req := httptest.NewRequest(http.MethodGet, "/api/templates", nil) - w := httptest.NewRecorder() - - s.handleTemplates(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result TemplateListResponse - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - // Should return default templates when empty - assert.NotEmpty(t, result.Templates) -} - -func TestHandleTemplates_ListWithCategory(t *testing.T) { - s := createTestServerWithTemplates() - - // Add templates with different categories - s.emailTemplates["tmpl-1"] = EmailTemplate{ - ID: "tmpl-1", - Name: "Greeting 1", - Body: "Hello", - Category: "greeting", - } - s.emailTemplates["tmpl-2"] = EmailTemplate{ - ID: "tmpl-2", - Name: "Closing 1", - Body: "Bye", - Category: "closing", - } - - req := httptest.NewRequest(http.MethodGet, "/api/templates?category=greeting", nil) - w := httptest.NewRecorder() - - s.handleTemplates(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result TemplateListResponse - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - // Should only return greeting templates - for _, tmpl := range result.Templates { - assert.Equal(t, "greeting", tmpl.Category) - } -} - -func TestHandleTemplates_Create(t *testing.T) { - s := createTestServerWithTemplates() - - body := `{"name": "New Template", "body": "Hello {{name}}", "category": "greeting"}` - req := httptest.NewRequest(http.MethodPost, "/api/templates", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - s.handleTemplates(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusCreated, resp.StatusCode) - - var created EmailTemplate - err := json.NewDecoder(resp.Body).Decode(&created) - require.NoError(t, err) - - assert.Equal(t, "New Template", created.Name) - assert.Equal(t, "Hello {{name}}", created.Body) - assert.Equal(t, "greeting", created.Category) - assert.Contains(t, created.Variables, "name") - assert.NotEmpty(t, created.ID) - assert.True(t, created.CreatedAt > 0) -} - -func TestHandleTemplates_CreateMissingName(t *testing.T) { - s := createTestServerWithTemplates() - - body := `{"body": "Hello"}` - req := httptest.NewRequest(http.MethodPost, "/api/templates", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - s.handleTemplates(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) -} - -func TestHandleTemplates_CreateMissingBody(t *testing.T) { - s := createTestServerWithTemplates() - - body := `{"name": "Test"}` - req := httptest.NewRequest(http.MethodPost, "/api/templates", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - s.handleTemplates(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) -} - -func TestHandleTemplates_MethodNotAllowed(t *testing.T) { - s := createTestServerWithTemplates() - - req := httptest.NewRequest(http.MethodDelete, "/api/templates", nil) - w := httptest.NewRecorder() - - s.handleTemplates(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) -} - -func TestHandleTemplateByID_Get(t *testing.T) { - s := createTestServerWithTemplates() - - // Add a template - s.emailTemplates["tmpl-test"] = EmailTemplate{ - ID: "tmpl-test", - Name: "Test Template", - Body: "Test body", - } - - req := httptest.NewRequest(http.MethodGet, "/api/templates/tmpl-test", nil) - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var template EmailTemplate - err := json.NewDecoder(resp.Body).Decode(&template) - require.NoError(t, err) - - assert.Equal(t, "tmpl-test", template.ID) - assert.Equal(t, "Test Template", template.Name) -} - -func TestHandleTemplateByID_GetDefault(t *testing.T) { - s := createTestServerWithTemplates() - - // Get a default template (no custom templates added) - req := httptest.NewRequest(http.MethodGet, "/api/templates/default-thanks", nil) - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var template EmailTemplate - err := json.NewDecoder(resp.Body).Decode(&template) - require.NoError(t, err) - - assert.Equal(t, "default-thanks", template.ID) -} - -func TestHandleTemplateByID_NotFound(t *testing.T) { - s := createTestServerWithTemplates() - - req := httptest.NewRequest(http.MethodGet, "/api/templates/nonexistent", nil) - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusNotFound, resp.StatusCode) -} - -func TestHandleTemplateByID_Delete(t *testing.T) { - s := createTestServerWithTemplates() - - // Add a template - s.emailTemplates["tmpl-delete"] = EmailTemplate{ - ID: "tmpl-delete", - Name: "To Delete", - Body: "Test body", - } - - req := httptest.NewRequest(http.MethodDelete, "/api/templates/tmpl-delete", nil) - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - // Verify deleted - _, exists := s.emailTemplates["tmpl-delete"] - assert.False(t, exists) -} - -func TestHandleTemplateByID_Update(t *testing.T) { - s := createTestServerWithTemplates() - - // Add a template - s.emailTemplates["tmpl-update"] = EmailTemplate{ - ID: "tmpl-update", - Name: "Original Name", - Body: "Original body", - CreatedAt: 1704067200, - } - - body := `{"name": "Updated Name", "body": "Updated body with {{variable}}"}` - req := httptest.NewRequest(http.MethodPut, "/api/templates/tmpl-update", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var updated EmailTemplate - err := json.NewDecoder(resp.Body).Decode(&updated) - require.NoError(t, err) - - assert.Equal(t, "Updated Name", updated.Name) - assert.Equal(t, "Updated body with {{variable}}", updated.Body) - assert.Contains(t, updated.Variables, "variable") - assert.True(t, updated.UpdatedAt > 0) -} - -func TestHandleTemplateByID_UpdateNotFound(t *testing.T) { - s := createTestServerWithTemplates() - - body := `{"name": "Updated"}` - req := httptest.NewRequest(http.MethodPut, "/api/templates/nonexistent", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusNotFound, resp.StatusCode) -} - -func TestHandleTemplateByID_MissingID(t *testing.T) { - s := createTestServerWithTemplates() - - req := httptest.NewRequest(http.MethodGet, "/api/templates/", nil) - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) -} - -func TestHandleTemplateByID_Expand(t *testing.T) { - s := createTestServerWithTemplates() - - // Add a template - s.emailTemplates["tmpl-expand"] = EmailTemplate{ - ID: "tmpl-expand", - Name: "Test", - Subject: "Hello {{name}}", - Body: "Dear {{name}}, welcome to {{company}}!", - Variables: []string{"name", "company"}, - } - - body := `{"variables": {"name": "John", "company": "Acme"}}` - req := httptest.NewRequest(http.MethodPost, "/api/templates/tmpl-expand/expand", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result map[string]any - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - assert.Equal(t, "Hello John", result["subject"]) - assert.Equal(t, "Dear John, welcome to Acme!", result["body"]) -} - -func TestHandleTemplateByID_ExpandNotFound(t *testing.T) { - s := createTestServerWithTemplates() - - body := `{"variables": {}}` - req := httptest.NewRequest(http.MethodPost, "/api/templates/nonexistent/expand", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusNotFound, resp.StatusCode) -} - -func TestHandleTemplateByID_ExpandDefaultTemplate(t *testing.T) { - s := createTestServerWithTemplates() - - body := `{"variables": {"name": "John", "topic": "meeting"}}` - req := httptest.NewRequest(http.MethodPost, "/api/templates/default-followup/expand", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var result map[string]any - err := json.NewDecoder(resp.Body).Decode(&result) - require.NoError(t, err) - - subject := result["subject"].(string) - resultBody := result["body"].(string) - - assert.Contains(t, subject, "meeting") - assert.Contains(t, resultBody, "John") - assert.Contains(t, resultBody, "meeting") -} - -func TestHandleTemplateByID_ExpandMethodNotAllowed(t *testing.T) { - s := createTestServerWithTemplates() - - // Add a template - s.emailTemplates["tmpl-expand"] = EmailTemplate{ - ID: "tmpl-expand", - Name: "Test", - Body: "Test", - } - - req := httptest.NewRequest(http.MethodGet, "/api/templates/tmpl-expand/expand", nil) - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) -} - -func TestHandleTemplateByID_MethodNotAllowed(t *testing.T) { - s := createTestServerWithTemplates() - - req := httptest.NewRequest(http.MethodPatch, "/api/templates/some-id", nil) - w := httptest.NewRecorder() - - s.handleTemplateByID(w, req) - - resp := w.Result() - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) -} diff --git a/internal/air/handlers_templates_helpers.go b/internal/air/handlers_templates_helpers.go deleted file mode 100644 index ade14e5..0000000 --- a/internal/air/handlers_templates_helpers.go +++ /dev/null @@ -1,100 +0,0 @@ -package air - -import ( - "regexp" - "time" -) - -// ============================================================================= -// Email Templates Helper Functions -// ============================================================================= - -// extractTemplateVariables extracts {{variable}} placeholders from text. -func extractTemplateVariables(text string) []string { - re := regexp.MustCompile(`\{\{(\w+)\}\}`) - matches := re.FindAllStringSubmatch(text, -1) - - seen := make(map[string]bool) - vars := make([]string, 0) - for _, match := range matches { - if len(match) > 1 && !seen[match[1]] { - vars = append(vars, match[1]) - seen[match[1]] = true - } - } - return vars -} - -// deduplicateStrings removes duplicate strings while preserving order. -func deduplicateStrings(strs []string) []string { - seen := make(map[string]bool) - result := make([]string, 0, len(strs)) - for _, s := range strs { - if !seen[s] { - seen[s] = true - result = append(result, s) - } - } - return result -} - -// defaultTemplates returns built-in templates. -func defaultTemplates() []EmailTemplate { - now := time.Now().Unix() - return []EmailTemplate{ - { - ID: "default-thanks", - Name: "Thank You", - Shortcut: "/thanks", - Body: "Thank you for your email. I appreciate you reaching out and will get back to you shortly.\n\nBest regards", - Category: "closing", - Variables: []string{}, - CreatedAt: now, - UpdatedAt: now, - }, - { - ID: "default-intro", - Name: "Introduction", - Shortcut: "/intro", - Subject: "Introduction: {{my_name}} from {{company}}", - Body: "Hi {{name}},\n\nI hope this email finds you well. My name is {{my_name}}, and I'm reaching out from {{company}}.\n\n{{purpose}}\n\nI'd love to schedule a brief call to discuss further. Would you have 15-20 minutes available this week?\n\nBest regards,\n{{my_name}}", - Category: "greeting", - Variables: []string{"name", "my_name", "company", "purpose"}, - CreatedAt: now, - UpdatedAt: now, - }, - { - ID: "default-followup", - Name: "Follow Up", - Shortcut: "/followup", - Subject: "Following up: {{topic}}", - Body: "Hi {{name}},\n\nI wanted to follow up on {{topic}}. Have you had a chance to review my previous message?\n\nPlease let me know if you have any questions or need additional information.\n\nBest regards", - Category: "follow-up", - Variables: []string{"name", "topic"}, - CreatedAt: now, - UpdatedAt: now, - }, - { - ID: "default-meeting", - Name: "Meeting Request", - Shortcut: "/meeting", - Subject: "Meeting Request: {{topic}}", - Body: "Hi {{name}},\n\nI'd like to schedule a meeting to discuss {{topic}}.\n\nWould any of the following times work for you?\n- {{time1}}\n- {{time2}}\n- {{time3}}\n\nPlease let me know what works best, or feel free to suggest an alternative time.\n\nBest regards", - Category: "greeting", - Variables: []string{"name", "topic", "time1", "time2", "time3"}, - CreatedAt: now, - UpdatedAt: now, - }, - { - ID: "default-ooo", - Name: "Out of Office", - Shortcut: "/ooo", - Subject: "Out of Office: {{start_date}} - {{end_date}}", - Body: "Hi,\n\nThank you for your email. I am currently out of the office from {{start_date}} to {{end_date}} with limited access to email.\n\nFor urgent matters, please contact {{backup_contact}}.\n\nI will respond to your email upon my return.\n\nBest regards", - Category: "auto-reply", - Variables: []string{"start_date", "end_date", "backup_contact"}, - CreatedAt: now, - UpdatedAt: now, - }, - } -} diff --git a/internal/air/handlers_templates_unit_test.go b/internal/air/handlers_templates_unit_test.go deleted file mode 100644 index 2bbb25c..0000000 --- a/internal/air/handlers_templates_unit_test.go +++ /dev/null @@ -1,221 +0,0 @@ -//go:build !integration - -package air - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestExtractTemplateVariables(t *testing.T) { - tests := []struct { - name string - text string - wantCount int - wantVars []string - }{ - { - name: "single variable", - text: "Hello {{name}}", - wantCount: 1, - wantVars: []string{"name"}, - }, - { - name: "multiple variables", - text: "Hello {{name}}, welcome to {{company}}", - wantCount: 2, - wantVars: []string{"name", "company"}, - }, - { - name: "duplicate variables", - text: "Hi {{name}}, {{name}} is great!", - wantCount: 1, - wantVars: []string{"name"}, - }, - { - name: "no variables", - text: "Hello world, no variables here", - wantCount: 0, - wantVars: []string{}, - }, - { - name: "empty string", - text: "", - wantCount: 0, - wantVars: []string{}, - }, - { - name: "complex template", - text: "Dear {{recipient}},\n\nThis is {{sender}} from {{company}}.\n\n{{message}}\n\nBest,\n{{sender}}", - wantCount: 4, - wantVars: []string{"recipient", "sender", "company", "message"}, - }, - { - name: "underscores in variable names", - text: "From {{start_date}} to {{end_date}}", - wantCount: 2, - wantVars: []string{"start_date", "end_date"}, - }, - { - name: "mixed content", - text: "Meeting with {{name}} at {curly braces} and {{location}}", - wantCount: 2, - wantVars: []string{"name", "location"}, - }, - { - name: "invalid placeholders ignored", - text: "Hello {{ name }} and {{valid}}", - wantCount: 1, - wantVars: []string{"valid"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractTemplateVariables(tt.text) - - assert.Len(t, result, tt.wantCount) - for _, v := range tt.wantVars { - assert.Contains(t, result, v) - } - }) - } -} - -func TestDeduplicateStrings(t *testing.T) { - tests := []struct { - name string - input []string - output []string - }{ - { - name: "no duplicates", - input: []string{"a", "b", "c"}, - output: []string{"a", "b", "c"}, - }, - { - name: "with duplicates", - input: []string{"a", "b", "a", "c", "b"}, - output: []string{"a", "b", "c"}, - }, - { - name: "all duplicates", - input: []string{"x", "x", "x"}, - output: []string{"x"}, - }, - { - name: "empty slice", - input: []string{}, - output: []string{}, - }, - { - name: "single element", - input: []string{"only"}, - output: []string{"only"}, - }, - { - name: "preserves order", - input: []string{"z", "a", "m", "a", "z"}, - output: []string{"z", "a", "m"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := deduplicateStrings(tt.input) - - assert.Equal(t, tt.output, result) - }) - } -} - -func TestDefaultTemplates(t *testing.T) { - templates := defaultTemplates() - - // Check that we have expected default templates - assert.NotEmpty(t, templates) - - // Check for expected templates - expectedIDs := []string{ - "default-thanks", - "default-intro", - "default-followup", - "default-meeting", - "default-ooo", - } - - for _, id := range expectedIDs { - found := false - for _, tmpl := range templates { - if tmpl.ID == id { - found = true - assert.NotEmpty(t, tmpl.Name) - assert.NotEmpty(t, tmpl.Body) - assert.NotEmpty(t, tmpl.Shortcut) - assert.True(t, tmpl.CreatedAt > 0) - assert.True(t, tmpl.UpdatedAt > 0) - break - } - } - assert.True(t, found, "Expected template with ID %s", id) - } -} - -func TestDefaultTemplates_Variables(t *testing.T) { - templates := defaultTemplates() - - // Find specific templates and check their variables - for _, tmpl := range templates { - switch tmpl.ID { - case "default-thanks": - // Thanks template has no variables - assert.Empty(t, tmpl.Variables) - case "default-intro": - // Intro template has name, my_name, company, purpose - assert.Contains(t, tmpl.Variables, "name") - assert.Contains(t, tmpl.Variables, "my_name") - assert.Contains(t, tmpl.Variables, "company") - case "default-followup": - // Follow up template has name, topic - assert.Contains(t, tmpl.Variables, "name") - assert.Contains(t, tmpl.Variables, "topic") - case "default-meeting": - // Meeting template has name, topic, time1, time2, time3 - assert.Contains(t, tmpl.Variables, "name") - assert.Contains(t, tmpl.Variables, "topic") - case "default-ooo": - // OOO template has start_date, end_date, backup_contact - assert.Contains(t, tmpl.Variables, "start_date") - assert.Contains(t, tmpl.Variables, "end_date") - } - } -} - -func TestEmailTemplate_Fields(t *testing.T) { - template := EmailTemplate{ - ID: "test-id", - Name: "Test Template", - Subject: "Test Subject: {{topic}}", - Body: "Hello {{name}}, this is about {{topic}}.", - Shortcut: "/test", - Variables: []string{"name", "topic"}, - Category: "test", - UsageCount: 5, - CreatedAt: 1704067200, - UpdatedAt: 1704153600, - Metadata: map[string]string{"key": "value"}, - } - - assert.Equal(t, "test-id", template.ID) - assert.Equal(t, "Test Template", template.Name) - assert.Equal(t, "Test Subject: {{topic}}", template.Subject) - assert.Equal(t, "Hello {{name}}, this is about {{topic}}.", template.Body) - assert.Equal(t, "/test", template.Shortcut) - assert.Equal(t, []string{"name", "topic"}, template.Variables) - assert.Equal(t, "test", template.Category) - assert.Equal(t, 5, template.UsageCount) - assert.Equal(t, int64(1704067200), template.CreatedAt) - assert.Equal(t, int64(1704153600), template.UpdatedAt) - assert.Equal(t, "value", template.Metadata["key"]) -} diff --git a/internal/air/handlers_types.go b/internal/air/handlers_types.go deleted file mode 100644 index 327a1f0..0000000 --- a/internal/air/handlers_types.go +++ /dev/null @@ -1,388 +0,0 @@ -package air - -import ( - "io" - "net/http" - - "github.com/nylas/cli/internal/domain" - "github.com/nylas/cli/internal/httputil" -) - -// Grant represents an authenticated account for API responses. -type Grant struct { - ID string `json:"id"` - Email string `json:"email"` - Provider string `json:"provider"` -} - -// grantFromDomain converts a domain.GrantInfo to a Grant for API responses. -func grantFromDomain(g domain.GrantInfo) Grant { - return Grant{ - ID: g.ID, - Email: g.Email, - Provider: string(g.Provider), - } -} - -// ConfigStatusResponse represents the config status API response. -type ConfigStatusResponse struct { - Configured bool `json:"configured"` - Region string `json:"region"` - ClientID string `json:"client_id,omitempty"` - HasAPIKey bool `json:"has_api_key"` - GrantCount int `json:"grant_count"` - DefaultGrant string `json:"default_grant,omitempty"` -} - -// GrantsResponse represents the grants list API response. -type GrantsResponse struct { - Grants []Grant `json:"grants"` - DefaultGrant string `json:"default_grant"` -} - -// SetDefaultGrantRequest represents the request to set default grant. -type SetDefaultGrantRequest struct { - GrantID string `json:"grant_id"` -} - -// SetDefaultGrantResponse represents the response for setting default grant. -type SetDefaultGrantResponse struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` -} - -// FolderResponse represents a folder in API responses. -type FolderResponse struct { - ID string `json:"id"` - Name string `json:"name"` - SystemFolder string `json:"system_folder,omitempty"` - TotalCount int `json:"total_count"` - UnreadCount int `json:"unread_count"` -} - -// FoldersResponse represents the folders list API response. -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"` -} - -// WorkspaceResponse represents the agent workspace API response. -type WorkspaceResponse struct { - Workspace *domain.Workspace `json:"workspace"` -} - -// AgentListsResponse represents the agent lists API response. -type AgentListsResponse struct { - Lists []domain.AgentList `json:"lists"` -} - -// EmailParticipantResponse represents an email participant. -type EmailParticipantResponse struct { - Name string `json:"name"` - Email string `json:"email"` -} - -// AttachmentResponse represents an email attachment. -type AttachmentResponse struct { - ID string `json:"id"` - Filename string `json:"filename"` - ContentType string `json:"content_type"` - Size int64 `json:"size"` -} - -// EmailResponse represents an email in API responses. -type EmailResponse struct { - ID string `json:"id"` - ThreadID string `json:"thread_id,omitempty"` - Subject string `json:"subject"` - Snippet string `json:"snippet"` - Body string `json:"body,omitempty"` - From []EmailParticipantResponse `json:"from"` - To []EmailParticipantResponse `json:"to,omitempty"` - Cc []EmailParticipantResponse `json:"cc,omitempty"` - Date int64 `json:"date"` // Unix timestamp - Unread bool `json:"unread"` - Starred bool `json:"starred"` - Folders []string `json:"folders,omitempty"` - Attachments []AttachmentResponse `json:"attachments,omitempty"` -} - -// EmailsResponse represents the emails list API response. -type EmailsResponse struct { - Emails []EmailResponse `json:"emails"` - NextCursor string `json:"next_cursor,omitempty"` - HasMore bool `json:"has_more"` -} - -// UpdateEmailRequest represents a request to update an email. -// Folders: nil = leave alone; non-nil empty = Gmail archive -// (json.Unmarshal preserves this; see domain.UpdateMessageRequest). -type UpdateEmailRequest struct { - Unread *bool `json:"unread,omitempty"` - Starred *bool `json:"starred,omitempty"` - Folders []string `json:"folders,omitempty"` -} - -// UpdateEmailResponse represents the response for updating an email. -type UpdateEmailResponse struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` -} - -// DraftRequest represents a request to create or update a draft. -type DraftRequest struct { - To []EmailParticipantResponse `json:"to"` - Cc []EmailParticipantResponse `json:"cc,omitempty"` - Bcc []EmailParticipantResponse `json:"bcc,omitempty"` - Subject string `json:"subject"` - Body string `json:"body"` - ReplyToMsgID string `json:"reply_to_message_id,omitempty"` -} - -// DraftResponse represents a draft in API responses. -type DraftResponse struct { - ID string `json:"id"` - Subject string `json:"subject"` - Body string `json:"body,omitempty"` - To []EmailParticipantResponse `json:"to,omitempty"` - Cc []EmailParticipantResponse `json:"cc,omitempty"` - Bcc []EmailParticipantResponse `json:"bcc,omitempty"` - Date int64 `json:"date"` -} - -// DraftsResponse represents the drafts list API response. -type DraftsResponse struct { - Drafts []DraftResponse `json:"drafts"` -} - -// SendMessageRequest represents a request to send a message directly. -// -// GrantID, when set, identifies which connected account to send from. It must -// match one of the user's stored, Air-supported grants. If empty, the server -// falls back to the active default grant. -type SendMessageRequest struct { - GrantID string `json:"grant_id,omitempty"` - To []EmailParticipantResponse `json:"to"` - Cc []EmailParticipantResponse `json:"cc,omitempty"` - Bcc []EmailParticipantResponse `json:"bcc,omitempty"` - Subject string `json:"subject"` - Body string `json:"body"` - ReplyToMsgID string `json:"reply_to_message_id,omitempty"` -} - -// SendMessageResponse represents the response for sending a message. -type SendMessageResponse struct { - Success bool `json:"success"` - MessageID string `json:"message_id,omitempty"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` -} - -// CalendarResponse represents a calendar in API responses. -type CalendarResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Timezone string `json:"timezone,omitempty"` - IsPrimary bool `json:"is_primary"` - ReadOnly bool `json:"read_only"` - HexColor string `json:"hex_color,omitempty"` -} - -// CalendarsResponse represents the calendars list API response. -type CalendarsResponse struct { - Calendars []CalendarResponse `json:"calendars"` -} - -// EventParticipantResponse represents an event participant. -type EventParticipantResponse struct { - Name string `json:"name,omitempty"` - Email string `json:"email"` - Status string `json:"status,omitempty"` -} - -// ConferencingResponse represents video conferencing details. -type ConferencingResponse struct { - Provider string `json:"provider,omitempty"` - URL string `json:"url,omitempty"` -} - -// EventResponse represents an event in API responses. -type EventResponse struct { - ID string `json:"id"` - CalendarID string `json:"calendar_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Location string `json:"location,omitempty"` - StartTime int64 `json:"start_time"` - EndTime int64 `json:"end_time"` - Timezone string `json:"timezone,omitempty"` - IsAllDay bool `json:"is_all_day"` - Status string `json:"status,omitempty"` - Busy bool `json:"busy"` - Participants []EventParticipantResponse `json:"participants,omitempty"` - Conferencing *ConferencingResponse `json:"conferencing,omitempty"` - HtmlLink string `json:"html_link,omitempty"` -} - -// EventsResponse represents the events list API response. -type EventsResponse struct { - Events []EventResponse `json:"events"` - NextCursor string `json:"next_cursor,omitempty"` - HasMore bool `json:"has_more"` -} - -// CreateEventRequest represents a request to create an event. -type CreateEventRequest struct { - CalendarID string `json:"calendar_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Location string `json:"location,omitempty"` - StartTime int64 `json:"start_time"` - EndTime int64 `json:"end_time"` - Timezone string `json:"timezone,omitempty"` - IsAllDay bool `json:"is_all_day"` - Busy bool `json:"busy"` - Participants []EventParticipantResponse `json:"participants,omitempty"` -} - -// UpdateEventRequest represents a request to update an event. -type UpdateEventRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Location *string `json:"location,omitempty"` - StartTime *int64 `json:"start_time,omitempty"` - EndTime *int64 `json:"end_time,omitempty"` - Timezone *string `json:"timezone,omitempty"` - IsAllDay *bool `json:"is_all_day,omitempty"` - Busy *bool `json:"busy,omitempty"` - Participants []EventParticipantResponse `json:"participants,omitempty"` -} - -// EventActionResponse represents the response for event actions (create/update/delete). -type EventActionResponse struct { - Success bool `json:"success"` - Event *EventResponse `json:"event,omitempty"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` -} - -// ==================================== -// CONTACTS TYPES -// ==================================== - -// ContactEmailResponse represents a contact email in API responses. -type ContactEmailResponse struct { - Email string `json:"email"` - Type string `json:"type,omitempty"` -} - -// ContactPhoneResponse represents a contact phone number in API responses. -type ContactPhoneResponse struct { - Number string `json:"number"` - Type string `json:"type,omitempty"` -} - -// ContactAddressResponse represents a contact address in API responses. -type ContactAddressResponse struct { - Type string `json:"type,omitempty"` - StreetAddress string `json:"street_address,omitempty"` - City string `json:"city,omitempty"` - State string `json:"state,omitempty"` - PostalCode string `json:"postal_code,omitempty"` - Country string `json:"country,omitempty"` -} - -// ContactResponse represents a contact in API responses. -type ContactResponse struct { - ID string `json:"id"` - GivenName string `json:"given_name,omitempty"` - Surname string `json:"surname,omitempty"` - DisplayName string `json:"display_name"` - Nickname string `json:"nickname,omitempty"` - CompanyName string `json:"company_name,omitempty"` - JobTitle string `json:"job_title,omitempty"` - Birthday string `json:"birthday,omitempty"` - Notes string `json:"notes,omitempty"` - PictureURL string `json:"picture_url,omitempty"` - Emails []ContactEmailResponse `json:"emails,omitempty"` - PhoneNumbers []ContactPhoneResponse `json:"phone_numbers,omitempty"` - Addresses []ContactAddressResponse `json:"addresses,omitempty"` - Source string `json:"source,omitempty"` -} - -// ContactsResponse represents the contacts list API response. -type ContactsResponse struct { - Contacts []ContactResponse `json:"contacts"` - NextCursor string `json:"next_cursor,omitempty"` - HasMore bool `json:"has_more"` -} - -// CreateContactRequest represents a request to create a contact. -type CreateContactRequest struct { - GivenName string `json:"given_name,omitempty"` - Surname string `json:"surname,omitempty"` - Nickname string `json:"nickname,omitempty"` - CompanyName string `json:"company_name,omitempty"` - JobTitle string `json:"job_title,omitempty"` - Birthday string `json:"birthday,omitempty"` - Notes string `json:"notes,omitempty"` - Emails []ContactEmailResponse `json:"emails,omitempty"` - PhoneNumbers []ContactPhoneResponse `json:"phone_numbers,omitempty"` - Addresses []ContactAddressResponse `json:"addresses,omitempty"` -} - -// UpdateContactRequest represents a request to update a contact. -type UpdateContactRequest struct { - GivenName *string `json:"given_name,omitempty"` - Surname *string `json:"surname,omitempty"` - Nickname *string `json:"nickname,omitempty"` - CompanyName *string `json:"company_name,omitempty"` - JobTitle *string `json:"job_title,omitempty"` - Birthday *string `json:"birthday,omitempty"` - Notes *string `json:"notes,omitempty"` - Emails []ContactEmailResponse `json:"emails,omitempty"` - PhoneNumbers []ContactPhoneResponse `json:"phone_numbers,omitempty"` - Addresses []ContactAddressResponse `json:"addresses,omitempty"` -} - -// ContactActionResponse represents the response for contact actions (create/update/delete). -type ContactActionResponse struct { - Success bool `json:"success"` - Contact *ContactResponse `json:"contact,omitempty"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` -} - -// ContactGroupResponse represents a contact group in API responses. -type ContactGroupResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path,omitempty"` -} - -// ContactGroupsResponse represents the contact groups list API response. -type ContactGroupsResponse struct { - Groups []ContactGroupResponse `json:"groups"` -} - -// limitedBody wraps a request body with a size limit. -func limitedBody(w http.ResponseWriter, r *http.Request) io.ReadCloser { - return httputil.LimitedBody(w, r, httputil.MaxRequestBodySize) -} - -// writeJSON writes a JSON response. -func writeJSON(w http.ResponseWriter, status int, data any) { - httputil.WriteJSON(w, status, data) -} diff --git a/internal/air/handlers_types_base_test.go b/internal/air/handlers_types_base_test.go deleted file mode 100644 index 7e5b1a1..0000000 --- a/internal/air/handlers_types_base_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package air - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/nylas/cli/internal/domain" -) - -// ================================ -// HELPER FUNCTION TESTS -// ================================ - -func TestWriteJSON(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - - data := map[string]string{"key": "value"} - writeJSON(w, http.StatusOK, data) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - contentType := w.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("expected content-type application/json, got %s", contentType) - } - - var resp map[string]string - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp["key"] != "value" { - t.Errorf("expected key=value, got key=%s", resp["key"]) - } -} - -func TestWriteJSON_NilValue(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - writeJSON(w, http.StatusOK, nil) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - if w.Body.String() != "null\n" { - t.Errorf("expected 'null', got %s", w.Body.String()) - } -} - -func TestWriteJSON_EmptyMap(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - writeJSON(w, http.StatusOK, map[string]any{}) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - if w.Body.String() != "{}\n" { - t.Errorf("expected '{}', got %s", w.Body.String()) - } -} - -func TestParticipantsToEmail(t *testing.T) { - t.Parallel() - - participants := []EmailParticipantResponse{ - {Name: "John Doe", Email: "john@example.com"}, - {Name: "Jane Smith", Email: "jane@example.com"}, - } - - result := participantsToEmail(participants) - - if len(result) != 2 { - t.Fatalf("expected 2 participants, got %d", len(result)) - } - - if result[0].Name != "John Doe" { - t.Errorf("expected name 'John Doe', got %s", result[0].Name) - } - - if result[0].Email != "john@example.com" { - t.Errorf("expected email 'john@example.com', got %s", result[0].Email) - } -} - -func TestParticipantsToEmail_Empty(t *testing.T) { - t.Parallel() - - result := participantsToEmail(nil) - if len(result) != 0 { - t.Errorf("expected empty slice, got %d items", len(result)) - } - - result = participantsToEmail([]EmailParticipantResponse{}) - if len(result) != 0 { - t.Errorf("expected empty slice, got %d items", len(result)) - } -} - -func TestParticipantsToEmail_Multiple(t *testing.T) { - t.Parallel() - - participants := []EmailParticipantResponse{ - {Name: "Person One", Email: "one@example.com"}, - {Name: "Person Two", Email: "two@example.com"}, - {Name: "", Email: "three@example.com"}, - } - - result := participantsToEmail(participants) - - if len(result) != 3 { - t.Errorf("expected 3 participants, got %d", len(result)) - } - - if result[0].Name != "Person One" || result[0].Email != "one@example.com" { - t.Error("first participant not converted correctly") - } - - if result[2].Email != "three@example.com" { - t.Error("third participant email not converted correctly") - } -} - -func TestGrantFromDomain(t *testing.T) { - t.Parallel() - - domainGrant := struct { - ID string - Email string - Provider string - }{ - ID: "grant-123", - Email: "test@example.com", - Provider: "google", - } - - // Since grantFromDomain expects domain.GrantInfo, we test the conversion logic - result := Grant{ - ID: domainGrant.ID, - Email: domainGrant.Email, - Provider: domainGrant.Provider, - } - - if result.ID != "grant-123" { - t.Errorf("expected ID 'grant-123', got %s", result.ID) - } - - if result.Email != "test@example.com" { - t.Errorf("expected email 'test@example.com', got %s", result.Email) - } - - if result.Provider != "google" { - t.Errorf("expected provider 'google', got %s", result.Provider) - } -} - -func TestGrantFromDomain_Basic(t *testing.T) { - t.Parallel() - - grantInfo := domain.GrantInfo{ - ID: "grant-123", - Email: "user@example.com", - Provider: domain.ProviderGoogle, - } - - grant := grantFromDomain(grantInfo) - - if grant.ID != "grant-123" { - t.Errorf("expected ID 'grant-123', got %s", grant.ID) - } - if grant.Email != "user@example.com" { - t.Errorf("expected Email 'user@example.com', got %s", grant.Email) - } - if grant.Provider != "google" { - t.Errorf("expected Provider 'google', got %s", grant.Provider) - } -} - -func TestGrantFromDomain_DifferentProviders(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - provider domain.Provider - expected string - }{ - {"Google", domain.ProviderGoogle, "google"}, - {"Microsoft", domain.ProviderMicrosoft, "microsoft"}, - {"IMAP", domain.ProviderIMAP, "imap"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - grantInfo := domain.GrantInfo{ - ID: "grant-123", - Email: "user@example.com", - Provider: tt.provider, - } - - grant := grantFromDomain(grantInfo) - - if grant.Provider != tt.expected { - t.Errorf("expected Provider '%s', got %s", tt.expected, grant.Provider) - } - }) - } -} - -// ================================ -// CONTACT HELPER FUNCTION TESTS -// ================================ diff --git a/internal/air/handlers_types_conflicts_test.go b/internal/air/handlers_types_conflicts_test.go deleted file mode 100644 index 7eecf74..0000000 --- a/internal/air/handlers_types_conflicts_test.go +++ /dev/null @@ -1,301 +0,0 @@ -package air - -import ( - "testing" - - "github.com/nylas/cli/internal/domain" -) - -func TestFindConflicts(t *testing.T) { - t.Parallel() - - // Test with overlapping events - events := []domain.Event{ - { - ID: "event-1", - Title: "Meeting 1", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 1000, - EndTime: 2000, - }, - }, - { - ID: "event-2", - Title: "Meeting 2", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 1500, - EndTime: 2500, - }, - }, - { - ID: "event-3", - Title: "Meeting 3", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 3000, - EndTime: 4000, - }, - }, - } - - conflicts := findConflicts(events) - - // event-1 and event-2 overlap - if len(conflicts) != 1 { - t.Errorf("expected 1 conflict, got %d", len(conflicts)) - } - - if len(conflicts) > 0 { - if conflicts[0].Event1.ID != "event-1" || conflicts[0].Event2.ID != "event-2" { - t.Error("expected conflict between event-1 and event-2") - } - } -} - -func TestFindConflicts_NoOverlap(t *testing.T) { - t.Parallel() - - events := []domain.Event{ - { - ID: "event-1", - Title: "Meeting 1", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 1000, - EndTime: 2000, - }, - }, - { - ID: "event-2", - Title: "Meeting 2", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 3000, - EndTime: 4000, - }, - }, - } - - conflicts := findConflicts(events) - - if len(conflicts) != 0 { - t.Errorf("expected no conflicts, got %d", len(conflicts)) - } -} - -func TestFindConflicts_CancelledEventsIgnored(t *testing.T) { - t.Parallel() - - events := []domain.Event{ - { - ID: "event-1", - Title: "Meeting 1", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 1000, - EndTime: 2000, - }, - }, - { - ID: "event-2", - Title: "Meeting 2", - Status: "cancelled", // This should be ignored - Busy: true, - When: domain.EventWhen{ - StartTime: 1500, - EndTime: 2500, - }, - }, - } - - conflicts := findConflicts(events) - - if len(conflicts) != 0 { - t.Errorf("expected no conflicts (cancelled event should be ignored), got %d", len(conflicts)) - } -} - -func TestFindConflicts_FreeEventsIgnored(t *testing.T) { - t.Parallel() - - events := []domain.Event{ - { - ID: "event-1", - Title: "Meeting 1", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 1000, - EndTime: 2000, - }, - }, - { - ID: "event-2", - Title: "Free Time", - Status: "confirmed", - Busy: false, // Free, not busy - When: domain.EventWhen{ - StartTime: 1500, - EndTime: 2500, - }, - }, - } - - conflicts := findConflicts(events) - - if len(conflicts) != 0 { - t.Errorf("expected no conflicts (free event should be ignored), got %d", len(conflicts)) - } -} - -func TestFindConflicts_AllDayEvents(t *testing.T) { - t.Parallel() - - // All-day event should conflict with timed event on same day - events := []domain.Event{ - { - ID: "all-day-1", - Title: "Holiday", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - Date: "2024-12-25", // All-day event - }, - }, - { - ID: "timed-1", - Title: "Meeting", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 1735142400, // Dec 25, 2024 12:00 UTC - EndTime: 1735146000, // Dec 25, 2024 13:00 UTC - }, - }, - } - - conflicts := findConflicts(events) - - // All-day event and timed event overlap - if len(conflicts) != 1 { - t.Errorf("expected 1 conflict (all-day vs timed), got %d", len(conflicts)) - } -} - -func TestFindConflicts_MultipleConflicts(t *testing.T) { - t.Parallel() - - // Three overlapping events should produce 3 conflicts (each pair) - events := []domain.Event{ - { - ID: "event-1", - Title: "Meeting 1", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 1000, - EndTime: 3000, - }, - }, - { - ID: "event-2", - Title: "Meeting 2", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 1500, - EndTime: 3500, - }, - }, - { - ID: "event-3", - Title: "Meeting 3", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 2000, - EndTime: 4000, - }, - }, - } - - conflicts := findConflicts(events) - - // event-1 overlaps event-2, event-1 overlaps event-3, event-2 overlaps event-3 - if len(conflicts) != 3 { - t.Errorf("expected 3 conflicts, got %d", len(conflicts)) - } -} - -func TestFindConflicts_EmptyList(t *testing.T) { - t.Parallel() - - conflicts := findConflicts([]domain.Event{}) - - if len(conflicts) != 0 { - t.Errorf("expected no conflicts for empty list, got %d", len(conflicts)) - } -} - -func TestFindConflicts_SingleEvent(t *testing.T) { - t.Parallel() - - events := []domain.Event{ - { - ID: "event-1", - Title: "Only Meeting", - Status: "confirmed", - Busy: true, - When: domain.EventWhen{ - StartTime: 1000, - EndTime: 2000, - }, - }, - } - - conflicts := findConflicts(events) - - if len(conflicts) != 0 { - t.Errorf("expected no conflicts for single event, got %d", len(conflicts)) - } -} - -func TestRoundUpTo5Min(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input int64 - expected int64 - }{ - {"already aligned", 1735142400, 1735142400}, // 12:00:00 stays 12:00:00 - {"1 second after", 1735142401, 1735142700}, // 12:00:01 -> 12:05:00 - {"2 minutes in", 1735142520, 1735142700}, // 12:02:00 -> 12:05:00 - {"4 min 59 sec", 1735142699, 1735142700}, // 12:04:59 -> 12:05:00 - {"zero", 0, 0}, - {"5 min aligned", 300, 300}, - {"10 min aligned", 600, 600}, - {"6 minutes", 360, 600}, // 00:06:00 -> 00:10:00 - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := roundUpTo5Min(tt.input) - if result != tt.expected { - t.Errorf("roundUpTo5Min(%d) = %d, want %d", tt.input, result, tt.expected) - } - }) - } -} - -// ================================ -// CSS STYLING TESTS -// ================================ diff --git a/internal/air/handlers_types_css_test.go b/internal/air/handlers_types_css_test.go deleted file mode 100644 index 3164128..0000000 --- a/internal/air/handlers_types_css_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package air - -import ( - "strings" - "testing" -) - -func TestEmailBodyCSS_HasLightBackground(t *testing.T) { - t.Parallel() - - // Read the preview.css file from embedded files - cssContent, err := staticFiles.ReadFile("static/css/preview.css") - if err != nil { - t.Fatalf("failed to read preview.css: %v", err) - } - - css := string(cssContent) - - // Verify email iframe container has white/light background for readability - tests := []struct { - name string - contains string - reason string - }{ - { - "email iframe container has light background", - "background: #ffffff", - "HTML emails have inline styles for light backgrounds - need white bg for readability", - }, - { - "email body selector exists", - ".email-detail-body", - "Email body styling must be defined", - }, - { - "email iframe container selector exists", - ".email-iframe-container", - "Email iframe container styling must be defined for sandboxed email rendering", - }, - { - "email iframe styling exists", - ".email-body-iframe", - "Sandboxed iframe styling must be defined for security", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !strings.Contains(css, tt.contains) { - t.Errorf("preview.css missing required style: %s\nReason: %s", tt.contains, tt.reason) - } - }) - } -} - -// ================================ -// RESPONSE CONVERTER TESTS -// ================================ diff --git a/internal/air/handlers_types_responses_test.go b/internal/air/handlers_types_responses_test.go deleted file mode 100644 index 5de19a0..0000000 --- a/internal/air/handlers_types_responses_test.go +++ /dev/null @@ -1,350 +0,0 @@ -package air - -import ( - "testing" - "time" - - "github.com/nylas/cli/internal/air/cache" - "github.com/nylas/cli/internal/domain" -) - -func TestDraftToResponse_Basic(t *testing.T) { - t.Parallel() - - draft := domain.Draft{ - ID: "draft-123", - Subject: "Draft Subject", - Body: "Draft body
", - } - - resp := draftToResponse(draft) - - if resp.ID != "draft-123" { - t.Errorf("expected ID 'draft-123', got %s", resp.ID) - } - if resp.Subject != "Draft Subject" { - t.Errorf("expected Subject 'Draft Subject', got %s", resp.Subject) - } - if resp.Body != "Draft body
" { - t.Errorf("expected Body to match, got %s", resp.Body) - } -} - -func TestDraftToResponse_WithRecipients(t *testing.T) { - t.Parallel() - - draft := domain.Draft{ - ID: "draft-123", - To: []domain.EmailParticipant{ - {Name: "To Person", Email: "to@example.com"}, - }, - Cc: []domain.EmailParticipant{ - {Name: "CC Person", Email: "cc@example.com"}, - }, - Bcc: []domain.EmailParticipant{ - {Name: "BCC Person", Email: "bcc@example.com"}, - }, - } - - resp := draftToResponse(draft) - - if len(resp.To) != 1 || resp.To[0].Email != "to@example.com" { - t.Error("To recipients not converted correctly") - } - if len(resp.Cc) != 1 || resp.Cc[0].Email != "cc@example.com" { - t.Error("Cc recipients not converted correctly") - } - if len(resp.Bcc) != 1 || resp.Bcc[0].Email != "bcc@example.com" { - t.Error("Bcc recipients not converted correctly") - } -} - -func TestCalendarToResponse_Basic(t *testing.T) { - t.Parallel() - - cal := domain.Calendar{ - ID: "cal-123", - Name: "Work Calendar", - Description: "Work events", - Timezone: "America/New_York", - IsPrimary: true, - ReadOnly: false, - HexColor: "#4285f4", - } - - resp := calendarToResponse(cal) - - if resp.ID != "cal-123" { - t.Errorf("expected ID 'cal-123', got %s", resp.ID) - } - if resp.Name != "Work Calendar" { - t.Errorf("expected Name 'Work Calendar', got %s", resp.Name) - } - if resp.Timezone != "America/New_York" { - t.Errorf("expected Timezone 'America/New_York', got %s", resp.Timezone) - } - if !resp.IsPrimary { - t.Error("expected IsPrimary to be true") - } - if resp.ReadOnly { - t.Error("expected ReadOnly to be false") - } - if resp.HexColor != "#4285f4" { - t.Errorf("expected HexColor '#4285f4', got %s", resp.HexColor) - } -} - -func TestContactToResponse_Basic(t *testing.T) { - t.Parallel() - - contact := domain.Contact{ - ID: "contact-123", - GivenName: "John", - Surname: "Doe", - Nickname: "Johnny", - CompanyName: "Acme Corp", - JobTitle: "Engineer", - Birthday: "1990-01-15", - Notes: "Test notes", - PictureURL: "https://example.com/photo.jpg", - Source: "google", - } - - resp := contactToResponse(contact) - - if resp.ID != "contact-123" { - t.Errorf("expected ID 'contact-123', got %s", resp.ID) - } - if resp.GivenName != "John" { - t.Errorf("expected GivenName 'John', got %s", resp.GivenName) - } - if resp.Surname != "Doe" { - t.Errorf("expected Surname 'Doe', got %s", resp.Surname) - } - if resp.CompanyName != "Acme Corp" { - t.Errorf("expected CompanyName 'Acme Corp', got %s", resp.CompanyName) - } -} - -func TestContactToResponse_WithEmails(t *testing.T) { - t.Parallel() - - contact := domain.Contact{ - ID: "contact-123", - Emails: []domain.ContactEmail{ - {Email: "john@work.com", Type: "work"}, - {Email: "john@home.com", Type: "home"}, - }, - } - - resp := contactToResponse(contact) - - if len(resp.Emails) != 2 { - t.Errorf("expected 2 emails, got %d", len(resp.Emails)) - } - if resp.Emails[0].Email != "john@work.com" { - t.Errorf("expected first email 'john@work.com', got %s", resp.Emails[0].Email) - } - if resp.Emails[0].Type != "work" { - t.Errorf("expected first email type 'work', got %s", resp.Emails[0].Type) - } -} - -func TestContactToResponse_WithPhoneNumbers(t *testing.T) { - t.Parallel() - - contact := domain.Contact{ - ID: "contact-123", - PhoneNumbers: []domain.ContactPhone{ - {Number: "+1-555-123-4567", Type: "mobile"}, - {Number: "+1-555-987-6543", Type: "work"}, - }, - } - - resp := contactToResponse(contact) - - if len(resp.PhoneNumbers) != 2 { - t.Errorf("expected 2 phone numbers, got %d", len(resp.PhoneNumbers)) - } - if resp.PhoneNumbers[0].Number != "+1-555-123-4567" { - t.Errorf("expected first phone '+1-555-123-4567', got %s", resp.PhoneNumbers[0].Number) - } -} - -func TestContactToResponse_WithAddresses(t *testing.T) { - t.Parallel() - - contact := domain.Contact{ - ID: "contact-123", - PhysicalAddresses: []domain.ContactAddress{ - { - Type: "work", - StreetAddress: "123 Main St", - City: "San Francisco", - State: "CA", - PostalCode: "94102", - Country: "USA", - }, - }, - } - - resp := contactToResponse(contact) - - if len(resp.Addresses) != 1 { - t.Errorf("expected 1 address, got %d", len(resp.Addresses)) - } - if resp.Addresses[0].City != "San Francisco" { - t.Errorf("expected City 'San Francisco', got %s", resp.Addresses[0].City) - } -} - -func TestCachedEventToResponse(t *testing.T) { - t.Parallel() - - startTime := time.Date(2024, 1, 20, 9, 0, 0, 0, time.UTC) - endTime := time.Date(2024, 1, 20, 10, 0, 0, 0, time.UTC) - - cachedEvent := &cache.CachedEvent{ - ID: "event-123", - CalendarID: "cal-456", - Title: "Team Meeting", - Description: "Weekly sync", - Location: "Conference Room A", - StartTime: startTime, - EndTime: endTime, - AllDay: false, - Status: "confirmed", - Busy: true, - } - - resp := cachedEventToResponse(cachedEvent) - - if resp.ID != "event-123" { - t.Errorf("ID = %q, want %q", resp.ID, "event-123") - } - if resp.CalendarID != "cal-456" { - t.Errorf("CalendarID = %q, want %q", resp.CalendarID, "cal-456") - } - if resp.Title != "Team Meeting" { - t.Errorf("Title = %q, want %q", resp.Title, "Team Meeting") - } - if resp.Description != "Weekly sync" { - t.Errorf("Description = %q, want %q", resp.Description, "Weekly sync") - } - if resp.Location != "Conference Room A" { - t.Errorf("Location = %q, want %q", resp.Location, "Conference Room A") - } - if resp.StartTime != startTime.Unix() { - t.Errorf("StartTime = %d, want %d", resp.StartTime, startTime.Unix()) - } - if resp.EndTime != endTime.Unix() { - t.Errorf("EndTime = %d, want %d", resp.EndTime, endTime.Unix()) - } - if resp.IsAllDay { - t.Error("IsAllDay should be false") - } - if resp.Status != "confirmed" { - t.Errorf("Status = %q, want %q", resp.Status, "confirmed") - } - if !resp.Busy { - t.Error("Busy should be true") - } -} - -func TestCachedEventToResponse_AllDayEvent(t *testing.T) { - t.Parallel() - - startTime := time.Date(2024, 1, 20, 0, 0, 0, 0, time.UTC) - endTime := time.Date(2024, 1, 21, 0, 0, 0, 0, time.UTC) - - cachedEvent := &cache.CachedEvent{ - ID: "event-allday", - Title: "Holiday", - StartTime: startTime, - EndTime: endTime, - AllDay: true, - } - - resp := cachedEventToResponse(cachedEvent) - - if resp.ID != "event-allday" { - t.Errorf("ID = %q, want %q", resp.ID, "event-allday") - } - if !resp.IsAllDay { - t.Error("IsAllDay should be true") - } -} - -func TestCachedContactToResponse(t *testing.T) { - t.Parallel() - - cachedContact := &cache.CachedContact{ - ID: "contact-123", - GivenName: "Jane", - Surname: "Smith", - DisplayName: "Jane Smith", - Email: "jane@example.com", - Phone: "+1-555-1234", - Company: "Acme Corp", - JobTitle: "Engineer", - Notes: "Met at conference", - } - - resp := cachedContactToResponse(cachedContact) - - if resp.ID != "contact-123" { - t.Errorf("ID = %q, want %q", resp.ID, "contact-123") - } - if resp.GivenName != "Jane" { - t.Errorf("GivenName = %q, want %q", resp.GivenName, "Jane") - } - if resp.Surname != "Smith" { - t.Errorf("Surname = %q, want %q", resp.Surname, "Smith") - } - if resp.DisplayName != "Jane Smith" { - t.Errorf("DisplayName = %q, want %q", resp.DisplayName, "Jane Smith") - } - if len(resp.Emails) != 1 || resp.Emails[0].Email != "jane@example.com" || resp.Emails[0].Type != "personal" { - t.Errorf("Emails = %+v, want [{jane@example.com personal}]", resp.Emails) - } - if len(resp.PhoneNumbers) != 1 || resp.PhoneNumbers[0].Number != "+1-555-1234" || resp.PhoneNumbers[0].Type != "mobile" { - t.Errorf("PhoneNumbers = %+v, want [{+1-555-1234 mobile}]", resp.PhoneNumbers) - } - if resp.CompanyName != "Acme Corp" { - t.Errorf("CompanyName = %q, want %q", resp.CompanyName, "Acme Corp") - } - if resp.JobTitle != "Engineer" { - t.Errorf("JobTitle = %q, want %q", resp.JobTitle, "Engineer") - } - if resp.Notes != "Met at conference" { - t.Errorf("Notes = %q, want %q", resp.Notes, "Met at conference") - } -} - -func TestCachedContactToResponse_MinimalData(t *testing.T) { - t.Parallel() - - cachedContact := &cache.CachedContact{ - ID: "contact-minimal", - GivenName: "Bob", - } - - resp := cachedContactToResponse(cachedContact) - - if resp.ID != "contact-minimal" { - t.Errorf("ID = %q, want %q", resp.ID, "contact-minimal") - } - if resp.GivenName != "Bob" { - t.Errorf("GivenName = %q, want %q", resp.GivenName, "Bob") - } - if resp.Surname != "" { - t.Errorf("Surname should be empty, got %q", resp.Surname) - } - // Email and Phone should still have entries (even if empty) - if len(resp.Emails) != 1 { - t.Error("Emails should have one entry") - } - if len(resp.PhoneNumbers) != 1 { - t.Error("PhoneNumbers should have one entry") - } -} diff --git a/internal/air/handlers_types_utilities_test.go b/internal/air/handlers_types_utilities_test.go deleted file mode 100644 index 04b5563..0000000 --- a/internal/air/handlers_types_utilities_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package air - -import ( - "testing" -) - -func TestContainsEmail(t *testing.T) { - t.Parallel() - - emails := []ContactEmailResponse{ - {Email: "test@example.com", Type: "work"}, - {Email: "john@demo.org", Type: "personal"}, - } - - tests := []struct { - query string - expected bool - }{ - {"test@example.com", true}, - {"example.com", true}, - {"demo.org", true}, - {"john", true}, - {"notfound", false}, - {"xyz@other.com", false}, - } - - for _, tt := range tests { - t.Run(tt.query, func(t *testing.T) { - result := containsEmail(emails, tt.query) - if result != tt.expected { - t.Errorf("containsEmail(%q) = %v, want %v", tt.query, result, tt.expected) - } - }) - } -} - -func TestMatchesContactQuery(t *testing.T) { - t.Parallel() - - contact := ContactResponse{ - ID: "test-1", - GivenName: "John", - Surname: "Doe", - DisplayName: "John Doe", - CompanyName: "Acme Corp", - Notes: "Important client", - Emails: []ContactEmailResponse{ - {Email: "john@acme.com", Type: "work"}, - }, - } - - tests := []struct { - query string - expected bool - }{ - {"john", true}, - {"doe", true}, - {"John Doe", true}, - {"acme", true}, - {"important", true}, - {"client", true}, - {"notfound", false}, - {"xyz", false}, - } - - for _, tt := range tests { - t.Run(tt.query, func(t *testing.T) { - result := matchesContactQuery(contact, tt.query) - if result != tt.expected { - t.Errorf("matchesContactQuery(%q) = %v, want %v", tt.query, result, tt.expected) - } - }) - } -} - -// ================================ -// CONFLICT DETECTION TESTS -// ================================ diff --git a/internal/air/handlers_undo_send.go b/internal/air/handlers_undo_send.go deleted file mode 100644 index 7d2359c..0000000 --- a/internal/air/handlers_undo_send.go +++ /dev/null @@ -1,151 +0,0 @@ -package air - -import ( - "cmp" - "encoding/json" - "net/http" - "slices" - "time" -) - -// ============================================================================= -// Undo Send Handlers -// ============================================================================= - -// handleUndoSend handles undo send operations. -func (s *Server) handleUndoSend(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.getUndoSendConfig(w, r) - case http.MethodPut: - s.updateUndoSendConfig(w, r) - case http.MethodPost: - s.undoSend(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } -} - -// handlePendingSends lists pending sends in grace period. -func (s *Server) handlePendingSends(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - s.pendingSendMu.RLock() - pending := make([]PendingSend, 0) - now := time.Now().Unix() - for _, ps := range s.pendingSends { - if !ps.Cancelled && ps.SendAt > now { - pending = append(pending, ps) - } - } - s.pendingSendMu.RUnlock() - - // Sort by send time (soonest first) - slices.SortFunc(pending, func(a, b PendingSend) int { - return cmp.Compare(a.SendAt, b.SendAt) - }) - - writeJSON(w, http.StatusOK, map[string]any{ - "pending": pending, - "count": len(pending), - }) -} - -// getUndoSendConfig returns the undo send configuration. -func (s *Server) getUndoSendConfig(w http.ResponseWriter, _ *http.Request) { - config := s.getOrCreateUndoSendConfig() - writeJSON(w, http.StatusOK, config) -} - -// updateUndoSendConfig updates the undo send configuration. -func (s *Server) updateUndoSendConfig(w http.ResponseWriter, r *http.Request) { - var config UndoSendConfig - if err := json.NewDecoder(limitedBody(w, r)).Decode(&config); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request"}) - return - } - - // Validate grace period (5-60 seconds) - if config.GracePeriodSec < 5 { - config.GracePeriodSec = 5 - } else if config.GracePeriodSec > 60 { - config.GracePeriodSec = 60 - } - - func() { - s.undoSendMu.Lock() - defer s.undoSendMu.Unlock() - s.undoSendConfig = &config - }() - - writeJSON(w, http.StatusOK, map[string]any{ - "success": true, - "config": config, - }) -} - -// undoSend cancels a pending send. -func (s *Server) undoSend(w http.ResponseWriter, r *http.Request) { - var req struct { - MessageID string `json:"message_id"` - } - if err := json.NewDecoder(limitedBody(w, r)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request"}) - return - } - - if req.MessageID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Message ID required"}) - return - } - - s.pendingSendMu.Lock() - defer s.pendingSendMu.Unlock() - - ps, exists := s.pendingSends[req.MessageID] - if !exists { - writeJSON(w, http.StatusNotFound, UndoSendResponse{ - Success: false, - Error: "Message not found or already sent", - }) - return - } - - now := time.Now().Unix() - if ps.SendAt <= now { - writeJSON(w, http.StatusBadRequest, UndoSendResponse{ - Success: false, - Error: "Grace period expired, message already sent", - }) - return - } - - // Mark as cancelled - ps.Cancelled = true - s.pendingSends[req.MessageID] = ps - - writeJSON(w, http.StatusOK, UndoSendResponse{ - Success: true, - MessageID: req.MessageID, - Message: "Message cancelled successfully", - }) -} - -// getOrCreateUndoSendConfig returns the current undo send config. -func (s *Server) getOrCreateUndoSendConfig() UndoSendConfig { - s.undoSendMu.RLock() - if s.undoSendConfig != nil { - config := *s.undoSendConfig - s.undoSendMu.RUnlock() - return config - } - s.undoSendMu.RUnlock() - - return UndoSendConfig{ - Enabled: true, - GracePeriodSec: 10, - } -} diff --git a/internal/air/handlers_vipsenders_test.go b/internal/air/handlers_vipsenders_test.go deleted file mode 100644 index 0c23389..0000000 --- a/internal/air/handlers_vipsenders_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestHandleVIPSenders_Get(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodGet, "/api/inbox/vip", nil) - w := httptest.NewRecorder() - - server.handleVIPSenders(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - var resp map[string]any - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if _, ok := resp["vip_senders"]; !ok { - t.Error("expected vip_senders in response") - } -} - -func TestHandleVIPSenders_Add(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - body, _ := json.Marshal(map[string]string{"email": "boss@company.com"}) - req := httptest.NewRequest(http.MethodPost, "/api/inbox/vip", bytes.NewReader(body)) - w := httptest.NewRecorder() - - server.handleVIPSenders(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - // Verify VIP was added - config := server.getOrCreateSplitInboxConfig() - found := false - for _, vip := range config.VIPSenders { - if vip == "boss@company.com" { - found = true - break - } - } - if !found { - t.Error("expected VIP sender to be added") - } -} - -func TestHandleVIPSenders_Remove(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - // First add a VIP - server.addVIPSender("boss@company.com") - - // Then remove - req := httptest.NewRequest(http.MethodDelete, "/api/inbox/vip?email=boss@company.com", nil) - w := httptest.NewRecorder() - - server.handleVIPSenders(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d", w.Code) - } - - // Verify VIP was removed - config := server.getOrCreateSplitInboxConfig() - for _, vip := range config.VIPSenders { - if vip == "boss@company.com" { - t.Error("expected VIP sender to be removed") - } - } -} - -func TestCategorizeEmail_VIPPriority(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - // Add VIP sender - server.addVIPSender("boss@company.com") - - // Categorize email from VIP (should override newsletter pattern) - category, _ := server.categorizeEmail("newsletter@boss@company.com", "Newsletter", nil) - if category != CategoryVIP { - t.Errorf("expected VIP category for VIP sender, got %s", category) - } -} - -func TestMatchesRule(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - tests := []struct { - name string - rule CategoryRule - from string - subject string - want bool - }{ - { - name: "sender match", - rule: CategoryRule{Type: "sender", Pattern: "newsletter"}, - from: "newsletter@example.com", - subject: "", - want: true, - }, - { - name: "subject match", - rule: CategoryRule{Type: "subject", Pattern: "urgent"}, - from: "", - subject: "urgent: please respond", // lowercase as passed by categorizeEmail - want: true, - }, - { - name: "domain match", - rule: CategoryRule{Type: "domain", Pattern: "@company.com"}, - from: "user@company.com", - subject: "", - want: true, - }, - { - name: "regex match", - rule: CategoryRule{Type: "sender", Pattern: "^no-?reply@", IsRegex: true}, - from: "noreply@example.com", - subject: "", - want: true, - }, - { - name: "no match", - rule: CategoryRule{Type: "sender", Pattern: "xyz123"}, - from: "user@example.com", - subject: "", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := server.matchesRule(tt.rule, tt.from, tt.subject, nil) - if got != tt.want { - t.Errorf("matchesRule() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleVIPSenders_MethodNotAllowed(t *testing.T) { - t.Parallel() - server := &Server{demoMode: true} - - req := httptest.NewRequest(http.MethodPut, "/api/inbox/vip", nil) - w := httptest.NewRecorder() - - server.handleVIPSenders(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -// ============================================================================= -// Snooze Tests -// ============================================================================= diff --git a/internal/air/integration_ai_test.go b/internal/air/integration_ai_test.go deleted file mode 100644 index a62020d..0000000 --- a/internal/air/integration_ai_test.go +++ /dev/null @@ -1,593 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// AI INTEGRATION TESTS -// ================================ - -func TestIntegration_AISummarize(t *testing.T) { - server := testServer(t) - - // Test with a simple prompt - reqBody := AIRequest{ - EmailID: "test-email-id", - Prompt: "Say 'test successful' in exactly those words", - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAISummarize(w, req) - - // If claude CLI is installed, we should get a response - // If not, we should get an error about CLI not found - if w.Code == http.StatusOK { - var resp AIResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Errorf("expected success, got error: %s", resp.Error) - } - - t.Logf("AI response: %s", resp.Summary) - } else if w.Code == http.StatusInternalServerError { - var resp AIResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if strings.Contains(resp.Error, "Claude Code CLI not found") { - t.Skip("Claude Code CLI not installed, skipping AI test") - } - - t.Logf("AI error: %s", resp.Error) - } else { - t.Fatalf("unexpected status %d: %s", w.Code, w.Body.String()) - } -} - -func TestIntegration_AISummarize_MethodNotAllowed(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/ai/summarize", nil) - w := httptest.NewRecorder() - - server.handleAISummarize(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestIntegration_AISummarize_EmptyPrompt(t *testing.T) { - server := testServer(t) - - reqBody := AIRequest{ - EmailID: "test-email-id", - Prompt: "", - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAISummarize(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp AIResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected failure for empty prompt") - } - - if resp.Error != "Prompt is required" { - t.Errorf("expected 'Prompt is required', got: %s", resp.Error) - } -} - -// ============================================================================= -// Smart Replies Integration Tests -// ============================================================================= - -func TestIntegration_AISmartReplies_MethodNotAllowed(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/ai/smart-replies", nil) - w := httptest.NewRecorder() - - server.handleAISmartReplies(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestIntegration_AISmartReplies_EmptyBody(t *testing.T) { - server := testServer(t) - - reqBody := SmartReplyRequest{ - EmailID: "test-email-id", - Subject: "Test Subject", - From: "sender@example.com", - Body: "", - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/smart-replies", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAISmartReplies(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp SmartReplyResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected failure for empty body") - } - - if resp.Error != "Email body is required" { - t.Errorf("expected 'Email body is required', got: %s", resp.Error) - } -} - -func TestIntegration_AISmartReplies_WithContent(t *testing.T) { - server := testServer(t) - - reqBody := SmartReplyRequest{ - EmailID: "test-email-id", - Subject: "Meeting Request", - From: "john@example.com", - Body: "Hi, can we schedule a meeting tomorrow at 2pm to discuss the project?", - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/smart-replies", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAISmartReplies(w, req) - - if w.Code == http.StatusOK { - var resp SmartReplyResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Errorf("expected success, got error: %s", resp.Error) - } - - if len(resp.Replies) != 3 { - t.Errorf("expected 3 replies, got %d", len(resp.Replies)) - } - - t.Logf("Smart replies: %v", resp.Replies) - } else if w.Code == http.StatusInternalServerError { - var resp SmartReplyResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if strings.Contains(resp.Error, "Claude Code CLI not found") || - strings.Contains(resp.Error, "claude code CLI not found") { - t.Skip("Claude Code CLI not installed, skipping AI test") - } - - t.Logf("AI error: %s", resp.Error) - } else { - t.Fatalf("unexpected status %d: %s", w.Code, w.Body.String()) - } -} - -// ============================================================================= -// Enhanced Summary Integration Tests -// ============================================================================= - -func TestIntegration_AIEnhancedSummary_MethodNotAllowed(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/ai/enhanced-summary", nil) - w := httptest.NewRecorder() - - server.handleAIEnhancedSummary(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestIntegration_AIEnhancedSummary_EmptyBody(t *testing.T) { - server := testServer(t) - - reqBody := EnhancedSummaryRequest{ - EmailID: "test-email-id", - Subject: "Test Subject", - From: "sender@example.com", - Body: "", - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/enhanced-summary", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIEnhancedSummary(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp EnhancedSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected failure for empty body") - } - - if resp.Error != "Email body is required" { - t.Errorf("expected 'Email body is required', got: %s", resp.Error) - } -} - -func TestIntegration_AIEnhancedSummary_WithContent(t *testing.T) { - server := testServer(t) - - reqBody := EnhancedSummaryRequest{ - EmailID: "test-email-id", - Subject: "Project Update - Action Required", - From: "manager@example.com", - Body: `Hi Team, - -I wanted to update you on the project status. We've made good progress this week. - -Action items: -1. Please review the attached document by Friday -2. Schedule a follow-up meeting for next week -3. Update the project timeline - -Let me know if you have any questions. - -Best, -Manager`, - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/enhanced-summary", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIEnhancedSummary(w, req) - - if w.Code == http.StatusOK { - var resp EnhancedSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Errorf("expected success, got error: %s", resp.Error) - } - - if resp.Summary == "" { - t.Error("expected non-empty summary") - } - - // Validate sentiment is one of the expected values - validSentiments := map[string]bool{"positive": true, "neutral": true, "negative": true, "urgent": true} - if !validSentiments[resp.Sentiment] { - t.Errorf("unexpected sentiment: %s", resp.Sentiment) - } - - // Validate category is one of the expected values - validCategories := map[string]bool{"meeting": true, "task": true, "fyi": true, "question": true, "social": true} - if !validCategories[resp.Category] { - t.Errorf("unexpected category: %s", resp.Category) - } - - t.Logf("Summary: %s", resp.Summary) - t.Logf("Action Items: %v", resp.ActionItems) - t.Logf("Sentiment: %s, Category: %s", resp.Sentiment, resp.Category) - } else if w.Code == http.StatusInternalServerError { - var resp EnhancedSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if strings.Contains(resp.Error, "Claude Code CLI not found") || - strings.Contains(resp.Error, "claude code CLI not found") { - t.Skip("Claude Code CLI not installed, skipping AI test") - } - - t.Logf("AI error: %s", resp.Error) - } else { - t.Fatalf("unexpected status %d: %s", w.Code, w.Body.String()) - } -} - -// ============================================================================= -// Auto-Label Integration Tests -// ============================================================================= - -func TestIntegration_AIAutoLabel_MethodNotAllowed(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/ai/auto-label", nil) - w := httptest.NewRecorder() - - server.handleAIAutoLabel(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestIntegration_AIAutoLabel_MissingContent(t *testing.T) { - server := testServer(t) - - reqBody := AutoLabelRequest{ - EmailID: "test-email-id", - Subject: "", - From: "sender@example.com", - Body: "", - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/auto-label", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIAutoLabel(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp AutoLabelResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected failure for missing content") - } - - if resp.Error != "Email subject or body is required" { - t.Errorf("expected 'Email subject or body is required', got: %s", resp.Error) - } -} - -func TestIntegration_AIAutoLabel_WithContent(t *testing.T) { - server := testServer(t) - - reqBody := AutoLabelRequest{ - EmailID: "test-email-id", - Subject: "Q4 Budget Review Meeting - URGENT", - From: "cfo@company.com", - Body: `Hi Team, - -We need to review the Q4 budget numbers urgently before the board meeting on Friday. - -Please bring your department's spending reports and forecasts. - -This is high priority - please confirm attendance. - -Thanks, -CFO`, - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/auto-label", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIAutoLabel(w, req) - - if w.Code == http.StatusOK { - var resp AutoLabelResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Errorf("expected success, got error: %s", resp.Error) - } - - if len(resp.Labels) == 0 { - t.Error("expected at least one label") - } - - // Validate priority is one of the expected values - validPriorities := map[string]bool{"high": true, "normal": true, "low": true} - if !validPriorities[resp.Priority] { - t.Errorf("unexpected priority: %s", resp.Priority) - } - - // Validate category is one of the expected values - validCategories := map[string]bool{ - "meeting": true, "task": true, "fyi": true, "question": true, - "social": true, "newsletter": true, "promotion": true, - "urgent": true, "personal": true, "work": true, - } - if !validCategories[resp.Category] { - t.Errorf("unexpected category: %s", resp.Category) - } - - t.Logf("Labels: %v", resp.Labels) - t.Logf("Category: %s, Priority: %s", resp.Category, resp.Priority) - } else if w.Code == http.StatusInternalServerError { - var resp AutoLabelResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if strings.Contains(resp.Error, "Claude Code CLI not found") || - strings.Contains(resp.Error, "claude code CLI not found") { - t.Skip("Claude Code CLI not installed, skipping AI test") - } - - t.Logf("AI error: %s", resp.Error) - } else { - t.Fatalf("unexpected status %d: %s", w.Code, w.Body.String()) - } -} - -// ============================================================================= -// Thread Summary Integration Tests -// ============================================================================= - -func TestIntegration_AIThreadSummary_MethodNotAllowed(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/ai/thread-summary", nil) - w := httptest.NewRecorder() - - server.handleAIThreadSummary(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("expected status 405, got %d", w.Code) - } -} - -func TestIntegration_AIThreadSummary_NoMessages(t *testing.T) { - server := testServer(t) - - reqBody := ThreadSummaryRequest{ - ThreadID: "thread-123", - Messages: []ThreadMessage{}, - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/thread-summary", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIThreadSummary(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected status 400, got %d", w.Code) - } - - var resp ThreadSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Error("expected failure for no messages") - } - - if resp.Error != "At least one message is required" { - t.Errorf("expected 'At least one message is required', got: %s", resp.Error) - } -} - -func TestIntegration_AIThreadSummary_WithMessages(t *testing.T) { - server := testServer(t) - - reqBody := ThreadSummaryRequest{ - ThreadID: "thread-123", - Messages: []ThreadMessage{ - { - From: "alice@company.com", - Subject: "Project Kickoff", - Body: "Hi team, let's kick off the new project. I've attached the initial requirements.", - Date: 1703980800, - }, - { - From: "bob@company.com", - Subject: "Re: Project Kickoff", - Body: "Thanks Alice! I've reviewed the requirements. I have a few questions about the timeline.", - Date: 1703984400, - }, - { - From: "alice@company.com", - Subject: "Re: Project Kickoff", - Body: "Sure Bob! Let's schedule a call tomorrow to discuss. Does 2pm work?", - Date: 1703988000, - }, - { - From: "bob@company.com", - Subject: "Re: Project Kickoff", - Body: "2pm works perfectly. I'll send a calendar invite.", - Date: 1703991600, - }, - }, - } - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest(http.MethodPost, "/api/ai/thread-summary", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAIThreadSummary(w, req) - - if w.Code == http.StatusOK { - var resp ThreadSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Errorf("expected success, got error: %s", resp.Error) - } - - if resp.Summary == "" { - t.Error("expected non-empty summary") - } - - if resp.MessageCount != 4 { - t.Errorf("expected message count 4, got %d", resp.MessageCount) - } - - if len(resp.Participants) != 2 { - t.Errorf("expected 2 participants, got %d", len(resp.Participants)) - } - - t.Logf("Summary: %s", resp.Summary) - t.Logf("Key Points: %v", resp.KeyPoints) - t.Logf("Action Items: %v", resp.ActionItems) - t.Logf("Participants: %v", resp.Participants) - t.Logf("Timeline: %s", resp.Timeline) - t.Logf("Next Steps: %s", resp.NextSteps) - } else if w.Code == http.StatusInternalServerError { - var resp ThreadSummaryResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if strings.Contains(resp.Error, "Claude Code CLI not found") || - strings.Contains(resp.Error, "claude code CLI not found") { - t.Skip("Claude Code CLI not installed, skipping AI test") - } - - t.Logf("AI error: %s", resp.Error) - } else { - t.Fatalf("unexpected status %d: %s", w.Code, w.Body.String()) - } -} - -// ============================================================================= -// Phase 7: Middleware Integration Tests -// ============================================================================= - -// TestIntegration_Middleware_Compression verifies gzip compression works with full server. diff --git a/internal/air/integration_base_test.go b/internal/air/integration_base_test.go deleted file mode 100644 index dba8966..0000000 --- a/internal/air/integration_base_test.go +++ /dev/null @@ -1,121 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "strconv" - "strings" - "testing" - - "github.com/nylas/cli/internal/adapters/config" - "github.com/nylas/cli/internal/adapters/keyring" - "github.com/nylas/cli/internal/adapters/nylas" - authapp "github.com/nylas/cli/internal/app/auth" - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/domain" - "github.com/nylas/cli/internal/ports" -) - -// testServer creates a real Air server with actual credentials for integration testing. -func testServer(t *testing.T) *Server { - t.Helper() - - configStore := config.NewDefaultFileStore() - secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) - if err != nil { - t.Skipf("Skipping: cannot access secret store: %v", err) - } - - grantStore, err := common.NewDefaultGrantStore() - if err != nil { - t.Skipf("Skipping: cannot access grant store: %v", err) - } - configSvc := authapp.NewConfigService(configStore, secretStore) - - // Check configuration - status, err := configSvc.GetStatus() - if err != nil || !status.IsConfigured { - t.Skip("Skipping: Nylas CLI not configured. Run 'nylas auth login' first.") - } - - // Check for default grant - defaultGrantID, err := grantStore.GetDefaultGrant() - if err != nil || defaultGrantID == "" { - t.Skip("Skipping: No default grant configured. Run 'nylas auth login' first.") - } - - // Check that default grant is Google provider - grants, err := grantStore.ListGrants() - if err != nil { - t.Skipf("Skipping: cannot list grants: %v", err) - } - - var defaultGrant *domain.GrantInfo - for i := range grants { - if grants[i].ID == defaultGrantID { - defaultGrant = &grants[i] - break - } - } - - if defaultGrant == nil { - t.Skip("Skipping: default grant not found in grant list") - } - - if defaultGrant.Provider != domain.ProviderGoogle { - t.Skipf("Skipping: default grant is %s, not Google. These tests require a Google account as default.", defaultGrant.Provider) - } - - t.Logf("Running integration tests with Google account: %s", defaultGrant.Email) - - // Create Nylas client - cfg, err := configStore.Load() - if err != nil { - t.Skipf("Skipping: cannot load config: %v", err) - } - - apiKey, _ := secretStore.Get(ports.KeyAPIKey) - clientID, _ := secretStore.Get(ports.KeyClientID) - clientSecret, _ := secretStore.Get(ports.KeyClientSecret) - - if apiKey == "" { - t.Skip("Skipping: no API key configured") - } - - client := nylas.NewHTTPClient() - client.SetRegion(cfg.Region) - client.SetCredentials(clientID, clientSecret, apiKey) - - // Load templates - tmpl, err := loadTemplates() - if err != nil { - t.Fatalf("failed to load templates: %v", err) - } - - return &Server{ - addr: ":7365", - demoMode: false, - configSvc: configSvc, - configStore: configStore, - secretStore: secretStore, - grantStore: grantStore, - nylasClient: client, - templates: tmpl, - } -} - -// Helper functions used across integration tests - -func formatInt64(n int64) string { - return strconv.FormatInt(n, 10) -} - -func containsAny(s string, substrings ...string) bool { - for _, sub := range substrings { - if strings.Contains(s, sub) { - return true - } - } - return false -} diff --git a/internal/air/integration_bundles_test.go b/internal/air/integration_bundles_test.go deleted file mode 100644 index c8f03ba..0000000 --- a/internal/air/integration_bundles_test.go +++ /dev/null @@ -1,283 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestIntegration_GetBundles(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/bundles", nil) - w := httptest.NewRecorder() - - server.handleGetBundles(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var bundles []*Bundle - if err := json.NewDecoder(w.Body).Decode(&bundles); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Verify default bundles exist - bundleIDs := make(map[string]bool) - for _, b := range bundles { - bundleIDs[b.ID] = true - } - - expectedBundles := []string{"newsletters", "receipts", "social", "updates", "promotions"} - for _, id := range expectedBundles { - if !bundleIDs[id] { - t.Errorf("expected bundle %q not found", id) - } - } -} - -func TestIntegration_CategorizeEmailNewsletter(t *testing.T) { - server := testServer(t) - - body := map[string]string{ - "from": "newsletter@company.com", - "subject": "Your weekly digest is here", - "emailId": "test-email-1", - } - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/bundles/categorize", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleBundleCategorize(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var result map[string]string - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if result["bundleId"] != "newsletters" { - t.Errorf("expected bundleId 'newsletters', got %q", result["bundleId"]) - } -} - -func TestIntegration_CategorizeEmailReceipt(t *testing.T) { - server := testServer(t) - - body := map[string]string{ - "from": "noreply@amazon.com", - "subject": "Your order confirmation #12345", - "emailId": "test-email-2", - } - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/bundles/categorize", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleBundleCategorize(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var result map[string]string - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if result["bundleId"] != "receipts" { - t.Errorf("expected bundleId 'receipts', got %q", result["bundleId"]) - } -} - -func TestIntegration_CategorizeEmailSocial(t *testing.T) { - server := testServer(t) - - body := map[string]string{ - "from": "notifications@twitter.com", - "subject": "You have new followers", - "emailId": "test-email-3", - } - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/bundles/categorize", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleBundleCategorize(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var result map[string]string - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if result["bundleId"] != "social" { - t.Errorf("expected bundleId 'social', got %q", result["bundleId"]) - } -} - -func TestIntegration_CategorizeEmailPromotion(t *testing.T) { - server := testServer(t) - - body := map[string]string{ - "from": "deals@shop.com", - "subject": "SALE: 50% off everything today!", - "emailId": "test-email-4", - } - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/bundles/categorize", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleBundleCategorize(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var result map[string]string - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if result["bundleId"] != "promotions" { - t.Errorf("expected bundleId 'promotions', got %q", result["bundleId"]) - } -} - -func TestIntegration_CategorizeEmailNoMatch(t *testing.T) { - server := testServer(t) - - body := map[string]string{ - "from": "friend@gmail.com", - "subject": "Hey, how are you doing?", - "emailId": "test-email-5", - } - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/bundles/categorize", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleBundleCategorize(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var result map[string]string - if err := json.NewDecoder(w.Body).Decode(&result); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Personal email should not match any bundle - if result["bundleId"] != "" { - t.Errorf("expected no bundleId for personal email, got %q", result["bundleId"]) - } -} - -func TestIntegration_GetBundleEmails(t *testing.T) { - server := testServer(t) - - // First categorize an email - body := map[string]string{ - "from": "newsletter@test.com", - "subject": "Weekly newsletter", - "emailId": "bundle-test-email", - } - bodyBytes, _ := json.Marshal(body) - - req := httptest.NewRequest(http.MethodPost, "/api/bundles/categorize", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - server.handleBundleCategorize(w, req) - - // Then get emails in bundle - req = httptest.NewRequest(http.MethodGet, "/api/bundles/emails?bundleId=newsletters", nil) - w = httptest.NewRecorder() - - server.handleGetBundleEmails(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var emailIds []string - if err := json.NewDecoder(w.Body).Decode(&emailIds); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Should contain our categorized email - found := false - for _, id := range emailIds { - if id == "bundle-test-email" { - found = true - break - } - } - - if !found { - t.Error("expected to find categorized email in bundle") - } -} - -func TestIntegration_UpdateBundle(t *testing.T) { - server := testServer(t) - - bundle := Bundle{ - ID: "newsletters", - Name: "My Newsletters", - Icon: "📧", - Collapsed: false, - } - bodyBytes, _ := json.Marshal(bundle) - - req := httptest.NewRequest(http.MethodPut, "/api/bundles", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleUpdateBundle(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify update persisted - req = httptest.NewRequest(http.MethodGet, "/api/bundles", nil) - w = httptest.NewRecorder() - server.handleGetBundles(w, req) - - var bundles []*Bundle - if err := json.NewDecoder(w.Body).Decode(&bundles); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - for _, b := range bundles { - if b.ID == "newsletters" { - if b.Name != "My Newsletters" { - t.Errorf("expected name 'My Newsletters', got %q", b.Name) - } - if b.Collapsed { - t.Error("expected collapsed to be false") - } - break - } - } -} diff --git a/internal/air/integration_cache_test.go b/internal/air/integration_cache_test.go deleted file mode 100644 index 742d343..0000000 --- a/internal/air/integration_cache_test.go +++ /dev/null @@ -1,123 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -// CACHE INTEGRATION TESTS -// ================================ - -func TestIntegration_CacheStatus(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/cache/status", nil) - w := httptest.NewRecorder() - - server.handleCacheStatus(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp CacheStatusResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Cache status: enabled=%v, online=%v, sync_interval=%d min", - resp.Enabled, resp.Online, resp.SyncInterval) - - if len(resp.Accounts) > 0 { - for _, acc := range resp.Accounts { - t.Logf(" Account: %s (emails=%d, events=%d, size=%d bytes)", - acc.Email, acc.EmailCount, acc.EventCount, acc.SizeBytes) - } - } -} - -func TestIntegration_CacheSettings(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/cache/settings", nil) - w := httptest.NewRecorder() - - server.handleCacheSettings(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp CacheSettingsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Cache settings: enabled=%v, max_size=%dMB, ttl=%d days, theme=%s", - resp.Enabled, resp.MaxSizeMB, resp.TTLDays, resp.Theme) - - // Verify settings have reasonable values - if resp.MaxSizeMB < 50 { - t.Errorf("MaxSizeMB should be at least 50, got %d", resp.MaxSizeMB) - } - if resp.TTLDays < 1 { - t.Errorf("TTLDays should be at least 1, got %d", resp.TTLDays) - } -} - -func TestIntegration_CacheSearch_EmptyQuery(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/cache/search?q=", nil) - w := httptest.NewRecorder() - - server.handleCacheSearch(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp CacheSearchResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Empty query should return empty results - if len(resp.Results) != 0 { - t.Errorf("expected empty results for empty query, got %d", len(resp.Results)) - } -} - -func TestIntegration_CacheSearch_WithQuery(t *testing.T) { - server := testServer(t) - - // Search for a common term - req := httptest.NewRequest(http.MethodGet, "/api/cache/search?q=test", nil) - w := httptest.NewRecorder() - - server.handleCacheSearch(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp CacheSearchResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Cache search for 'test': found %d results", len(resp.Results)) - - for i, r := range resp.Results { - if i < 5 { // Log first 5 results - t.Logf(" [%s] %s - %s", r.Type, r.Title, r.Subtitle) - } - } -} - -// ================================ diff --git a/internal/air/integration_calendar_test.go b/internal/air/integration_calendar_test.go deleted file mode 100644 index b05f73c..0000000 --- a/internal/air/integration_calendar_test.go +++ /dev/null @@ -1,479 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// CALENDARS INTEGRATION TESTS -// ================================ - -func TestIntegration_ListCalendars(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/calendars", nil) - w := httptest.NewRecorder() - - server.handleListCalendars(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp CalendarsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if len(resp.Calendars) == 0 { - t.Error("expected at least one calendar") - } - - // Check for primary calendar - hasPrimary := false - for _, c := range resp.Calendars { - if c.IsPrimary { - hasPrimary = true - t.Logf("Primary calendar: %s (%s)", c.Name, c.ID) - } else { - t.Logf("Calendar: %s (read_only: %v)", c.Name, c.ReadOnly) - } - } - - if !hasPrimary { - t.Error("expected a primary calendar") - } -} - -// ================================ -// EVENTS INTEGRATION TESTS -// ================================ - -func TestIntegration_ListEvents(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/events?limit=10", nil) - w := httptest.NewRecorder() - - server.handleListEvents(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp EventsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d events (has_more: %v)", len(resp.Events), resp.HasMore) - - for _, event := range resp.Events { - if event.ID == "" { - t.Error("expected event to have ID") - } - if event.StartTime == 0 { - t.Error("expected event to have StartTime") - } - - startTime := time.Unix(event.StartTime, 0) - t.Logf("Event: %s @ %s", event.Title, startTime.Format("2006-01-02 15:04")) - } -} - -func TestIntegration_ListEvents_WithDateRange(t *testing.T) { - server := testServer(t) - - // Get events for the current week - now := time.Now() - startOfWeek := now.AddDate(0, 0, -int(now.Weekday())).Truncate(24 * time.Hour) - endOfWeek := startOfWeek.AddDate(0, 0, 7) - - req := httptest.NewRequest(http.MethodGet, - "/api/events?limit=20&start="+formatInt64(startOfWeek.Unix())+"&end="+formatInt64(endOfWeek.Unix()), nil) - w := httptest.NewRecorder() - - server.handleListEvents(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp EventsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d events for week %s - %s", - len(resp.Events), - startOfWeek.Format("2006-01-02"), - endOfWeek.Format("2006-01-02")) -} - -func TestIntegration_CreateUpdateDeleteEvent(t *testing.T) { - server := testServer(t) - - // Step 1: Create an event - createBody := `{ - "calendar_id": "primary", - "title": "Air Integration Test Event", - "description": "Test event created by integration tests", - "start_time": ` + formatInt64(time.Now().Add(24*time.Hour).Unix()) + `, - "end_time": ` + formatInt64(time.Now().Add(25*time.Hour).Unix()) + `, - "busy": true - }` - - createReq := httptest.NewRequest(http.MethodPost, "/api/events", strings.NewReader(createBody)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - - server.handleEventsRoute(createW, createReq) - - if createW.Code != http.StatusOK { - t.Fatalf("create event: expected status 200, got %d: %s", createW.Code, createW.Body.String()) - } - - var createResp EventActionResponse - if err := json.NewDecoder(createW.Body).Decode(&createResp); err != nil { - t.Fatalf("create event: failed to decode response: %v", err) - } - - if !createResp.Success { - t.Fatal("create event: expected success to be true") - } - - if createResp.Event == nil { - t.Fatal("create event: expected event in response") - } - - eventID := createResp.Event.ID - calendarID := createResp.Event.CalendarID - t.Logf("Created event: %s (calendar: %s)", eventID, calendarID) - - // Cleanup: delete the event at the end of the test - defer func() { - deleteReq := httptest.NewRequest(http.MethodDelete, - "/api/events/"+eventID+"?calendar_id="+calendarID, nil) - deleteW := httptest.NewRecorder() - server.handleEventByID(deleteW, deleteReq) - t.Logf("Cleanup: deleted event %s (status: %d)", eventID, deleteW.Code) - }() - - // Step 2: Update the event - updateBody := `{ - "title": "Updated Air Integration Test Event", - "description": "Updated description" - }` - - updateReq := httptest.NewRequest(http.MethodPut, - "/api/events/"+eventID+"?calendar_id="+calendarID, - strings.NewReader(updateBody)) - updateReq.Header.Set("Content-Type", "application/json") - updateW := httptest.NewRecorder() - - server.handleEventByID(updateW, updateReq) - - if updateW.Code != http.StatusOK { - t.Fatalf("update event: expected status 200, got %d: %s", updateW.Code, updateW.Body.String()) - } - - var updateResp EventActionResponse - if err := json.NewDecoder(updateW.Body).Decode(&updateResp); err != nil { - t.Fatalf("update event: failed to decode response: %v", err) - } - - if !updateResp.Success { - t.Fatal("update event: expected success to be true") - } - - t.Logf("Updated event: %s", eventID) - - // Step 3: Get the event to verify update - getReq := httptest.NewRequest(http.MethodGet, - "/api/events/"+eventID+"?calendar_id="+calendarID, nil) - getW := httptest.NewRecorder() - - server.handleEventByID(getW, getReq) - - if getW.Code != http.StatusOK { - t.Fatalf("get event: expected status 200, got %d: %s", getW.Code, getW.Body.String()) - } - - var getResp EventResponse - if err := json.NewDecoder(getW.Body).Decode(&getResp); err != nil { - t.Fatalf("get event: failed to decode response: %v", err) - } - - if getResp.Title != "Updated Air Integration Test Event" { - t.Errorf("get event: expected updated title, got '%s'", getResp.Title) - } - - t.Logf("Verified event update: title=%s", getResp.Title) -} - -func TestIntegration_EventByID_NotFound(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/events/nonexistent-event-12345?calendar_id=primary", nil) - w := httptest.NewRecorder() - - server.handleEventByID(w, req) - - // Should return 404 Not Found - if w.Code != http.StatusNotFound { - t.Logf("Note: got status %d for non-existent event (may vary by provider)", w.Code) - } -} - -// ================================ -// ================================ -// AVAILABILITY INTEGRATION TESTS -// ================================ - -func TestIntegration_Availability(t *testing.T) { - server := testServer(t) - - // Get availability for next week - now := time.Now() - startTime := now.Unix() - endTime := now.Add(7 * 24 * time.Hour).Unix() - - // Get current user email - grants, _ := server.grantStore.ListGrants() - defaultID, _ := server.grantStore.GetDefaultGrant() - var email string - for _, g := range grants { - if g.ID == defaultID { - email = g.Email - break - } - } - - if email == "" { - t.Skip("Skipping: no default grant email found") - } - - body := `{ - "start_time": ` + formatInt64(startTime) + `, - "end_time": ` + formatInt64(endTime) + `, - "duration_minutes": 30, - "participants": ["` + email + `"] - }` - - req := httptest.NewRequest(http.MethodPost, "/api/availability", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleAvailability(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp AvailabilityResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d available slots", len(resp.Slots)) - - for i, slot := range resp.Slots { - if i < 5 { // Log first 5 slots - start := time.Unix(slot.StartTime, 0) - end := time.Unix(slot.EndTime, 0) - t.Logf(" Slot: %s - %s", start.Format("2006-01-02 15:04"), end.Format("15:04")) - } - } -} - -func TestIntegration_Availability_GET(t *testing.T) { - server := testServer(t) - - // Get availability using query params - now := time.Now() - startTime := now.Unix() - endTime := now.Add(7 * 24 * time.Hour).Unix() - - req := httptest.NewRequest(http.MethodGet, - "/api/availability?start_time="+formatInt64(startTime)+ - "&end_time="+formatInt64(endTime)+ - "&duration_minutes=60", nil) - w := httptest.NewRecorder() - - server.handleAvailability(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp AvailabilityResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d available 60-minute slots", len(resp.Slots)) -} - -// ================================ -// FREE/BUSY INTEGRATION TESTS -// ================================ - -func TestIntegration_FreeBusy(t *testing.T) { - server := testServer(t) - - // Get free/busy for next week - now := time.Now() - startTime := now.Unix() - endTime := now.Add(7 * 24 * time.Hour).Unix() - - // Get current user email - grants, _ := server.grantStore.ListGrants() - defaultID, _ := server.grantStore.GetDefaultGrant() - var email string - for _, g := range grants { - if g.ID == defaultID { - email = g.Email - break - } - } - - if email == "" { - t.Skip("Skipping: no default grant email found") - } - - body := `{ - "start_time": ` + formatInt64(startTime) + `, - "end_time": ` + formatInt64(endTime) + `, - "emails": ["` + email + `"] - }` - - req := httptest.NewRequest(http.MethodPost, "/api/freebusy", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - server.handleFreeBusy(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp FreeBusyResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Got free/busy data for %d calendars", len(resp.Data)) - - for _, cal := range resp.Data { - t.Logf(" %s: %d busy slots", cal.Email, len(cal.TimeSlots)) - for i, slot := range cal.TimeSlots { - if i < 3 { // Log first 3 slots - start := time.Unix(slot.StartTime, 0) - end := time.Unix(slot.EndTime, 0) - t.Logf(" %s: %s - %s", slot.Status, start.Format("2006-01-02 15:04"), end.Format("15:04")) - } - } - } -} - -func TestIntegration_FreeBusy_GET(t *testing.T) { - server := testServer(t) - - // Get free/busy using query params - now := time.Now() - startTime := now.Unix() - endTime := now.Add(7 * 24 * time.Hour).Unix() - - req := httptest.NewRequest(http.MethodGet, - "/api/freebusy?start_time="+formatInt64(startTime)+ - "&end_time="+formatInt64(endTime), nil) - w := httptest.NewRecorder() - - server.handleFreeBusy(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp FreeBusyResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Got free/busy data via GET for %d calendars", len(resp.Data)) -} - -// ================================ -// CONFLICTS INTEGRATION TESTS -// ================================ - -func TestIntegration_Conflicts(t *testing.T) { - server := testServer(t) - - // Check for conflicts in current week - now := time.Now() - startOfWeek := now.AddDate(0, 0, -int(now.Weekday())).Truncate(24 * time.Hour) - endOfWeek := startOfWeek.AddDate(0, 0, 7) - - req := httptest.NewRequest(http.MethodGet, - "/api/events/conflicts?start_time="+formatInt64(startOfWeek.Unix())+ - "&end_time="+formatInt64(endOfWeek.Unix()), nil) - w := httptest.NewRecorder() - - server.handleConflicts(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp ConflictsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d conflicts for week %s - %s", - len(resp.Conflicts), - startOfWeek.Format("2006-01-02"), - endOfWeek.Format("2006-01-02")) - - for _, conflict := range resp.Conflicts { - t.Logf(" Conflict: '%s' overlaps with '%s'", - conflict.Event1.Title, conflict.Event2.Title) - } -} - -func TestIntegration_Conflicts_NextMonth(t *testing.T) { - server := testServer(t) - - // Check for conflicts in next month - now := time.Now() - startOfMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location()) - endOfMonth := startOfMonth.AddDate(0, 1, 0) - - req := httptest.NewRequest(http.MethodGet, - "/api/events/conflicts?calendar_id=primary&start_time="+formatInt64(startOfMonth.Unix())+ - "&end_time="+formatInt64(endOfMonth.Unix()), nil) - w := httptest.NewRecorder() - - server.handleConflicts(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp ConflictsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d conflicts for month %s", len(resp.Conflicts), startOfMonth.Format("2006-01")) -} - -// ================================ diff --git a/internal/air/integration_contacts_test.go b/internal/air/integration_contacts_test.go deleted file mode 100644 index a117ecb..0000000 --- a/internal/air/integration_contacts_test.go +++ /dev/null @@ -1,161 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -// CONTACTS INTEGRATION TESTS -// ================================ - -func TestIntegration_ListContacts(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/contacts?limit=10", nil) - w := httptest.NewRecorder() - - server.handleListContacts(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp ContactsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d contacts (has_more: %v)", len(resp.Contacts), resp.HasMore) - - if len(resp.Contacts) == 0 { - t.Log("Warning: no contacts found in account") - return - } - - // Verify contact structure - first := resp.Contacts[0] - if first.ID == "" { - t.Error("expected contact to have ID") - } - - t.Logf("First contact: %s (%s)", first.DisplayName, first.ID) -} - -func TestIntegration_ListContacts_WithGroup(t *testing.T) { - server := testServer(t) - - // First get list of groups - groupReq := httptest.NewRequest(http.MethodGet, "/api/contacts/groups", nil) - groupW := httptest.NewRecorder() - server.handleContactGroups(groupW, groupReq) - - if groupW.Code != http.StatusOK { - t.Skipf("Skipping: cannot get contact groups: %s", groupW.Body.String()) - } - - var groupResp ContactGroupsResponse - if err := json.NewDecoder(groupW.Body).Decode(&groupResp); err != nil { - t.Fatalf("failed to decode group response: %v", err) - } - - if len(groupResp.Groups) == 0 { - t.Skip("Skipping: no contact groups in account") - } - - // Test filtering by first group - groupID := groupResp.Groups[0].ID - req := httptest.NewRequest(http.MethodGet, "/api/contacts?group="+groupID+"&limit=5", nil) - w := httptest.NewRecorder() - - server.handleListContacts(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp ContactsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d contacts in group %s", len(resp.Contacts), groupResp.Groups[0].Name) -} - -func TestIntegration_GetContact(t *testing.T) { - server := testServer(t) - - // First get a list of contacts to get a valid ID - listReq := httptest.NewRequest(http.MethodGet, "/api/contacts?limit=1", nil) - listW := httptest.NewRecorder() - server.handleListContacts(listW, listReq) - - if listW.Code != http.StatusOK { - t.Skipf("Skipping: cannot list contacts: %s", listW.Body.String()) - } - - var listResp ContactsResponse - if err := json.NewDecoder(listW.Body).Decode(&listResp); err != nil { - t.Fatalf("failed to decode list response: %v", err) - } - - if len(listResp.Contacts) == 0 { - t.Skip("Skipping: no contacts in account to test") - } - - contactID := listResp.Contacts[0].ID - - // Now get the specific contact - req := httptest.NewRequest(http.MethodGet, "/api/contacts/"+contactID, nil) - w := httptest.NewRecorder() - - server.handleContactByID(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp ContactResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.ID != contactID { - t.Errorf("expected ID %s, got %s", contactID, resp.ID) - } - - t.Logf("Got contact: %s (%s)", resp.DisplayName, resp.ID) -} - -func TestIntegration_ContactGroups(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/contacts/groups", nil) - w := httptest.NewRecorder() - - server.handleContactGroups(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp ContactGroupsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d contact groups", len(resp.Groups)) - - for _, g := range resp.Groups { - if g.ID == "" { - t.Error("expected group to have ID") - } - t.Logf("Group: %s (%s)", g.Name, g.ID) - } -} - -// ================================ diff --git a/internal/air/integration_core_test.go b/internal/air/integration_core_test.go deleted file mode 100644 index ee3bd6f..0000000 --- a/internal/air/integration_core_test.go +++ /dev/null @@ -1,196 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -// ================================ -// CONFIG INTEGRATION TESTS -// ================================ - -func TestIntegration_ConfigStatus(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/config", nil) - w := httptest.NewRecorder() - - server.handleConfigStatus(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp ConfigStatusResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Configured { - t.Error("expected Configured to be true") - } - - if !resp.HasAPIKey { - t.Error("expected HasAPIKey to be true") - } - - // Note: GrantCount and DefaultGrant may be empty if ConfigService - // doesn't have access to the grant store. This is not a failure, - // just log the values for debugging. - t.Logf("Config: region=%s, grants=%d, default=%s", resp.Region, resp.GrantCount, resp.DefaultGrant) -} - -// ================================ -// GRANTS INTEGRATION TESTS -// ================================ - -func TestIntegration_ListGrants(t *testing.T) { - server := testServer(t) - - 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: %s", w.Code, w.Body.String()) - } - - 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) == 0 { - t.Error("expected at least one grant") - } - - if resp.DefaultGrant == "" { - t.Error("expected DefaultGrant to be set") - } - - // Find the Google grant - hasGoogle := false - for _, g := range resp.Grants { - if g.Provider == "google" { - hasGoogle = true - t.Logf("Found Google grant: %s (%s)", g.Email, g.ID) - } - } - - if !hasGoogle { - t.Error("expected at least one Google grant") - } -} - -// ================================ -// FOLDERS INTEGRATION TESTS -// ================================ - -func TestIntegration_ListFolders(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/folders", nil) - w := httptest.NewRecorder() - - server.handleListFolders(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp FoldersResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if len(resp.Folders) == 0 { - t.Error("expected at least one folder") - } - - // Check for standard Gmail folders/labels - folderNames := make(map[string]bool) - for _, f := range resp.Folders { - folderNames[f.Name] = true - if f.SystemFolder != "" { - t.Logf("Folder: %s (system: %s, unread: %d)", f.Name, f.SystemFolder, f.UnreadCount) - } - } - - // Gmail should have INBOX - if !folderNames["INBOX"] && !folderNames["Inbox"] { - t.Log("Warning: INBOX folder not found (may have different name)") - } -} - -// ================================ -// INDEX PAGE INTEGRATION TESTS -// ================================ - -func TestIntegration_IndexPage(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - - server.handleIndex(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - body := w.Body.String() - - // Check for basic HTML structure - if !containsAny(body, " 0 { - t.Error("expected Emails to be empty (loaded via JS)") - } - - if len(data.Events) > 0 { - t.Error("expected Events to be empty (loaded via JS)") - } - - t.Logf("Page data: user=%s, provider=%s, grants=%d", - data.UserEmail, data.Provider, len(data.Grants)) -} diff --git a/internal/air/integration_email_test.go b/internal/air/integration_email_test.go deleted file mode 100644 index 43c416c..0000000 --- a/internal/air/integration_email_test.go +++ /dev/null @@ -1,165 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -// EMAILS INTEGRATION TESTS -// ================================ - -func TestIntegration_ListEmails(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/emails?limit=5", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp EmailsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d emails (has_more: %v)", len(resp.Emails), resp.HasMore) - - if len(resp.Emails) == 0 { - t.Log("Warning: no emails found in account") - return - } - - // Verify email structure - first := resp.Emails[0] - if first.ID == "" { - t.Error("expected email to have ID") - } - if len(first.From) == 0 { - t.Error("expected email to have From") - } - if first.Date == 0 { - t.Error("expected email to have Date") - } - - t.Logf("First email: %s from %s", first.Subject, first.From[0].Email) -} - -func TestIntegration_ListEmails_WithFilters(t *testing.T) { - server := testServer(t) - - // Test unread filter - req := httptest.NewRequest(http.MethodGet, "/api/emails?limit=5&unread=true", nil) - w := httptest.NewRecorder() - - server.handleListEmails(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp EmailsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d unread emails", len(resp.Emails)) - - // All returned emails should be unread - for _, email := range resp.Emails { - if !email.Unread { - t.Errorf("expected email %s to be unread", email.ID) - } - } -} - -func TestIntegration_GetEmail(t *testing.T) { - server := testServer(t) - - // First get a list of emails to get a valid ID - listReq := httptest.NewRequest(http.MethodGet, "/api/emails?limit=1", nil) - listW := httptest.NewRecorder() - server.handleListEmails(listW, listReq) - - if listW.Code != http.StatusOK { - t.Skipf("Skipping: cannot list emails: %s", listW.Body.String()) - } - - var listResp EmailsResponse - if err := json.NewDecoder(listW.Body).Decode(&listResp); err != nil { - t.Fatalf("failed to decode list response: %v", err) - } - - if len(listResp.Emails) == 0 { - t.Skip("Skipping: no emails in account to test") - } - - emailID := listResp.Emails[0].ID - - // Now get the specific email - req := httptest.NewRequest(http.MethodGet, "/api/emails/"+emailID, nil) - w := httptest.NewRecorder() - - server.handleEmailByID(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp EmailResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.ID != emailID { - t.Errorf("expected ID %s, got %s", emailID, resp.ID) - } - - // Full email should have body - if resp.Body == "" { - t.Log("Warning: email body is empty") - } - - t.Logf("Got email: %s (body length: %d)", resp.Subject, len(resp.Body)) -} - -// ================================ -// DRAFTS INTEGRATION TESTS -// ================================ - -func TestIntegration_ListDrafts(t *testing.T) { - server := testServer(t) - - req := httptest.NewRequest(http.MethodGet, "/api/drafts", nil) - w := httptest.NewRecorder() - - server.handleDrafts(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp DraftsResponse - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - t.Logf("Found %d drafts", len(resp.Drafts)) - - // Drafts are optional, so just verify the response structure - for _, draft := range resp.Drafts { - if draft.ID == "" { - t.Error("expected draft to have ID") - } - t.Logf("Draft: %s", draft.Subject) - } -} - -// ================================ diff --git a/internal/air/integration_middleware_test.go b/internal/air/integration_middleware_test.go deleted file mode 100644 index 3c64b47..0000000 --- a/internal/air/integration_middleware_test.go +++ /dev/null @@ -1,254 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestIntegration_Middleware_Compression(t *testing.T) { - // Create test server with middleware - mux := http.NewServeMux() - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(strings.Repeat("test data ", 100))) // 1000 bytes uncompressed - })) - - handler := CompressionMiddleware(mux) - ts := httptest.NewServer(handler) - defer ts.Close() - - // Test WITH gzip - client := &http.Client{} - req, _ := http.NewRequest(http.MethodGet, ts.URL, nil) - req.Header.Set("Accept-Encoding", "gzip") - resp, err := client.Do(req) - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.Header.Get("Content-Encoding") != "gzip" { - t.Error("expected Content-Encoding: gzip") - } - - // Test WITHOUT gzip - req2, _ := http.NewRequest(http.MethodGet, ts.URL, nil) - resp2, err := client.Do(req2) - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer resp2.Body.Close() - - if resp2.Header.Get("Content-Encoding") == "gzip" { - t.Error("expected no gzip compression without Accept-Encoding header") - } - - t.Log("✓ Compression middleware working correctly") -} - -// TestIntegration_Middleware_SecurityHeaders verifies all security headers are present. -func TestIntegration_Middleware_SecurityHeaders(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - - // Use the server's handler (which has all middleware applied) - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := SecurityHeadersMiddleware(mux) - handler.ServeHTTP(w, req) - - requiredHeaders := map[string]string{ - "X-Frame-Options": "SAMEORIGIN", - "X-Content-Type-Options": "nosniff", - "X-XSS-Protection": "1; mode=block", - "Referrer-Policy": "strict-origin-when-cross-origin", - "Content-Security-Policy": "", // Just verify it exists - } - - for header, expected := range requiredHeaders { - actual := w.Header().Get(header) - if actual == "" { - t.Errorf("missing security header: %s", header) - } else if expected != "" && actual != expected { - t.Errorf("%s: expected %q, got %q", header, expected, actual) - } - } - - // Verify CSP includes correct API endpoints - csp := w.Header().Get("Content-Security-Policy") - if !strings.Contains(csp, "https://api.us.nylas.com") { - t.Error("CSP should include api.us.nylas.com") - } - if !strings.Contains(csp, "https://api.eu.nylas.com") { - t.Error("CSP should include api.eu.nylas.com") - } - - t.Log("✓ All security headers present and correct") -} - -// TestIntegration_Middleware_CacheHeaders verifies cache headers based on endpoint type. -func TestIntegration_Middleware_CacheHeaders(t *testing.T) { - tests := []struct { - path string - expectedCache string - }{ - // CSS files use minimal caching for instant updates during local development - {"/static/css/main.css", "no-cache, must-revalidate"}, - // API responses are never cached - {"/api/emails", "no-cache, no-store, must-revalidate"}, - // HTML pages use minimal caching for instant updates - {"/", "no-cache, must-revalidate"}, - } - - for _, tt := range tests { - req := httptest.NewRequest(http.MethodGet, tt.path, nil) - w := httptest.NewRecorder() - - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := CacheMiddleware(mux) - handler.ServeHTTP(w, req) - - cacheControl := w.Header().Get("Cache-Control") - if cacheControl != tt.expectedCache { - t.Errorf("%s: expected Cache-Control %q, got %q", tt.path, tt.expectedCache, cacheControl) - } else { - t.Logf("✓ %s: correct cache headers", tt.path) - } - } -} - -// TestIntegration_Middleware_CORS verifies CORS headers and preflight. -func TestIntegration_Middleware_CORS(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/api/test", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := HostValidationMiddleware(CORSMiddleware(mux)) - - // Test regular request - req := httptest.NewRequest(http.MethodGet, "/api/test", nil) - req.Host = "localhost:7365" - req.Header.Set("Origin", "http://localhost:7365") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Header().Get("Access-Control-Allow-Origin") != "http://localhost:7365" { - t.Error("expected reflected same-origin Access-Control-Allow-Origin header") - } - - // Test OPTIONS preflight - reqOptions := httptest.NewRequest(http.MethodOptions, "/api/test", nil) - reqOptions.Host = "localhost:7365" - reqOptions.Header.Set("Origin", "http://localhost:7365") - wOptions := httptest.NewRecorder() - handler.ServeHTTP(wOptions, reqOptions) - - if wOptions.Code != http.StatusNoContent { - t.Errorf("OPTIONS should return 204, got %d", wOptions.Code) - } - - if wOptions.Header().Get("Access-Control-Allow-Methods") == "" { - t.Error("OPTIONS should include Access-Control-Allow-Methods") - } - - reqCrossOrigin := httptest.NewRequest(http.MethodOptions, "/api/test", nil) - reqCrossOrigin.Host = "localhost:7365" - reqCrossOrigin.Header.Set("Origin", "https://evil.example") - wCrossOrigin := httptest.NewRecorder() - handler.ServeHTTP(wCrossOrigin, reqCrossOrigin) - - if wCrossOrigin.Code != http.StatusForbidden { - t.Errorf("cross-origin OPTIONS should return 403, got %d", wCrossOrigin.Code) - } - - t.Log("✓ CORS headers working correctly") -} - -// TestIntegration_Middleware_PerformanceMonitoring verifies Server-Timing headers. -func TestIntegration_Middleware_PerformanceMonitoring(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - time.Sleep(5 * time.Millisecond) // Simulate work - w.WriteHeader(http.StatusOK) - }) - - handler := PerformanceMonitoringMiddleware(mux) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - serverTiming := w.Header().Get("Server-Timing") - if serverTiming == "" { - t.Error("expected Server-Timing header") - } - - if !strings.HasPrefix(serverTiming, "total;dur=") { - t.Errorf("expected Server-Timing format 'total;dur=X', got %s", serverTiming) - } - - t.Logf("✓ Server-Timing header present: %s", serverTiming) -} - -// TestIntegration_Middleware_FullStack verifies all middleware working together. -func TestIntegration_Middleware_FullStack(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/api/test", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(strings.Repeat("integration test data ", 50))) - }) - - // Apply full middleware stack (same order as server.go) - handler := HostValidationMiddleware( - CORSMiddleware( - OriginProtectionMiddleware( - SecurityHeadersMiddleware( - CompressionMiddleware( - CacheMiddleware( - PerformanceMonitoringMiddleware(mux))))))) - - req := httptest.NewRequest(http.MethodGet, "/api/test", nil) - req.Host = "localhost:7365" - req.Header.Set("Origin", "http://localhost:7365") - req.Header.Set("Accept-Encoding", "gzip") - w := httptest.NewRecorder() - - handler.ServeHTTP(w, req) - - // Verify all middleware features are active - checks := map[string]func() bool{ - "CORS": func() bool { return w.Header().Get("Access-Control-Allow-Origin") == "http://localhost:7365" }, - "Security": func() bool { return w.Header().Get("X-Frame-Options") == "SAMEORIGIN" }, - "Compression": func() bool { return w.Header().Get("Content-Encoding") == "gzip" }, - "Cache": func() bool { return w.Header().Get("Cache-Control") == "no-cache, no-store, must-revalidate" }, - "Performance": func() bool { return strings.HasPrefix(w.Header().Get("Server-Timing"), "total;dur=") }, - } - - for name, check := range checks { - if !check() { - t.Errorf("%s middleware not working in full stack", name) - } else { - t.Logf("✓ %s middleware active", name) - } - } - - t.Log("✓ Full middleware stack integration successful") -} diff --git a/internal/air/integration_productivity_test.go b/internal/air/integration_productivity_test.go deleted file mode 100644 index 18c0504..0000000 --- a/internal/air/integration_productivity_test.go +++ /dev/null @@ -1,348 +0,0 @@ -//go:build integration -// +build integration - -package air - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// TestIntegration_FocusMode tests focus mode endpoints -func TestIntegration_FocusMode(t *testing.T) { - server := testServer(t) - - // Test GET focus mode state - req := httptest.NewRequest(http.MethodGet, "/api/focus", nil) - w := httptest.NewRecorder() - server.handleFocusModeRoute(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var stateResp map[string]any - if err := json.NewDecoder(w.Body).Decode(&stateResp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Verify we got a state back - if _, ok := stateResp["state"]; !ok { - t.Error("expected 'state' key in response") - } - - // Test POST to start focus mode - startReq := httptest.NewRequest(http.MethodPost, "/api/focus", - strings.NewReader(`{"duration": 25, "pomodoroMode": true}`)) - startReq.Header.Set("Content-Type", "application/json") - w = httptest.NewRecorder() - server.handleFocusModeRoute(w, startReq) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 starting focus, got %d: %s", w.Code, w.Body.String()) - } - - var startResp FocusModeState - if err := json.NewDecoder(w.Body).Decode(&startResp); err != nil { - t.Fatalf("failed to decode start response: %v", err) - } - - if !startResp.IsActive { - t.Error("expected focus mode to be active after starting") - } - if startResp.Duration != 25 { - t.Errorf("expected duration 25, got %d", startResp.Duration) - } - if !startResp.PomodoroMode { - t.Error("expected pomodoro mode to be enabled") - } - - // Test DELETE to stop focus mode - stopReq := httptest.NewRequest(http.MethodDelete, "/api/focus", nil) - w = httptest.NewRecorder() - server.handleFocusModeRoute(w, stopReq) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 stopping focus, got %d: %s", w.Code, w.Body.String()) - } -} - -// TestIntegration_FocusModeSettings tests focus mode settings -func TestIntegration_FocusModeSettings(t *testing.T) { - server := testServer(t) - - // Test GET settings - req := httptest.NewRequest(http.MethodGet, "/api/focus/settings", nil) - w := httptest.NewRecorder() - server.handleFocusModeSettings(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var settings FocusModeSettings - if err := json.NewDecoder(w.Body).Decode(&settings); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Verify default settings - if settings.DefaultDuration <= 0 { - t.Error("expected positive default duration") - } - if settings.PomodoroWork <= 0 { - t.Error("expected positive pomodoro work time") - } -} - -// TestIntegration_ReplyLater tests reply later endpoints -func TestIntegration_ReplyLater(t *testing.T) { - server := testServer(t) - - // Test GET empty list - req := httptest.NewRequest(http.MethodGet, "/api/reply-later", nil) - w := httptest.NewRecorder() - server.handleReplyLaterRoute(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var items []*ReplyLaterItem - if err := json.NewDecoder(w.Body).Decode(&items); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Test POST to add item - addReq := httptest.NewRequest(http.MethodPost, "/api/reply-later", - strings.NewReader(`{"emailId": "test-email-123", "subject": "Test Subject", "from": "test@example.com", "priority": 1}`)) - addReq.Header.Set("Content-Type", "application/json") - w = httptest.NewRecorder() - server.handleReplyLaterRoute(w, addReq) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 adding item, got %d: %s", w.Code, w.Body.String()) - } - - var item ReplyLaterItem - if err := json.NewDecoder(w.Body).Decode(&item); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if item.EmailID != "test-email-123" { - t.Errorf("expected email ID 'test-email-123', got '%s'", item.EmailID) - } - if item.Priority != 1 { - t.Errorf("expected priority 1, got %d", item.Priority) - } -} - -// TestIntegration_Analytics tests analytics endpoints -func TestIntegration_Analytics(t *testing.T) { - server := testServer(t) - - // Test dashboard endpoint - req := httptest.NewRequest(http.MethodGet, "/api/analytics/dashboard", nil) - w := httptest.NewRecorder() - server.handleGetAnalyticsDashboard(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var analytics EmailAnalytics - if err := json.NewDecoder(w.Body).Decode(&analytics); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Verify analytics data - if analytics.TotalReceived < 0 { - t.Error("expected non-negative total received") - } - - // Test trends endpoint - req = httptest.NewRequest(http.MethodGet, "/api/analytics/trends?period=week", nil) - w = httptest.NewRecorder() - server.handleGetAnalyticsTrends(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 for trends, got %d: %s", w.Code, w.Body.String()) - } - - // Test productivity stats - req = httptest.NewRequest(http.MethodGet, "/api/analytics/productivity", nil) - w = httptest.NewRecorder() - server.handleGetProductivityStats(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 for productivity, got %d: %s", w.Code, w.Body.String()) - } -} - -// TestIntegration_Notetaker tests notetaker endpoints -func TestIntegration_Notetaker(t *testing.T) { - server := testServer(t) - - // Test GET empty list - req := httptest.NewRequest(http.MethodGet, "/api/notetakers", nil) - w := httptest.NewRecorder() - server.handleNotetakersRoute(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - // Test POST to create notetaker - createReq := httptest.NewRequest(http.MethodPost, "/api/notetakers", - strings.NewReader(`{"meetingLink": "https://meet.google.com/abc-defg-hij"}`)) - createReq.Header.Set("Content-Type", "application/json") - w = httptest.NewRecorder() - server.handleNotetakersRoute(w, createReq) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 creating notetaker, got %d: %s", w.Code, w.Body.String()) - } - - var nt NotetakerResponse - if err := json.NewDecoder(w.Body).Decode(&nt); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if nt.ID == "" { - t.Error("expected notetaker ID") - } - if nt.Provider != "google_meet" { - t.Errorf("expected provider 'google_meet', got '%s'", nt.Provider) - } - // Accept both "scheduled" and "connecting" as valid initial states - // The Nylas API may return "connecting" initially before transitioning to "scheduled" - if nt.State != "scheduled" && nt.State != "connecting" { - t.Errorf("expected state 'scheduled' or 'connecting', got '%s'", nt.State) - } -} - -// TestIntegration_Screener tests screener endpoints -func TestIntegration_Screener(t *testing.T) { - server := testServer(t) - - // Test GET pending senders - req := httptest.NewRequest(http.MethodGet, "/api/screener", nil) - w := httptest.NewRecorder() - server.handleGetScreenedSenders(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - // Test adding to screener - addReq := httptest.NewRequest(http.MethodPost, "/api/screener/add", - strings.NewReader(`{"email": "new@sender.com", "name": "New Sender"}`)) - addReq.Header.Set("Content-Type", "application/json") - w = httptest.NewRecorder() - server.handleAddToScreener(w, addReq) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 adding to screener, got %d: %s", w.Code, w.Body.String()) - } - - // Test allowing sender - allowReq := httptest.NewRequest(http.MethodPost, "/api/screener/allow", - strings.NewReader(`{"email": "new@sender.com", "destination": "inbox"}`)) - allowReq.Header.Set("Content-Type", "application/json") - w = httptest.NewRecorder() - server.handleScreenerAllow(w, allowReq) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 allowing sender, got %d: %s", w.Code, w.Body.String()) - } -} - -// TestIntegration_AIConfig tests AI configuration endpoints -func TestIntegration_AIConfig(t *testing.T) { - server := testServer(t) - - // Test GET config - req := httptest.NewRequest(http.MethodGet, "/api/ai/config", nil) - w := httptest.NewRecorder() - server.handleAIConfigRoute(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var config AIConfig - if err := json.NewDecoder(w.Body).Decode(&config); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Verify config - if config.Provider == "" { - t.Error("expected provider to be set") - } - if config.Model == "" { - t.Error("expected model to be set") - } - - // Test GET providers - req = httptest.NewRequest(http.MethodGet, "/api/ai/providers", nil) - w = httptest.NewRecorder() - server.handleGetAIProviders(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 for providers, got %d: %s", w.Code, w.Body.String()) - } - - var providers []map[string]any - if err := json.NewDecoder(w.Body).Decode(&providers); err != nil { - t.Fatalf("failed to decode providers: %v", err) - } - - if len(providers) < 1 { - t.Error("expected at least one AI provider") - } -} - -// TestIntegration_ReadReceipts tests read receipt endpoints -func TestIntegration_ReadReceipts(t *testing.T) { - server := testServer(t) - - // Test GET receipts - req := httptest.NewRequest(http.MethodGet, "/api/receipts", nil) - w := httptest.NewRecorder() - server.handleGetReadReceipts(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - // Test GET settings - req = httptest.NewRequest(http.MethodGet, "/api/receipts/settings", nil) - w = httptest.NewRecorder() - server.handleReadReceiptSettings(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 for settings, got %d: %s", w.Code, w.Body.String()) - } - - var settings ReadReceiptSettings - if err := json.NewDecoder(w.Body).Decode(&settings); err != nil { - t.Fatalf("failed to decode settings: %v", err) - } - - // Test tracking pixel endpoint - req = httptest.NewRequest(http.MethodGet, "/api/track/open?id=test123", nil) - w = httptest.NewRecorder() - server.handleTrackOpen(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200 for tracking, got %d", w.Code) - } - - // Verify it returns a GIF - contentType := w.Header().Get("Content-Type") - if contentType != "image/gif" { - t.Errorf("expected Content-Type 'image/gif', got '%s'", contentType) - } -} diff --git a/internal/air/layout_regression_test.go b/internal/air/layout_regression_test.go deleted file mode 100644 index 1e7e0b0..0000000 --- a/internal/air/layout_regression_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package air - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// TestEmailListNoMaxHeightConstraint is a regression test to ensure that the -// email list is not constrained by the accessibility-aria.css [role="listbox"] rule. -// -// Bug history: The email list had role="listbox" for accessibility, but the -// generic [role="listbox"] selector in accessibility.css had max-height: 300px, -// which prevented the email list from filling the viewport height. -// -// Fix: Changed the selector to [role="listbox"]:not(.email-list) to exclude -// the email list from this constraint. -func TestEmailListNoMaxHeightConstraint(t *testing.T) { - t.Parallel() - - // Read the accessibility-aria.css file (ARIA roles are defined here) - cssPath := filepath.Join("static", "css", "accessibility-aria.css") - // #nosec G304 -- test reading project CSS file, path is hardcoded - content, err := os.ReadFile(cssPath) - if err != nil { - t.Fatalf("Failed to read accessibility-aria.css: %v", err) - } - - cssContent := string(content) - - // Verify that the [role="listbox"] selector excludes .email-list - if !strings.Contains(cssContent, `[role="listbox"]:not(.email-list)`) { - t.Error("accessibility-aria.css must use [role=\"listbox\"]:not(.email-list) to exclude email list from max-height constraint") - } - - // Verify the old broken selector is not present - if strings.Contains(cssContent, `[role="listbox"] {`) && !strings.Contains(cssContent, `:not(.email-list)`) { - t.Error("Found [role=\"listbox\"] without :not(.email-list) - this will constrain email list to 300px!") - } -} - -// TestEmailListContainerUsesGrid verifies that email-list-container uses CSS Grid -// instead of flexbox for more reliable height calculation. -func TestEmailListContainerUsesGrid(t *testing.T) { - t.Parallel() - - // Read the email-list.css file - cssPath := filepath.Join("static", "css", "email-list.css") - // #nosec G304 -- test reading project CSS file, path is hardcoded - content, err := os.ReadFile(cssPath) - if err != nil { - t.Fatalf("Failed to read email-list.css: %v", err) - } - - cssContent := string(content) - - // Verify .email-list-container uses CSS Grid - if !strings.Contains(cssContent, "display: grid") { - t.Error("email-list-container must use 'display: grid' for proper layout") - } - - if !strings.Contains(cssContent, "grid-template-rows: auto 1fr") { - t.Error("email-list-container must use 'grid-template-rows: auto 1fr' to size header and list") - } -} - -// TestEmailViewUsesGrid verifies that email-view uses CSS Grid for layout. -func TestEmailViewUsesGrid(t *testing.T) { - t.Parallel() - - // Read the calendar-grid.css file (where email-view.active is defined) - cssPath := filepath.Join("static", "css", "calendar-grid.css") - // #nosec G304 -- test reading project CSS file, path is hardcoded - content, err := os.ReadFile(cssPath) - if err != nil { - t.Fatalf("Failed to read calendar-grid.css: %v", err) - } - - cssContent := string(content) - - // Verify .email-view.active uses CSS Grid - if !strings.Contains(cssContent, ".email-view.active") { - t.Error("calendar-grid.css must define .email-view.active") - } - - // Check for grid layout (need to find the rule and verify it has display: grid) - emailViewActiveIndex := strings.Index(cssContent, ".email-view.active") - if emailViewActiveIndex == -1 { - t.Fatal("Could not find .email-view.active in calendar-grid.css") - } - - // Get the next 500 characters after .email-view.active to check for grid properties - snippet := cssContent[emailViewActiveIndex : emailViewActiveIndex+500] - - if !strings.Contains(snippet, "display: grid") { - t.Error("email-view.active must use 'display: grid'") - } - - if !strings.Contains(snippet, "grid-template-columns") { - t.Error("email-view.active must define grid-template-columns for sidebar|email-list|preview layout") - } -} - -// TestMainLayoutHasExplicitHeight verifies that main-layout has an explicit height -// calculation to ensure proper flexbox/grid sizing. -func TestMainLayoutHasExplicitHeight(t *testing.T) { - t.Parallel() - - // Read the layout.css file - cssPath := filepath.Join("static", "css", "layout.css") - // #nosec G304 -- test reading project CSS file, path is hardcoded - content, err := os.ReadFile(cssPath) - if err != nil { - t.Fatalf("Failed to read layout.css: %v", err) - } - - cssContent := string(content) - - // Verify .main-layout has height calculation - if !strings.Contains(cssContent, "calc(100vh") { - t.Error("main-layout should use calc(100vh - ...) for explicit height calculation") - } -} - -func TestAccountDropdownIsViewportBoundedAndScrollable(t *testing.T) { - t.Parallel() - - cssContent, err := staticFiles.ReadFile("static/css/components-account.css") - if err != nil { - t.Fatalf("failed to read components-account.css: %v", err) - } - - rule := cssRule(t, string(cssContent), ".account-dropdown") - required := []string{ - "max-height:", - "overflow-y: auto", - "overscroll-behavior: contain", - } - for _, declaration := range required { - if !strings.Contains(rule, declaration) { - t.Errorf(".account-dropdown must include %q so long account lists fit the viewport and scroll", declaration) - } - } -} - -func cssRule(t *testing.T, css, selector string) string { - t.Helper() - - start := strings.Index(css, selector+" {") - if start == -1 { - t.Fatalf("missing CSS rule for %s", selector) - } - end := strings.Index(css[start:], "}") - if end == -1 { - t.Fatalf("unterminated CSS rule for %s", selector) - } - return css[start : start+end] -} diff --git a/internal/air/layout_test.go b/internal/air/layout_test.go deleted file mode 100644 index aa878c4..0000000 --- a/internal/air/layout_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package air - -import ( - "testing" -) - -// TestEmailListContainerLayout verifies that the email list container uses CSS Grid -// and fills the available height properly. This prevents regressions where the email -// list was constrained to 300px height instead of filling the viewport. -func TestEmailListContainerLayout(t *testing.T) { - t.Parallel() - - // This test verifies the CSS structure by checking the static CSS files - // The actual visual rendering would require a browser-based test (e.g., Playwright) - - // For now, we verify that the CSS files contain the correct Grid layout - // In the future, this could be expanded to use Playwright for visual regression testing - - // TODO: Add Playwright test to verify: - // 1. .email-list-container has CSS Grid layout (not flexbox) - // 2. .email-list-container height fills available space (not 300px) - // 3. Email list can scroll through all emails - // 4. No empty space below the email list - - t.Skip("Visual layout test requires browser automation - skipped for now") -} diff --git a/internal/air/middleware.go b/internal/air/middleware.go deleted file mode 100644 index f33e116..0000000 --- a/internal/air/middleware.go +++ /dev/null @@ -1,260 +0,0 @@ -package air - -import ( - "compress/gzip" - "io" - "net/http" - "strconv" - "strings" - "time" - - "github.com/nylas/cli/internal/webguard" -) - -// gzipResponseWriter wraps http.ResponseWriter to support gzip compression. -type gzipResponseWriter struct { - io.Writer - http.ResponseWriter - statusCode int -} - -// WriteHeader captures the status code before writing. -func (w *gzipResponseWriter) WriteHeader(status int) { - w.statusCode = status - w.ResponseWriter.WriteHeader(status) -} - -// Write compresses the response body. -func (w *gzipResponseWriter) Write(b []byte) (int, error) { - return w.Writer.Write(b) -} - -// CompressionMiddleware adds gzip compression to responses. -// This significantly reduces bandwidth and improves load times. -func CompressionMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check if client accepts gzip - if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { - next.ServeHTTP(w, r) - return - } - - // Don't compress already compressed formats - if strings.HasSuffix(r.URL.Path, ".gz") || - strings.HasSuffix(r.URL.Path, ".jpg") || - strings.HasSuffix(r.URL.Path, ".jpeg") || - strings.HasSuffix(r.URL.Path, ".png") || - strings.HasSuffix(r.URL.Path, ".gif") || - strings.HasSuffix(r.URL.Path, ".woff") || - strings.HasSuffix(r.URL.Path, ".woff2") { - next.ServeHTTP(w, r) - return - } - - // Create gzip writer - gz := gzip.NewWriter(w) - defer func() { - _ = gz.Close() // Error is non-actionable in deferred context - }() - - // Wrap response writer - gzw := &gzipResponseWriter{ - Writer: gz, - ResponseWriter: w, - statusCode: http.StatusOK, - } - - // Set headers - w.Header().Set("Content-Encoding", "gzip") - w.Header().Del("Content-Length") // Length will change after compression - - next.ServeHTTP(gzw, r) - }) -} - -// CacheMiddleware adds appropriate cache headers for static assets. -// This reduces server load and improves perceived performance. -func CacheMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - - // For local development tools, minimal caching is better - // Only cache static images/fonts that rarely change - if strings.HasSuffix(path, ".woff") || - strings.HasSuffix(path, ".woff2") || - strings.HasSuffix(path, ".png") || - strings.HasSuffix(path, ".jpg") || - strings.HasSuffix(path, ".jpeg") || - strings.HasSuffix(path, ".gif") || - strings.HasSuffix(path, ".svg") || - strings.HasSuffix(path, ".ico") { - // Images and fonts - cache for 1 day - w.Header().Set("Cache-Control", "public, max-age=86400") - } else if strings.HasPrefix(path, "/api/") { - // API responses - no cache (always fresh) - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.Header().Set("Pragma", "no-cache") - w.Header().Set("Expires", "0") - } else { - // Everything else (JS, CSS, HTML) - minimal cache for instant updates - w.Header().Set("Cache-Control", "no-cache, must-revalidate") - } - - next.ServeHTTP(w, r) - }) -} - -// PerformanceMonitoringMiddleware tracks request timing and adds performance headers. -// This helps identify slow endpoints and enables browser performance monitoring. -func PerformanceMonitoringMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - // Create custom response writer to capture status code and add timing after response - srw := &timingResponseWriter{ - ResponseWriter: w, - statusCode: http.StatusOK, - start: start, - } - - // Process request - next.ServeHTTP(srw, r) - }) -} - -// timingResponseWriter wraps http.ResponseWriter to add performance timing. -type timingResponseWriter struct { - http.ResponseWriter - statusCode int - start time.Time - headerWritten bool -} - -// WriteHeader captures the status code and adds Server-Timing header. -func (w *timingResponseWriter) WriteHeader(code int) { - if !w.headerWritten { - w.statusCode = code - - // Add Server-Timing header before writing - duration := time.Since(w.start) - w.ResponseWriter.Header().Set("Server-Timing", - "total;dur="+formatDuration(duration)) - - w.headerWritten = true - w.ResponseWriter.WriteHeader(code) - } -} - -// Write ensures headers are written before body. -func (w *timingResponseWriter) Write(b []byte) (int, error) { - if !w.headerWritten { - w.WriteHeader(http.StatusOK) - } - return w.ResponseWriter.Write(b) -} - -// formatDuration formats duration in milliseconds with 2 decimal places. -func formatDuration(d time.Duration) string { - ms := float64(d.Nanoseconds()) / 1e6 - // Use strconv for accurate formatting - formatted := strconv.FormatFloat(ms, 'f', 2, 64) - // Remove trailing zeros and decimal point if not needed - formatted = strings.TrimRight(formatted, "0") - formatted = strings.TrimRight(formatted, ".") - return formatted -} - -// SecurityHeadersMiddleware adds security headers to all responses. -// This improves security posture and prevents common attacks. -func SecurityHeadersMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Prevent clickjacking - w.Header().Set("X-Frame-Options", "SAMEORIGIN") - - // Prevent MIME sniffing - w.Header().Set("X-Content-Type-Options", "nosniff") - - // Enable XSS protection - w.Header().Set("X-XSS-Protection", "1; mode=block") - - // Referrer policy - w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - - // Content Security Policy. - // - // 'unsafe-inline' is removed from script-src: all inline onclick/ - // onchange handlers and inline - - - Skip to main content - - - -