From 4c5c3b09c86642029efc905da6c25a680d81a5ec Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 11 Jun 2026 22:31:08 -0400 Subject: [PATCH 1/4] TW-5364: fix code review findings, add agent lists support and docs links - Fix RFC3339 offset/timezone mismatch validation in calendar event parsing - Fix stale-data races in TUI events and messages views (calendar/folder guards) - Add shared apiError helper for AI client HTTP error handling - Send 'in' folder filter in threads query (was silently dropped) - Add full agent lists support (/v3/lists): domain, ports, adapter, CLI commands - Fix agent rule matrix in_list tests to provision real lists (API now validates IDs) - Add developer.nylas.com documentation links across command help text - Add agent lists reference and getting-started guide to docs - Sweep it-rule-matrix-* lists in make test-cleanup --- Makefile | 13 + docs/COMMANDS.md | 20 +- docs/INDEX.md | 2 +- docs/commands/agent-getting-started.md | 325 ++++++++++ docs/commands/agent-list.md | 109 ++++ docs/commands/agent-policy.md | 4 - docs/commands/agent-rule.md | 6 +- docs/commands/agent.md | 12 + docs/commands/timezone.md | 38 +- internal/adapters/ai/base_client.go | 19 +- internal/adapters/ai/base_client_test.go | 95 +++ internal/adapters/ai/claude_client.go | 94 ++- internal/adapters/ai/claude_sse_test.go | 216 +++++++ internal/adapters/ai/ollama_client.go | 4 + internal/adapters/ai/ollama_client_test.go | 48 ++ internal/adapters/ai/pattern_learner.go | 6 +- .../adapters/ai/pattern_learner_analysis.go | 35 -- .../ai/pattern_learner_analysis_test.go | 103 +--- internal/adapters/grantcache/cache.go | 6 + internal/adapters/keyring/file.go | 6 + internal/adapters/nylas/attachments.go | 13 + internal/adapters/nylas/attachments_test.go | 30 + .../adapters/nylas/calendars_converters.go | 1 + internal/adapters/nylas/calendars_events.go | 3 + .../adapters/nylas/calendars_events_test.go | 17 + internal/adapters/nylas/calendars_types.go | 13 +- internal/adapters/nylas/client.go | 12 +- internal/adapters/nylas/demo_list.go | 82 +++ internal/adapters/nylas/list.go | 176 ++++++ internal/adapters/nylas/list_test.go | 264 ++++++++ internal/adapters/nylas/mock_list.go | 72 +++ internal/adapters/nylas/threads.go | 1 + internal/adapters/nylas/threads_http_test.go | 13 + internal/air/server_modules_test.go | 16 - internal/air/server_template.go | 7 +- internal/chat/agent_stream.go | 11 + internal/chat/agent_stream_test.go | 25 + internal/chat/approval.go | 18 +- internal/chat/approval_test.go | 77 ++- internal/chat/handlers.go | 62 +- internal/chat/handlers_approval_test.go | 230 ++++++- internal/chat/handlers_conv.go | 2 +- internal/chat/memory.go | 33 +- internal/chat/memory_test.go | 26 + internal/chat/server.go | 49 +- internal/chat/server_test.go | 105 ++++ internal/chat/static/js/chat.js | 6 +- internal/chat/static/js/markdown.js | 23 +- internal/chat/static/js/markdown.node.test.js | 39 ++ internal/cli/admin/admin.go | 4 +- internal/cli/admin/applications.go | 9 +- internal/cli/admin/callback_uris.go | 5 +- internal/cli/admin/connectors.go | 9 +- internal/cli/admin/credentials.go | 9 +- internal/cli/admin/grants.go | 4 +- internal/cli/agent/account.go | 2 + internal/cli/agent/agent.go | 3 + internal/cli/agent/agent_test.go | 12 +- internal/cli/agent/list_test.go | 66 ++ internal/cli/agent/lists.go | 215 +++++++ .../cli/agent/lists_create_update_delete.go | 237 ++++++++ internal/cli/agent/policy.go | 25 +- internal/cli/agent/rule.go | 4 +- .../cli/agent/rule_create_update_delete.go | 2 +- internal/cli/ai/clear_data.go | 5 +- internal/cli/audit/logs.go | 7 +- internal/cli/auth/auth.go | 4 +- internal/cli/calendar/availability.go | 4 +- internal/cli/calendar/calendar.go | 4 +- internal/cli/calendar/calendar_events_test.go | 20 + .../cli/calendar/calendar_helpers_test.go | 124 +++- internal/cli/calendar/crud.go | 6 +- internal/cli/calendar/dst_helpers.go | 7 +- internal/cli/calendar/events.go | 4 +- internal/cli/calendar/events_crud.go | 44 +- internal/cli/calendar/events_list.go | 23 +- internal/cli/calendar/events_rsvp.go | 56 +- .../calendar/events_update_timezone_test.go | 194 ++++++ internal/cli/calendar/helpers_parse_test.go | 351 ----------- .../cli/calendar/helpers_timezone_test.go | 99 +++ internal/cli/calendar/recurring.go | 9 +- .../calendar/time_parsing_extended_test.go | 563 ------------------ internal/cli/calendar/time_parsing_helpers.go | 396 ------------ internal/cli/calendar/timezone_helpers.go | 55 +- internal/cli/calendar/virtual.go | 9 +- .../cli/calendar/working_hours_helpers.go | 15 +- internal/cli/common/common_test.go | 48 +- internal/cli/common/crud.go | 10 +- internal/cli/common/crud_test.go | 171 ++++++ internal/cli/common/errors.go | 13 + internal/cli/common/errors_test.go | 35 ++ internal/cli/common/fileio.go | 16 + internal/cli/common/fileio_test.go | 77 +++ internal/cli/common/pagination.go | 6 + internal/cli/common/pagination_test.go | 57 +- internal/cli/contacts/contacts.go | 4 +- internal/cli/dashboard/dashboard.go | 4 +- internal/cli/email/attachments.go | 21 +- internal/cli/email/drafts.go | 10 +- internal/cli/email/email.go | 4 +- internal/cli/email/folders.go | 4 +- internal/cli/email/reply.go | 6 +- internal/cli/email/scheduled.go | 10 +- internal/cli/email/send.go | 12 +- internal/cli/email/signatures.go | 5 +- internal/cli/email/templates_delete.go | 9 +- internal/cli/email/templates_use.go | 8 +- internal/cli/email/threads.go | 10 +- internal/cli/integration/agent_policy_test.go | 3 - .../cli/integration/agent_rule_matrix_test.go | 105 +++- .../cli/integration/local_regressions_test.go | 8 +- internal/cli/integration/test_setup.go | 13 +- internal/cli/notetaker/notetaker.go | 4 +- internal/cli/otp/otp.go | 4 +- internal/cli/root.go | 3 +- internal/cli/scheduler/bookings.go | 9 +- internal/cli/scheduler/configurations.go | 9 +- internal/cli/scheduler/scheduler.go | 4 +- internal/cli/scheduler/sessions.go | 4 +- internal/cli/slack/auth.go | 5 +- internal/cli/slack/files.go | 16 +- internal/cli/slack/send.go | 19 +- internal/cli/templatecmd/template.go | 4 +- internal/cli/update/update.go | 11 +- internal/cli/webhook/pubsub.go | 4 +- internal/cli/webhook/webhook.go | 4 +- internal/cli/workflow/workflow.go | 4 +- internal/domain/config.go | 4 + internal/domain/errors.go | 1 + internal/domain/list.go | 18 + internal/domain/policy.go | 7 - internal/domain/workspace.go | 6 +- internal/domain/workspace_test.go | 45 ++ internal/ports/list.go | 19 + internal/ports/nylas.go | 1 + internal/tui/app_control.go | 28 +- internal/tui/app_init.go | 5 +- internal/tui/app_ui.go | 18 +- internal/tui/availability_actions.go | 56 +- internal/tui/availability_base.go | 92 ++- internal/tui/compose_actions.go | 24 +- internal/tui/drafts.go | 30 +- internal/tui/event_loop_safety_test.go | 474 +++++++++++++++ internal/tui/flash_helpers.go | 13 +- internal/tui/folder_panel.go | 31 +- internal/tui/views_contacts.go | 30 +- internal/tui/views_events.go | 123 ++-- internal/tui/views_events_detail.go | 10 +- internal/tui/views_events_recurring.go | 20 +- internal/tui/views_grants.go | 25 +- internal/tui/views_messages.go | 61 +- internal/tui/views_messages_actions.go | 40 +- internal/tui/views_messages_detail.go | 6 +- internal/tui/views_webhooks.go | 25 +- internal/tui/webhook_server.go | 25 +- internal/ui/server.go | 9 +- internal/ui/server_handlers_test.go | 29 + internal/ui/server_index.go | 8 +- internal/ui/server_security_test.go | 67 +++ internal/ui/server_templates_test.go | 63 ++ internal/ui/static/app.js | 18 +- internal/ui/static/js/actions.js | 47 ++ internal/ui/static/js/commands-core.js | 2 +- internal/ui/static/js/dropdowns.js | 6 +- internal/ui/static/js/helpers.js | 38 +- internal/ui/static/js/params.js | 8 +- internal/ui/static/js/state.js | 10 +- internal/ui/templates.go | 14 + internal/ui/templates/base.gohtml | 17 +- internal/ui/templates/pages/admin.gohtml | 6 +- internal/ui/templates/pages/agent.gohtml | 6 +- internal/ui/templates/pages/audit.gohtml | 6 +- internal/ui/templates/pages/auth.gohtml | 8 +- internal/ui/templates/pages/calendar.gohtml | 8 +- internal/ui/templates/pages/contacts.gohtml | 8 +- internal/ui/templates/pages/dashboard.gohtml | 6 +- internal/ui/templates/pages/email.gohtml | 8 +- internal/ui/templates/pages/notetaker.gohtml | 8 +- internal/ui/templates/pages/otp.gohtml | 6 +- internal/ui/templates/pages/overview.gohtml | 10 +- internal/ui/templates/pages/scheduler.gohtml | 6 +- internal/ui/templates/pages/timezone.gohtml | 6 +- internal/ui/templates/pages/webhook.gohtml | 8 +- internal/ui/templates/partials/content.gohtml | 30 +- internal/ui/templates/partials/header.gohtml | 10 +- internal/webguard/middleware.go | 46 ++ internal/webguard/middleware_test.go | 42 ++ 187 files changed, 5516 insertions(+), 2250 deletions(-) create mode 100644 docs/commands/agent-getting-started.md create mode 100644 docs/commands/agent-list.md create mode 100644 internal/adapters/ai/claude_sse_test.go create mode 100644 internal/adapters/nylas/demo_list.go create mode 100644 internal/adapters/nylas/list.go create mode 100644 internal/adapters/nylas/list_test.go create mode 100644 internal/adapters/nylas/mock_list.go create mode 100644 internal/chat/server_test.go create mode 100644 internal/chat/static/js/markdown.node.test.js create mode 100644 internal/cli/agent/list_test.go create mode 100644 internal/cli/agent/lists.go create mode 100644 internal/cli/agent/lists_create_update_delete.go create mode 100644 internal/cli/calendar/events_update_timezone_test.go delete mode 100644 internal/cli/calendar/helpers_parse_test.go delete mode 100644 internal/cli/calendar/time_parsing_extended_test.go delete mode 100644 internal/cli/calendar/time_parsing_helpers.go create mode 100644 internal/cli/common/crud_test.go create mode 100644 internal/cli/common/fileio.go create mode 100644 internal/cli/common/fileio_test.go create mode 100644 internal/domain/list.go create mode 100644 internal/domain/workspace_test.go create mode 100644 internal/ports/list.go create mode 100644 internal/tui/event_loop_safety_test.go create mode 100644 internal/ui/static/js/actions.js diff --git a/Makefile b/Makefile index 8fa3efb..40a2faf 100644 --- a/Makefile +++ b/Makefile @@ -237,6 +237,19 @@ test-cleanup: fi \ done @echo "" + @echo "4. Cleaning test agent lists..." + @./bin/nylas agent list list 2>/dev/null | \ + grep -A1 "^it-rule-matrix-" | \ + grep "ID:" | \ + awk '{print $$2}' | \ + while read list_id; do \ + if [ ! -z "$$list_id" ]; then \ + echo " Deleting test list: $$list_id"; \ + ./bin/nylas agent list delete $$list_id --yes 2>/dev/null && \ + echo " ✓ Deleted list $$list_id" || echo " ⚠ Could not delete $$list_id"; \ + fi \ + done + @echo "" @echo "✓ Test cleanup complete" # ============================================================================ diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index b33ae01..39b3ce4 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -370,8 +370,13 @@ nylas calendar focus-time list # List focus ti **Timezone features:** ```bash nylas calendar events list --timezone America/Los_Angeles --show-tz +nylas calendar events create --title T --start "2026-06-15 14:00" \ + --timezone America/New_York --lock-timezone # Create in a specific zone and lock it +nylas calendar events update --unlock-timezone ``` +Event times are parsed in your system timezone unless `--timezone` is set; the zone is recorded on the event. `--lock-timezone` pins the event to that zone in list/show views. All-day events (`--all-day`) take a date only (`YYYY-MM-DD`) — a time component is an error. + **AI scheduling:** ```bash nylas calendar schedule ai "meeting with John next Tuesday afternoon" @@ -479,10 +484,23 @@ nylas agent rule create --name NAME --trigger outbound --condition recipient.dom nylas agent rule create --data-file rule.json # Create a rule from full JSON nylas agent rule update --name NAME --description TEXT # Update a rule nylas agent rule delete --yes # Delete a rule +nylas agent list list # List all lists +nylas agent list get # Show one list and its items +nylas agent list create --name NAME --type domain # Create a list (type: domain, tld, or address) +nylas agent list create --name NAME --type address --item ceo@example.com # Create and seed items +nylas agent list update --name NAME # Update a list's metadata +nylas agent list items # Show list items +nylas agent list add ... # Add items to a list +nylas agent list remove ... # Remove items from a list +nylas agent list delete --yes # Delete a list nylas agent status # Check connector + account status ``` -**Details:** `docs/commands/agent.md`, `docs/commands/agent-policy.md`, `docs/commands/agent-rule.md` +Lists hold normalized values referenced by rule `in_list` conditions, e.g. +`--condition from.domain,in_list,`. A list's type is immutable and +determines which rule fields it can match. + +**Details:** `docs/commands/agent-getting-started.md` (start here), `docs/commands/agent.md`, `docs/commands/agent-policy.md`, `docs/commands/agent-rule.md`, `docs/commands/agent-list.md` --- diff --git a/docs/INDEX.md b/docs/INDEX.md index 49e6615..13d62ad 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -73,7 +73,7 @@ Quick navigation guide to find the right documentation for your needs. - **Calendar** → [commands/calendar.md](commands/calendar.md) - **Contacts** → [commands/contacts.md](commands/contacts.md) - **Webhooks** → [commands/webhooks.md](commands/webhooks.md) -- **Agent accounts** → [commands/agent.md](commands/agent.md) +- **Agent accounts** → [commands/agent-getting-started.md](commands/agent-getting-started.md) (guide), [commands/agent.md](commands/agent.md) (reference) - **Scheduler** → [commands/scheduler.md](commands/scheduler.md) - **Admin** → [commands/admin.md](commands/admin.md) - **Timezone** → [commands/timezone.md](commands/timezone.md) diff --git a/docs/commands/agent-getting-started.md b/docs/commands/agent-getting-started.md new file mode 100644 index 0000000..1342d8c --- /dev/null +++ b/docs/commands/agent-getting-started.md @@ -0,0 +1,325 @@ +# Getting Started with Agent Accounts + +A hands-on walkthrough for setting up and operating Nylas agent accounts from +the CLI — accounts, workspaces, policies, rules, and lists, with examples for +every step. + +## What Agent Accounts Are + +Agent accounts are **Nylas-managed email identities** (provider `nylas`). +Unlike OAuth grants, they don't connect to Gmail or Outlook — Nylas itself +hosts the mailbox (e.g. `support@yourapp.nylas.email`). They're built for +AI agents and automation: a real inbox your agent can send from, receive to, +and automate, without a human's mailbox behind it. + +### How the resources fit together + +``` +Application +├── Policies coarse settings bundles (limits, options, spam) +├── Rules mail-flow automation (trigger → conditions → actions) +│ └── in_list ──────► Lists (typed value sets: domain / tld / address) +└── Workspaces the attachment point that makes it all take effect + ├── policy_id one policy per workspace + ├── rule_ids[] many rules per workspace + └── Agent accounts mail through these is governed by the workspace +``` + +A rule or policy does nothing until a **workspace** references it, and an +agent account is governed by whatever its workspace references. + +API reference: https://developer.nylas.com/docs/v3/agent-accounts/ + +## Prerequisites + +```bash +nylas init # one-time CLI setup (API key, region) +nylas doctor # verify configuration is healthy +``` + +You need an API key with admin access. Agent account emails use your +application's agent domain (e.g. `yourapp.nylas.email`). + +## Step 1 — Create an Agent Account + +```bash +nylas agent account create support@yourapp.nylas.email +``` + +**Example output:** + +``` +✓ Agent account created successfully! + +support@yourapp.nylas.email + ID: 11111111-1111-1111-1111-111111111111 + Workspace ID: aaaaaaaa-1111-1111-1111-111111111111 + Status: active +``` + +Notes: + +- the `nylas` connector is created automatically on first use +- the API auto-creates a **default workspace and policy** for the account +- add `--app-password 'ValidAgentPass123ABC!'` to also enable IMAP/SMTP + mail-client access (see Step 7) +- `--json` prints the raw payload for scripting: + +```bash +nylas agent account create support@yourapp.nylas.email --json +``` + +Check overall readiness at any time: + +```bash +nylas agent status +``` + +## Step 2 — Inspect and Manage Accounts + +```bash +nylas agent account list # all agent accounts +nylas agent account get support@yourapp.nylas.email +nylas agent account get 11111111-1111-1111-1111-111111111111 # by ID +nylas agent account update support@yourapp.nylas.email --app-password 'NewPass456DEF!' +nylas agent account delete support@yourapp.nylas.email --yes +``` + +Accounts are grants — switch the CLI's active grant to one to operate as it: + +```bash +nylas auth switch 11111111-1111-1111-1111-111111111111 +``` + +## Step 3 — Send and Receive Email + +With the agent account as the active grant: + +```bash +nylas email send --to user@example.com --subject "Hello" --body "From your agent" +nylas email list --limit 10 +nylas email threads list +``` + +Notes: + +- sends go through the per-grant endpoint (`/v3/grants/{id}/messages/send`) +- the sender address defaults to the agent account's email +- GPG signing and stored signatures are not supported for agent sends +- send volume is subject to plan limits: + https://developer.nylas.com/docs/v3/agent-accounts/send-limits/ + +## Step 4 — Policies + +Policies are settings bundles (limits, options, spam detection) that +workspaces attach via `policy_id`. Your account already has a default one. + +```bash +nylas agent policy list +nylas agent policy get +nylas agent policy create --name "Strict Policy" +nylas agent policy create --data '{"name":"Strict Policy","rules":["rule-123"]}' +nylas agent policy create --data-file policy.json +nylas agent policy update --name "Renamed Policy" +nylas agent policy delete --yes # must be unattached +``` + +To switch a workspace (and therefore its accounts) to a different policy: + +```bash +nylas workspace update --policy-id +``` + +**Example output (`policy list`):** + +``` +Policies (2) + +1. Default Policy + ID: pppppppp-1111-1111-1111-111111111111 + Attached: support@yourapp.nylas.email (workspace aaaaaaaa-...) + +2. Strict Policy + ID: pppppppp-2222-2222-2222-222222222222 + Attached: (none) +``` + +## Step 5 — Lists + +Lists are typed value sets used by rule `in_list` conditions. The type is +**immutable** and determines which rule fields the list can match: + +| List type | Matches rule fields | +|-----------|---------------------| +| `domain` | `from.domain`, `recipient.domain` | +| `tld` | `from.tld`, `recipient.tld` | +| `address` | `from.address`, `recipient.address` | + +```bash +# Create a list (optionally seeding items) +nylas agent list create --name "Blocked domains" --type domain --item spam.com --item junk.net + +# Inspect +nylas agent list list +nylas agent list get +nylas agent list items + +# Manage items (up to 1000 per request; values are lowercased, trimmed, +# validated against the type; duplicates silently ignored) +nylas agent list add phishing.example +nylas agent list remove junk.net + +# Update metadata (type cannot change) and delete +nylas agent list update --name "Blocklist" --description "Known bad senders" +nylas agent list delete --yes +``` + +**Example output (`list create`):** + +``` +✓ List created successfully! + +Blocked domains + ID: dddddddd-1111-1111-1111-111111111111 + Type: domain + Items: 2 +``` + +Rules referencing a list pick up item changes **immediately** — no rule +update needed. Deleting a list doesn't break rules; they just stop matching. + +## Step 6 — Rules + +Rules automate mail flow: a `trigger` (`inbound`/`outbound`), conditions, and +actions. The CLI creates the rule and attaches it to your default agent +account's workspace in one step. + +```bash +# Simple: spam-flag a domain +nylas agent rule create \ + --name "Block Example" \ + --condition from.domain,is,example.com \ + --action mark_as_spam + +# Multiple conditions and actions, any-match +nylas agent rule create \ + --name "Tidy newsletters" \ + --match-operator any \ + --condition from.domain,contains,newsletter \ + --condition from.address,is,digest@example.com \ + --action archive --action mark_as_read + +# Outbound trigger +nylas agent rule create \ + --name "Archive outbound mail" \ + --trigger outbound \ + --condition recipient.domain,is,example.com \ + --condition outbound.type,is,compose \ + --action archive + +# Using a list (create the list first — the API validates list IDs exist) +nylas agent rule create \ + --name "Block listed domains" \ + --condition from.domain,in_list, \ + --action mark_as_spam + +# Multiple lists in one condition +nylas agent rule create \ + --name "Block all listed" \ + --condition "from.domain,in_list,," \ + --action block + +# Priority and state +nylas agent rule create --name "Low priority" --priority 5 --disabled \ + --condition from.tld,is,xyz --action trash + +# From raw JSON +nylas agent rule create --data-file rule.json + +# Manage +nylas agent rule list +nylas agent rule read +nylas agent rule update --name "Updated Rule" --enabled +nylas agent rule delete --yes # detaches from workspaces first +``` + +Supported condition operators: `is`, `is_not`, `contains`, `in_list`. +Common actions: `archive`, `mark_as_read`, `mark_as_starred`, `mark_as_spam`, +`block`, `trash`. + +## Step 7 — Mail-Client Access (IMAP/SMTP) + +With an app password set (at create time or via `account update`), agent +mailboxes work in any mail client: + +```bash +nylas agent account update support@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' +``` + +Guide: https://developer.nylas.com/docs/v3/agent-accounts/mail-clients/ + +## Step 8 — Workspaces Directly + +Most flows don't need direct workspace surgery (the CLI attaches rules and +the API creates a default workspace per account), but for full control: + +```bash +nylas workspace list +nylas workspace get +nylas workspace create --name "Support workspace" +nylas workspace update --policy-id +nylas workspace delete --yes +``` + +## Worked Example: OTP-Reading Agent + +A common pattern — an agent account that receives one-time passcodes and +your automation extracts them: + +```bash +nylas agent account create otp-bot@yourapp.nylas.email +nylas auth switch +nylas otp get # latest OTP code from the inbox +nylas otp watch # stream new codes as they arrive +``` + +Guide: https://developer.nylas.com/docs/cookbook/agent-accounts/extract-otp-code/ + +## Limits and Troubleshooting + +**Plan limits.** Applications are capped per plan — for example 5 rules and +10 lists on the free plan. Hitting a cap returns a 403 mentioning the plan; +the CLI surfaces it as a plan-limit error rather than a permission error. + +**`invalid list ID ... (invalid_field)`** when creating a rule: the +`in_list` condition references a list that doesn't exist. Create the list +first (`nylas agent list create`) and use the returned ID — fabricated or +deleted IDs are rejected. + +**`rule_ids entry not found or does not belong to this application`** when +creating/attaching rules: the workspace's `rule_ids` contains a stale entry +(a rule that was deleted without being detached — e.g. an interrupted +cleanup). Inspect and repair: + +```bash +nylas workspace get # shows rule_ids +nylas agent rule list # rules that actually exist +# detach stale entries by updating the workspace with only valid rule IDs +``` + +**`default grant is not a nylas agent account`**: rule/policy commands that +resolve the default account need the active grant to be an agent account — +run `nylas auth switch ` first. + +**Connector readiness**: `nylas agent status` verifies the `nylas` connector +exists and shows managed accounts. + +## See Also + +- [Agent command reference](agent.md) +- [Agent policies](agent-policy.md) +- [Agent rules](agent-rule.md) +- [Agent lists](agent-list.md) +- Provisioning guide: https://developer.nylas.com/docs/v3/agent-accounts/provisioning/ +- Mailboxes: https://developer.nylas.com/docs/v3/agent-accounts/mailboxes/ +- Policies, rules & lists: https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/ diff --git a/docs/commands/agent-list.md b/docs/commands/agent-list.md new file mode 100644 index 0000000..6561c01 --- /dev/null +++ b/docs/commands/agent-list.md @@ -0,0 +1,109 @@ +# Agent Lists + +Detailed reference for `nylas agent list`. + +Lists are backed by `/v3/lists` and hold normalized values referenced by agent +rule `in_list` conditions. Each list has an immutable type — `domain`, `tld`, +or `address` — that determines which rule fields it can match: + +| List type | Matches rule fields | +|-----------|---------------------| +| `domain` | `from.domain`, `recipient.domain` | +| `tld` | `from.tld`, `recipient.tld` | +| `address` | `from.address`, `recipient.address` | + +API reference: https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/ + +## Commands + +```bash +nylas agent list list +nylas agent list get +nylas agent list create --name "Blocked domains" --type domain +nylas agent list create --name "VIPs" --type address --item ceo@example.com --item cfo@example.com +nylas agent list update --name "New name" +nylas agent list items +nylas agent list add spam.com junk.net +nylas agent list remove spam.com +nylas agent list delete --yes +``` + +## Listing Lists + +```bash +nylas agent list list +nylas agent list list --json +``` + +Lists all lists from `/v3/lists` with their type and item count. + +## Showing a List + +```bash +nylas agent list get +nylas agent list get --json +nylas agent list items +``` + +Notes: + +- `get` shows the list metadata and its items +- `items` shows only the items +- `--json` returns raw payloads + +## Creating Lists + +```bash +nylas agent list create --name "Blocked domains" --type domain +nylas agent list create --name "VIPs" --type address --item ceo@example.com +``` + +Notes: + +- `--type` is required and immutable after creation (`domain`, `tld`, or `address`) +- `--item` is repeatable and seeds the list right after creation +- `--description` is optional + +## Managing Items + +```bash +nylas agent list add spam.com junk.net +nylas agent list remove spam.com +``` + +Notes: + +- up to 1000 items per request +- values are lowercased, trimmed, and validated against the list's type by the API +- duplicate additions are silently ignored +- rules referencing the list pick up item changes immediately + +## Using Lists in Rules + +Reference list IDs in `in_list` rule conditions: + +```bash +nylas agent rule create \ + --name "Block listed domains" \ + --condition from.domain,in_list, \ + --action mark_as_spam +``` + +For multiple lists, pass additional comma-separated IDs: +`--condition from.domain,in_list,,`. + +The API rejects rule conditions that reference list IDs that don't exist, so +create the list first and use the returned ID. + +## Updating and Deleting + +```bash +nylas agent list update --name "New name" --description "Updated" +nylas agent list delete --yes +``` + +Notes: + +- only name and description can be updated; the type is immutable +- deletion requires `--yes` +- rules referencing a deleted list will no longer match it diff --git a/docs/commands/agent-policy.md b/docs/commands/agent-policy.md index 0301285..838446b 100644 --- a/docs/commands/agent-policy.md +++ b/docs/commands/agent-policy.md @@ -77,10 +77,6 @@ Example payload: "limit_inbox_retention_period": 30, "limit_spam_retention_period": 7 }, - "options": { - "additional_folders": [], - "use_cidr_aliasing": false - }, "spam_detection": { "use_list_dnsbl": false, "use_header_anomaly_detection": false, diff --git a/docs/commands/agent-rule.md b/docs/commands/agent-rule.md index 23d409f..53935f1 100644 --- a/docs/commands/agent-rule.md +++ b/docs/commands/agent-rule.md @@ -2,7 +2,7 @@ Detailed reference for `nylas agent rule`. -Rules are backed by `/v3/rules` and attach to workspaces via `rules_ids[]`. +Rules are backed by `/v3/rules` and attach to workspaces via `rule_ids[]`. ## Commands @@ -89,7 +89,7 @@ nylas agent rule create --data-file rule.json nylas agent rule create --data '{"name":"Block Example","trigger":"inbound","match":{"operator":"any","conditions":[{"field":"from.domain","operator":"is","value":"example.com"}]},"actions":[{"type":"mark_as_spam"}]}' ``` -The rule is created via `/v3/rules` then attached to the default grant's workspace `rules_ids[]`. +The rule is created via `/v3/rules` then attached to the default grant's workspace `rule_ids[]`. ## Updating Rules @@ -116,7 +116,7 @@ Behavior: ## Relationship to Workspaces -Rules attach to workspaces via `rules_ids[]`. The practical flow: +Rules attach to workspaces via `rule_ids[]`. The practical flow: 1. create a workspace: `nylas workspace create --name "My Workspace"` 2. create a policy: `nylas agent policy create --name "Strict Policy"` diff --git a/docs/commands/agent.md b/docs/commands/agent.md index aeb9966..e27cf82 100644 --- a/docs/commands/agent.md +++ b/docs/commands/agent.md @@ -2,6 +2,8 @@ Manage Nylas agent resources from the CLI. +**New to agent accounts?** Start with the [getting started guide](agent-getting-started.md). + Agent accounts are managed email identities backed by provider `nylas`. Unlike OAuth grants, they do not require a third-party mailbox connection. Account operations live under `nylas agent account`, while `nylas agent status` reports connector and account readiness. ## Commands @@ -24,9 +26,18 @@ nylas agent rule create --name "Block Example" --condition from.domain,is,exampl nylas agent rule create --name "Archive outbound mail" --trigger outbound --condition recipient.domain,is,example.com --condition outbound.type,is,compose --action archive nylas agent rule update --name "Updated Rule" --description "Block example.org" nylas agent rule delete +nylas agent list list +nylas agent list get +nylas agent list create --name "Blocked domains" --type domain --item spam.com +nylas agent list add junk.net +nylas agent list remove junk.net +nylas agent list delete --yes nylas agent status ``` +Lists hold normalized values (domains, TLDs, or addresses) referenced by rule +`in_list` conditions. **Details:** [Agent list reference](agent-list.md) + ## List Agent Accounts ```bash @@ -204,4 +215,5 @@ When the active grant is an agent account (`provider=nylas`): - [Agent policies](agent-policy.md) - [Agent rules](agent-rule.md) +- [Agent lists](agent-list.md) - [Email commands](email.md) diff --git a/docs/commands/timezone.md b/docs/commands/timezone.md index cef6e00..aec772f 100644 --- a/docs/commands/timezone.md +++ b/docs/commands/timezone.md @@ -388,7 +388,35 @@ nylas calendar events list --show-tz nylas calendar events show --timezone Europe/London ``` -**Auto-detection:** Commands use your system timezone by default. +**Auto-detection:** Commands use your system timezone by default (detected from the `TZ` environment variable, then the `/etc/localtime` symlink). + +### Creating Events in a Specific Timezone + +Event start/end times are parsed in your system timezone by default. Use `--timezone` on `events create` and `events update` to parse them in another IANA zone; the zone is recorded on the event (`start_timezone`/`end_timezone`). On `events update`, `--timezone` only applies while parsing a new time, so it requires `--start`: + +```bash +# 2pm New York time, regardless of where you run the command +nylas calendar events create --title "NY Standup" \ + --start "2026-06-15 14:00" --timezone America/New_York +``` + +**All-day events take a date only** (`YYYY-MM-DD`). Combining `--all-day` with a time component (e.g., `--start "2026-06-15 10:00"`) is an error — remove `--all-day` to create a timed event. + +### Timezone Locking + +Lock an event to its timezone with `--lock-timezone` on `events create` or `events update` — useful for in-person meetings that should always display in the venue's timezone: + +```bash +# Create a locked event (confirmation shows the recorded zone) +nylas calendar events create --title "On-site" \ + --start "2026-06-15 09:00" --timezone Europe/London --lock-timezone + +# Lock or unlock an existing event +nylas calendar events update --lock-timezone +nylas calendar events update --unlock-timezone +``` + +Locked events keep their recorded timezone in list/show views (shown with a 🔒 indicator) and are never converted to the viewer's timezone. ### DST (Daylight Saving Time) Warnings @@ -423,13 +451,9 @@ working_hours: See [Calendar Commands](commands/calendar.md) for detailed configuration examples. -### Natural Language Time Parsing - -Parser supports: `"in 2 hours"`, `"tomorrow at 3pm"`, `"next Tuesday 2pm"`, `"Dec 25 10:00 AM"`, ISO formats. Integration with event creation is planned. - -### Upcoming Features +### Event Time Formats -**Timezone Locking (Planned):** Lock events to specific timezone for in-person meetings. +Event creation accepts `YYYY-MM-DD HH:MM`, `YYYY-MM-DDTHH:MM[:SS]`, RFC3339, or `YYYY-MM-DD` (all-day). Natural language scheduling is available via `nylas calendar schedule ai`. --- diff --git a/internal/adapters/ai/base_client.go b/internal/adapters/ai/base_client.go index 0a2da8f..a6a6fbf 100644 --- a/internal/adapters/ai/base_client.go +++ b/internal/adapters/ai/base_client.go @@ -14,6 +14,10 @@ import ( "github.com/nylas/cli/internal/domain" ) +// maxErrorBodyBytes caps how much of an error response body is read into +// error messages, matching the 10KB cap used by the Nylas HTTP client. +const maxErrorBodyBytes = 10 * 1024 + // BaseClient provides common HTTP client functionality for AI providers. type BaseClient struct { apiKey string @@ -87,14 +91,23 @@ func (b *BaseClient) DoJSONRequest(ctx context.Context, method, endpoint string, return resp, nil } +// apiError builds an error from a non-2xx response, including up to +// maxErrorBodyBytes of the body and falling back to a status-only message +// when the body can't be read or is empty. It does not close the body. +func apiError(resp *http.Response) error { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) + if err != nil || len(body) == 0 { + return fmt.Errorf("API error (status %d)", resp.StatusCode) + } + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) +} + // ReadJSONResponse reads and unmarshals a JSON response. func (b *BaseClient) ReadJSONResponse(resp *http.Response, v any) error { defer func() { _ = resp.Body.Close() }() - // Check for HTTP errors if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + return apiError(resp) } // Decode response diff --git a/internal/adapters/ai/base_client_test.go b/internal/adapters/ai/base_client_test.go index feaadf2..4384ee9 100644 --- a/internal/adapters/ai/base_client_test.go +++ b/internal/adapters/ai/base_client_test.go @@ -3,8 +3,11 @@ package ai import ( "context" "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -245,6 +248,35 @@ func TestReadJSONResponse(t *testing.T) { } }) + t.Run("truncates oversized error body", func(t *testing.T) { + // Error bodies are capped at maxErrorBodyBytes (10KB), matching the + // Nylas HTTP client, so a hostile/huge error response can't balloon memory. + hugeBody := strings.Repeat("x", 64*1024) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(hugeBody)) + })) + defer server.Close() + + client := NewBaseClient("key", "model", server.URL, 0) + resp, err := client.DoJSONRequest(context.Background(), http.MethodGet, "/test", nil, nil) + if err != nil { + t.Fatalf("unexpected request error: %v", err) + } + + var result map[string]any + err = client.ReadJSONResponse(resp, &result) + if err == nil { + t.Fatal("expected error for HTTP 500") + } + if !contains(err.Error(), "API error (status 500)") { + t.Errorf("expected API error message, got: %v", err) + } + if got := strings.Count(err.Error(), "x"); got != maxErrorBodyBytes { + t.Errorf("error body length = %d, want truncated to %d", got, maxErrorBodyBytes) + } + }) + t.Run("handles invalid JSON", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -420,6 +452,69 @@ func TestFallbackStreamChat(t *testing.T) { } // Helper function +func TestAPIError(t *testing.T) { + t.Run("includes body in message", func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error":"bad request"}`)), + } + err := apiError(resp) + if err == nil { + t.Fatal("expected error") + } + if !contains(err.Error(), `API error (status 400): {"error":"bad request"}`) { + t.Errorf("expected body in error message, got: %v", err) + } + }) + + t.Run("falls back to status-only on empty body", func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("")), + } + err := apiError(resp) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "API error (status 500)" { + t.Errorf("expected status-only message, got: %v", err) + } + }) + + t.Run("truncates oversized body", func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(strings.Repeat("x", 64*1024))), + } + err := apiError(resp) + if err == nil { + t.Fatal("expected error") + } + if got := strings.Count(err.Error(), "x"); got != maxErrorBodyBytes { + t.Errorf("error body length = %d, want truncated to %d", got, maxErrorBodyBytes) + } + }) + + t.Run("falls back to status-only on body read error", func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: io.NopCloser(errReader{}), + } + err := apiError(resp) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "API error (status 502)" { + t.Errorf("expected status-only message, got: %v", err) + } + }) +} + +// errReader always fails, simulating a connection dropped mid-body. +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { return 0, errors.New("read failed") } + func contains(s, substr string) bool { return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) >= len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsSubstring(s, substr))) } diff --git a/internal/adapters/ai/claude_client.go b/internal/adapters/ai/claude_client.go index a57df82..3a36136 100644 --- a/internal/adapters/ai/claude_client.go +++ b/internal/adapters/ai/claude_client.go @@ -1,6 +1,7 @@ package ai import ( + "bufio" "context" "encoding/json" "fmt" @@ -179,7 +180,10 @@ func (c *ClaudeClient) StreamChat(ctx context.Context, req *domain.ChatRequest, } defer func() { _ = resp.Body.Close() }() - // Simple SSE parsing (production would use proper SSE library) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return apiError(resp) + } + scanner := &sseScanner{reader: resp.Body} for scanner.Scan() { event := scanner.Event() @@ -246,48 +250,92 @@ func (c *ClaudeClient) convertTools(tools []domain.Tool) []map[string]any { return result } -// Simple SSE event scanner +// sseEvent is a single parsed Server-Sent Event. type sseEvent struct { Type string Data map[string]any } +// sseScanner is a line-based SSE reader (see internal/adapters/mcp/proxy.go +// readSSE for the same approach). It buffers across reads, accumulates +// "data:" payload lines, dispatches one event per blank-line boundary, and +// skips non-data lines (event:, comments, pings) without ending the stream. type sseScanner struct { - reader io.Reader - err error - event sseEvent + reader io.Reader + scanner *bufio.Scanner + err error + event sseEvent } +// Scan advances to the next parseable event. It returns false at end of +// stream or on read error (check Err). func (s *sseScanner) Scan() bool { - buf := make([]byte, 4096) - n, err := s.reader.Read(buf) - if err != nil { - if err != io.EOF { - s.err = err - } - return false + if s.scanner == nil { + s.scanner = bufio.NewScanner(s.reader) + // Anthropic events can be large; allow lines up to 1MB. + s.scanner.Buffer(make([]byte, 64*1024), 1024*1024) } - // Simplified SSE parsing - data := string(buf[:n]) - if strings.Contains(data, "data: {") { - start := strings.Index(data, "{") - end := strings.LastIndex(data, "}") - if start >= 0 && end > start { - jsonData := data[start : end+1] - var evt map[string]any - if err := json.Unmarshal([]byte(jsonData), &evt); err == nil { - if t, ok := evt["type"].(string); ok { - s.event = sseEvent{Type: t, Data: evt} + var data strings.Builder + for s.scanner.Scan() { + line := strings.TrimSuffix(s.scanner.Text(), "\r") + + if line == "" { + // Blank line marks the end of an event; dispatch accumulated data. + if data.Len() > 0 { + if s.parseEvent(data.String()) { return true } + data.Reset() + } + continue + } + + if payload, ok := strings.CutPrefix(line, "data:"); ok { + payload = strings.TrimPrefix(payload, " ") + if data.Len() > 0 { + data.WriteByte('\n') } + data.WriteString(payload) } + // Other fields (event:, id:, retry:) and comments (":...") are skipped. } + // Dispatch a trailing event not terminated by a blank line. + if data.Len() > 0 { + if s.parseEvent(data.String()) { + return true + } + // Mid-stream parse failures are skipped because the next event + // recovers the stream; here there is no next event, so dropping + // the accumulated data silently would hide data loss. Prefer the + // underlying read error (it explains the truncation) if present. + if err := s.scanner.Err(); err != nil { + s.err = err + } else { + s.err = fmt.Errorf("malformed trailing SSE event at end of stream (%d bytes)", data.Len()) + } + return false + } + + s.err = s.scanner.Err() return false } +// parseEvent unmarshals an event payload; returns false for unparseable data. +func (s *sseScanner) parseEvent(payload string) bool { + var evt map[string]any + if err := json.Unmarshal([]byte(payload), &evt); err != nil { + return false + } + t, ok := evt["type"].(string) + if !ok { + return false + } + s.event = sseEvent{Type: t, Data: evt} + return true +} + func (s *sseScanner) Event() sseEvent { return s.event } diff --git a/internal/adapters/ai/claude_sse_test.go b/internal/adapters/ai/claude_sse_test.go new file mode 100644 index 0000000..31809b9 --- /dev/null +++ b/internal/adapters/ai/claude_sse_test.go @@ -0,0 +1,216 @@ +package ai + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/nylas/cli/internal/domain" +) + +// chunkedReader yields one predefined chunk per Read call to simulate +// SSE events arriving split across network reads. +type chunkedReader struct { + chunks []string + i int +} + +func (r *chunkedReader) Read(p []byte) (int, error) { + if r.i >= len(r.chunks) { + return 0, io.EOF + } + n := copy(p, r.chunks[r.i]) + r.i++ + return n, nil +} + +func collectSSEEvents(t *testing.T, r io.Reader) []sseEvent { + t.Helper() + scanner := &sseScanner{reader: r} + var events []sseEvent + for scanner.Scan() { + events = append(events, scanner.Event()) + } + if err := scanner.Err(); err != nil { + t.Fatalf("scanner error: %v", err) + } + return events +} + +func assertEventTypes(t *testing.T, events []sseEvent, want []string) { + t.Helper() + if len(events) != len(want) { + t.Fatalf("got %d events, want %d (events: %+v)", len(events), len(want), events) + } + for i, eventType := range want { + if events[i].Type != eventType { + t.Errorf("event[%d].Type = %q, want %q", i, events[i].Type, eventType) + } + } +} + +func TestSSEScanner_MultipleEventsInOneChunk(t *testing.T) { + stream := "event: message_start\n" + + "data: {\"type\":\"message_start\"}\n" + + "\n" + + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel\"}}\n" + + "\n" + + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"lo\"}}\n" + + "\n" + + "data: {\"type\":\"message_stop\"}\n" + + "\n" + + events := collectSSEEvents(t, strings.NewReader(stream)) + assertEventTypes(t, events, []string{ + "message_start", + "content_block_delta", + "content_block_delta", + "message_stop", + }) +} + +func TestSSEScanner_EventSplitAcrossReads(t *testing.T) { + reader := &chunkedReader{chunks: []string{ + "data: {\"type\":\"content_blo", + "ck_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\n", + "data: {\"type\":\"mess", + "age_stop\"}\n\n", + }} + + events := collectSSEEvents(t, reader) + assertEventTypes(t, events, []string{"content_block_delta", "message_stop"}) +} + +func TestSSEScanner_SkipsNonDataLines(t *testing.T) { + // Comments, event: lines, pings, and blank lines must not terminate + // the stream — even when a whole read contains no data line. + reader := &chunkedReader{chunks: []string{ + ": keepalive comment\n\n", + "event: ping\ndata: {\"type\":\"ping\"}\n\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"after\"}}\n\n", + "data: {\"type\":\"message_stop\"}\n\n", + }} + + events := collectSSEEvents(t, reader) + assertEventTypes(t, events, []string{"ping", "content_block_delta", "message_stop"}) +} + +func TestSSEScanner_TrailingMalformedEventSurfacesError(t *testing.T) { + // Stream ends without a trailing blank line and the accumulated data is + // not valid JSON (e.g. a connection cut mid-payload). Unlike mid-stream + // parse failures — which are skipped because the next event recovers the + // stream — there is no next event here, so dropping the data silently + // would be undetectable data loss. Err() must report it. + stream := "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n" + + "\n" + + "data: {\"type\":\"message_stop\"" // truncated: no closing brace, no blank line + + scanner := &sseScanner{reader: strings.NewReader(stream)} + var events []sseEvent + for scanner.Scan() { + events = append(events, scanner.Event()) + } + + assertEventTypes(t, events, []string{"content_block_delta"}) + if err := scanner.Err(); err == nil { + t.Fatal("Err() = nil, want error for malformed trailing event") + } +} + +func TestSSEScanner_TrailingWellFormedEventWithoutBlankLine(t *testing.T) { + // A valid final event that is missing only the terminating blank line + // must still be delivered, with no error. + stream := "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n" + + "\n" + + "data: {\"type\":\"message_stop\"}" + + events := collectSSEEvents(t, strings.NewReader(stream)) + assertEventTypes(t, events, []string{"content_block_delta", "message_stop"}) +} + +func TestClaudeClient_StreamChat_StreamsAllChunks(t *testing.T) { + stream := "event: message_start\n" + + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}\n" + + "\n" + + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n" + + "\n" + + ": ping\n" + + "\n" + + "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n" + + "\n" + + "event: message_stop\n" + + "data: {\"type\":\"message_stop\"}\n" + + "\n" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(stream)) + })) + defer server.Close() + + client := NewClaudeClient(&domain.ClaudeConfig{APIKey: "test-key"}) + client.baseURL = server.URL + + var chunks []string + err := client.StreamChat(context.Background(), &domain.ChatRequest{ + Messages: []domain.ChatMessage{{Role: "user", Content: "Hello"}}, + }, func(chunk string) error { + chunks = append(chunks, chunk) + return nil + }) + if err != nil { + t.Fatalf("StreamChat() error = %v", err) + } + + got := strings.Join(chunks, "") + if got != "Hello world" { + t.Errorf("streamed content = %q, want %q (chunks: %v)", got, "Hello world", chunks) + } +} + +func TestClaudeClient_StreamChat_HTTPError(t *testing.T) { + tests := []struct { + name string + status int + }{ + {"unauthorized", http.StatusUnauthorized}, + {"rate limited", http.StatusTooManyRequests}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"api_error","message":"request failed"}}`)) + })) + defer server.Close() + + client := NewClaudeClient(&domain.ClaudeConfig{APIKey: "test-key"}) + client.baseURL = server.URL + + var chunks []string + err := client.StreamChat(context.Background(), &domain.ChatRequest{ + Messages: []domain.ChatMessage{{Role: "user", Content: "Hello"}}, + }, func(chunk string) error { + chunks = append(chunks, chunk) + return nil + }) + + if err == nil { + t.Fatalf("StreamChat() error = nil, want error for HTTP %d", tt.status) + } + if !strings.Contains(err.Error(), strconv.Itoa(tt.status)) { + t.Errorf("error %q does not mention status %d", err.Error(), tt.status) + } + if len(chunks) != 0 { + t.Errorf("expected no chunks on HTTP error, got %v", chunks) + } + }) + } +} diff --git a/internal/adapters/ai/ollama_client.go b/internal/adapters/ai/ollama_client.go index 6b9c3c3..977deb3 100644 --- a/internal/adapters/ai/ollama_client.go +++ b/internal/adapters/ai/ollama_client.go @@ -152,6 +152,10 @@ func (c *OllamaClient) StreamChat(ctx context.Context, req *domain.ChatRequest, } defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return apiError(resp) + } + // Stream response decoder := json.NewDecoder(resp.Body) for { diff --git a/internal/adapters/ai/ollama_client_test.go b/internal/adapters/ai/ollama_client_test.go index 64115a3..bd575be 100644 --- a/internal/adapters/ai/ollama_client_test.go +++ b/internal/adapters/ai/ollama_client_test.go @@ -2,6 +2,10 @@ package ai import ( "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" "testing" "github.com/nylas/cli/internal/domain" @@ -100,3 +104,47 @@ func TestOllamaClient_IsAvailable(t *testing.T) { // We're just testing that the method doesn't panic _ = client.IsAvailable(ctx) } + +func TestOllamaClient_StreamChat_HTTPError(t *testing.T) { + tests := []struct { + name string + status int + }{ + {"unauthorized", http.StatusUnauthorized}, + {"rate limited", http.StatusTooManyRequests}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(`{"error":"request failed"}`)) + })) + defer server.Close() + + client := NewOllamaClient(&domain.OllamaConfig{ + Host: server.URL, + Model: "mistral:latest", + }) + + var chunks []string + err := client.StreamChat(context.Background(), &domain.ChatRequest{ + Messages: []domain.ChatMessage{{Role: "user", Content: "Hello"}}, + }, func(chunk string) error { + chunks = append(chunks, chunk) + return nil + }) + + if err == nil { + t.Fatalf("StreamChat() error = nil, want error for HTTP %d", tt.status) + } + if !strings.Contains(err.Error(), strconv.Itoa(tt.status)) { + t.Errorf("error %q does not mention status %d", err.Error(), tt.status) + } + if len(chunks) != 0 { + t.Errorf("expected no chunks on HTTP error, got %v", chunks) + } + }) + } +} diff --git a/internal/adapters/ai/pattern_learner.go b/internal/adapters/ai/pattern_learner.go index 23abf5a..d6e355c 100644 --- a/internal/adapters/ai/pattern_learner.go +++ b/internal/adapters/ai/pattern_learner.go @@ -258,13 +258,13 @@ func (p *PatternLearner) generateRecommendations(ctx context.Context, events []d // Parse recommendations (simple line-based parsing) recommendations := []string{} - lines := splitLines(response.Content) + lines := strings.Split(response.Content, "\n") for _, line := range lines { - trimmed := trimSpace(line) + trimmed := strings.TrimSpace(line) if trimmed != "" && len(trimmed) > 10 { // Remove numbering if present if len(trimmed) > 3 && trimmed[0] >= '1' && trimmed[0] <= '9' && trimmed[1] == '.' { - trimmed = trimSpace(trimmed[3:]) + trimmed = strings.TrimSpace(trimmed[3:]) } recommendations = append(recommendations, trimmed) } diff --git a/internal/adapters/ai/pattern_learner_analysis.go b/internal/adapters/ai/pattern_learner_analysis.go index 7178eca..1d1113e 100644 --- a/internal/adapters/ai/pattern_learner_analysis.go +++ b/internal/adapters/ai/pattern_learner_analysis.go @@ -262,38 +262,3 @@ func (p *PatternLearner) analyzeProductivityPatterns(events []domain.Event) []Pr return insights } - -// Helper functions for string operations - -func splitLines(s string) []string { - lines := []string{} - current := "" - for i := 0; i < len(s); i++ { - if s[i] == '\n' { - lines = append(lines, current) - current = "" - } else { - current += string(s[i]) - } - } - if current != "" { - lines = append(lines, current) - } - return lines -} - -func trimSpace(s string) string { - // Trim leading and trailing whitespace - start := 0 - end := len(s) - - for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') { - start++ - } - - for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') { - end-- - } - - return s[start:end] -} diff --git a/internal/adapters/ai/pattern_learner_analysis_test.go b/internal/adapters/ai/pattern_learner_analysis_test.go index b3a1efc..43dd012 100644 --- a/internal/adapters/ai/pattern_learner_analysis_test.go +++ b/internal/adapters/ai/pattern_learner_analysis_test.go @@ -416,107 +416,8 @@ func TestPatternLearner_AnalyzeProductivityPatterns(t *testing.T) { } } -func TestSplitLines(t *testing.T) { - tests := []struct { - name string - input string - want []string - }{ - { - name: "splits on newlines", - input: "line1\nline2\nline3", - want: []string{"line1", "line2", "line3"}, - }, - { - name: "handles empty string", - input: "", - want: []string{}, - }, - { - name: "handles single line", - input: "single line", - want: []string{"single line"}, - }, - { - name: "handles trailing newline", - input: "line1\nline2\n", - want: []string{"line1", "line2"}, // Trailing newline produces no empty string - }, - { - name: "handles consecutive newlines", - input: "line1\n\nline3", - want: []string{"line1", "", "line3"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := splitLines(tt.input) - assert.Equal(t, tt.want, result) - }) - } -} - -func TestTrimSpace(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "trims leading spaces", - input: " hello", - want: "hello", - }, - { - name: "trims trailing spaces", - input: "hello ", - want: "hello", - }, - { - name: "trims both ends", - input: " hello ", - want: "hello", - }, - { - name: "trims tabs", - input: "\t\thello\t\t", - want: "hello", - }, - { - name: "trims newlines", - input: "\n\nhello\n\n", - want: "hello", - }, - { - name: "trims carriage returns", - input: "\r\rhello\r\r", - want: "hello", - }, - { - name: "handles empty string", - input: "", - want: "", - }, - { - name: "handles only whitespace", - input: " \t\n\r ", - want: "", - }, - { - name: "preserves internal whitespace", - input: " hello world ", - want: "hello world", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := trimSpace(tt.input) - assert.Equal(t, tt.want, result) - }) - } -} +// Note: splitLines/trimSpace helpers were removed in favor of stdlib +// strings.Split/strings.TrimSpace (used directly in pattern_learner.go). func TestPatternLearner_ConfidenceCalculation(t *testing.T) { learner := &PatternLearner{} diff --git a/internal/adapters/grantcache/cache.go b/internal/adapters/grantcache/cache.go index 98b38da..128edcd 100644 --- a/internal/adapters/grantcache/cache.go +++ b/internal/adapters/grantcache/cache.go @@ -230,6 +230,12 @@ func (s *Store) write(shape *fileShape) error { _ = tmp.Close() return err } + // Flush to disk before the rename so a crash can't leave an empty or + // truncated grant cache behind the new name. + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } if err := tmp.Close(); err != nil { return err } diff --git a/internal/adapters/keyring/file.go b/internal/adapters/keyring/file.go index 2b8e030..a9fef6d 100644 --- a/internal/adapters/keyring/file.go +++ b/internal/adapters/keyring/file.go @@ -289,6 +289,12 @@ func (f *EncryptedFileStore) saveSecrets(secrets map[string]string) error { _ = tmp.Close() return err } + // Flush to disk before the rename so a crash can't leave an empty or + // truncated credential store behind the new name. + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } if err := tmp.Close(); err != nil { return err } diff --git a/internal/adapters/nylas/attachments.go b/internal/adapters/nylas/attachments.go index 2375e75..ab788ba 100644 --- a/internal/adapters/nylas/attachments.go +++ b/internal/adapters/nylas/attachments.go @@ -47,17 +47,30 @@ func (c *HTTPClient) GetAttachment(ctx context.Context, grantID, messageID, atta func (c *HTTPClient) DownloadAttachment(ctx context.Context, grantID, messageID, attachmentID string) (io.ReadCloser, error) { queryURL := fmt.Sprintf("%s/v3/grants/%s/messages/%s/attachments/%s/download", c.baseURL, url.PathEscape(grantID), url.PathEscape(messageID), url.PathEscape(attachmentID)) + // The response body streams under the request context, so the default + // API timeout would cut off large/slow downloads mid-stream. Apply the + // dedicated download timeout when the caller hasn't set a deadline. + cancel := context.CancelFunc(func() {}) + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + ctx, cancel = context.WithTimeout(ctx, domain.TimeoutDownload) + } + req, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil) if err != nil { + cancel() return nil, err } c.setAuthHeader(req) resp, err := c.doRequest(ctx, req) if err != nil { + cancel() return nil, fmt.Errorf("%w: %v", domain.ErrNetworkError, err) } + // Release the download context when the body is closed or fully read. + resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel} + if resp.StatusCode == http.StatusNotFound { _ = resp.Body.Close() return nil, domain.ErrAttachmentNotFound diff --git a/internal/adapters/nylas/attachments_test.go b/internal/adapters/nylas/attachments_test.go index 4d18f49..e724858 100644 --- a/internal/adapters/nylas/attachments_test.go +++ b/internal/adapters/nylas/attachments_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/nylas/cli/internal/adapters/nylas" "github.com/nylas/cli/internal/domain" @@ -205,6 +206,35 @@ func TestHTTPClient_DownloadAttachment(t *testing.T) { assert.Error(t, err) assert.Nil(t, reader) }) + + t.Run("streams past the default request timeout", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("first-chunk-")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + // Stall mid-stream for longer than the default request timeout. + time.Sleep(300 * time.Millisecond) + _, _ = w.Write([]byte("second-chunk")) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + // Shrink the default API timeout below the server's mid-stream stall to + // prove downloads use the dedicated (longer) download timeout instead. + client.SetRequestTimeout(50 * time.Millisecond) + + reader, err := client.DownloadAttachment(context.Background(), "grant-123", "msg-456", "attach-789") + require.NoError(t, err) + defer func() { _ = reader.Close() }() + + content, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, "first-chunk-second-chunk", string(content)) + }) } func TestMockClient_ListAttachments(t *testing.T) { diff --git a/internal/adapters/nylas/calendars_converters.go b/internal/adapters/nylas/calendars_converters.go index da974b1..250cbce 100644 --- a/internal/adapters/nylas/calendars_converters.go +++ b/internal/adapters/nylas/calendars_converters.go @@ -118,6 +118,7 @@ func convertEvent(e eventResponse) domain.Event { MasterEventID: e.MasterEventID, ICalUID: e.ICalUID, HtmlLink: e.HtmlLink, + Metadata: e.Metadata, CreatedAt: time.Unix(e.CreatedAt, 0), UpdatedAt: time.Unix(e.UpdatedAt, 0), Object: e.Object, diff --git a/internal/adapters/nylas/calendars_events.go b/internal/adapters/nylas/calendars_events.go index fc737e4..5b40b0e 100644 --- a/internal/adapters/nylas/calendars_events.go +++ b/internal/adapters/nylas/calendars_events.go @@ -185,6 +185,9 @@ func (c *HTTPClient) UpdateEvent(ctx context.Context, grantID, calendarID, event if req.Reminders != nil { payload["reminders"] = req.Reminders } + if len(req.Metadata) > 0 { + payload["metadata"] = req.Metadata + } resp, err := c.doJSONRequest(ctx, "PUT", queryURL, payload) if err != nil { diff --git a/internal/adapters/nylas/calendars_events_test.go b/internal/adapters/nylas/calendars_events_test.go index 0253b3a..ea0fb69 100644 --- a/internal/adapters/nylas/calendars_events_test.go +++ b/internal/adapters/nylas/calendars_events_test.go @@ -302,6 +302,10 @@ func TestHTTPClient_GetEvent(t *testing.T) { "status": "yes", }, }, + "metadata": map[string]any{ + "timezone_locked": "true", + "project": "apollo", + }, }, }, statusCode: http.StatusOK, @@ -351,6 +355,12 @@ func TestHTTPClient_GetEvent(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.eventID, event.ID) + if wantMeta, ok := tt.serverResponse["data"].(map[string]any)["metadata"]; ok { + for k, v := range wantMeta.(map[string]any) { + assert.Equal(t, v, event.Metadata[k], + "metadata must survive the adapter conversion (IsTimezoneLocked depends on it)") + } + } }) } } @@ -527,6 +537,13 @@ func TestHTTPClient_UpdateEvent(t *testing.T) { }, wantFields: []string{"participants"}, }, + { + name: "updates metadata", + request: &domain.UpdateEventRequest{ + Metadata: map[string]string{"timezone_locked": "true"}, + }, + wantFields: []string{"metadata"}, + }, } for _, tt := range tests { diff --git a/internal/adapters/nylas/calendars_types.go b/internal/adapters/nylas/calendars_types.go index 7c22645..dd654e0 100644 --- a/internal/adapters/nylas/calendars_types.go +++ b/internal/adapters/nylas/calendars_types.go @@ -66,12 +66,13 @@ type eventResponse struct { ReminderMethod string `json:"reminder_method"` } `json:"overrides"` } `json:"reminders"` - MasterEventID string `json:"master_event_id"` - ICalUID string `json:"ical_uid"` - HtmlLink string `json:"html_link"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - Object string `json:"object"` + MasterEventID string `json:"master_event_id"` + ICalUID string `json:"ical_uid"` + HtmlLink string `json:"html_link"` + Metadata map[string]string `json:"metadata"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Object string `json:"object"` } // GetCalendars retrieves all calendars for a grant. diff --git a/internal/adapters/nylas/client.go b/internal/adapters/nylas/client.go index 1b8869d..aa7c61b 100644 --- a/internal/adapters/nylas/client.go +++ b/internal/adapters/nylas/client.go @@ -121,6 +121,11 @@ func (c *HTTPClient) SetMaxRetries(retries int) { c.maxRetries = retries } +// SetRequestTimeout sets the default per-request timeout (for testing purposes). +func (c *HTTPClient) SetRequestTimeout(timeout time.Duration) { + c.requestTimeout = timeout +} + // setAuthHeader sets the authorization header on the request. func (c *HTTPClient) setAuthHeader(req *http.Request) { if c.apiKey != "" { @@ -255,7 +260,6 @@ func (c *HTTPClient) doRequest(ctx context.Context, req *http.Request) (*http.Re req.Header.Set("User-Agent", version.UserAgent()) var lastErr error - var lastResp *http.Response for attempt := 0; attempt <= c.maxRetries; attempt++ { // Apply rate limiting - wait for permission to proceed @@ -310,7 +314,6 @@ func (c *HTTPClient) doRequest(ctx context.Context, req *http.Request) (*http.Re // Check if we should retry based on status code if c.shouldRetryStatus(resp.StatusCode) && attempt < c.maxRetries { - lastResp = resp _ = resp.Body.Close() // Close body before retry; error doesn't affect retry logic delay := c.calculateBackoff(attempt, resp) @@ -325,10 +328,7 @@ func (c *HTTPClient) doRequest(ctx context.Context, req *http.Request) (*http.Re return resp, nil } - // All retries exhausted - if lastResp != nil { - return lastResp, nil - } + // Unreachable: the final attempt always returns above; this satisfies the compiler. return nil, lastErr } diff --git a/internal/adapters/nylas/demo_list.go b/internal/adapters/nylas/demo_list.go new file mode 100644 index 0000000..34be3f3 --- /dev/null +++ b/internal/adapters/nylas/demo_list.go @@ -0,0 +1,82 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +func (d *DemoClient) ListLists(ctx context.Context) ([]domain.AgentList, error) { + return []domain.AgentList{ + { + ID: "list-demo-1", + Name: "Demo blocked domains", + Description: "Domains blocked in the demo workspace", + Type: "domain", + ItemsCount: 2, + ApplicationID: "app-demo", + OrganizationID: "org-demo", + }, + { + ID: "list-demo-2", + Name: "Demo VIP addresses", + Type: "address", + ItemsCount: 1, + ApplicationID: "app-demo", + OrganizationID: "org-demo", + }, + }, nil +} + +func (d *DemoClient) GetList(ctx context.Context, listID string) (*domain.AgentList, error) { + return &domain.AgentList{ + ID: listID, + Name: "Demo blocked domains", + Description: "Domains blocked in the demo workspace", + Type: "domain", + ItemsCount: 2, + ApplicationID: "app-demo", + OrganizationID: "org-demo", + }, nil +} + +func (d *DemoClient) CreateList(ctx context.Context, payload map[string]any) (*domain.AgentList, error) { + list := &domain.AgentList{ID: "list-demo-new", ApplicationID: "app-demo", OrganizationID: "org-demo"} + if name, ok := payload["name"].(string); ok { + list.Name = name + } + if listType, ok := payload["type"].(string); ok { + list.Type = listType + } + if description, ok := payload["description"].(string); ok { + list.Description = description + } + return list, nil +} + +func (d *DemoClient) UpdateList(ctx context.Context, listID string, payload map[string]any) (*domain.AgentList, error) { + list := &domain.AgentList{ID: listID, Type: "domain", ApplicationID: "app-demo", OrganizationID: "org-demo"} + if name, ok := payload["name"].(string); ok { + list.Name = name + } + if description, ok := payload["description"].(string); ok { + list.Description = description + } + return list, nil +} + +func (d *DemoClient) DeleteList(ctx context.Context, listID string) error { + return nil +} + +func (d *DemoClient) GetListItems(ctx context.Context, listID string) ([]string, error) { + return []string{"spam-demo.com", "junk-demo.net"}, nil +} + +func (d *DemoClient) AddListItems(ctx context.Context, listID string, items []string) (*domain.AgentList, error) { + return &domain.AgentList{ID: listID, Type: "domain", ItemsCount: 2 + len(items)}, nil +} + +func (d *DemoClient) RemoveListItems(ctx context.Context, listID string, items []string) (*domain.AgentList, error) { + return &domain.AgentList{ID: listID, Type: "domain", ItemsCount: max(0, 2-len(items))}, nil +} diff --git a/internal/adapters/nylas/list.go b/internal/adapters/nylas/list.go new file mode 100644 index 0000000..e6f89e6 --- /dev/null +++ b/internal/adapters/nylas/list.go @@ -0,0 +1,176 @@ +package nylas + +import ( + "context" + "fmt" + "net/url" + + "github.com/nylas/cli/internal/domain" +) + +type listListResponse struct { + Data struct { + Items []domain.AgentList `json:"items"` + } `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` +} + +type listResponse struct { + Data domain.AgentList `json:"data"` +} + +type listItemsResponse struct { + Data struct { + Items []string `json:"items"` + } `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// ListLists lists all lists available to the authenticated application. +func (c *HTTPClient) ListLists(ctx context.Context) ([]domain.AgentList, error) { + baseURL := fmt.Sprintf("%s/v3/lists", c.baseURL) + pageToken := "" + lists := make([]domain.AgentList, 0) + + for { + queryBuilder := NewQueryBuilder() + if pageToken != "" { + queryBuilder.Add("page_token", pageToken) + } + queryURL := queryBuilder.BuildURL(baseURL) + + var result listListResponse + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + + lists = append(lists, result.Data.Items...) + + if result.NextCursor == "" { + break + } + if result.NextCursor == pageToken { + return nil, fmt.Errorf("failed to paginate lists: repeated cursor %q", result.NextCursor) + } + pageToken = result.NextCursor + } + + return lists, nil +} + +// GetList retrieves a list by ID. +func (c *HTTPClient) GetList(ctx context.Context, listID string) (*domain.AgentList, error) { + queryURL := fmt.Sprintf("%s/v3/lists/%s", c.baseURL, url.PathEscape(listID)) + + var result listResponse + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrListNotFound); err != nil { + return nil, err + } + + return &result.Data, nil +} + +// CreateList creates a new list. +func (c *HTTPClient) CreateList(ctx context.Context, payload map[string]any) (*domain.AgentList, error) { + queryURL := fmt.Sprintf("%s/v3/lists", c.baseURL) + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, payload) + if err != nil { + return nil, err + } + + var result listResponse + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + return &result.Data, nil +} + +// UpdateList updates an existing list's metadata (name, description). +func (c *HTTPClient) UpdateList(ctx context.Context, listID string, payload map[string]any) (*domain.AgentList, error) { + queryURL := fmt.Sprintf("%s/v3/lists/%s", c.baseURL, url.PathEscape(listID)) + + resp, err := c.doJSONRequest(ctx, "PUT", queryURL, payload) + if err != nil { + return nil, err + } + + var result listResponse + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + if result.Data.ID == "" { + result.Data.ID = listID + } + + return &result.Data, nil +} + +// DeleteList deletes a list by ID. +func (c *HTTPClient) DeleteList(ctx context.Context, listID string) error { + queryURL := fmt.Sprintf("%s/v3/lists/%s", c.baseURL, url.PathEscape(listID)) + return c.doDelete(ctx, queryURL) +} + +// GetListItems retrieves all items of a list, following pagination. +func (c *HTTPClient) GetListItems(ctx context.Context, listID string) ([]string, error) { + baseURL := fmt.Sprintf("%s/v3/lists/%s/items", c.baseURL, url.PathEscape(listID)) + pageToken := "" + items := make([]string, 0) + + for { + queryBuilder := NewQueryBuilder() + if pageToken != "" { + queryBuilder.Add("page_token", pageToken) + } + queryURL := queryBuilder.BuildURL(baseURL) + + var result listItemsResponse + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrListNotFound); err != nil { + return nil, err + } + + items = append(items, result.Data.Items...) + + if result.NextCursor == "" { + break + } + if result.NextCursor == pageToken { + return nil, fmt.Errorf("failed to paginate list items: repeated cursor %q", result.NextCursor) + } + pageToken = result.NextCursor + } + + return items, nil +} + +// AddListItems adds items to a list (up to 1000 per request). Values are +// normalized and validated against the list's type by the API. +func (c *HTTPClient) AddListItems(ctx context.Context, listID string, items []string) (*domain.AgentList, error) { + return c.modifyListItems(ctx, "POST", listID, items) +} + +// RemoveListItems removes items from a list. +func (c *HTTPClient) RemoveListItems(ctx context.Context, listID string, items []string) (*domain.AgentList, error) { + return c.modifyListItems(ctx, "DELETE", listID, items) +} + +func (c *HTTPClient) modifyListItems(ctx context.Context, method, listID string, items []string) (*domain.AgentList, error) { + queryURL := fmt.Sprintf("%s/v3/lists/%s/items", c.baseURL, url.PathEscape(listID)) + + resp, err := c.doJSONRequest(ctx, method, queryURL, map[string]any{"items": items}) + if err != nil { + return nil, err + } + + var result listResponse + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + if result.Data.ID == "" { + result.Data.ID = listID + } + + return &result.Data, nil +} diff --git a/internal/adapters/nylas/list_test.go b/internal/adapters/nylas/list_test.go new file mode 100644 index 0000000..dc7e832 --- /dev/null +++ b/internal/adapters/nylas/list_test.go @@ -0,0 +1,264 @@ +package nylas + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListLists(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/lists", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "items": []map[string]any{ + {"id": "list-001", "name": "Blocked domains", "type": "domain", "items_count": 2}, + {"id": "list-002", "name": "VIP addresses", "type": "address", "items_count": 5}, + }, + }, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + lists, err := client.ListLists(context.Background()) + require.NoError(t, err) + require.Len(t, lists, 2) + assert.Equal(t, "list-001", lists[0].ID) + assert.Equal(t, "domain", lists[0].Type) + assert.Equal(t, 2, lists[0].ItemsCount) + assert.Equal(t, "list-002", lists[1].ID) +} + +func TestListLists_Pagination(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + assert.Empty(t, r.URL.Query().Get("page_token")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"items": []map[string]any{{"id": "list-001"}}}, + "next_cursor": "cursor-2", + }) + return + } + assert.Equal(t, "cursor-2", r.URL.Query().Get("page_token")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"items": []map[string]any{{"id": "list-002"}}}, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + lists, err := client.ListLists(context.Background()) + require.NoError(t, err) + require.Len(t, lists, 2) + assert.Equal(t, 2, calls) +} + +func TestGetList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/lists/list-001", r.URL.Path) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"id": "list-001", "name": "Blocked domains", "type": "domain"}, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + list, err := client.GetList(context.Background(), "list-001") + require.NoError(t, err) + assert.Equal(t, "list-001", list.ID) + assert.Equal(t, "Blocked domains", list.Name) +} + +func TestGetList_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"type": "not_found"}}) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + _, err := client.GetList(context.Background(), "missing") + require.Error(t, err) + assert.ErrorIs(t, err, domain.ErrListNotFound) +} + +func TestCreateList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/lists", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "Blocked domains", payload["name"]) + assert.Equal(t, "domain", payload["type"]) + + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"id": "list-new", "name": "Blocked domains", "type": "domain", "items_count": 0}, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + list, err := client.CreateList(context.Background(), map[string]any{"name": "Blocked domains", "type": "domain"}) + require.NoError(t, err) + assert.Equal(t, "list-new", list.ID) + assert.Equal(t, 0, list.ItemsCount) +} + +func TestUpdateList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/lists/list-001", r.URL.Path) + assert.Equal(t, http.MethodPut, r.Method) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"name": "Renamed"}, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + list, err := client.UpdateList(context.Background(), "list-001", map[string]any{"name": "Renamed"}) + require.NoError(t, err) + // ID is backfilled when the API omits it from the update response. + assert.Equal(t, "list-001", list.ID) + assert.Equal(t, "Renamed", list.Name) +} + +func TestDeleteList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/lists/list-001", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + _ = json.NewEncoder(w).Encode(map[string]any{"request_id": "req-1"}) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + require.NoError(t, client.DeleteList(context.Background(), "list-001")) +} + +func TestGetListItems(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/lists/list-001/items", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"items": []string{"spam.com", "junk.net"}}, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + items, err := client.GetListItems(context.Background(), "list-001") + require.NoError(t, err) + assert.Equal(t, []string{"spam.com", "junk.net"}, items) +} + +func TestGetListItems_Pagination(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "/v3/lists/list-001/items", r.URL.Path) + if calls == 1 { + assert.Empty(t, r.URL.Query().Get("page_token")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"items": []string{"spam.com"}}, + "next_cursor": "cursor-2", + }) + return + } + assert.Equal(t, "cursor-2", r.URL.Query().Get("page_token")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"items": []string{"junk.net"}}, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + items, err := client.GetListItems(context.Background(), "list-001") + require.NoError(t, err) + assert.Equal(t, []string{"spam.com", "junk.net"}, items) + assert.Equal(t, 2, calls) +} + +func TestAddListItems(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/lists/list-001/items", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, []any{"spam.com", "junk.net"}, payload["items"]) + + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"id": "list-001", "items_count": 2}, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + list, err := client.AddListItems(context.Background(), "list-001", []string{"spam.com", "junk.net"}) + require.NoError(t, err) + assert.Equal(t, 2, list.ItemsCount) +} + +func TestRemoveListItems(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/lists/list-001/items", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, []any{"spam.com"}, payload["items"]) + + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"id": "list-001", "items_count": 1}, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + list, err := client.RemoveListItems(context.Background(), "list-001", []string{"spam.com"}) + require.NoError(t, err) + assert.Equal(t, 1, list.ItemsCount) +} diff --git a/internal/adapters/nylas/mock_list.go b/internal/adapters/nylas/mock_list.go new file mode 100644 index 0000000..14a99ff --- /dev/null +++ b/internal/adapters/nylas/mock_list.go @@ -0,0 +1,72 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +func (m *MockClient) ListLists(ctx context.Context) ([]domain.AgentList, error) { + return []domain.AgentList{ + { + ID: "list-1", + Name: "Blocked domains", + Type: "domain", + ItemsCount: 2, + ApplicationID: "app-123", + OrganizationID: "org-123", + }, + }, nil +} + +func (m *MockClient) GetList(ctx context.Context, listID string) (*domain.AgentList, error) { + return &domain.AgentList{ + ID: listID, + Name: "Blocked domains", + Type: "domain", + ItemsCount: 2, + ApplicationID: "app-123", + OrganizationID: "org-123", + }, nil +} + +func (m *MockClient) CreateList(ctx context.Context, payload map[string]any) (*domain.AgentList, error) { + list := &domain.AgentList{ID: "list-new", ItemsCount: 0} + if name, ok := payload["name"].(string); ok { + list.Name = name + } + if listType, ok := payload["type"].(string); ok { + list.Type = listType + } + if description, ok := payload["description"].(string); ok { + list.Description = description + } + return list, nil +} + +func (m *MockClient) UpdateList(ctx context.Context, listID string, payload map[string]any) (*domain.AgentList, error) { + list := &domain.AgentList{ID: listID, Type: "domain"} + if name, ok := payload["name"].(string); ok { + list.Name = name + } + if description, ok := payload["description"].(string); ok { + list.Description = description + } + return list, nil +} + +func (m *MockClient) DeleteList(ctx context.Context, listID string) error { + return nil +} + +func (m *MockClient) GetListItems(ctx context.Context, listID string) ([]string, error) { + return []string{"spam.com", "junk.net"}, nil +} + +func (m *MockClient) AddListItems(ctx context.Context, listID string, items []string) (*domain.AgentList, error) { + return &domain.AgentList{ID: listID, Type: "domain", ItemsCount: 2 + len(items)}, nil +} + +func (m *MockClient) RemoveListItems(ctx context.Context, listID string, items []string) (*domain.AgentList, error) { + return &domain.AgentList{ID: listID, Type: "domain", ItemsCount: max(0, 2-len(items))}, nil +} diff --git a/internal/adapters/nylas/threads.go b/internal/adapters/nylas/threads.go index 0a0e9a2..7dee809 100644 --- a/internal/adapters/nylas/threads.go +++ b/internal/adapters/nylas/threads.go @@ -51,6 +51,7 @@ func (c *HTTPClient) GetThreads(ctx context.Context, grantID string, params *dom AddBoolPtr("unread", params.Unread). AddBoolPtr("starred", params.Starred). Add("q", params.SearchQuery). + AddSlice("in", params.In). BuildURL(baseURL) var result struct { diff --git a/internal/adapters/nylas/threads_http_test.go b/internal/adapters/nylas/threads_http_test.go index 7342b11..7fbf8a1 100644 --- a/internal/adapters/nylas/threads_http_test.go +++ b/internal/adapters/nylas/threads_http_test.go @@ -206,6 +206,19 @@ func TestHTTPClient_GetThreads_QueryParams(t *testing.T) { "q": "meeting notes", }, }, + { + name: "includes in folder filter", + // The TUI folder panel and `nylas email threads --folder` rely on + // server-side folder filtering; dropping `in` silently lists + // threads from every folder. + params: &domain.ThreadQueryParams{ + Limit: 10, + In: []string{"folder-123"}, + }, + wantQuery: map[string]string{ + "in": "folder-123", + }, + }, } for _, tt := range tests { diff --git a/internal/air/server_modules_test.go b/internal/air/server_modules_test.go index 12c726d..a141b13 100644 --- a/internal/air/server_modules_test.go +++ b/internal/air/server_modules_test.go @@ -2,7 +2,6 @@ package air import ( "errors" - "html/template" "testing" "time" @@ -408,21 +407,6 @@ func TestLoadTemplates(t *testing.T) { } } -func TestTemplateFuncs_SafeHTML(t *testing.T) { - t.Parallel() - - safeHTMLFunc, exists := templateFuncs["safeHTML"] - if !exists { - t.Fatal("expected safeHTML function to exist in templateFuncs") - } - - // The safeHTML function returns template.HTML, not any - result := safeHTMLFunc.(func(string) template.HTML)("

Test

") - if string(result) != "

Test

" { - t.Errorf("expected '

Test

', got %s", result) - } -} - // ============================================================================= // BuildPageData Tests (server_template.go) // ============================================================================= diff --git a/internal/air/server_template.go b/internal/air/server_template.go index 025b49a..8924da1 100644 --- a/internal/air/server_template.go +++ b/internal/air/server_template.go @@ -138,12 +138,7 @@ func loadTemplates() (*template.Template, error) { } // Template functions. -var templateFuncs = template.FuncMap{ - "safeHTML": func(s string) template.HTML { - //nolint:gosec // G203: We control the input, this is for rendering pre-defined HTML - return template.HTML(s) - }, -} +var templateFuncs = template.FuncMap{} var ( templatesOnce sync.Once diff --git a/internal/chat/agent_stream.go b/internal/chat/agent_stream.go index 4e0484f..5ab0c0f 100644 --- a/internal/chat/agent_stream.go +++ b/internal/chat/agent_stream.go @@ -76,6 +76,17 @@ func (a *Agent) streamClaude(ctx context.Context, prompt string, onToken TokenCa } } + // A scanner error means the stream was truncated mid-read (e.g. a line + // exceeding the buffer); any collected output is unreliable, so surface + // the error instead of returning a partial response. Drain the rest of + // stdout first so the child is not stuck writing to a full pipe, then + // reap the process. + if scanErr := scanner.Err(); scanErr != nil { + _, _ = io.Copy(io.Discard, stdout) + _ = cmd.Wait() + return "", fmt.Errorf("claude stream read: %w", scanErr) + } + if err := cmd.Wait(); err != nil { // If we got output, return it despite the error if full.Len() > 0 { diff --git a/internal/chat/agent_stream_test.go b/internal/chat/agent_stream_test.go index 847fc82..0b56286 100644 --- a/internal/chat/agent_stream_test.go +++ b/internal/chat/agent_stream_test.go @@ -2,6 +2,9 @@ package chat import ( "context" + "os" + "path/filepath" + "runtime" "strings" "testing" @@ -9,6 +12,28 @@ import ( "github.com/stretchr/testify/require" ) +func TestStreamClaude_ScannerError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a shell script as a fake claude binary") + } + t.Parallel() + + // A single output line larger than the scanner's 1MB buffer triggers + // bufio.ErrTooLong. Without the scanner.Err() check this was silently + // swallowed: the loop just stopped and an empty response was returned + // with a nil error, hiding the truncation from the user. + dir := t.TempDir() + script := filepath.Join(dir, "fake-claude.sh") + content := "#!/bin/sh\nhead -c 2097152 /dev/zero | tr '\\0' 'a'\n" + require.NoError(t, os.WriteFile(script, []byte(content), 0o700)) + + agent := Agent{Type: AgentClaude, Path: script} + _, err := agent.streamClaude(context.Background(), "prompt", nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "claude stream read") +} + func TestSupportsStreaming(t *testing.T) { tests := []struct { name string diff --git a/internal/chat/approval.go b/internal/chat/approval.go index 445d844..28afd72 100644 --- a/internal/chat/approval.go +++ b/internal/chat/approval.go @@ -1,6 +1,7 @@ package chat import ( + "context" "sync" "sync/atomic" "time" @@ -36,13 +37,18 @@ type PendingApproval struct { ch chan ApprovalDecision } -// Wait blocks until the user approves/rejects or the timeout expires. -func (pa *PendingApproval) Wait() (ApprovalDecision, bool) { +// Wait blocks until the user approves/rejects, the timeout expires, or ctx is +// cancelled. The bool result is true only when a real decision was received; +// when it is false the caller must Discard the approval so it does not leak +// in the store and cannot be resolved late. +func (pa *PendingApproval) Wait(ctx context.Context) (ApprovalDecision, bool) { select { case decision := <-pa.ch: return decision, true case <-time.After(approvalTimeout): return ApprovalDecision{Approved: false, Reason: "timed out"}, false + case <-ctx.Done(): + return ApprovalDecision{Approved: false, Reason: "request cancelled"}, false } } @@ -82,6 +88,14 @@ func (s *ApprovalStore) Resolve(id string, decision ApprovalDecision) bool { return true } +// Discard removes a pending approval that was never resolved (timeout or +// context cancellation). After Discard, a late Resolve returns false so the +// approve/reject endpoints report the approval as gone instead of silently +// succeeding. +func (s *ApprovalStore) Discard(id string) { + s.pending.Delete(id) +} + // nextID generates a sequential approval ID. func (s *ApprovalStore) nextID() string { n := s.counter.Add(1) diff --git a/internal/chat/approval_test.go b/internal/chat/approval_test.go index 5e3013d..4e17150 100644 --- a/internal/chat/approval_test.go +++ b/internal/chat/approval_test.go @@ -1,6 +1,7 @@ package chat import ( + "context" "strings" "sync" "testing" @@ -146,7 +147,7 @@ func TestPendingApproval_Wait(t *testing.T) { store.Resolve(pa.ID, expectedDecision) }() - decision, ok := pa.Wait() + decision, ok := pa.Wait(context.Background()) if !ok { t.Fatal("Wait returned false, want true") } @@ -164,6 +165,78 @@ func TestPendingApproval_Wait(t *testing.T) { // Skip this in normal test runs t.Skip("Timeout test would take 5 minutes") }) + + t.Run("wait unblocks on context cancellation", func(t *testing.T) { + t.Parallel() + + store := NewApprovalStore() + call := ToolCall{Name: "send_email", Args: map[string]any{}} + pa := store.Create(call, map[string]any{}) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + done := make(chan struct{}) + var decision ApprovalDecision + var ok bool + go func() { + decision, ok = pa.Wait(ctx) + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Wait did not unblock on context cancellation") + } + + if ok { + t.Error("Wait returned ok=true on cancellation, want false") + } + if decision.Approved { + t.Error("Cancelled wait must not approve the action") + } + }) +} + +func TestApprovalStore_Discard(t *testing.T) { + t.Parallel() + + store := NewApprovalStore() + call := ToolCall{Name: "send_email", Args: map[string]any{}} + pa := store.Create(call, map[string]any{}) + + store.Discard(pa.ID) + + // A late resolve after discard must fail so the HTTP endpoints can + // report the approval as gone instead of returning a misleading 200. + if store.Resolve(pa.ID, ApprovalDecision{Approved: true}) { + t.Error("Resolve succeeded after Discard, want false") + } +} + +func TestApprovalStore_DiscardAfterCancelledWait(t *testing.T) { + t.Parallel() + + store := NewApprovalStore() + call := ToolCall{Name: "send_email", Args: map[string]any{}} + pa := store.Create(call, map[string]any{}) + + // Simulate the handler flow: Wait aborted by ctx, then Discard so the + // pending entry does not leak in the store forever. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, ok := pa.Wait(ctx); ok { + t.Fatal("Wait with cancelled context returned ok=true, want false") + } + store.Discard(pa.ID) + + if _, loaded := store.pending.Load(pa.ID); loaded { + t.Error("pending entry still present after Discard, approval leaked") + } } func TestPendingApproval_WaitConcurrent(t *testing.T) { @@ -188,7 +261,7 @@ func TestPendingApproval_WaitConcurrent(t *testing.T) { store.Resolve(pa.ID, ApprovalDecision{Approved: idx%2 == 0}) }() - decision, ok := pa.Wait() + decision, ok := pa.Wait(context.Background()) if !ok { t.Errorf("Wait for approval %d failed", idx) } diff --git a/internal/chat/handlers.go b/internal/chat/handlers.go index 6742338..9241381 100644 --- a/internal/chat/handlers.go +++ b/internal/chat/handlers.go @@ -20,6 +20,11 @@ type chatRequest struct { // maxToolIterations is the maximum number of tool call rounds per message. const maxToolIterations = 5 +// defaultRunTimeout is the budget for one agent-run segment (agent calls and +// tool execution). Approval waits are not charged against it: after a gated +// tool call resolves, the remaining work gets a fresh budget. +const defaultRunTimeout = 120 * time.Second + // handleChat processes a chat message via SSE streaming. // POST /api/chat func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { @@ -47,7 +52,10 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } } + // Capture agent and context builder once, under the lock, so a concurrent + // SetAgent cannot race with this request's reads. agent := s.ActiveAgent() + contextBuilder := s.ActiveContext() // Load or create conversation var conv *Conversation @@ -85,9 +93,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { conv, _ = s.memory.Get(conv.ID) // Check if compaction needed - if s.context.NeedsCompaction(conv) { + if contextBuilder.NeedsCompaction(conv) { ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) - _ = s.context.Compact(ctx, conv) + _ = contextBuilder.Compact(ctx, conv) cancel() conv, _ = s.memory.Get(conv.ID) // reload after compaction } @@ -95,11 +103,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { // Send thinking event sendSSE(w, flusher, "thinking", map[string]string{"agent": string(agent.Type)}) - // Build prompt and run agent loop - ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) - defer cancel() + // Build prompt and run agent loop. ctx may be replaced with a fresh + // budget after an approval wait, so defer through a closure to always + // release the latest one. + runTimeout := s.runTimeout + if runTimeout <= 0 { + runTimeout = defaultRunTimeout + } + ctx, cancel := context.WithTimeout(r.Context(), runTimeout) + defer func() { cancel() }() - prompt := s.context.BuildPrompt(conv, req.Message) + prompt := contextBuilder.BuildPrompt(conv, req.Message) var finalResponse string for i := range maxToolIterations { @@ -156,8 +170,32 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { "preview": preview, }) - // Block until user approves/rejects or timeout - decision, _ := pa.Wait() + // The server's WriteTimeout is counted from the start of the + // request; a long agent run followed by the full approval + // window could exceed it and kill the SSE stream mid-wait. + // Push the connection write deadline out to cover the wait + // plus the post-approval run budget (best effort: not every + // ResponseWriter supports deadlines). + _ = http.NewResponseController(w).SetWriteDeadline( + time.Now().Add(approvalTimeout + runTimeout)) + + // Block until user approves/rejects, the approval timeout + // expires, or the CLIENT disconnects. The wait is bound to + // the raw request context — NOT the agent-run ctx, which may + // be nearly exhausted by the time the agent asks — so the + // user keeps the full approval window. If no decision + // arrived, discard the pending entry so it cannot leak or be + // resolved late. + decision, resolved := pa.Wait(r.Context()) + if !resolved { + s.approvals.Discard(pa.ID) + } + + // Time spent waiting on the user is not charged against the + // agent run: restart the run budget so an action approved + // late doesn't execute (and follow-up agent iterations don't + // run) against an already-expired context. + ctx, cancel = renewRunContext(r.Context(), cancel, runTimeout) sendSSE(w, flusher, "approval_resolved", map[string]any{ "approval_id": pa.ID, @@ -241,6 +279,14 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { }) } +// renewRunContext releases the previous agent-run context and returns a fresh +// one with a full timeout budget, derived from the request context so it is +// still cancelled when the client disconnects. +func renewRunContext(parent context.Context, oldCancel context.CancelFunc, timeout time.Duration) (context.Context, context.CancelFunc) { + oldCancel() + return context.WithTimeout(parent, timeout) +} + // generateTitle asks the agent to generate a short title for the conversation. func (s *Server) generateTitle(convID, userMsg, assistantMsg string) { agent := s.ActiveAgent() diff --git a/internal/chat/handlers_approval_test.go b/internal/chat/handlers_approval_test.go index cea8988..47f6e1b 100644 --- a/internal/chat/handlers_approval_test.go +++ b/internal/chat/handlers_approval_test.go @@ -1,14 +1,23 @@ package chat import ( + "bufio" "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" ) func TestHandleApprove(t *testing.T) { @@ -81,7 +90,7 @@ func TestHandleApprove(t *testing.T) { // Start goroutine to receive decision go func() { - decision, ok := pa.Wait() + decision, ok := pa.Wait(context.Background()) assert.True(t, ok) assert.True(t, decision.Approved) assert.Empty(t, decision.Reason) @@ -115,6 +124,40 @@ func TestHandleApprove(t *testing.T) { } } +// TestHandleApprove_AfterTimeoutDiscard verifies that approving an action +// whose Wait already gave up (timeout / cancellation discarded it) returns a +// non-200 error instead of silently "succeeding" against a dead approval. +func TestHandleApprove_AfterTimeoutDiscard(t *testing.T) { + s := &Server{ + approvals: NewApprovalStore(), + } + + pa := s.approvals.Create( + ToolCall{Name: "send_email", Args: map[string]any{"to": "test@example.com"}}, + map[string]any{"to": "test@example.com"}, + ) + + // Simulate the handler flow when Wait does not get a decision. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, resolved := pa.Wait(ctx); resolved { + t.Fatal("Wait with cancelled context returned resolved=true, want false") + } + s.approvals.Discard(pa.ID) + + body, err := json.Marshal(map[string]any{"approval_id": pa.ID}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/chat/approve", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.handleApprove(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "approval not found or already resolved") +} + func TestHandleReject(t *testing.T) { tests := []struct { name string @@ -197,7 +240,7 @@ func TestHandleReject(t *testing.T) { // Start goroutine to receive decision go func() { - decision, ok := pa.Wait() + decision, ok := pa.Wait(context.Background()) assert.True(t, ok) assert.False(t, decision.Approved) assert.Equal(t, tt.expectedReason, decision.Reason) @@ -230,3 +273,186 @@ func TestHandleReject(t *testing.T) { }) } } + +// newApprovalChatServer builds a Server whose agent is a fake codex script: +// the first invocation emits a gated send_email tool call, later invocations +// return plain text. runTimeout simulates the agent-run deadline. +func newApprovalChatServer(t *testing.T, runTimeout time.Duration, client *nylas.MockClient) (*Server, string) { + t.Helper() + + dir := t.TempDir() + state := filepath.Join(dir, "called") + script := filepath.Join(dir, "fake-codex") + body := "#!/bin/sh\n" + + "if [ ! -f " + state + " ]; then\n" + + " touch " + state + "\n" + + " echo 'TOOL_CALL: {\"name\":\"send_email\",\"args\":{\"to\":\"a@example.com\",\"subject\":\"Hi\",\"body\":\"Hello\"}}'\n" + + "else\n" + + " echo 'Email sent.'\n" + + "fi\n" + require.NoError(t, os.WriteFile(script, []byte(body), 0o700)) + + agent := &Agent{Type: AgentCodex, Path: script} + mem, err := NewMemoryStore(t.TempDir()) + require.NoError(t, err) + + // Pre-create the conversation with a title so handleChat does not spawn + // the async generateTitle goroutine (which would outlive the test). + conv, err := mem.Create(string(AgentCodex)) + require.NoError(t, err) + require.NoError(t, mem.UpdateTitle(conv.ID, "approval test")) + + s := &Server{ + agent: agent, + agents: []Agent{*agent}, + grantID: "test-grant", + memory: mem, + executor: NewToolExecutor(client, "test-grant", nil), + context: NewContextBuilder(agent, mem, "test-grant", false), + session: NewActiveSession(), + approvals: NewApprovalStore(), + runTimeout: runTimeout, + } + return s, conv.ID +} + +// nextSSEEvent reads the next SSE event from the stream. +func nextSSEEvent(t *testing.T, sc *bufio.Scanner) (string, map[string]any) { + t.Helper() + + var event string + for sc.Scan() { + line := sc.Text() + switch { + case strings.HasPrefix(line, "event: "): + event = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "data: "): + raw := strings.TrimPrefix(line, "data: ") + var data map[string]any + if raw != "null" { + require.NoError(t, json.Unmarshal([]byte(raw), &data)) + } + return event, data + } + } + t.Fatalf("SSE stream ended unexpectedly (last event: %q, err: %v)", event, sc.Err()) + return "", nil +} + +// readUntilSSEEvent reads SSE events until one with the given name arrives. +func readUntilSSEEvent(t *testing.T, sc *bufio.Scanner, name string) map[string]any { + t.Helper() + for { + event, data := nextSSEEvent(t, sc) + if event == name { + return data + } + } +} + +func (s *Server) approveViaHandler(t *testing.T, approvalID string) int { + t.Helper() + + body, err := json.Marshal(map[string]any{"approval_id": approvalID}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/chat/approve", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.handleApprove(w, req) + return w.Code +} + +// TestHandleChat_ApprovalSurvivesAgentRunDeadline reproduces the UX +// regression where the approval wait was bounded by the agent-run deadline +// instead of the approval window: a user approving AFTER the agent-run +// deadline must still win, and the approved tool call must then execute with +// a fresh, non-expired context. +func TestHandleChat_ApprovalSurvivesAgentRunDeadline(t *testing.T) { + client := nylas.NewMockClient() + execCtxErr := make(chan error, 1) + client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { + execCtxErr <- ctx.Err() + return &domain.Message{ID: "msg-1"}, nil + } + + // Must outlast the fake agent script's exec (cold start ~150ms, but + // 1s proved flaky when the full suite runs in parallel and starves the + // process start), while still expiring during the approval wait below. + runTimeout := 3 * time.Second + s, convID := newApprovalChatServer(t, runTimeout, client) + + ts := httptest.NewServer(http.HandlerFunc(s.handleChat)) + defer ts.Close() + + reqBody := `{"message":"send the email","conversation_id":"` + convID + `"}` + resp, err := http.Post(ts.URL, "application/json", strings.NewReader(reqBody)) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + sc := bufio.NewScanner(resp.Body) + required := readUntilSSEEvent(t, sc, "approval_required") + approvalID, _ := required["approval_id"].(string) + require.NotEmpty(t, approvalID) + + // Let the agent-run deadline expire while the user is deciding. + time.Sleep(runTimeout + 500*time.Millisecond) + + // The late approval must still land (404 here means the wait was + // killed by the agent-run deadline and the approval discarded). + require.Equal(t, http.StatusOK, s.approveViaHandler(t, approvalID), + "approval after agent-run deadline must succeed") + + resolved := readUntilSSEEvent(t, sc, "approval_resolved") + assert.Equal(t, true, resolved["approved"], "late approval must be honored") + + // The approved action must execute on a fresh, non-expired context. + select { + case ctxErr := <-execCtxErr: + assert.NoError(t, ctxErr, "approved tool call ran with an expired context") + case <-time.After(5 * time.Second): + t.Fatal("approved tool call was never executed") + } + + result := readUntilSSEEvent(t, sc, "tool_result") + errVal, _ := result["error"].(string) + assert.Empty(t, errVal, "approved tool call must not fail") + + readUntilSSEEvent(t, sc, "done") +} + +// TestHandleChat_ClientDisconnectDiscardsApproval verifies that closing the +// SSE connection while an approval is pending still cancels the wait and +// discards the pending approval so a late approve returns 404. +func TestHandleChat_ClientDisconnectDiscardsApproval(t *testing.T) { + client := nylas.NewMockClient() + client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { + t.Error("tool must not execute after client disconnect") + return &domain.Message{ID: "msg-1"}, nil + } + + s, convID := newApprovalChatServer(t, 5*time.Second, client) + + ts := httptest.NewServer(http.HandlerFunc(s.handleChat)) + defer ts.Close() + + reqBody := `{"message":"send the email","conversation_id":"` + convID + `"}` + resp, err := http.Post(ts.URL, "application/json", strings.NewReader(reqBody)) + require.NoError(t, err) + + sc := bufio.NewScanner(resp.Body) + required := readUntilSSEEvent(t, sc, "approval_required") + approvalID, _ := required["approval_id"].(string) + require.NotEmpty(t, approvalID) + + // Disconnect the client mid-wait. + require.NoError(t, resp.Body.Close()) + + // The wait must observe the disconnect and discard the approval. + require.Eventually(t, func() bool { + _, pending := s.approvals.pending.Load(approvalID) + return !pending + }, 5*time.Second, 20*time.Millisecond, "approval not discarded after client disconnect") + + assert.Equal(t, http.StatusNotFound, s.approveViaHandler(t, approvalID), + "approve after disconnect must report the approval as gone") +} diff --git a/internal/chat/handlers_conv.go b/internal/chat/handlers_conv.go index ff9a2ea..5ef9ecd 100644 --- a/internal/chat/handlers_conv.go +++ b/internal/chat/handlers_conv.go @@ -58,7 +58,7 @@ func (s *Server) listConversations(w http.ResponseWriter, _ *http.Request) { } func (s *Server) createConversation(w http.ResponseWriter, _ *http.Request) { - conv, err := s.memory.Create(string(s.agent.Type)) + conv, err := s.memory.Create(string(s.ActiveAgent().Type)) if err != nil { http.Error(w, "Failed to create conversation", http.StatusInternalServerError) return diff --git a/internal/chat/memory.go b/internal/chat/memory.go index 39966b2..5ff977b 100644 --- a/internal/chat/memory.go +++ b/internal/chat/memory.go @@ -254,12 +254,43 @@ func (m *MemoryStore) readFile(path string) (*Conversation, error) { return &conv, nil } +// writeFile persists a conversation atomically: it writes to a temp file in +// the same directory and renames it into place, so concurrent readers never +// observe a partially written file (same pattern as adapters/keyring/file.go). func (m *MemoryStore) writeFile(conv *Conversation) error { data, err := json.MarshalIndent(conv, "", " ") if err != nil { return fmt.Errorf("marshal conversation: %w", err) } - return os.WriteFile(m.filePath(conv.ID), data, 0600) + + tmp, err := os.CreateTemp(m.basePath, "."+conv.ID+".tmp.*") + if err != nil { + return fmt.Errorf("create temp conversation file: %w", err) + } + tmpPath := tmp.Name() + defer func() { + if tmpPath != "" { + _ = os.Remove(tmpPath) + } + }() + + if err := tmp.Chmod(0600); err != nil { + _ = tmp.Close() + return fmt.Errorf("chmod temp conversation file: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp conversation file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp conversation file: %w", err) + } + if err := os.Rename(tmpPath, m.filePath(conv.ID)); err != nil { + return fmt.Errorf("rename conversation file: %w", err) + } + tmpPath = "" // success: nothing to clean up + + return nil } func generateID() (string, error) { diff --git a/internal/chat/memory_test.go b/internal/chat/memory_test.go index 2c10f34..225e3cd 100644 --- a/internal/chat/memory_test.go +++ b/internal/chat/memory_test.go @@ -511,6 +511,32 @@ func TestMemoryStore_ConcurrentAccess(t *testing.T) { }) } +func TestMemoryStore_WriteFileAtomic(t *testing.T) { + store := setupMemoryStore(t) + + conv, err := store.Create("claude") + require.NoError(t, err) + require.NoError(t, store.AddMessage(conv.ID, Message{Role: "user", Content: "hello"})) + + // The write must go through a temp-file + rename: after a successful + // write no temp file may remain, only the final conversation file. A + // direct os.WriteFile would not leave temp files either, but a crash + // mid-write would corrupt the file; the rename pattern is what we verify + // indirectly here by checking the directory contains exactly the final + // files and the content is complete, valid JSON. + entries, err := os.ReadDir(store.basePath) + require.NoError(t, err) + for _, e := range entries { + assert.NotContains(t, e.Name(), ".tmp.", "temp file leaked after write: %s", e.Name()) + assert.True(t, filepath.Ext(e.Name()) == ".json", "unexpected file: %s", e.Name()) + } + + got, err := store.Get(conv.ID) + require.NoError(t, err) + require.Len(t, got.Messages, 1) + assert.Equal(t, "hello", got.Messages[0].Content) +} + // Helper function to set up a memory store for testing func setupMemoryStore(t *testing.T) *MemoryStore { t.Helper() diff --git a/internal/chat/server.go b/internal/chat/server.go index 59e5a12..e41aed4 100644 --- a/internal/chat/server.go +++ b/internal/chat/server.go @@ -34,6 +34,10 @@ type Server struct { session *ActiveSession approvals *ApprovalStore tmpl *template.Template + + // runTimeout bounds one agent-run segment in handleChat (zero means + // defaultRunTimeout); overridable in tests. + runTimeout time.Duration } // ActiveAgent returns the current agent (thread-safe). @@ -43,6 +47,14 @@ func (s *Server) ActiveAgent() *Agent { return s.agent } +// ActiveContext returns the current context builder (thread-safe). SetAgent +// replaces s.context under agentMu, so readers must go through this accessor. +func (s *Server) ActiveContext() *ContextBuilder { + s.agentMu.RLock() + defer s.agentMu.RUnlock() + return s.context +} + // SetAgent switches the active agent by type. Returns false if not found. func (s *Server) SetAgent(agentType AgentType) bool { agent := FindAgent(s.agents, agentType) @@ -65,19 +77,20 @@ func NewServer(addr string, agent *Agent, agents []Agent, nylas ports.NylasClien tmpl, _ := template.New("").ParseFS(templateFiles, "templates/*.gohtml") return &Server{ - addr: addr, - agent: agent, - agents: agents, - nylas: nylas, - slack: slack, - hasSlack: hasSlack, - grantID: grantID, - memory: memory, - executor: executor, - context: ctx, - session: NewActiveSession(), - approvals: NewApprovalStore(), - tmpl: tmpl, + addr: addr, + agent: agent, + agents: agents, + nylas: nylas, + slack: slack, + hasSlack: hasSlack, + grantID: grantID, + memory: memory, + executor: executor, + context: ctx, + session: NewActiveSession(), + approvals: NewApprovalStore(), + tmpl: tmpl, + runTimeout: defaultRunTimeout, } } @@ -110,16 +123,18 @@ func (s *Server) Start() error { // Index page mux.HandleFunc("/", s.handleIndex) - // Wrap with loopback-only host validation and same-origin protection so - // that DNS-rebinding and cross-origin pages cannot drive the chat API. + // Wrap with loopback-only host validation, same-origin protection and + // security headers (strict CSP) so that DNS-rebinding and cross-origin + // pages cannot drive the chat API and injected markup cannot execute. handler := webguard.HostValidationMiddleware( - webguard.OriginProtectionMiddleware(mux)) + webguard.OriginProtectionMiddleware( + webguard.SecurityHeadersMiddleware(mux))) server := &http.Server{ Addr: s.addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, - WriteTimeout: 360 * time.Second, // long for SSE streaming + approval gating + WriteTimeout: 360 * time.Second, // baseline for SSE streaming; handleChat extends the per-connection write deadline during approval waits IdleTimeout: 120 * time.Second, MaxHeaderBytes: 1 << 20, } diff --git a/internal/chat/server_test.go b/internal/chat/server_test.go new file mode 100644 index 0000000..3c5089c --- /dev/null +++ b/internal/chat/server_test.go @@ -0,0 +1,105 @@ +package chat + +import ( + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestServerStart_SecurityHeaders verifies the running chat server actually +// serves the webguard security headers (strict CSP). The middleware is unit +// tested in webguard; this guards the wiring in Start(), which a refactor +// could silently drop without any other test failing. +func TestServerStart_SecurityHeaders(t *testing.T) { + memory := setupMemoryStore(t) + agent := Agent{Type: AgentClaude, Version: "1.0"} + server := NewServer(freeLoopbackAddr(t), &agent, []Agent{agent}, nil, "grant-id", memory, nil) + + // Start blocks on ListenAndServe and the server has no shutdown seam, so + // it runs until the test binary exits. + go func() { _ = server.Start() }() + + resp := waitForServer(t, "http://"+server.addr+"/api/health") + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusOK, resp.StatusCode) + csp := resp.Header.Get("Content-Security-Policy") + require.NotEmpty(t, csp, "chat server response is missing the CSP header — SecurityHeadersMiddleware not wired in Start()") + assert.Contains(t, csp, "script-src 'self';", "CSP must keep strict script-src") + assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options")) + assert.Equal(t, "SAMEORIGIN", resp.Header.Get("X-Frame-Options")) +} + +// freeLoopbackAddr reserves a loopback port and returns it as host:port. +func freeLoopbackAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + return addr +} + +// waitForServer polls url until the server responds or the deadline passes. +func waitForServer(t *testing.T, url string) *http.Response { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + resp, err := http.Get(url) // #nosec G107 -- loopback test URL + if err == nil { + return resp + } + if time.Now().After(deadline) { + t.Fatalf("server at %s did not come up: %v", url, err) + } + if !strings.Contains(err.Error(), "connection refused") { + t.Logf("waiting for server: %v", err) + } + time.Sleep(20 * time.Millisecond) + } +} + +// TestServer_ConcurrentSetAgentAndHandlers exercises agent switching racing +// with handlers that read the agent and context builder. s.agent and +// s.context are written under agentMu by SetAgent, so every read must go +// through the ActiveAgent/ActiveContext accessors — run with -race to verify. +func TestServer_ConcurrentSetAgentAndHandlers(t *testing.T) { + t.Parallel() + + memory := setupMemoryStore(t) + agents := []Agent{ + {Type: AgentClaude, Version: "1.0"}, + {Type: AgentOllama, Version: "1.0"}, + } + server := NewServer("127.0.0.1:0", &agents[0], agents, nil, "grant-id", memory, nil) + + var wg sync.WaitGroup + for i := range 50 { + wg.Add(2) + go func(i int) { + defer wg.Done() + if i%2 == 0 { + server.SetAgent(AgentClaude) + } else { + server.SetAgent(AgentOllama) + } + }(i) + go func() { + defer wg.Done() + req := httptest.NewRequest(http.MethodPost, "/api/conversations", nil) + w := httptest.NewRecorder() + server.handleConversations(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + require.NotNil(t, server.ActiveContext()) + require.NotNil(t, server.ActiveAgent()) + }() + } + wg.Wait() +} diff --git a/internal/chat/static/js/chat.js b/internal/chat/static/js/chat.js index abacd47..5a351b3 100644 --- a/internal/chat/static/js/chat.js +++ b/internal/chat/static/js/chat.js @@ -367,9 +367,9 @@ const Chat = { }, escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + // Delegate to Markdown.escape, which is quote-safe (also escapes + // " and ') and therefore safe in attribute context too. + return Markdown.escape(text); } }; diff --git a/internal/chat/static/js/markdown.js b/internal/chat/static/js/markdown.js index 05bef38..88bb05c 100644 --- a/internal/chat/static/js/markdown.js +++ b/internal/chat/static/js/markdown.js @@ -30,9 +30,9 @@ const Markdown = { // Links — scheme-validate the URL so attacker-controlled markdown // coming from agent output cannot inject `javascript:` URLs. The URL - // is already HTML-escaped by escape() above (the regex runs against - // the escaped html), so it is already safe to drop into a - // double-quoted attribute. + // and label are already HTML-escaped by escape() above (the regex + // runs against the escaped html); escape() also encodes quotes, so + // the URL cannot break out of the double-quoted href attribute. html = html.replace( /\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { @@ -53,10 +53,17 @@ const Markdown = { return html; }, + // escape HTML-escapes text so it is safe in both element content and + // double/single-quoted attribute context. The textContent/innerHTML DOM + // trick is intentionally NOT used here: it leaves " and ' unescaped, + // which allows breaking out of attribute values (e.g. href="..."). escape(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + return String(text ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); }, // safeUrl returns the URL if it uses an http(s) or mailto: scheme; otherwise @@ -79,3 +86,7 @@ const Markdown = { }, }; + +if (typeof module !== 'undefined' && module.exports) { + module.exports = Markdown; +} diff --git a/internal/chat/static/js/markdown.node.test.js b/internal/chat/static/js/markdown.node.test.js new file mode 100644 index 0000000..2a536c4 --- /dev/null +++ b/internal/chat/static/js/markdown.node.test.js @@ -0,0 +1,39 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const Markdown = require('./markdown.js'); + +test('escape encodes quotes so attribute context cannot be broken out of', () => { + assert.equal( + Markdown.escape(`&"quoted"'single'`), + '<b>&"quoted"'single'</b>' + ); +}); + +test('render neutralises attribute-breakout payload in link URL', () => { + // Reproduces the reported exploit: a double quote in the URL used to + // close the href attribute and inject an onmouseover handler. + const html = Markdown.render('[click](http://x" onmouseover="alert(1))'); + + assert.ok(!html.includes('onmouseover="alert(1)"'), 'must not inject event handler attribute'); + assert.ok(html.includes('href="http://x"'), 'quote must stay encoded inside href'); +}); + +test('render neutralises attribute-breakout payload in link label', () => { + const html = Markdown.render('[x" onmouseover="alert(1)](http://example.com)'); + + assert.ok(!html.includes('onmouseover="alert(1)"'), 'must not inject event handler attribute'); +}); + +test('render blocks javascript: URLs', () => { + const html = Markdown.render('[click](javascript:alert(1))'); + + assert.ok(!html.includes('javascript:'), 'dangerous scheme must be replaced'); + assert.ok(html.includes('href="#"'), 'href must fall back to #'); +}); + +test('render keeps plain http links working', () => { + const html = Markdown.render('[docs](https://developer.nylas.com/)'); + + assert.ok(html.includes('docs')); +}); diff --git a/internal/cli/admin/admin.go b/internal/cli/admin/admin.go index 63fba62..31063cc 100644 --- a/internal/cli/admin/admin.go +++ b/internal/cli/admin/admin.go @@ -13,7 +13,9 @@ func NewAdminCmd() *cobra.Command { Long: `Administration commands for managing applications, connectors, credentials, and grants. These commands require API key authentication and are used for managing -the Nylas platform at an organizational level.`, +the Nylas platform at an organizational level. + +API reference: https://developer.nylas.com/docs/reference/api/applications/`, } cmd.AddCommand(newApplicationsCmd()) diff --git a/internal/cli/admin/applications.go b/internal/cli/admin/applications.go index 377b988..b90bb3e 100644 --- a/internal/cli/admin/applications.go +++ b/internal/cli/admin/applications.go @@ -16,7 +16,9 @@ func newApplicationsCmd() *cobra.Command { Use: "applications", Aliases: []string{"app", "apps"}, Short: "Manage Nylas applications", - Long: "Manage Nylas applications in your organization.", + Long: `Manage Nylas applications in your organization. + +API reference: https://developer.nylas.com/docs/reference/api/applications/`, } cmd.AddCommand(newAppListCmd()) @@ -244,10 +246,7 @@ func newAppDeleteCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !yes { - fmt.Printf("Are you sure you want to delete application %s? (y/N): ", args[0]) - var confirm string - _, _ = fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" { + if !common.Confirm(fmt.Sprintf("Are you sure you want to delete application %s?", args[0]), false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/admin/callback_uris.go b/internal/cli/admin/callback_uris.go index b391a3e..5ec918e 100644 --- a/internal/cli/admin/callback_uris.go +++ b/internal/cli/admin/callback_uris.go @@ -203,10 +203,7 @@ func newCallbackURIDeleteCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !yes { - fmt.Printf("Are you sure you want to delete callback URI %s? (y/N): ", args[0]) - var confirm string - _, _ = fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" { + if !common.Confirm(fmt.Sprintf("Are you sure you want to delete callback URI %s?", args[0]), false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/admin/connectors.go b/internal/cli/admin/connectors.go index 32e100f..1a9c43c 100644 --- a/internal/cli/admin/connectors.go +++ b/internal/cli/admin/connectors.go @@ -16,7 +16,9 @@ func newConnectorsCmd() *cobra.Command { Use: "connectors", Aliases: []string{"connector", "conn"}, Short: "Manage email provider connectors", - Long: "Manage email provider connectors (Google, Microsoft, IMAP, etc.).", + Long: `Manage email provider connectors (Google, Microsoft, IMAP, etc.). + +API reference: https://developer.nylas.com/docs/reference/api/connectors-integrations/`, } cmd.AddCommand(newConnectorListCmd()) @@ -254,10 +256,7 @@ func newConnectorDeleteCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !yes { - fmt.Printf("Are you sure you want to delete connector %s? (y/N): ", args[0]) - var confirm string - _, _ = fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" { + if !common.Confirm(fmt.Sprintf("Are you sure you want to delete connector %s?", args[0]), false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/admin/credentials.go b/internal/cli/admin/credentials.go index f3cac3b..9c90106 100644 --- a/internal/cli/admin/credentials.go +++ b/internal/cli/admin/credentials.go @@ -16,7 +16,9 @@ func newCredentialsCmd() *cobra.Command { Use: "credentials", Aliases: []string{"credential", "cred"}, Short: "Manage connector credentials", - Long: "Manage authentication credentials for connectors (OAuth, service accounts, etc.).", + Long: `Manage authentication credentials for connectors (OAuth, service accounts, etc.). + +API reference: https://developer.nylas.com/docs/reference/api/connector-credentials/`, } cmd.AddCommand(newCredentialListCmd()) @@ -204,10 +206,7 @@ func newCredentialDeleteCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !yes { - fmt.Printf("Are you sure you want to delete credential %s? (y/N): ", args[0]) - var confirm string - _, _ = fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" { + if !common.Confirm(fmt.Sprintf("Are you sure you want to delete credential %s?", args[0]), false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/admin/grants.go b/internal/cli/admin/grants.go index 41a702b..489cb08 100644 --- a/internal/cli/admin/grants.go +++ b/internal/cli/admin/grants.go @@ -16,7 +16,9 @@ func newGrantsCmd() *cobra.Command { Use: "grants", Aliases: []string{"grant"}, Short: "Manage grants", - Long: "View and manage grants across all applications.", + Long: `View and manage grants across all applications. + +API reference: https://developer.nylas.com/docs/reference/api/manage-grants/`, } cmd.AddCommand(newGrantListCmd()) diff --git a/internal/cli/agent/account.go b/internal/cli/agent/account.go index 52a00fb..4753b9c 100644 --- a/internal/cli/agent/account.go +++ b/internal/cli/agent/account.go @@ -12,6 +12,8 @@ Agent accounts are managed email identities backed by the Nylas provider. This command always uses provider=nylas and keeps connector setup out of the user's path. +API reference: https://developer.nylas.com/docs/v3/agent-accounts/provisioning/ + Examples: # Create a new agent account nylas agent account create me@yourapp.nylas.email diff --git a/internal/cli/agent/agent.go b/internal/cli/agent/agent.go index 07abf03..100581c 100644 --- a/internal/cli/agent/agent.go +++ b/internal/cli/agent/agent.go @@ -14,6 +14,8 @@ Agent account operations live under the account subcommand. Top-level status reports the readiness of the nylas connector and the currently configured managed accounts. +API reference: https://developer.nylas.com/docs/v3/agent-accounts/ + Examples: # Create a new agent account nylas agent account create me@yourapp.nylas.email @@ -40,6 +42,7 @@ managed accounts. cmd.AddCommand(newAccountCmd()) cmd.AddCommand(newPolicyCmd()) cmd.AddCommand(newRuleCmd()) + cmd.AddCommand(newAgentListCmd()) cmd.AddCommand(newStatusCmd()) return cmd diff --git a/internal/cli/agent/agent_test.go b/internal/cli/agent/agent_test.go index 20a5675..2f5e077 100644 --- a/internal/cli/agent/agent_test.go +++ b/internal/cli/agent/agent_test.go @@ -19,7 +19,7 @@ func TestNewAgentCmd(t *testing.T) { assert.Contains(t, cmd.Short, "agent") assert.Contains(t, cmd.Long, "account subcommand") - expected := []string{"account", "policy", "rule", "status"} + expected := []string{"account", "policy", "rule", "list", "status"} cmdMap := make(map[string]bool) for _, sub := range cmd.Commands() { cmdMap[sub.Name()] = true @@ -252,8 +252,6 @@ func TestPrintPolicyDetails(t *testing.T) { dailyMessages := int64(500) inboxRetention := 30 spamRetention := 7 - additionalFolders := []string{"archive", "support"} - useCidrAliasing := false useDNSBL := false useHeaderAnomaly := true spamSensitivity := 1.0 @@ -273,10 +271,6 @@ func TestPrintPolicyDetails(t *testing.T) { LimitInboxRetentionPeriodInDays: &inboxRetention, LimitSpamRetentionPeriodInDays: &spamRetention, }, - Options: &domain.PolicyOptions{ - AdditionalFolders: &additionalFolders, - UseCidrAliasing: &useCidrAliasing, - }, SpamDetection: &domain.PolicySpamDetection{ UseListDNSBL: &useDNSBL, UseHeaderAnomalyDetection: &useHeaderAnomaly, @@ -298,10 +292,6 @@ func TestPrintPolicyDetails(t *testing.T) { assert.Contains(t, output, "50480000 bytes") assert.Contains(t, output, "Allowed types:") assert.Contains(t, output, "application/pdf") - assert.Contains(t, output, "Options:") - assert.Contains(t, output, "Additional folders:") - assert.Contains(t, output, "archive") - assert.Contains(t, output, "CIDR aliasing:") assert.Contains(t, output, "Spam detection:") assert.Contains(t, output, "Use DNSBL:") assert.Contains(t, output, "Header anomaly detection:") diff --git a/internal/cli/agent/list_test.go b/internal/cli/agent/list_test.go new file mode 100644 index 0000000..f777375 --- /dev/null +++ b/internal/cli/agent/list_test.go @@ -0,0 +1,66 @@ +package agent + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAgentListCmd(t *testing.T) { + cmd := newAgentListCmd() + + assert.Equal(t, "list", cmd.Use) + assert.Contains(t, cmd.Aliases, "lists") + assert.Contains(t, cmd.Short, "lists") + assert.Contains(t, cmd.Long, "/v3/lists") + + expected := []string{"list", "get", "create", "update", "delete", "items", "add", "remove"} + cmdMap := make(map[string]bool) + for _, sub := range cmd.Commands() { + cmdMap[sub.Name()] = true + } + for _, name := range expected { + assert.True(t, cmdMap[name], "missing subcommand %s", name) + } +} + +func TestBuildListCreatePayload(t *testing.T) { + payload, err := buildListCreatePayload("Blocked domains", "domain", "bad senders") + assert.NoError(t, err) + assert.Equal(t, "Blocked domains", payload["name"]) + assert.Equal(t, "domain", payload["type"]) + assert.Equal(t, "bad senders", payload["description"]) + + // description is optional and omitted when empty + payload, err = buildListCreatePayload("Blocked domains", "tld", "") + assert.NoError(t, err) + _, hasDescription := payload["description"] + assert.False(t, hasDescription) + + // name is required + _, err = buildListCreatePayload("", "domain", "") + assert.ErrorContains(t, err, "name is required") + + // type must be one of domain, tld, address — it is immutable after + // creation and determines which rule fields the list can match + _, err = buildListCreatePayload("Blocked", "country", "") + assert.ErrorContains(t, err, "invalid list type") +} + +func TestAgentListCreateCmd(t *testing.T) { + cmd := newAgentListCreateCmd() + + assert.Equal(t, "create", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("name")) + assert.NotNil(t, cmd.Flags().Lookup("type")) + assert.NotNil(t, cmd.Flags().Lookup("description")) + assert.NotNil(t, cmd.Flags().Lookup("item")) +} + +func TestAgentListDeleteCmd_RequiresYes(t *testing.T) { + cmd := newAgentListDeleteCmd() + cmd.SetArgs([]string{"list-123"}) + + err := cmd.Execute() + assert.ErrorContains(t, err, "confirmation") +} diff --git a/internal/cli/agent/lists.go b/internal/cli/agent/lists.go new file mode 100644 index 0000000..6d74113 --- /dev/null +++ b/internal/cli/agent/lists.go @@ -0,0 +1,215 @@ +package agent + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newAgentListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"lists"}, + Short: "Manage agent lists", + Long: `Manage lists used by agent rule in_list conditions. + +Lists are backed by the /v3/lists API. Each list holds normalized values of a +single immutable type (domain, tld, or address); rule conditions reference +lists by ID with the in_list operator, and a list's type determines which +rule fields it can match. + +API reference: https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/ + +Examples: + nylas agent list list + nylas agent list create --name "Blocked domains" --type domain --item spam.com + nylas agent list get + nylas agent list add junk.net + nylas agent list remove junk.net + nylas agent list delete --yes`, + } + + cmd.AddCommand(newAgentListListCmd()) + cmd.AddCommand(newAgentListGetCmd()) + cmd.AddCommand(newAgentListCreateCmd()) + cmd.AddCommand(newAgentListUpdateCmd()) + cmd.AddCommand(newAgentListDeleteCmd()) + cmd.AddCommand(newAgentListItemsCmd()) + cmd.AddCommand(newAgentListAddCmd()) + cmd.AddCommand(newAgentListRemoveCmd()) + + return cmd +} + +func newAgentListListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List lists", + Long: `List all lists from /v3/lists. + +Examples: + nylas agent list list + nylas agent list list --json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runAgentListList(common.IsJSON(cmd)) + }, + } +} + +func runAgentListList(jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + lists, err := client.ListLists(ctx) + if err != nil { + return struct{}{}, common.WrapListError("lists", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(lists) + } + + if len(lists) == 0 { + common.PrintEmptyStateWithHint("lists", "Create one with: nylas agent list create --name \"List Name\" --type domain") + return struct{}{}, nil + } + + _, _ = common.BoldWhite.Printf("Lists (%d)\n\n", len(lists)) + for _, list := range lists { + printAgentListSummary(list) + } + return struct{}{}, nil + }) + + return err +} + +func newAgentListGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Show a list and its items", + Long: `Show details and items for a single list. + +Examples: + nylas agent list get + nylas agent list get --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAgentListGet(args[0], common.IsJSON(cmd)) + }, + } +} + +func runAgentListGet(listID string, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + list, err := client.GetList(ctx, listID) + if err != nil { + return struct{}{}, common.WrapGetError("list", err) + } + + items, err := client.GetListItems(ctx, listID) + if err != nil { + return struct{}{}, common.WrapGetError("list items", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(map[string]any{"list": list, "items": items}) + } + + printAgentListDetails(*list, items) + return struct{}{}, nil + }) + + return err +} + +func newAgentListItemsCmd() *cobra.Command { + return &cobra.Command{ + Use: "items ", + Short: "Show list items", + Long: `Show the items of a list. + +Examples: + nylas agent list items + nylas agent list items --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAgentListItems(args[0], common.IsJSON(cmd)) + }, + } +} + +func runAgentListItems(listID string, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + items, err := client.GetListItems(ctx, listID) + if err != nil { + return struct{}{}, common.WrapGetError("list items", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(items) + } + + if len(items) == 0 { + common.PrintEmptyStateWithHint("items", fmt.Sprintf("Add some with: nylas agent list add %s ", listID)) + return struct{}{}, nil + } + + _, _ = common.BoldWhite.Printf("Items (%d)\n\n", len(items)) + for _, item := range items { + fmt.Printf(" %s\n", item) + } + fmt.Println() + return struct{}{}, nil + }) + + return err +} + +func buildListCreatePayload(name, listType, description string) (map[string]any, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, common.NewUserError("list name is required", "Pass --name \"List Name\"") + } + listType = strings.ToLower(strings.TrimSpace(listType)) + if !slices.Contains(domain.AgentListTypes, listType) { + return nil, common.NewUserError( + fmt.Sprintf("invalid list type: %s", listType), + fmt.Sprintf("Use one of: %s. The type is immutable after creation.", strings.Join(domain.AgentListTypes, ", ")), + ) + } + + payload := map[string]any{"name": name, "type": listType} + if description = strings.TrimSpace(description); description != "" { + payload["description"] = description + } + return payload, nil +} + +func printAgentListSummary(list domain.AgentList) { + _, _ = common.BoldWhite.Printf("%s\n", list.Name) + fmt.Printf(" ID: %s\n", list.ID) + fmt.Printf(" Type: %s\n", list.Type) + fmt.Printf(" Items: %d\n", list.ItemsCount) + if list.Description != "" { + fmt.Printf(" Description: %s\n", list.Description) + } + fmt.Println() +} + +func printAgentListDetails(list domain.AgentList, items []string) { + printAgentListSummary(list) + if len(items) == 0 { + fmt.Println(" (no items)") + return + } + _, _ = common.BoldWhite.Printf("Items (%d)\n", len(items)) + for _, item := range items { + fmt.Printf(" %s\n", item) + } + fmt.Println() +} diff --git a/internal/cli/agent/lists_create_update_delete.go b/internal/cli/agent/lists_create_update_delete.go new file mode 100644 index 0000000..9036aa8 --- /dev/null +++ b/internal/cli/agent/lists_create_update_delete.go @@ -0,0 +1,237 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newAgentListCreateCmd() *cobra.Command { + var ( + name string + listType string + description string + items []string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a list", + Long: `Create a new list, optionally seeding it with items. + +The type (domain, tld, or address) is immutable after creation and determines +which rule fields the list can match in in_list conditions. + +Examples: + nylas agent list create --name "Blocked domains" --type domain + nylas agent list create --name "VIPs" --type address --item ceo@example.com --item cfo@example.com`, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := buildListCreatePayload(name, listType, description) + if err != nil { + return err + } + return runAgentListCreate(payload, items, common.IsJSON(cmd)) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "List name") + cmd.Flags().StringVar(&listType, "type", "", "List type: domain, tld, or address (immutable)") + cmd.Flags().StringVar(&description, "description", "", "List description") + cmd.Flags().StringArrayVar(&items, "item", nil, "Item to add after creation (repeatable)") + + return cmd +} + +func runAgentListCreate(payload map[string]any, items []string, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + list, err := client.CreateList(ctx, payload) + if err != nil { + return struct{}{}, common.WrapCreateError("list", err) + } + + if len(items) > 0 { + updated, err := client.AddListItems(ctx, list.ID, items) + if err != nil { + return struct{}{}, fmt.Errorf("list %s created but adding items failed: %w", list.ID, err) + } + list = updated + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(list) + } + + common.PrintSuccess("List created successfully!") + fmt.Println() + printAgentListSummary(*list) + return struct{}{}, nil + }) + + return err +} + +func newAgentListUpdateCmd() *cobra.Command { + var ( + name string + description string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a list's name or description", + Long: `Update a list's metadata. The type cannot be changed. + +Examples: + nylas agent list update --name "New name" + nylas agent list update --description "Updated description"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + payload := map[string]any{} + if cmd.Flags().Changed("name") { + if strings.TrimSpace(name) == "" { + return common.NewUserError("list name cannot be empty", "Pass --name \"List Name\"") + } + payload["name"] = strings.TrimSpace(name) + } + if cmd.Flags().Changed("description") { + payload["description"] = strings.TrimSpace(description) + } + if len(payload) == 0 { + return common.NewUserError("nothing to update", "Pass --name and/or --description") + } + return runAgentListUpdate(args[0], payload, common.IsJSON(cmd)) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "New list name") + cmd.Flags().StringVar(&description, "description", "", "New list description") + + return cmd +} + +func runAgentListUpdate(listID string, payload map[string]any, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + list, err := client.UpdateList(ctx, listID, payload) + if err != nil { + return struct{}{}, common.WrapUpdateError("list", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(list) + } + + common.PrintSuccess("List updated successfully!") + fmt.Println() + printAgentListSummary(*list) + return struct{}{}, nil + }) + + return err +} + +func newAgentListDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a list", + Long: `Delete a list. + +Rules referencing the list via in_list conditions will no longer match it. + +Examples: + nylas agent list delete --yes`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !yes { + return common.NewUserError("deletion requires confirmation", "Re-run with --yes to delete the list") + } + return runAgentListDelete(args[0]) + }, + } + + common.AddYesFlag(cmd, &yes) + + return cmd +} + +func runAgentListDelete(listID string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + if err := client.DeleteList(ctx, listID); err != nil { + return struct{}{}, common.WrapDeleteError("list", err) + } + common.PrintSuccess("List deleted successfully!") + return struct{}{}, nil + }) + + return err +} + +func newAgentListAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add [item...]", + Short: "Add items to a list", + Long: `Add items to a list (up to 1000 per request). + +Values are lowercased, trimmed, and validated against the list's type by the +API; duplicates are silently ignored. + +Examples: + nylas agent list add spam.com junk.net`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runAgentListModifyItems(args[0], args[1:], true, common.IsJSON(cmd)) + }, + } +} + +func newAgentListRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove [item...]", + Short: "Remove items from a list", + Long: `Remove items from a list. + +Examples: + nylas agent list remove spam.com`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runAgentListModifyItems(args[0], args[1:], false, common.IsJSON(cmd)) + }, + } +} + +func runAgentListModifyItems(listID string, items []string, add bool, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + var ( + list *domain.AgentList + err error + ) + if add { + list, err = client.AddListItems(ctx, listID, items) + } else { + list, err = client.RemoveListItems(ctx, listID, items) + } + if err != nil { + return struct{}{}, common.WrapUpdateError("list items", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(list) + } + + if add { + common.PrintSuccess("Added %d item(s)", len(items)) + } else { + common.PrintSuccess("Removed %d item(s)", len(items)) + } + fmt.Printf(" List %s now has %d item(s)\n", list.ID, list.ItemsCount) + return struct{}{}, nil + }) + + return err +} diff --git a/internal/cli/agent/policy.go b/internal/cli/agent/policy.go index 863f2be..19d0ed2 100644 --- a/internal/cli/agent/policy.go +++ b/internal/cli/agent/policy.go @@ -32,6 +32,8 @@ func newPolicyCmd() *cobra.Command { Policies are backed by the /v3/policies API. Agent accounts inherit policy settings from their workspace policy_id attachment. +API reference: https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/ + Examples: nylas agent policy list nylas agent policy get @@ -165,7 +167,6 @@ func printPolicyDetails(policy domain.Policy) { printPolicyStringListSection("Rules", policy.Rules) printPolicyLimitsSection(policy.Limits) - printPolicyOptionsSection(policy.Options) printPolicySpamDetectionSection(policy.SpamDetection) fmt.Println() } @@ -248,28 +249,6 @@ func printPolicyLimitsSection(limits *domain.PolicyLimits) { } } -func printPolicyOptionsSection(options *domain.PolicyOptions) { - printPolicySectionHeader("Options") - if options == nil { - fmt.Println(" none") - return - } - - printed := false - if options.AdditionalFolders != nil { - printPolicyValueList("Additional folders", *options.AdditionalFolders) - printed = true - } - if options.UseCidrAliasing != nil { - printPolicyField("CIDR aliasing", fmt.Sprintf("%t", *options.UseCidrAliasing)) - printed = true - } - - if !printed { - fmt.Println(" none") - } -} - func printPolicySpamDetectionSection(spamDetection *domain.PolicySpamDetection) { printPolicySectionHeader("Spam detection") if spamDetection == nil { diff --git a/internal/cli/agent/rule.go b/internal/cli/agent/rule.go index dc0d377..173dfd9 100644 --- a/internal/cli/agent/rule.go +++ b/internal/cli/agent/rule.go @@ -20,7 +20,9 @@ func newRuleCmd() *cobra.Command { Long: `Manage rules attached to agent account workspaces. Rules are backed by the /v3/rules API. They attach to workspaces via -rules_ids[]. +rule_ids[]. + +API reference: https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/ Examples: nylas agent rule list diff --git a/internal/cli/agent/rule_create_update_delete.go b/internal/cli/agent/rule_create_update_delete.go index c565bb2..6230a71 100644 --- a/internal/cli/agent/rule_create_update_delete.go +++ b/internal/cli/agent/rule_create_update_delete.go @@ -26,7 +26,7 @@ func newRuleCreateCmd() *cobra.Command { Long: `Create a new rule and attach it to the default agent workspace. Rules are created through /v3/rules, then attached to the workspace via -rules_ids. The workspace is resolved from the current default grant. +rule_ids. The workspace is resolved from the current default grant. Examples: nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action block diff --git a/internal/cli/ai/clear_data.go b/internal/cli/ai/clear_data.go index e18f24f..2e92a8a 100644 --- a/internal/cli/ai/clear_data.go +++ b/internal/cli/ai/clear_data.go @@ -41,11 +41,8 @@ Examples: fmt.Println(" - Usage statistics") fmt.Println(" - Cached responses") fmt.Println() - fmt.Print("Are you sure? (yes/no): ") - var response string - _, err := fmt.Scanln(&response) - if err != nil || (response != "yes" && response != "y") { + if !common.Confirm("Are you sure?", false) { fmt.Println("\n❌ Cancelled") return nil } diff --git a/internal/cli/audit/logs.go b/internal/cli/audit/logs.go index d19f9b2..b8922eb 100644 --- a/internal/cli/audit/logs.go +++ b/internal/cli/audit/logs.go @@ -172,13 +172,8 @@ func newClearCmd() *cobra.Command { if !force { fmt.Printf("This will delete %d log files (%s).\n", fileCount, common.FormatSize(totalSize)) - fmt.Print("Are you sure? [y/N]: ") - var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { - return nil // No input, assume no - } - if confirm != "y" && confirm != "Y" && confirm != "yes" { + if !common.Confirm("Are you sure?", false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/auth/auth.go b/internal/cli/auth/auth.go index f897c34..ad0239c 100644 --- a/internal/cli/auth/auth.go +++ b/internal/cli/auth/auth.go @@ -27,7 +27,9 @@ Commands: providers List available authentication providers detect Detect provider from email address scopes Show OAuth scopes for a grant - migrate Migrate credentials to system keyring`, + migrate Migrate credentials to system keyring + +API reference: https://developer.nylas.com/docs/v3/auth/`, } cmd.AddCommand(newLoginCmd()) diff --git a/internal/cli/calendar/availability.go b/internal/cli/calendar/availability.go index 6ef84b7..334e219 100644 --- a/internal/cli/calendar/availability.go +++ b/internal/cli/calendar/availability.go @@ -20,7 +20,9 @@ func newAvailabilityCmd() *cobra.Command { Long: `Check calendar availability and find free meeting times. Use 'nylas calendar availability check' to see free/busy times for calendars. -Use 'nylas calendar availability find' to find available meeting slots.`, +Use 'nylas calendar availability find' to find available meeting slots. + +API reference: https://developer.nylas.com/docs/reference/api/availability/`, } cmd.AddCommand(newFreeBusyCmd()) diff --git a/internal/cli/calendar/calendar.go b/internal/cli/calendar/calendar.go index 2e72001..3be5f85 100644 --- a/internal/cli/calendar/calendar.go +++ b/internal/cli/calendar/calendar.go @@ -20,7 +20,9 @@ func NewCalendarCmd() *cobra.Command { Short: "Manage calendars and events", Long: `Manage calendars and events from your connected accounts. -View calendars, list events, create new events, and more.`, +View calendars, list events, create new events, and more. + +API reference: https://developer.nylas.com/docs/v3/calendar/`, } cmd.AddCommand(newListCmd()) diff --git a/internal/cli/calendar/calendar_events_test.go b/internal/cli/calendar/calendar_events_test.go index 6326702..e0e3c8d 100644 --- a/internal/cli/calendar/calendar_events_test.go +++ b/internal/cli/calendar/calendar_events_test.go @@ -122,6 +122,16 @@ func TestEventsCreateCmd(t *testing.T) { flag := cmd.Flags().Lookup("calendar") assert.NotNil(t, flag) }) + + t.Run("has_timezone_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("timezone") + assert.NotNil(t, flag) + }) + + t.Run("has_lock_timezone_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("lock-timezone") + assert.NotNil(t, flag) + }) } func TestEventsDeleteCmd(t *testing.T) { @@ -214,6 +224,16 @@ func TestEventsUpdateCmd(t *testing.T) { flag := cmd.Flags().Lookup("calendar") assert.NotNil(t, flag) }) + + t.Run("has_timezone_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("timezone") + assert.NotNil(t, flag) + }) + + t.Run("has_lock_timezone_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("lock-timezone") + assert.NotNil(t, flag) + }) } func TestEventsRSVPCmd(t *testing.T) { diff --git a/internal/cli/calendar/calendar_helpers_test.go b/internal/cli/calendar/calendar_helpers_test.go index a5f4595..47fbf8e 100644 --- a/internal/cli/calendar/calendar_helpers_test.go +++ b/internal/cli/calendar/calendar_helpers_test.go @@ -84,14 +84,14 @@ func TestParseDuration(t *testing.T) { func TestParseEventTime(t *testing.T) { t.Run("parses_all_day_event", func(t *testing.T) { - when, err := parseEventTime("2024-01-15", "", true) + when, err := parseEventTime("2024-01-15", "", true, "") assert.NoError(t, err) assert.Equal(t, "date", when.Object) assert.Equal(t, "2024-01-15", when.Date) }) t.Run("parses_timed_event", func(t *testing.T) { - when, err := parseEventTime("2024-01-15 14:00", "2024-01-15 15:00", false) + when, err := parseEventTime("2024-01-15 14:00", "2024-01-15 15:00", false, "") assert.NoError(t, err) assert.Equal(t, "timespan", when.Object) assert.NotZero(t, when.StartTime) @@ -99,7 +99,7 @@ func TestParseEventTime(t *testing.T) { }) t.Run("defaults_end_to_one_hour", func(t *testing.T) { - when, err := parseEventTime("2024-01-15 14:00", "", false) + when, err := parseEventTime("2024-01-15 14:00", "", false, "") assert.NoError(t, err) assert.Equal(t, "timespan", when.Object) // End should be 1 hour after start @@ -107,7 +107,7 @@ func TestParseEventTime(t *testing.T) { }) t.Run("parses_date_range", func(t *testing.T) { - when, err := parseEventTime("2024-01-15", "2024-01-17", true) + when, err := parseEventTime("2024-01-15", "2024-01-17", true, "") assert.NoError(t, err) assert.Equal(t, "datespan", when.Object) assert.Equal(t, "2024-01-15", when.StartDate) @@ -115,9 +115,123 @@ func TestParseEventTime(t *testing.T) { }) t.Run("returns_error_for_invalid_start", func(t *testing.T) { - _, err := parseEventTime("invalid", "", false) + _, err := parseEventTime("invalid", "", false, "") assert.Error(t, err) }) + + t.Run("records_explicit_timezone_on_timed_event", func(t *testing.T) { + when, err := parseEventTime("2024-01-15 14:00", "2024-01-15 15:00", false, "America/New_York") + assert.NoError(t, err) + assert.Equal(t, "timespan", when.Object) + assert.Equal(t, "America/New_York", when.StartTimezone) + assert.Equal(t, "America/New_York", when.EndTimezone) + + // Timestamps must agree with the recorded zone: 14:00 wall clock in NY + loc, lerr := time.LoadLocation("America/New_York") + assert.NoError(t, lerr) + assert.Equal(t, time.Date(2024, 1, 15, 14, 0, 0, 0, loc).Unix(), when.StartTime) + assert.Equal(t, time.Date(2024, 1, 15, 15, 0, 0, 0, loc).Unix(), when.EndTime) + }) + + t.Run("defaults_timezone_to_system_zone", func(t *testing.T) { + when, err := parseEventTime("2024-01-15 14:00", "", false, "") + assert.NoError(t, err) + assert.Equal(t, getLocalTimeZone(), when.StartTimezone) + assert.Equal(t, getLocalTimeZone(), when.EndTimezone) + }) + + t.Run("returns_error_for_invalid_timezone", func(t *testing.T) { + _, err := parseEventTime("2024-01-15 14:00", "", false, "Not/AZone") + assert.Error(t, err) + }) + + t.Run("all_day_does_not_set_timezone", func(t *testing.T) { + when, err := parseEventTime("2024-01-15", "", true, "America/New_York") + assert.NoError(t, err) + assert.Empty(t, when.StartTimezone) + assert.Empty(t, when.EndTimezone) + }) + + t.Run("rfc3339_offset_matching_timezone_accepted", func(t *testing.T) { + // June 15: America/New_York is EDT (-04:00), so the offset agrees + // with the recorded zone and the wall time is preserved. + when, err := parseEventTime("2026-06-15T14:00:00-04:00", "", false, "America/New_York") + assert.NoError(t, err) + assert.Equal(t, "timespan", when.Object) + assert.Equal(t, "America/New_York", when.StartTimezone) + + loc, lerr := time.LoadLocation("America/New_York") + assert.NoError(t, lerr) + assert.Equal(t, time.Date(2026, 6, 15, 14, 0, 0, 0, loc).Unix(), when.StartTime) + }) + + t.Run("rfc3339_offset_conflicting_timezone_errors", func(t *testing.T) { + // +09:00 disagrees with America/New_York (-04:00 on June 15). The + // epoch would follow the offset while start_timezone records New + // York, so the event would display at a different wall time than + // the user typed — reject instead of storing the mismatch. + _, err := parseEventTime("2026-06-15T14:00:00+09:00", "", false, "America/New_York") + assert.Error(t, err) + assert.Contains(t, err.Error(), "offset") + }) + + t.Run("rfc3339_end_offset_conflicting_timezone_errors", func(t *testing.T) { + _, err := parseEventTime("2026-06-15 14:00", "2026-06-15T16:00:00+09:00", false, "America/New_York") + assert.Error(t, err) + assert.Contains(t, err.Error(), "offset") + }) + + t.Run("rfc3339_dst_gap_offset_errors", func(t *testing.T) { + // 02:30 EST on 2026-03-08 doesn't exist: clocks jump 02:00 EST → + // 03:00 EDT, so at that UTC instant New York is already -04:00. + _, err := parseEventTime("2026-03-08T02:30:00-05:00", "", false, "America/New_York") + assert.Error(t, err) + assert.Contains(t, err.Error(), "offset") + }) + + t.Run("rfc3339_dst_fold_offsets_accepted", func(t *testing.T) { + // Both fall-back representations name real instants: 01:30-04:00 is + // before the 06:00 UTC fallback (still EDT), 01:30-05:00 after (EST). + _, err := parseEventTime("2026-11-01T01:30:00-04:00", "", false, "America/New_York") + assert.NoError(t, err) + _, err = parseEventTime("2026-11-01T01:30:00-05:00", "", false, "America/New_York") + assert.NoError(t, err) + }) + + t.Run("rfc3339_z_with_utc_equivalent_zone_accepted", func(t *testing.T) { + // Africa/Abidjan is permanently UTC+00:00, so a Z input agrees. + when, err := parseEventTime("2026-06-15T14:00:00Z", "", false, "Africa/Abidjan") + assert.NoError(t, err) + assert.Equal(t, "Africa/Abidjan", when.StartTimezone) + }) + + t.Run("rfc3339_z_with_utc_timezone_accepted", func(t *testing.T) { + when, err := parseEventTime("2026-06-15T14:00:00Z", "", false, "UTC") + assert.NoError(t, err) + assert.Equal(t, "UTC", when.StartTimezone) + assert.Equal(t, time.Date(2026, 6, 15, 14, 0, 0, 0, time.UTC).Unix(), when.StartTime) + }) + + t.Run("all_day_with_time_component_errors", func(t *testing.T) { + // --all-day must never silently create a timed event + when, err := parseEventTime("2024-01-15 10:00", "", true, "") + assert.Error(t, err) + assert.Nil(t, when) + assert.Contains(t, err.Error(), "all-day") + }) + + t.Run("locked_timezone_round_trip", func(t *testing.T) { + // --lock-timezone relies on When.StartTimezone being populated: + // GetLockedTimezone() must return the zone the event was created in. + when, err := parseEventTime("2024-01-15 14:00", "", false, "Asia/Tokyo") + assert.NoError(t, err) + + event := domain.Event{ + Metadata: map[string]string{"timezone_locked": "true"}, + When: *when, + } + assert.Equal(t, "Asia/Tokyo", event.GetLockedTimezone()) + }) } func TestFormatEventTime(t *testing.T) { diff --git a/internal/cli/calendar/crud.go b/internal/cli/calendar/crud.go index b771849..ae8ee8e 100644 --- a/internal/cli/calendar/crud.go +++ b/internal/cli/calendar/crud.go @@ -169,17 +169,13 @@ func newDeleteCmd() *cobra.Command { return struct{}{}, common.WrapGetError("calendar", err) } - fmt.Println("Delete this calendar?") fmt.Printf(" Name: %s\n", cal.Name) fmt.Printf(" ID: %s\n", cal.ID) if cal.IsPrimary { _, _ = common.Yellow.Printf(" Warning: This is a PRIMARY calendar!\n") } - fmt.Print("\n[y/N]: ") - var confirm string - _, _ = fmt.Scanln(&confirm) // Ignore error - empty string treated as "no" - if confirm != "y" && confirm != "Y" && confirm != "yes" { + if !common.Confirm("\nDelete this calendar?", false) { fmt.Println("Cancelled.") return struct{}{}, nil } diff --git a/internal/cli/calendar/dst_helpers.go b/internal/cli/calendar/dst_helpers.go index 7ec03ac..2a5d344 100644 --- a/internal/cli/calendar/dst_helpers.go +++ b/internal/cli/calendar/dst_helpers.go @@ -2,7 +2,6 @@ package calendar import ( "fmt" - "strings" "time" "github.com/nylas/cli/internal/adapters/utilities/timezone" @@ -121,9 +120,5 @@ func confirmDSTConflict(warning *domain.DSTWarning) bool { } // Ask for confirmation - fmt.Print("Create anyway? [y/N]: ") - var confirm string - _, _ = fmt.Scanln(&confirm) - - return strings.ToLower(confirm) == "y" || strings.ToLower(confirm) == "yes" + return common.Confirm("Create anyway?", false) } diff --git a/internal/cli/calendar/events.go b/internal/cli/calendar/events.go index f669fbb..b2facf8 100644 --- a/internal/cli/calendar/events.go +++ b/internal/cli/calendar/events.go @@ -9,7 +9,9 @@ func newEventsCmd() *cobra.Command { Use: "events", Aliases: []string{"ev", "event"}, Short: "Manage calendar events", - Long: "List, create, update, delete, and manage calendar events", + Long: `List, create, update, delete, and manage calendar events + +API reference: https://developer.nylas.com/docs/reference/api/events/`, } cmd.AddCommand(newEventsListCmd()) diff --git a/internal/cli/calendar/events_crud.go b/internal/cli/calendar/events_crud.go index 8f57454..4578f6a 100644 --- a/internal/cli/calendar/events_crud.go +++ b/internal/cli/calendar/events_crud.go @@ -27,6 +27,7 @@ func newEventsCreateCmd() *cobra.Command { ignoreDSTWarning bool ignoreWorkingHours bool lockTimezone bool + eventTimezone string ) cmd := &cobra.Command{ @@ -66,8 +67,8 @@ Examples: return struct{}{}, err } - // Parse times - when, err := parseEventTime(startTime, endTime, allDay) + // Parse times in the requested timezone (system timezone if not set) + when, err := parseEventTime(startTime, endTime, allDay, eventTimezone) if err != nil { return struct{}{}, err } @@ -191,6 +192,7 @@ Examples: cmd.Flags().BoolVar(&ignoreDSTWarning, "ignore-dst-warning", false, "Skip DST conflict warnings") cmd.Flags().BoolVar(&ignoreWorkingHours, "ignore-working-hours", false, "Skip working hours validation") cmd.Flags().BoolVar(&lockTimezone, "lock-timezone", false, "Lock event to its timezone (always display in this timezone)") + cmd.Flags().StringVar(&eventTimezone, "timezone", "", "IANA timezone for start/end times (e.g., America/Los_Angeles). Defaults to system timezone.") _ = cmd.MarkFlagRequired("title") _ = cmd.MarkFlagRequired("start") @@ -268,6 +270,7 @@ func newEventsUpdateCmd() *cobra.Command { visibility string lockTimezone bool unlockTimezone bool + eventTimezone string ) cmd := &cobra.Command{ @@ -289,6 +292,15 @@ Examples: eventID := args[0] grantArgs := args[1:] + // The timezone is only applied while parsing new times, so alone + // it would silently do nothing. + if cmd.Flags().Changed("timezone") && !cmd.Flags().Changed("start") { + return common.NewUserError( + "--timezone requires --start", + "Provide --start (and optionally --end) to re-set the event time in that timezone", + ) + } + _, err := common.WithClient(grantArgs, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { // Get calendar ID if not specified calID, err := GetDefaultCalendarID(ctx, client, grantID, calendarID, false) @@ -319,9 +331,9 @@ Examples: req.Visibility = &visibility } - // Handle time changes + // Handle time changes (parsed in the requested timezone, system timezone if not set) if cmd.Flags().Changed("start") { - when, err := parseEventTime(startTime, endTime, allDay) + when, err := parseEventTime(startTime, endTime, allDay, eventTimezone) if err != nil { return struct{}{}, err } @@ -345,16 +357,23 @@ Examples: ) } - if lockTimezone { - if req.Metadata == nil { - req.Metadata = make(map[string]string) + if lockTimezone || unlockTimezone { + // The update replaces the metadata object wholesale, so + // merge with the event's existing metadata to avoid + // clobbering unrelated keys. + existing, err := client.GetEvent(ctx, grantID, calID, eventID) + if err != nil { + return struct{}{}, common.WrapFetchError("event", err) } - req.Metadata["timezone_locked"] = "true" - } else if unlockTimezone { - if req.Metadata == nil { - req.Metadata = make(map[string]string) + req.Metadata = make(map[string]string, len(existing.Metadata)+1) + for k, v := range existing.Metadata { + req.Metadata[k] = v + } + if lockTimezone { + req.Metadata["timezone_locked"] = "true" + } else { + req.Metadata["timezone_locked"] = "false" } - req.Metadata["timezone_locked"] = "false" } event, err := common.RunWithSpinnerResult("Updating event...", func() (*domain.Event, error) { @@ -397,6 +416,7 @@ Examples: cmd.Flags().StringVar(&visibility, "visibility", "", "Event visibility (public, private, default)") cmd.Flags().BoolVar(&lockTimezone, "lock-timezone", false, "Lock event to its timezone") cmd.Flags().BoolVar(&unlockTimezone, "unlock-timezone", false, "Remove timezone lock from event") + cmd.Flags().StringVar(&eventTimezone, "timezone", "", "IANA timezone for start/end times (e.g., America/Los_Angeles). Defaults to system timezone.") return cmd } diff --git a/internal/cli/calendar/events_list.go b/internal/cli/calendar/events_list.go index 4a79702..4209da1 100644 --- a/internal/cli/calendar/events_list.go +++ b/internal/cli/calendar/events_list.go @@ -38,12 +38,9 @@ Examples: nylas calendar events list --show-tz`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Auto-detect timezone if not specified - if targetTZ == "" && cmd.Flags().Changed("timezone") { - // User explicitly set --timezone="" to clear - targetTZ = "" - } else if targetTZ == "" { - // Default to local timezone for conversion display + // Auto-detect timezone if not specified. + // An explicit --timezone="" disables conversion. + if targetTZ == "" && !cmd.Flags().Changed("timezone") { targetTZ = getLocalTimeZone() } @@ -102,6 +99,18 @@ Examples: fmt.Printf("Found %d event(s):\n\n", len(events)) + // Resolve the local timezone name at most once for the whole + // list: getLocalTimeZone reads env vars and resolves symlinks + // on every call, which is wasteful per event. Not memoized at + // package level so t.Setenv-based tests keep working. + var cachedLocalTZ string + localTZ := func() string { + if cachedLocalTZ == "" { + cachedLocalTZ = getLocalTimeZone() + } + return cachedLocalTZ + } + for _, event := range events { // Title with timezone badge (if showing timezone info) fmt.Printf("%s", common.Cyan.Sprint(event.Title)) @@ -110,7 +119,7 @@ Examples: start := event.When.StartDateTime() originalTZ := start.Location().String() if originalTZ == "Local" { - originalTZ = getLocalTimeZone() + originalTZ = localTZ() } // Add colored timezone badge diff --git a/internal/cli/calendar/events_rsvp.go b/internal/cli/calendar/events_rsvp.go index 3ab825d..0ee309b 100644 --- a/internal/cli/calendar/events_rsvp.go +++ b/internal/cli/calendar/events_rsvp.go @@ -134,12 +134,23 @@ func formatParticipantStatus(status string) string { } } -func parseEventTime(startStr, endStr string, allDay bool) (*domain.EventWhen, error) { +// parseEventTime parses start/end input into an EventWhen. +// Timed events are parsed in tz (an IANA timezone ID, defaulting to the system +// timezone when empty) and record it in StartTimezone/EndTimezone so the +// timestamps and zone always agree. All-day events take a date only. +func parseEventTime(startStr, endStr string, allDay bool, tz string) (*domain.EventWhen, error) { when := &domain.EventWhen{} // Try parsing as date first (YYYY-MM-DD) if allDay || len(startStr) <= 10 { startDate, err := time.Parse("2006-01-02", startStr) + if err != nil && allDay { + // Never fall through to a timed event when --all-day was requested + return nil, common.NewUserError( + fmt.Sprintf("invalid all-day start date: %s", startStr), + "All-day events take a date only (YYYY-MM-DD). Remove --all-day to create a timed event.", + ) + } if err == nil { when.Object = "date" when.Date = startDate.Format("2006-01-02") @@ -159,6 +170,18 @@ func parseEventTime(startStr, endStr string, allDay bool) (*domain.EventWhen, er } } + // Resolve the timezone for timed events: explicit value, else system zone + if tz == "" { + tz = getLocalTimeZone() + } + loc, err := time.LoadLocation(tz) + if err != nil { + return nil, common.NewUserError( + fmt.Sprintf("invalid timezone: %s", tz), + "Use IANA timezone IDs like 'America/Los_Angeles'.\nRun 'nylas timezone list' to see available timezones.", + ) + } + // Try parsing as datetime formats := []string{ "2006-01-02 15:04", @@ -171,7 +194,7 @@ func parseEventTime(startStr, endStr string, allDay bool) (*domain.EventWhen, er var startTime time.Time var parsed bool for _, format := range formats { - t, err := time.ParseInLocation(format, startStr, time.Local) + t, err := time.ParseInLocation(format, startStr, loc) if err == nil { startTime = t parsed = true @@ -181,14 +204,19 @@ func parseEventTime(startStr, endStr string, allDay bool) (*domain.EventWhen, er if !parsed { return nil, common.NewUserError(fmt.Sprintf("invalid start time format: %s", startStr), "use 'YYYY-MM-DD HH:MM' or 'YYYY-MM-DD'") } + if err := checkOffsetMatchesZone(startTime, loc, tz, "start"); err != nil { + return nil, err + } when.Object = "timespan" when.StartTime = startTime.Unix() + when.StartTimezone = tz + when.EndTimezone = tz if endStr != "" { var endTime time.Time for _, format := range formats { - t, err := time.ParseInLocation(format, endStr, time.Local) + t, err := time.ParseInLocation(format, endStr, loc) if err == nil { endTime = t break @@ -197,6 +225,9 @@ func parseEventTime(startStr, endStr string, allDay bool) (*domain.EventWhen, er if endTime.IsZero() { return nil, common.NewInputError(fmt.Sprintf("invalid end time format: %s", endStr)) } + if err := checkOffsetMatchesZone(endTime, loc, tz, "end"); err != nil { + return nil, err + } when.EndTime = endTime.Unix() } else { // Default to 1 hour duration @@ -205,3 +236,22 @@ func parseEventTime(startStr, endStr string, allDay bool) (*domain.EventWhen, er return when, nil } + +// checkOffsetMatchesZone rejects inputs whose explicit UTC offset (RFC3339) +// disagrees with the event timezone. ParseInLocation honors the input's +// offset over loc, so without this check the epoch would follow the offset +// while start_timezone/end_timezone record a different zone — the event +// would display at a different wall time than the user typed. Inputs without +// an offset parse in loc and always agree. +func checkOffsetMatchesZone(t time.Time, loc *time.Location, tz, field string) error { + _, inputOffset := t.Zone() + _, zoneOffset := t.In(loc).Zone() + if inputOffset == zoneOffset { + return nil + } + return common.NewUserError( + fmt.Sprintf("%s time UTC offset %s does not match timezone %s (%s)", + field, t.Format("-07:00"), tz, t.In(loc).Format("-07:00")), + "Remove the offset from the input (e.g. 'YYYY-MM-DD HH:MM'), or pass a --timezone that matches it.", + ) +} diff --git a/internal/cli/calendar/events_update_timezone_test.go b/internal/cli/calendar/events_update_timezone_test.go new file mode 100644 index 0000000..19095fc --- /dev/null +++ b/internal/cli/calendar/events_update_timezone_test.go @@ -0,0 +1,194 @@ +package calendar + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEventsUpdateCmd_TimezoneWiring runs the real `events update` command +// against a fake Nylas API and asserts the PUT payload. It guards the update +// path's wiring of --timezone into parseEventTime — the create path got this +// fix first; this proves update did too — and the --lock-timezone metadata +// surviving through the UpdateEvent adapter. +func TestEventsUpdateCmd_TimezoneWiring(t *testing.T) { + var captured struct { + When struct { + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + StartTimezone string `json:"start_timezone"` + EndTimezone string `json:"end_timezone"` + Object string `json:"object"` + } `json:"when"` + Metadata map[string]string `json:"metadata"` + } + requestSeen := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + // --lock-timezone fetches the event to merge existing metadata. + _, _ = w.Write([]byte(`{"request_id":"req-g","data":{"id":"event-1","title":"Existing"}}`)) + case http.MethodPut: + requestSeen = true + assert.Equal(t, "/v3/grants/grant-123/events/event-1", r.URL.Path) + assert.Equal(t, "cal-1", r.URL.Query().Get("calendar_id")) + require.NoError(t, json.NewDecoder(r.Body).Decode(&captured)) + _, _ = w.Write([]byte(`{"request_id":"req-1","data":{"id":"event-1","title":"Updated"}}`)) + default: + t.Errorf("unexpected %s request to %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusBadRequest) + } + })) + defer server.Close() + + // Isolate from the developer's real config/keyring; env API key wins. + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("NYLAS_API_KEY", "test-api-key") + t.Setenv("NYLAS_API_BASE_URL", server.URL) + + cmd := newEventsUpdateCmd() + cmd.SetArgs([]string{ + "event-1", "grant-123", + "--calendar", "cal-1", + "--start", "2024-01-15 14:00", + "--end", "2024-01-15 15:00", + "--timezone", "Asia/Tokyo", + "--lock-timezone", + }) + + require.NoError(t, cmd.Execute()) + require.True(t, requestSeen, "update command never hit the API") + + // The timestamps must be the Tokyo wall clock, and the recorded zones + // must match — otherwise --timezone on update silently falls back to the + // system zone. + tokyo, err := time.LoadLocation("Asia/Tokyo") + require.NoError(t, err) + assert.Equal(t, "timespan", captured.When.Object) + assert.Equal(t, time.Date(2024, 1, 15, 14, 0, 0, 0, tokyo).Unix(), captured.When.StartTime) + assert.Equal(t, time.Date(2024, 1, 15, 15, 0, 0, 0, tokyo).Unix(), captured.When.EndTime) + assert.Equal(t, "Asia/Tokyo", captured.When.StartTimezone) + assert.Equal(t, "Asia/Tokyo", captured.When.EndTimezone) + assert.Equal(t, "true", captured.Metadata["timezone_locked"], + "--lock-timezone metadata must survive through the UpdateEvent adapter") +} + +// TestEventsUpdateCmd_TimezoneWithoutStartFails verifies --timezone is rejected +// when --start is absent: the zone is only applied while parsing new times, so +// accepting it alone would silently do nothing. +func TestEventsUpdateCmd_TimezoneWithoutStartFails(t *testing.T) { + apiCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + http.Error(w, "should not be called", http.StatusBadRequest) + })) + defer server.Close() + + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("NYLAS_API_KEY", "test-api-key") + t.Setenv("NYLAS_API_BASE_URL", server.URL) + + cmd := newEventsUpdateCmd() + cmd.SetArgs([]string{ + "event-1", "grant-123", + "--calendar", "cal-1", + "--timezone", "Asia/Tokyo", + }) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--timezone requires --start") + assert.False(t, apiCalled, "--timezone without --start must be rejected before any API call") +} + +// TestEventsUpdateCmd_LockTimezonePreservesMetadata verifies the lock/unlock +// update merges with the event's existing metadata instead of clobbering it: +// the Nylas update replaces the metadata object wholesale, so the command must +// send back every existing key alongside timezone_locked. +func TestEventsUpdateCmd_LockTimezonePreservesMetadata(t *testing.T) { + var capturedMetadata map[string]string + putSeen := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + // Existing event carries unrelated metadata that must survive. + _, _ = w.Write([]byte(`{"request_id":"req-g","data":{"id":"event-1","title":"Existing","metadata":{"project":"apollo","priority":"high"}}}`)) + case http.MethodPut: + putSeen = true + var body struct { + Metadata map[string]string `json:"metadata"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + capturedMetadata = body.Metadata + _, _ = w.Write([]byte(`{"request_id":"req-p","data":{"id":"event-1","title":"Existing"}}`)) + default: + t.Errorf("unexpected %s request to %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusBadRequest) + } + })) + defer server.Close() + + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("NYLAS_API_KEY", "test-api-key") + t.Setenv("NYLAS_API_BASE_URL", server.URL) + + cmd := newEventsUpdateCmd() + cmd.SetArgs([]string{ + "event-1", "grant-123", + "--calendar", "cal-1", + "--lock-timezone", + }) + + require.NoError(t, cmd.Execute()) + require.True(t, putSeen, "update command never sent the PUT") + assert.Equal(t, "true", capturedMetadata["timezone_locked"]) + assert.Equal(t, "apollo", capturedMetadata["project"], + "existing metadata keys must be preserved when locking the timezone") + assert.Equal(t, "high", capturedMetadata["priority"]) +} + +// TestEventsUpdateCmd_InvalidTimezoneFailsBeforeAPI verifies a bad --timezone +// is rejected locally: the update must not reach the API with a half-built +// payload. +func TestEventsUpdateCmd_InvalidTimezoneFailsBeforeAPI(t *testing.T) { + apiCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + http.Error(w, "should not be called", http.StatusBadRequest) + })) + defer server.Close() + + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("NYLAS_API_KEY", "test-api-key") + t.Setenv("NYLAS_API_BASE_URL", server.URL) + + cmd := newEventsUpdateCmd() + cmd.SetArgs([]string{ + "event-1", "grant-123", + "--calendar", "cal-1", + "--start", "2024-01-15 14:00", + "--timezone", "Not/AZone", + }) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid timezone") + assert.False(t, apiCalled, "invalid timezone must be rejected before any API call") +} diff --git a/internal/cli/calendar/helpers_parse_test.go b/internal/cli/calendar/helpers_parse_test.go deleted file mode 100644 index 2a4bf58..0000000 --- a/internal/cli/calendar/helpers_parse_test.go +++ /dev/null @@ -1,351 +0,0 @@ -package calendar - -import ( - "testing" - "time" -) - -func TestParseNaturalTime(t *testing.T) { - // Note: Tests use current time, so relative time tests may vary - // Reference: Wednesday, Jan 15, 2025, 10:00 AM EST would be used for deterministic testing - - tests := []struct { - name string - input string - tz string - wantError bool - checkTime func(*testing.T, time.Time) - }{ - { - name: "empty input returns error", - input: "", - tz: "America/New_York", - wantError: true, - }, - { - name: "invalid timezone returns error", - input: "tomorrow at 3pm", - tz: "Invalid/Zone", - wantError: true, - }, - { - name: "relative time - in 2 hours", - input: "in 2 hours", - tz: "America/New_York", - checkTime: func(t *testing.T, result time.Time) { - // Check that time is roughly 2 hours from now - now := time.Now() - expected := now.Add(2 * time.Hour) - diff := result.Sub(expected) - if diff < -time.Minute || diff > time.Minute { - t.Errorf("Expected time ~2 hours from now, got %v (diff: %v)", result, diff) - } - }, - }, - { - name: "relative time - in 30 minutes", - input: "in 30 minutes", - tz: "America/New_York", - checkTime: func(t *testing.T, result time.Time) { - // Check that time is roughly 30 minutes from now - now := time.Now() - expected := now.Add(30 * time.Minute) - diff := result.Sub(expected) - if diff < -time.Minute || diff > time.Minute { - t.Errorf("Expected time ~30 minutes from now, got %v (diff: %v)", result, diff) - } - }, - }, - { - name: "relative day - tomorrow at 3pm", - input: "tomorrow at 3pm", - tz: "America/New_York", - checkTime: func(t *testing.T, result time.Time) { - // Check it's tomorrow and at 3pm - // Use the same timezone as parseNaturalTime to avoid CI/CD timezone issues - loc, _ := time.LoadLocation("America/New_York") - now := time.Now().In(loc) - if result.Day() != now.AddDate(0, 0, 1).Day() || result.Hour() != 15 { - t.Errorf("Expected tomorrow at 15:00, got %v at %02d:00", result.Day(), result.Hour()) - } - }, - }, - { - name: "relative day - today at 2:30pm", - input: "today at 2:30pm", - tz: "America/New_York", - checkTime: func(t *testing.T, result time.Time) { - // Check it's today at 2:30pm - // Use the same timezone as parseNaturalTime to avoid CI/CD timezone issues - loc, _ := time.LoadLocation("America/New_York") - now := time.Now().In(loc) - if result.Day() != now.Day() || result.Hour() != 14 || result.Minute() != 30 { - t.Errorf("Expected today at 14:30, got %v at %02d:%02d", result.Day(), result.Hour(), result.Minute()) - } - }, - }, - { - name: "specific weekday - next tuesday 2pm", - input: "next tuesday 2pm", - tz: "America/New_York", - checkTime: func(t *testing.T, result time.Time) { - // Check it's a Tuesday and at 2pm - if result.Weekday() != time.Tuesday { - t.Errorf("Expected Tuesday, got %v", result.Weekday()) - } - if result.Hour() != 14 { - t.Errorf("Expected 14:00, got %02d:00", result.Hour()) - } - // Check it's in the future - if !result.After(time.Now()) { - t.Error("Expected future date") - } - }, - }, - { - name: "absolute time - dec 25 10:00 am", - input: "Dec 25 10:00 AM", - tz: "America/New_York", - checkTime: func(t *testing.T, result time.Time) { - if result.Month() != time.December || result.Day() != 25 || result.Hour() != 10 { - t.Errorf("Expected Dec 25 10:00, got %v %v %02d:00", result.Month(), result.Day(), result.Hour()) - } - }, - }, - { - name: "ISO time - 2025-03-15 14:00", - input: "2025-03-15 14:00", - tz: "America/New_York", - checkTime: func(t *testing.T, result time.Time) { - if result.Year() != 2025 || result.Month() != time.March || result.Day() != 15 || result.Hour() != 14 { - t.Errorf("Expected 2025-03-15 14:00, got %v", result) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Mock time.Now() by using the parseNaturalTime implementation - // For these tests, we'll test the individual parser functions - result, err := parseNaturalTime(tt.input, tt.tz) - - if tt.wantError { - if err == nil { - t.Error("Expected error, got nil") - } - return - } - - if err != nil { - t.Errorf("Unexpected error: %v", err) - return - } - - if result == nil { - t.Error("Expected result, got nil") - return - } - - if tt.checkTime != nil { - tt.checkTime(t, result.Time) - } - - if result.Original != tt.input { - t.Errorf("Original = %q, want %q", result.Original, tt.input) - } - }) - } -} - -func TestExtractTimezoneFromInput(t *testing.T) { - tests := []struct { - name string - input string - wantTZ string - wantCleanInput string - }{ - { - name: "3pm PST extracts Pacific time", - input: "3pm PST", - wantTZ: "America/Los_Angeles", - wantCleanInput: "3pm", - }, - { - name: "3pm pst lowercase", - input: "3pm pst", - wantTZ: "America/Los_Angeles", - wantCleanInput: "3pm", - }, - { - name: "2:30pm EST extracts Eastern time", - input: "2:30pm EST", - wantTZ: "America/New_York", - wantCleanInput: "2:30pm", - }, - { - name: "14:00 UTC extracts UTC", - input: "14:00 UTC", - wantTZ: "UTC", - wantCleanInput: "14:00", - }, - { - name: "3pm without timezone returns nil", - input: "3pm", - wantTZ: "", - wantCleanInput: "3pm", - }, - { - name: "10am JST extracts Japan time", - input: "10am JST", - wantTZ: "Asia/Tokyo", - wantCleanInput: "10am", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - loc, cleanInput := extractTimezoneFromInput(tt.input) - - if tt.wantTZ == "" { - if loc != nil { - t.Errorf("Expected nil location, got %v", loc) - } - } else { - if loc == nil { - t.Errorf("Expected location %s, got nil", tt.wantTZ) - } else if loc.String() != tt.wantTZ { - t.Errorf("Location = %s, want %s", loc.String(), tt.wantTZ) - } - } - - if cleanInput != tt.wantCleanInput { - t.Errorf("CleanInput = %q, want %q", cleanInput, tt.wantCleanInput) - } - }) - } -} - -func TestParseTimeOfDay(t *testing.T) { - loc, _ := time.LoadLocation("America/New_York") - - tests := []struct { - name string - input string - wantHour int - wantMin int - wantError bool - }{ - { - name: "3pm", - input: "3pm", - wantHour: 15, - wantMin: 0, - }, - { - name: "3PM (uppercase)", - input: "3PM", - wantHour: 15, - wantMin: 0, - }, - { - name: "2:30pm", - input: "2:30pm", - wantHour: 14, - wantMin: 30, - }, - { - name: "2:30 PM (with space)", - input: "2:30 PM", - wantHour: 14, - wantMin: 30, - }, - { - name: "14:00 (24-hour)", - input: "14:00", - wantHour: 14, - wantMin: 0, - }, - { - name: "3pm PST (with timezone)", - input: "3pm PST", - wantHour: 15, - wantMin: 0, - }, - { - name: "2:30pm EST (with timezone)", - input: "2:30pm EST", - wantHour: 14, - wantMin: 30, - }, - { - name: "invalid format", - input: "invalid", - wantError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseTimeOfDay(tt.input, loc) - - if tt.wantError { - if err == nil { - t.Error("Expected error, got nil") - } - return - } - - if err != nil { - t.Errorf("Unexpected error: %v", err) - return - } - - if result.Hour() != tt.wantHour { - t.Errorf("Hour = %d, want %d", result.Hour(), tt.wantHour) - } - - if result.Minute() != tt.wantMin { - t.Errorf("Minute = %d, want %d", result.Minute(), tt.wantMin) - } - }) - } -} - -func TestNormalizeTimeString(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "uppercase to lowercase", - input: "TOMORROW AT 3PM", - want: "tomorrow at 3pm", - }, - { - name: "extra whitespace removed", - input: " tomorrow at 3pm ", - want: "tomorrow at 3pm", - }, - { - name: "mixed case normalized", - input: "Next Tuesday 2PM", - want: "next tuesday 2pm", - }, - { - name: "already normalized", - input: "tomorrow at 3pm", - want: "tomorrow at 3pm", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := normalizeTimeString(tt.input) - if result != tt.want { - t.Errorf("normalizeTimeString() = %q, want %q", result, tt.want) - } - }) - } -} diff --git a/internal/cli/calendar/helpers_timezone_test.go b/internal/cli/calendar/helpers_timezone_test.go index fa7576a..451cc66 100644 --- a/internal/cli/calendar/helpers_timezone_test.go +++ b/internal/cli/calendar/helpers_timezone_test.go @@ -1,6 +1,8 @@ package calendar import ( + "os" + "path/filepath" "testing" "time" ) @@ -86,3 +88,100 @@ func TestGetSystemTimeZone(t *testing.T) { t.Errorf("getSystemTimeZone() returned invalid timezone %q: %v", tz, err) } } + +func TestGetSystemTimeZone_RespectsTZEnv(t *testing.T) { + // Arizona does not observe DST and is not reachable via the UTC-offset + // heuristic, so this only passes if TZ is read directly. + t.Setenv("TZ", "America/Phoenix") + + if got := getSystemTimeZone(); got != "America/Phoenix" { + t.Errorf("getSystemTimeZone() = %q, want %q", got, "America/Phoenix") + } +} + +func TestGetSystemTimeZone_IgnoresInvalidTZEnv(t *testing.T) { + t.Setenv("TZ", "Not/AZone") + + tz := getSystemTimeZone() + if tz == "Not/AZone" { + t.Error("getSystemTimeZone() returned the invalid TZ value verbatim") + } + if _, err := time.LoadLocation(tz); err != nil { + t.Errorf("getSystemTimeZone() returned invalid timezone %q: %v", tz, err) + } +} + +func TestZoneFromZoneinfoPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "linux zoneinfo path", + path: "/usr/share/zoneinfo/America/New_York", + want: "America/New_York", + }, + { + name: "macOS zoneinfo path", + path: "/var/db/timezone/zoneinfo/Asia/Tokyo", + want: "Asia/Tokyo", + }, + { + name: "single-segment zone", + path: "/usr/share/zoneinfo/UTC", + want: "UTC", + }, + { + name: "no zoneinfo marker", + path: "/etc/localtime", + want: "", + }, + { + name: "invalid zone after marker", + path: "/usr/share/zoneinfo/Not/AZone", + want: "", + }, + { + name: "empty path", + path: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := zoneFromZoneinfoPath(tt.path); got != tt.want { + t.Errorf("zoneFromZoneinfoPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestZoneFromLocaltimeSymlink(t *testing.T) { + t.Run("resolves zone from symlink target", func(t *testing.T) { + dir := t.TempDir() + zoneDir := filepath.Join(dir, "zoneinfo", "America") + if err := os.MkdirAll(zoneDir, 0o750); err != nil { + t.Fatal(err) + } + target := filepath.Join(zoneDir, "Phoenix") + if err := os.WriteFile(target, []byte("TZif"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "localtime") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + if got := zoneFromLocaltimeSymlink(link); got != "America/Phoenix" { + t.Errorf("zoneFromLocaltimeSymlink() = %q, want %q", got, "America/Phoenix") + } + }) + + t.Run("missing path returns empty", func(t *testing.T) { + if got := zoneFromLocaltimeSymlink(filepath.Join(t.TempDir(), "missing")); got != "" { + t.Errorf("zoneFromLocaltimeSymlink() = %q, want empty", got) + } + }) +} diff --git a/internal/cli/calendar/recurring.go b/internal/cli/calendar/recurring.go index 4c4b056..5080ca8 100644 --- a/internal/cli/calendar/recurring.go +++ b/internal/cli/calendar/recurring.go @@ -20,7 +20,9 @@ func newRecurringCmd() *cobra.Command { Use: "recurring", Short: "Manage recurring events", Long: `Manage recurring calendar events, including viewing all instances, -updating or deleting specific occurrences.`, +updating or deleting specific occurrences. + +API reference: https://developer.nylas.com/docs/v3/calendar/recurring-events/`, } cmd.AddCommand(newRecurringListCmd()) @@ -242,10 +244,7 @@ This adds an exception to the recurrence rule.`, } if !skipConfirm { - fmt.Printf("Are you sure you want to delete this recurring event instance? (y/N): ") - var response string - _, _ = fmt.Scanln(&response) - if response != "y" && response != "Y" { + if !common.Confirm("Are you sure you want to delete this recurring event instance?", false) { fmt.Println("Cancelled") return nil } diff --git a/internal/cli/calendar/time_parsing_extended_test.go b/internal/cli/calendar/time_parsing_extended_test.go deleted file mode 100644 index b34005f..0000000 --- a/internal/cli/calendar/time_parsing_extended_test.go +++ /dev/null @@ -1,563 +0,0 @@ -//go:build !integration - -package calendar - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseISOTime(t *testing.T) { - loc, err := time.LoadLocation("America/New_York") - require.NoError(t, err) - now := time.Now().In(loc) - - tests := []struct { - name string - input string - wantYear int - wantMonth time.Month - wantDay int - wantHour int - wantMin int - wantErr bool - }{ - { - name: "RFC3339 format", - input: "2025-03-15T14:30:00-05:00", - wantYear: 2025, - wantMonth: time.March, - wantDay: 15, - wantHour: 14, - wantMin: 30, - wantErr: false, - }, - { - name: "ISO format with T separator", - input: "2025-03-15T14:30:00", - wantYear: 2025, - wantMonth: time.March, - wantDay: 15, - wantHour: 14, - wantMin: 30, - wantErr: false, - }, - { - name: "ISO format with space separator", - input: "2025-03-15 14:30:00", - wantYear: 2025, - wantMonth: time.March, - wantDay: 15, - wantHour: 14, - wantMin: 30, - wantErr: false, - }, - { - name: "ISO format with T and no seconds", - input: "2025-03-15T14:30", - wantYear: 2025, - wantMonth: time.March, - wantDay: 15, - wantHour: 14, - wantMin: 30, - wantErr: false, - }, - { - name: "ISO format with space and no seconds", - input: "2025-03-15 14:30", - wantYear: 2025, - wantMonth: time.March, - wantDay: 15, - wantHour: 14, - wantMin: 30, - wantErr: false, - }, - { - name: "December date", - input: "2025-12-25T10:00:00", - wantYear: 2025, - wantMonth: time.December, - wantDay: 25, - wantHour: 10, - wantMin: 0, - wantErr: false, - }, - { - name: "January 1st", - input: "2026-01-01T00:00:00", - wantYear: 2026, - wantMonth: time.January, - wantDay: 1, - wantHour: 0, - wantMin: 0, - wantErr: false, - }, - { - name: "invalid format", - input: "not a date", - wantErr: true, - }, - { - name: "invalid ISO format", - input: "2025/03/15 14:30", - wantErr: true, - }, - { - name: "date only without time", - input: "2025-03-15", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseISOTime(tt.input, loc, now) - - if tt.wantErr { - assert.Error(t, err) - assert.Nil(t, result) - return - } - - require.NoError(t, err) - require.NotNil(t, result) - - assert.Equal(t, tt.wantYear, result.Time.Year()) - assert.Equal(t, tt.wantMonth, result.Time.Month()) - assert.Equal(t, tt.wantDay, result.Time.Day()) - assert.Equal(t, tt.wantHour, result.Time.Hour()) - assert.Equal(t, tt.wantMin, result.Time.Minute()) - assert.Equal(t, loc.String(), result.Timezone) - }) - } -} - -func TestParseRelativeTime_Extended(t *testing.T) { - loc, err := time.LoadLocation("America/New_York") - require.NoError(t, err) - now := time.Now().In(loc) - - tests := []struct { - name string - input string - expectedDelta time.Duration - tolerance time.Duration - wantErr bool - }{ - { - name: "in 1 hour", - input: "in 1 hour", - expectedDelta: 1 * time.Hour, - tolerance: time.Second, - wantErr: false, - }, - { - name: "in 5 hours", - input: "in 5 hours", - expectedDelta: 5 * time.Hour, - tolerance: time.Second, - wantErr: false, - }, - { - name: "in 15 minutes", - input: "in 15 minutes", - expectedDelta: 15 * time.Minute, - tolerance: time.Second, - wantErr: false, - }, - { - name: "in 1 day", - input: "in 1 day", - expectedDelta: 24 * time.Hour, - tolerance: time.Second, - wantErr: false, - }, - { - name: "in 2 days", - input: "in 2 days", - expectedDelta: 48 * time.Hour, - tolerance: time.Second, - wantErr: false, - }, - { - name: "invalid relative time", - input: "in abc hours", - wantErr: true, - }, - { - name: "not relative format", - input: "tomorrow at 3pm", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseRelativeTime(tt.input, loc, now) - - if tt.wantErr { - assert.Error(t, err) - return - } - - require.NoError(t, err) - require.NotNil(t, result) - - expected := now.Add(tt.expectedDelta) - diff := result.Time.Sub(expected) - assert.True(t, diff >= -tt.tolerance && diff <= tt.tolerance, - "Expected time around %v, got %v (diff: %v)", expected, result.Time, diff) - }) - } -} - -func TestParseAbsoluteTime_Extended(t *testing.T) { - loc, err := time.LoadLocation("America/New_York") - require.NoError(t, err) - now := time.Now().In(loc) - - tests := []struct { - name string - input string - wantMonth time.Month - wantDay int - wantHour int - wantMin int - wantErr bool - }{ - // Note: Go time parsing with lowercase "jan" only matches literal "jan" - // For other months, use titlecase formats like "Jan" or "January" - { - name: "jan 15 3:00 pm lowercase", - input: "jan 15 3:00 pm", - wantMonth: time.January, - wantDay: 15, - wantHour: 15, - wantMin: 0, - wantErr: false, - }, - { - name: "jan 5 9:30 am lowercase", - input: "jan 5 9:30 am", - wantMonth: time.January, - wantDay: 5, - wantHour: 9, - wantMin: 30, - wantErr: false, - }, - { - name: "jan 1 8:00 am lowercase", - input: "jan 1 8:00 am", - wantMonth: time.January, - wantDay: 1, - wantHour: 8, - wantMin: 0, - wantErr: false, - }, - { - name: "jan 25 2:15 pm lowercase", - input: "jan 25 2:15 pm", - wantMonth: time.January, - wantDay: 25, - wantHour: 14, - wantMin: 15, - wantErr: false, - }, - { - name: "Jan 4 3:00 PM titlecase", - input: "Jan 4 3:00 PM", - wantMonth: time.January, - wantDay: 4, - wantHour: 15, - wantMin: 0, - wantErr: false, - }, - { - name: "February 10 3:00 PM full month", - input: "February 10 3:00 PM", - wantMonth: time.February, - wantDay: 10, - wantHour: 15, - wantMin: 0, - wantErr: false, - }, - { - name: "invalid month", - input: "foo 15 3:00 pm", - wantErr: true, - }, - { - name: "no time specified", - input: "jan 15", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseAbsoluteTime(tt.input, loc, now) - - if tt.wantErr { - assert.Error(t, err) - return - } - - require.NoError(t, err) - require.NotNil(t, result) - - assert.Equal(t, tt.wantMonth, result.Time.Month()) - assert.Equal(t, tt.wantDay, result.Time.Day()) - assert.Equal(t, tt.wantHour, result.Time.Hour()) - assert.Equal(t, tt.wantMin, result.Time.Minute()) - }) - } -} - -func TestParseRelativeDayTime_Extended(t *testing.T) { - loc, err := time.LoadLocation("America/New_York") - require.NoError(t, err) - now := time.Now().In(loc) - - tests := []struct { - name string - input string - checkDay func(time.Time, time.Time) bool - wantHour int - wantMinute int - wantErr bool - }{ - { - name: "tomorrow at 3pm", - input: "tomorrow at 3pm", - checkDay: func(result, now time.Time) bool { - return result.YearDay() == now.AddDate(0, 0, 1).YearDay() - }, - wantHour: 15, - wantMinute: 0, - wantErr: false, - }, - { - name: "tomorrow 2:30pm", - input: "tomorrow 2:30pm", - checkDay: func(result, now time.Time) bool { - return result.YearDay() == now.AddDate(0, 0, 1).YearDay() - }, - wantHour: 14, - wantMinute: 30, - wantErr: false, - }, - { - name: "today at 9am", - input: "today at 9am", - checkDay: func(result, now time.Time) bool { - return result.YearDay() == now.YearDay() - }, - wantHour: 9, - wantMinute: 0, - wantErr: false, - }, - { - name: "invalid day reference", - input: "yesterday at 3pm", - wantErr: true, - }, - { - name: "no time specified", - input: "tomorrow", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseRelativeDayTime(tt.input, loc, now) - - if tt.wantErr { - assert.Error(t, err) - return - } - - require.NoError(t, err) - require.NotNil(t, result) - - if tt.checkDay != nil { - assert.True(t, tt.checkDay(result.Time, now), "Day check failed") - } - assert.Equal(t, tt.wantHour, result.Time.Hour()) - assert.Equal(t, tt.wantMinute, result.Time.Minute()) - }) - } -} - -func TestParseSpecificDayTime_Extended(t *testing.T) { - loc, err := time.LoadLocation("America/New_York") - require.NoError(t, err) - now := time.Now().In(loc) - - tests := []struct { - name string - input string - wantWeekday time.Weekday - wantHour int - wantMinute int - wantErr bool - }{ - { - name: "next monday at 9am", - input: "next monday at 9am", - wantWeekday: time.Monday, - wantHour: 9, - wantMinute: 0, - wantErr: false, - }, - { - name: "next tuesday 2pm", - input: "next tuesday 2pm", - wantWeekday: time.Tuesday, - wantHour: 14, - wantMinute: 0, - wantErr: false, - }, - { - name: "next wednesday 10:30am", - input: "next wednesday 10:30am", - wantWeekday: time.Wednesday, - wantHour: 10, - wantMinute: 30, - wantErr: false, - }, - { - name: "next friday at 3pm", - input: "next friday at 3pm", - wantWeekday: time.Friday, - wantHour: 15, - wantMinute: 0, - wantErr: false, - }, - { - name: "next sunday 11am", - input: "next sunday 11am", - wantWeekday: time.Sunday, - wantHour: 11, - wantMinute: 0, - wantErr: false, - }, - { - name: "invalid weekday", - input: "next funday 3pm", - wantErr: true, - }, - { - name: "no time specified", - input: "next monday", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseSpecificDayTime(tt.input, loc, now) - - if tt.wantErr { - assert.Error(t, err) - return - } - - require.NoError(t, err) - require.NotNil(t, result) - - assert.Equal(t, tt.wantWeekday, result.Time.Weekday()) - assert.Equal(t, tt.wantHour, result.Time.Hour()) - assert.Equal(t, tt.wantMinute, result.Time.Minute()) - // Should be in the future - assert.True(t, result.Time.After(now), "Expected future date") - }) - } -} - -func TestTimezoneAbbreviations(t *testing.T) { - // Test that all abbreviations in the map are valid - for abbrev, iana := range timezoneAbbreviations { - t.Run(abbrev, func(t *testing.T) { - loc, err := time.LoadLocation(iana) - assert.NoError(t, err, "Abbreviation %s maps to invalid IANA zone %s", abbrev, iana) - assert.NotNil(t, loc) - }) - } -} - -func TestExtractTimezoneFromInput_EdgeCases(t *testing.T) { - tests := []struct { - name string - input string - wantTZ string - wantCleanInput string - }{ - { - name: "BST extracts London", - input: "3pm BST", - wantTZ: "Europe/London", - wantCleanInput: "3pm", - }, - { - name: "IST extracts Kolkata", - input: "10am IST", - wantTZ: "Asia/Kolkata", - wantCleanInput: "10am", - }, - { - name: "AEST extracts Sydney", - input: "9am AEST", - wantTZ: "Australia/Sydney", - wantCleanInput: "9am", - }, - { - name: "GMT extracts London", - input: "2pm GMT", - wantTZ: "Europe/London", - wantCleanInput: "2pm", - }, - { - name: "CST extracts Chicago", - input: "4pm CST", - wantTZ: "America/Chicago", - wantCleanInput: "4pm", - }, - { - name: "MST extracts Denver", - input: "5pm MST", - wantTZ: "America/Denver", - wantCleanInput: "5pm", - }, - { - name: "no timezone in middle of string", - input: "meeting EST tomorrow", - wantTZ: "", - wantCleanInput: "meeting EST tomorrow", - }, - { - name: "timezone at start ignored", - input: "EST 3pm", - wantTZ: "", - wantCleanInput: "EST 3pm", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - loc, cleanInput := extractTimezoneFromInput(tt.input) - - if tt.wantTZ == "" { - assert.Nil(t, loc) - } else { - require.NotNil(t, loc) - assert.Equal(t, tt.wantTZ, loc.String()) - } - - assert.Equal(t, tt.wantCleanInput, cleanInput) - }) - } -} diff --git a/internal/cli/calendar/time_parsing_helpers.go b/internal/cli/calendar/time_parsing_helpers.go deleted file mode 100644 index 7c6f6ef..0000000 --- a/internal/cli/calendar/time_parsing_helpers.go +++ /dev/null @@ -1,396 +0,0 @@ -package calendar - -import ( - "fmt" - "strings" - "time" - - "github.com/nylas/cli/internal/cli/common" -) - -// ============================================================================ -// Natural Language Time Parsing -// ============================================================================ - -// ParsedTime represents a parsed natural language time expression. -type ParsedTime struct { - Time time.Time - Timezone string - Original string -} - -// parseNaturalTime parses natural language time expressions. -// Supports formats like: -// - "tomorrow at 3pm" -// - "next Tuesday 2pm PST" -// - "Dec 25 10:00 AM" -// - "in 2 hours" -// - "2024-12-25 14:00" -func parseNaturalTime(input string, defaultTZ string) (*ParsedTime, error) { - if input == "" { - return nil, common.NewUserError( - "time expression is empty", - "Provide a time like 'tomorrow at 3pm' or 'Dec 25 10:00 AM'", - ) - } - - // Default timezone if not specified - if defaultTZ == "" { - defaultTZ = getLocalTimeZone() - } - - // Load the timezone - loc, err := time.LoadLocation(defaultTZ) - if err != nil { - return nil, common.NewUserError( - fmt.Sprintf("invalid timezone: %s", defaultTZ), - "Use IANA timezone IDs like 'America/Los_Angeles'", - ) - } - - now := time.Now().In(loc) - normalizedInput := normalizeTimeString(input) - - // Try parsing in order of specificity - // Note: Some parsers need normalized input, others need original - parsers := []struct { - fn func(string, *time.Location, time.Time) (*ParsedTime, error) - useNormalized bool - }{ - {parseRelativeTime, true}, - {parseRelativeDayTime, true}, - {parseSpecificDayTime, true}, - {parseAbsoluteTime, false}, // Keep original for proper month name parsing - {parseISOTime, false}, // Keep original for ISO formats - } - - for _, parser := range parsers { - inputToUse := input - if parser.useNormalized { - inputToUse = normalizedInput - } - result, err := parser.fn(inputToUse, loc, now) - if err == nil && result != nil { - result.Original = input - return result, nil - } - } - - return nil, common.NewUserError( - fmt.Sprintf("could not parse time: %s", input), - "Try formats like:\n"+ - " - tomorrow at 3pm\n"+ - " - next Tuesday 2pm PST\n"+ - " - Dec 25 10:00 AM\n"+ - " - in 2 hours\n"+ - " - 2024-12-25 14:00", - ) -} - -// normalizeTimeString normalizes the input string for parsing. -func normalizeTimeString(s string) string { - // Convert to lowercase for case-insensitive matching - s = strings.ToLower(s) - // Remove extra whitespace - s = strings.TrimSpace(s) - // Collapse multiple spaces into one - s = strings.Join(strings.Fields(s), " ") - return s -} - -// parseRelativeTime parses relative time expressions like "in 2 hours", "in 30 minutes". -func parseRelativeTime(input string, loc *time.Location, now time.Time) (*ParsedTime, error) { - // Pattern: "in X hours/minutes/days" - patterns := []struct { - pattern string - unit time.Duration - }{ - {"in %d hour", time.Hour}, - {"in %d hours", time.Hour}, - {"in %d minute", time.Minute}, - {"in %d minutes", time.Minute}, - {"in %d day", 24 * time.Hour}, - {"in %d days", 24 * time.Hour}, - } - - for _, p := range patterns { - var amount int - _, err := fmt.Sscanf(input, p.pattern, &amount) - if err == nil { - result := now.Add(time.Duration(amount) * p.unit) - return &ParsedTime{ - Time: result, - Timezone: loc.String(), - }, nil - } - } - - return nil, fmt.Errorf("not a relative time") -} - -// parseRelativeDayTime parses relative day + time like "tomorrow at 3pm", "today at 2:30pm". -func parseRelativeDayTime(input string, loc *time.Location, now time.Time) (*ParsedTime, error) { - relativeDays := map[string]int{ - "today": 0, - "tomorrow": 1, - } - - for day, offset := range relativeDays { - if len(input) > len(day) && input[:len(day)] == day { - // Extract the time part - timePart := input[len(day):] - timePart = strings.TrimSpace(timePart) - - // Remove "at" if present - if len(timePart) > 3 && timePart[:3] == "at " { - timePart = timePart[3:] - } - - // Parse the time - parsedTime, err := parseTimeOfDay(timePart, loc) - if err != nil { - return nil, err - } - - // Set to the target day - targetDay := now.AddDate(0, 0, offset) - result := time.Date( - targetDay.Year(), - targetDay.Month(), - targetDay.Day(), - parsedTime.Hour(), - parsedTime.Minute(), - 0, 0, loc, - ) - - return &ParsedTime{ - Time: result, - Timezone: loc.String(), - }, nil - } - } - - return nil, fmt.Errorf("not a relative day time") -} - -// parseSpecificDayTime parses specific weekday + time like "next Tuesday 2pm", "Monday at 10am". -func parseSpecificDayTime(input string, loc *time.Location, now time.Time) (*ParsedTime, error) { - weekdays := map[string]time.Weekday{ - "monday": time.Monday, - "tuesday": time.Tuesday, - "wednesday": time.Wednesday, - "thursday": time.Thursday, - "friday": time.Friday, - "saturday": time.Saturday, - "sunday": time.Sunday, - } - - // Check for "next" prefix - isNext := false - checkInput := input - if len(input) > 5 && input[:5] == "next " { - isNext = true - checkInput = input[5:] - } - - for dayName, weekday := range weekdays { - if len(checkInput) > len(dayName) && checkInput[:len(dayName)] == dayName { - // Extract time part - timePart := checkInput[len(dayName):] - timePart = strings.TrimSpace(timePart) - - // Remove "at" if present - if len(timePart) > 3 && timePart[:3] == "at " { - timePart = timePart[3:] - } - - // Parse the time - parsedTime, err := parseTimeOfDay(timePart, loc) - if err != nil { - return nil, err - } - - // Find next occurrence of this weekday - daysUntil := int(weekday - now.Weekday()) - if daysUntil <= 0 || isNext { - daysUntil += 7 - } - - targetDay := now.AddDate(0, 0, daysUntil) - result := time.Date( - targetDay.Year(), - targetDay.Month(), - targetDay.Day(), - parsedTime.Hour(), - parsedTime.Minute(), - 0, 0, loc, - ) - - return &ParsedTime{ - Time: result, - Timezone: loc.String(), - }, nil - } - } - - return nil, fmt.Errorf("not a specific day time") -} - -// parseAbsoluteTime parses absolute dates like "Dec 25 10:00 AM", "December 25, 2024 2pm". -func parseAbsoluteTime(input string, loc *time.Location, now time.Time) (*ParsedTime, error) { - // Common date/time formats - try both lowercase and titlecase - formats := []string{ - // Lowercase formats (after normalization) - with leading zero for hours - "jan 2 03:04 pm", - "jan 2 03:04pm", - "jan 2 3:04 pm", - "jan 2 3:04pm", - "jan 2, 2006 03:04 pm", - "jan 2, 2006 3:04 pm", - "january 2 03:04 pm", - "january 2 3:04 pm", - "january 2, 2006 03:04 pm", - "january 2, 2006 3:04 pm", - // Titlecase formats (original input) - "Jan 2 03:04 PM", - "Jan 2 3:04 PM", - "Jan 2 03:04PM", - "Jan 2 3:04PM", - "Jan 2, 2006 03:04 PM", - "Jan 2, 2006 3:04 PM", - "January 2 03:04 PM", - "January 2 3:04 PM", - "January 2, 2006 03:04 PM", - "January 2, 2006 3:04 PM", - // Numeric formats - "2006-01-02 15:04", - "01/02/2006 03:04 PM", - "01/02/2006 3:04 PM", - "01/02/2006 15:04", - } - - for _, format := range formats { - t, err := time.ParseInLocation(format, input, loc) - if err == nil { - // If year is not in input, use current year - if t.Year() == 0 { - t = time.Date(now.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc) - } - return &ParsedTime{ - Time: t, - Timezone: loc.String(), - }, nil - } - } - - return nil, fmt.Errorf("not an absolute time") -} - -// parseISOTime parses ISO format times like "2024-12-25T14:00:00". -func parseISOTime(input string, loc *time.Location, now time.Time) (*ParsedTime, error) { - formats := []string{ - time.RFC3339, - "2006-01-02T15:04:05", - "2006-01-02 15:04:05", - "2006-01-02T15:04", - "2006-01-02 15:04", - } - - for _, format := range formats { - t, err := time.ParseInLocation(format, input, loc) - if err == nil { - return &ParsedTime{ - Time: t, - Timezone: loc.String(), - }, nil - } - } - - return nil, fmt.Errorf("not an ISO time") -} - -// timezoneAbbreviations maps common timezone abbreviations to IANA names. -var timezoneAbbreviations = map[string]string{ - "PST": "America/Los_Angeles", - "PDT": "America/Los_Angeles", - "EST": "America/New_York", - "EDT": "America/New_York", - "CST": "America/Chicago", - "CDT": "America/Chicago", - "MST": "America/Denver", - "MDT": "America/Denver", - "GMT": "Europe/London", - "BST": "Europe/London", - "IST": "Asia/Kolkata", - "JST": "Asia/Tokyo", - "AEST": "Australia/Sydney", - "AEDT": "Australia/Sydney", - "UTC": "UTC", -} - -// extractTimezoneFromInput extracts a timezone abbreviation from input and returns -// the location and cleaned input string. If no timezone found, returns nil location. -func extractTimezoneFromInput(input string) (*time.Location, string) { - upperInput := strings.ToUpper(input) - - // Check for timezone abbreviations at the end of input - for abbrev, iana := range timezoneAbbreviations { - // Check if input ends with the abbreviation (with space before) - suffix := " " + abbrev - if strings.HasSuffix(upperInput, suffix) { - cleanInput := strings.TrimSuffix(input, input[len(input)-len(suffix):]) - cleanInput = strings.TrimSpace(cleanInput) - if loc, err := time.LoadLocation(iana); err == nil { - return loc, cleanInput - } - } - } - - return nil, input -} - -// parseTimeOfDay parses time of day like "3pm", "2:30pm", "14:00", "3pm PST". -func parseTimeOfDay(input string, loc *time.Location) (time.Time, error) { - // Extract timezone from input if present (e.g., "3pm PST") - extractedLoc, cleanInput := extractTimezoneFromInput(input) - if extractedLoc != nil { - loc = extractedLoc - } - - // Normalize to lowercase, then try both lowercase and uppercase formats - originalInput := cleanInput - lowerInput := strings.ToLower(cleanInput) - - formats := []string{ - "3pm", - "3:04pm", - "3 pm", - "3:04 pm", - "15:04", - } - - // Try lowercase formats - for _, format := range formats { - t, err := time.ParseInLocation(format, lowerInput, loc) - if err == nil { - return t, nil - } - } - - // Try original input with uppercase formats (for backward compatibility) - upperFormats := []string{ - "3PM", - "3:04PM", - "3 PM", - "3:04 PM", - } - - for _, format := range upperFormats { - t, err := time.ParseInLocation(format, originalInput, loc) - if err == nil { - return t, nil - } - } - - return time.Time{}, common.NewInputError(fmt.Sprintf("invalid time format: %s", input)) -} diff --git a/internal/cli/calendar/timezone_helpers.go b/internal/cli/calendar/timezone_helpers.go index 6d45870..d0a1232 100644 --- a/internal/cli/calendar/timezone_helpers.go +++ b/internal/cli/calendar/timezone_helpers.go @@ -2,6 +2,9 @@ package calendar import ( "fmt" + "os" + "path/filepath" + "strings" "time" "github.com/nylas/cli/internal/adapters/utilities/timezone" @@ -37,19 +40,26 @@ func getLocalTimeZone() string { } // getSystemTimeZone attempts to detect the system's IANA timezone. -// Returns empty string if detection fails. +// Detection order: TZ environment variable, /etc/localtime symlink (Unix), +// then a UTC-offset heuristic as a last resort. func getSystemTimeZone() string { - // On Unix systems, check common environment variables - // TZ environment variable often contains IANA timezone - // This is a simplified implementation + // 1. TZ environment variable (POSIX allows a leading ':') + if tz := strings.TrimPrefix(os.Getenv("TZ"), ":"); tz != "" { + if _, err := time.LoadLocation(tz); err == nil { + return tz + } + } + + // 2. /etc/localtime symlink (macOS/Linux) + if tz := zoneFromLocaltimeSymlink("/etc/localtime"); tz != "" { + return tz + } - // For now, we'll use a heuristic based on UTC offset - // In production, you might use a library or system call + // 3. Last resort: guess from the current UTC offset. This cannot + // distinguish zones sharing an offset (e.g. Arizona vs Denver) and is + // wrong across DST transitions; it only runs if the above fail. now := time.Now() _, offset := now.Zone() - - // Map common offsets to likely timezones - // This is a simplified approach - in production, use proper detection offsetHours := offset / 3600 switch offsetHours { @@ -75,6 +85,33 @@ func getSystemTimeZone() string { } } +// zoneFromLocaltimeSymlink resolves a localtime symlink (e.g. /etc/localtime) +// and extracts the IANA zone name from its target path. +// Returns empty string if the path cannot be resolved. +func zoneFromLocaltimeSymlink(path string) string { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "" + } + return zoneFromZoneinfoPath(resolved) +} + +// zoneFromZoneinfoPath extracts a valid IANA zone name from a zoneinfo path +// like "/usr/share/zoneinfo/America/New_York". +// Returns empty string if the path has no "/zoneinfo/" segment or the zone is invalid. +func zoneFromZoneinfoPath(path string) string { + const marker = "/zoneinfo/" + idx := strings.LastIndex(path, marker) + if idx < 0 { + return "" + } + zone := path[idx+len(marker):] + if _, err := time.LoadLocation(zone); err != nil { + return "" + } + return zone +} + // validateTimeZone checks if a timezone string is a valid IANA ID. func validateTimeZone(tz string) error { if tz == "" { diff --git a/internal/cli/calendar/virtual.go b/internal/cli/calendar/virtual.go index 561b867..2e9ddc8 100644 --- a/internal/cli/calendar/virtual.go +++ b/internal/cli/calendar/virtual.go @@ -19,7 +19,9 @@ func newVirtualCmd() *cobra.Command { Use: "virtual", Short: "Manage virtual calendars", Long: `Virtual calendars allow scheduling without connecting to a third-party provider. -Perfect for conference rooms, equipment, or external contractors.`, +Perfect for conference rooms, equipment, or external contractors. + +API reference: https://developer.nylas.com/docs/v3/calendar/virtual-calendars/`, } cmd.AddCommand(newVirtualListCmd()) @@ -178,10 +180,7 @@ func newVirtualDeleteCmd() *cobra.Command { grantID := args[0] if !skipConfirm { - fmt.Printf("Are you sure you want to delete virtual calendar grant %s? (y/N): ", grantID) - var response string - _, _ = fmt.Scanln(&response) - if response != "y" && response != "Y" { + if !common.Confirm(fmt.Sprintf("Are you sure you want to delete virtual calendar grant %s?", grantID), false) { fmt.Println("Cancelled") return nil } diff --git a/internal/cli/calendar/working_hours_helpers.go b/internal/cli/calendar/working_hours_helpers.go index 3436c20..705e12c 100644 --- a/internal/cli/calendar/working_hours_helpers.go +++ b/internal/cli/calendar/working_hours_helpers.go @@ -15,6 +15,11 @@ import ( // checkWorkingHoursViolation checks if an event time falls outside working hours. // Returns warning message if outside working hours, empty string otherwise. +// +// NOTE: Working hours in the config are plain "HH:MM" wall-clock values with no +// timezone. The comparison uses eventTime's own wall clock (Hour/Minute in its +// location), so callers must pass eventTime in the timezone the working hours +// were configured for — normally the user's local/event timezone. func checkWorkingHoursViolation(eventTime time.Time, config *domain.Config) string { if config == nil || config.WorkingHours == nil { // No working hours configured, skip validation @@ -96,11 +101,7 @@ func confirmWorkingHoursViolation(violation string, eventTime time.Time, schedul fmt.Println() // Ask for confirmation - fmt.Print("Create anyway? [y/N]: ") - var confirm string - _, _ = fmt.Scanln(&confirm) - - return strings.ToLower(confirm) == "y" || strings.ToLower(confirm) == "yes" + return common.Confirm("Create anyway?", false) } // parseTimeString parses a time string in "HH:MM" format. @@ -117,6 +118,10 @@ func parseTimeString(s string) (hour, min int, err error) { // checkBreakViolation checks if an event time falls during a break period. // Returns error message if during break (hard block), empty string otherwise. +// +// NOTE: Like checkWorkingHoursViolation, this compares eventTime's own wall +// clock against timezone-less "HH:MM" config values; pass eventTime in the +// timezone the breaks were configured for. func checkBreakViolation(eventTime time.Time, config *domain.Config) string { if config == nil || config.WorkingHours == nil { return "" // No working hours or breaks configured diff --git a/internal/cli/common/common_test.go b/internal/cli/common/common_test.go index ae14f03..0f18d91 100644 --- a/internal/cli/common/common_test.go +++ b/internal/cli/common/common_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "os" "strings" "testing" "time" @@ -369,11 +370,56 @@ func TestConfirm(t *testing.T) { ResetLogger() InitLogger(false, true) // quiet mode - // In quiet mode, should return default + // In quiet mode, should return default WITHOUT prompting. + // Destructive commands rely on this: default-no confirms cancel in quiet + // mode, so --force/--yes stays the only way to skip confirmation. assert.True(t, Confirm("Continue?", true)) assert.False(t, Confirm("Continue?", false)) } +func TestConfirm_Interactive(t *testing.T) { + tests := []struct { + name string + input string + defaultYes bool + want bool + }{ + {"y accepts", "y\n", false, true}, + {"yes accepts", "yes\n", false, true}, + {"uppercase Y accepts", "Y\n", false, true}, + {"uppercase YES accepts", "YES\n", false, true}, + {"n rejects", "n\n", false, false}, + {"no rejects", "no\n", false, false}, + {"garbage rejects even with default yes", "maybe\n", true, false}, + {"empty input returns default no", "\n", false, false}, + {"empty input returns default yes", "\n", true, true}, + {"no input (EOF) returns default no", "", false, false}, + {"no input (EOF) returns default yes", "", true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ResetLogger() + InitLogger(false, false) // NOT quiet: exercise the stdin-reading path + + r, w, err := os.Pipe() + require.NoError(t, err) + _, err = w.WriteString(tt.input) + require.NoError(t, err) + require.NoError(t, w.Close()) + + oldStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { + os.Stdin = oldStdin + _ = r.Close() + }) + + assert.Equal(t, tt.want, Confirm("Proceed?", tt.defaultYes)) + }) + } +} + // ============================================================================= // Error Tests // ============================================================================= diff --git a/internal/cli/common/crud.go b/internal/cli/common/crud.go index 5e2b049..0b24b91 100644 --- a/internal/cli/common/crud.go +++ b/internal/cli/common/crud.go @@ -54,10 +54,7 @@ type DeleteConfig struct { func RunDelete(config DeleteConfig) error { // Confirmation prompt if !config.Force { - fmt.Printf("Are you sure you want to delete %s %s? [y/N] ", config.ResourceName, config.ResourceID) - var confirm string - _, _ = fmt.Scanln(&confirm) - if strings.ToLower(confirm) != "y" && strings.ToLower(confirm) != "yes" { + if !Confirm(fmt.Sprintf("Are you sure you want to delete %s %s?", config.ResourceName, config.ResourceID), false) { fmt.Println("Cancelled.") return nil } @@ -209,10 +206,7 @@ func NewDeleteCommand(config DeleteCommandConfig) *cobra.Command { // Confirm deletion if !force { - fmt.Printf("Are you sure you want to delete %s %s? [y/N] ", config.ResourceName, resourceID) - var confirm string - _, _ = fmt.Scanln(&confirm) - if strings.ToLower(confirm) != "y" && strings.ToLower(confirm) != "yes" { + if !Confirm(fmt.Sprintf("Are you sure you want to delete %s %s?", config.ResourceName, resourceID), false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/common/crud_test.go b/internal/cli/common/crud_test.go new file mode 100644 index 0000000..75b731a --- /dev/null +++ b/internal/cli/common/crud_test.go @@ -0,0 +1,171 @@ +//go:build !integration + +package common + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/nylas/cli/internal/ports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// withStdin replaces os.Stdin with a pipe containing input for the duration +// of the test. Used to drive the interactive Confirm path. +func withStdin(t *testing.T, input string) { + t.Helper() + + r, w, err := os.Pipe() + require.NoError(t, err) + _, err = w.WriteString(input) + require.NoError(t, err) + require.NoError(t, w.Close()) + + oldStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { + os.Stdin = oldStdin + _ = r.Close() + }) +} + +// TestRunDelete_Confirmation verifies the destructive-delete gate after the +// consolidation onto common.Confirm: the default answer is NO (empty input or +// no terminal must cancel), only an explicit yes or --force may delete. +func TestRunDelete_Confirmation(t *testing.T) { + tests := []struct { + name string + force bool + quiet bool // quiet mode: Confirm returns the default without prompting + stdin string // interactive input when not quiet + wantDelete bool + }{ + {name: "force skips confirmation and deletes", force: true, quiet: true, wantDelete: true}, + {name: "no input cancels (default no)", quiet: true, wantDelete: false}, + {name: "interactive empty line cancels", stdin: "\n", wantDelete: false}, + {name: "interactive n cancels", stdin: "n\n", wantDelete: false}, + {name: "interactive y deletes", stdin: "y\n", wantDelete: true}, + {name: "interactive yes deletes", stdin: "yes\n", wantDelete: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ResetLogger() + InitLogger(false, tt.quiet) + if !tt.quiet { + withStdin(t, tt.stdin) + } + + deleted := false + err := RunDelete(DeleteConfig{ + ResourceName: "contact", + ResourceID: "contact-123", + GrantID: "grant-123", + Force: tt.force, + DeleteFunc: func(ctx context.Context, grantID, resourceID string) error { + deleted = true + assert.Equal(t, "grant-123", grantID) + assert.Equal(t, "contact-123", resourceID) + return nil + }, + }) + + require.NoError(t, err) + assert.Equal(t, tt.wantDelete, deleted, + "delete invocation mismatch: cancelled confirmations must never delete") + }) + } +} + +func TestRunDelete_WrapsDeleteError(t *testing.T) { + ResetLogger() + InitLogger(false, true) + + deleteErr := errors.New("backend exploded") + err := RunDelete(DeleteConfig{ + ResourceName: "contact", + ResourceID: "contact-123", + GrantID: "grant-123", + Force: true, + DeleteFunc: func(ctx context.Context, grantID, resourceID string) error { + return deleteErr + }, + }) + + require.Error(t, err) + assert.ErrorIs(t, err, deleteErr) +} + +// TestNewDeleteCommand_Confirmation exercises the full cobra command path +// (no-grant variant) for cancel-on-default and --force skip. +func TestNewDeleteCommand_Confirmation(t *testing.T) { + tests := []struct { + name string + args []string + quiet bool + stdin string + wantDelete bool + }{ + {name: "default cancels without input", args: []string{"webhook-1"}, quiet: true, wantDelete: false}, + {name: "force flag skips confirmation", args: []string{"webhook-1", "--force"}, quiet: true, wantDelete: true}, + {name: "interactive y deletes", args: []string{"webhook-1"}, stdin: "y\n", wantDelete: true}, + {name: "interactive empty line cancels", args: []string{"webhook-1"}, stdin: "\n", wantDelete: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ResetLogger() + InitLogger(false, tt.quiet) + if !tt.quiet { + withStdin(t, tt.stdin) + } + + deleted := false + cmd := NewDeleteCommand(DeleteCommandConfig{ + Use: "delete ", + Short: "Delete a webhook", + ResourceName: "webhook", + GetClient: func() (ports.NylasClient, error) { return nil, nil }, + DeleteFuncNoGrant: func(ctx context.Context, resourceID string) error { + deleted = true + assert.Equal(t, "webhook-1", resourceID) + return nil + }, + }) + cmd.SetArgs(tt.args) + + require.NoError(t, cmd.Execute()) + assert.Equal(t, tt.wantDelete, deleted, + "delete invocation mismatch: cancelled confirmations must never delete") + }) + } +} + +// TestNewDeleteCommand_GrantPathForce verifies the grant-scoped delete path +// resolves the grant ID from the second positional argument and honors +// --force. +func TestNewDeleteCommand_GrantPathForce(t *testing.T) { + ResetLogger() + InitLogger(false, true) + + deleted := false + cmd := NewDeleteCommand(DeleteCommandConfig{ + Use: "delete [grant-id]", + Short: "Delete a contact", + ResourceName: "contact", + GetClient: func() (ports.NylasClient, error) { return nil, nil }, + DeleteFunc: func(ctx context.Context, grantID, resourceID string) error { + deleted = true + assert.Equal(t, "grant-456", grantID) + assert.Equal(t, "contact-1", resourceID) + return nil + }, + }) + cmd.SetArgs([]string{"contact-1", "grant-456", "--force"}) + + require.NoError(t, cmd.Execute()) + assert.True(t, deleted) +} diff --git a/internal/cli/common/errors.go b/internal/cli/common/errors.go index b06bd4a..084f2b5 100644 --- a/internal/cli/common/errors.go +++ b/internal/cli/common/errors.go @@ -98,6 +98,19 @@ func WrapError(err error) *CLIError { RequestID: apiErr.RequestID, } case apiErr.StatusCode == http.StatusForbidden: + // Billing-plan capacity limits (rules/lists) are returned as a 403 + // with a "...for this plan" message. These are not an API-key + // permission problem, so surface the real reason and a plan action. + msg := strings.TrimSpace(apiErr.Message) + if strings.Contains(strings.ToLower(msg), "for this plan") { + return &CLIError{ + Err: err, + Message: msg, + Suggestion: "This is a billing-plan limit, not a permissions issue. Remove existing items (e.g. 'nylas agent rule delete ') or upgrade your plan to raise the limit", + Code: ErrCodePermissionDenied, + RequestID: apiErr.RequestID, + } + } return &CLIError{ Err: err, Message: "Permission denied", diff --git a/internal/cli/common/errors_test.go b/internal/cli/common/errors_test.go index 887da22..ad8fdf2 100644 --- a/internal/cli/common/errors_test.go +++ b/internal/cli/common/errors_test.go @@ -161,6 +161,41 @@ func TestWrapError_GenericForbiddenFallsThrough(t *testing.T) { } } +func TestWrapError_PlanLimitForbidden(t *testing.T) { + // The inbox service returns a 403 (forbidden_access) for billing-plan + // capacity limits on rules and lists. These must NOT be reported as an + // API-key permission problem — the user needs to remove items or upgrade. + cases := []struct { + name string + message string + }{ + {"rules cap reached", "Maximum number of rules (5) reached for this plan"}, + {"lists cap reached", "Maximum number of lists (50) reached for this plan"}, + {"rules not allowed", "Rules are not allowed for this plan"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + apiErr := &domain.APIError{ + StatusCode: 403, + Type: "forbidden_access", + Message: tc.message, + RequestID: "req-plan-1", + } + result := WrapError(fmt.Errorf("failed to create rule: %w", apiErr)) + + require.NotNil(t, result) + // Surface the server's real reason, not "Permission denied". + assert.Equal(t, tc.message, result.Message) + assert.Equal(t, "req-plan-1", result.RequestID) + joined := strings.Join(append(result.Suggestions, result.Suggestion), " | ") + assert.Contains(t, strings.ToLower(joined), "plan", + "plan-limit 403 must suggest a plan/limit action") + assert.NotContains(t, strings.ToLower(joined), "api key", + "plan-limit 403 must not blame the API key") + }) + } +} + func TestWrapError_APIErrorStatusClassification(t *testing.T) { tests := []struct { name string diff --git a/internal/cli/common/fileio.go b/internal/cli/common/fileio.go new file mode 100644 index 0000000..d1147ad --- /dev/null +++ b/internal/cli/common/fileio.go @@ -0,0 +1,16 @@ +package common + +import "io" + +// CopyAndClose copies src into dst, then closes dst, returning the number of +// bytes written and the first error encountered. The Close error is checked +// because some filesystems only surface write failures at Close time, so +// callers must not report success until both the copy and the close succeed. +func CopyAndClose(dst io.WriteCloser, src io.Reader) (int64, error) { + written, err := io.Copy(dst, src) + closeErr := dst.Close() + if err != nil { + return written, err + } + return written, closeErr +} diff --git a/internal/cli/common/fileio_test.go b/internal/cli/common/fileio_test.go new file mode 100644 index 0000000..8bc7200 --- /dev/null +++ b/internal/cli/common/fileio_test.go @@ -0,0 +1,77 @@ +//go:build !integration + +package common + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeWriteCloser records writes and returns configurable errors. +type fakeWriteCloser struct { + buf bytes.Buffer + writeErr error + closeErr error + closed bool +} + +func (f *fakeWriteCloser) Write(p []byte) (int, error) { + if f.writeErr != nil { + return 0, f.writeErr + } + return f.buf.Write(p) +} + +func (f *fakeWriteCloser) Close() error { + f.closed = true + return f.closeErr +} + +func TestCopyAndClose(t *testing.T) { + t.Run("copies and closes on success", func(t *testing.T) { + dst := &fakeWriteCloser{} + + written, err := CopyAndClose(dst, strings.NewReader("hello")) + + require.NoError(t, err) + assert.Equal(t, int64(5), written) + assert.Equal(t, "hello", dst.buf.String()) + assert.True(t, dst.closed) + }) + + t.Run("propagates close error", func(t *testing.T) { + // Some filesystems only surface write failures at Close; success must + // not be reported when Close fails. + closeErr := errors.New("close failed: disk full") + dst := &fakeWriteCloser{closeErr: closeErr} + + _, err := CopyAndClose(dst, strings.NewReader("hello")) + + assert.ErrorIs(t, err, closeErr) + assert.True(t, dst.closed) + }) + + t.Run("propagates write error and still closes", func(t *testing.T) { + writeErr := errors.New("write failed") + dst := &fakeWriteCloser{writeErr: writeErr} + + _, err := CopyAndClose(dst, strings.NewReader("hello")) + + assert.ErrorIs(t, err, writeErr) + assert.True(t, dst.closed) + }) + + t.Run("write error takes precedence over close error", func(t *testing.T) { + writeErr := errors.New("write failed") + dst := &fakeWriteCloser{writeErr: writeErr, closeErr: errors.New("close failed")} + + _, err := CopyAndClose(dst, strings.NewReader("hello")) + + assert.ErrorIs(t, err, writeErr) + }) +} diff --git a/internal/cli/common/pagination.go b/internal/cli/common/pagination.go index ac8cc12..738062a 100644 --- a/internal/cli/common/pagination.go +++ b/internal/cli/common/pagination.go @@ -109,6 +109,12 @@ func FetchAllPages[T any](ctx context.Context, config PaginationConfig, fetcher break } + // Guard against server-side pagination bugs that would loop forever: + // an empty page that claims more results, or a cursor that doesn't advance. + if len(page.Data) == 0 || page.NextCursor == cursor { + break + } + cursor = page.NextCursor } diff --git a/internal/cli/common/pagination_test.go b/internal/cli/common/pagination_test.go index f86d27e..e13a75d 100644 --- a/internal/cli/common/pagination_test.go +++ b/internal/cli/common/pagination_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "errors" + "strconv" "testing" "time" @@ -217,7 +218,7 @@ func TestFetchAllPages_ContextCancellation(t *testing.T) { } return PageResult[string]{ Data: []string{"item"}, - NextCursor: "more", + NextCursor: "more-" + strconv.Itoa(fetcherCalls), // Advancing cursor }, nil } @@ -396,7 +397,7 @@ func TestFetchAllPages_WithProgress(t *testing.T) { } return PageResult[string]{ Data: []string{"item1", "item2"}, - NextCursor: "more", + NextCursor: "more-" + strconv.Itoa(fetcherCalls), // Advancing cursor }, nil } @@ -519,3 +520,55 @@ func TestPaginationMode(t *testing.T) { }) } } + +func TestFetchAllPages_StuckCursor(t *testing.T) { + ResetLogger() + InitLogger(false, true) + + config := DefaultPaginationConfig() + config.ShowProgress = false + + fetcherCalls := 0 + fetcher := func(ctx context.Context, cursor string) (PageResult[string], error) { + fetcherCalls++ + // Buggy server: always claims more results with a cursor that never advances. + return PageResult[string]{ + Data: []string{"item-" + strconv.Itoa(fetcherCalls)}, + NextCursor: "stuck-cursor", + }, nil + } + + results, err := FetchAllPages(context.Background(), config, fetcher) + + require.NoError(t, err) + // Page 1 uses cursor "", page 2 uses "stuck-cursor" and returns the same + // cursor again — pagination must stop instead of looping forever. + assert.Equal(t, 2, fetcherCalls) + assert.Len(t, results, 2) +} + +func TestFetchAllPages_EmptyPageClaimingMore(t *testing.T) { + ResetLogger() + InitLogger(false, true) + + config := DefaultPaginationConfig() + config.ShowProgress = false + + fetcherCalls := 0 + fetcher := func(ctx context.Context, cursor string) (PageResult[string], error) { + fetcherCalls++ + // Buggy server: returns no items but keeps advancing the cursor, + // claiming there is always more data. + return PageResult[string]{ + Data: nil, + NextCursor: "cursor-" + strconv.Itoa(fetcherCalls), + }, nil + } + + results, err := FetchAllPages(context.Background(), config, fetcher) + + require.NoError(t, err) + // An empty page that claims more results must terminate pagination. + assert.Equal(t, 1, fetcherCalls) + assert.Empty(t, results) +} diff --git a/internal/cli/contacts/contacts.go b/internal/cli/contacts/contacts.go index a1197dd..66df97d 100644 --- a/internal/cli/contacts/contacts.go +++ b/internal/cli/contacts/contacts.go @@ -13,7 +13,9 @@ func NewContactsCmd() *cobra.Command { Short: "Manage contacts", Long: `Manage contacts from your connected accounts. -View contacts, create new contacts, update and delete contacts.`, +View contacts, create new contacts, update and delete contacts. + +API reference: https://developer.nylas.com/docs/v3/email/contacts/`, } cmd.AddCommand(newListCmd()) diff --git a/internal/cli/dashboard/dashboard.go b/internal/cli/dashboard/dashboard.go index 3b42f55..43c6229 100644 --- a/internal/cli/dashboard/dashboard.go +++ b/internal/cli/dashboard/dashboard.go @@ -21,7 +21,9 @@ Commands: status Show current dashboard authentication status refresh Refresh dashboard session tokens apps Manage Nylas applications - orgs Manage organizations (list, switch)`, + orgs Manage organizations (list, switch) + +Guide: https://developer.nylas.com/docs/dev-guide/dashboard/`, } cmd.AddCommand(newRegisterCmd()) diff --git a/internal/cli/email/attachments.go b/internal/cli/email/attachments.go index 387a0ab..040e037 100644 --- a/internal/cli/email/attachments.go +++ b/internal/cli/email/attachments.go @@ -3,12 +3,12 @@ package email import ( "context" "fmt" - "io" "os" "path/filepath" "strings" "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" "github.com/nylas/cli/internal/ports" "github.com/spf13/cobra" ) @@ -18,7 +18,9 @@ func newAttachmentsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "attachments", Short: "Manage email attachments", - Long: "Commands to list, view, and download email attachments.", + Long: `Commands to list, view, and download email attachments. + +API reference: https://developer.nylas.com/docs/v3/email/attachments/`, } cmd.AddCommand(newAttachmentsListCmd()) @@ -169,8 +171,12 @@ func newAttachmentsDownloadCmd() *cobra.Command { return struct{}{}, common.NewInputError(fmt.Sprintf("output path is a directory: %s", finalOutputPath)) } - // Download the attachment - reader, err := client.DownloadAttachment(ctx, grantID, messageID, attachmentID) + // Download the attachment with a dedicated long timeout: + // the WithClient context carries the default API timeout, + // which would cut off large downloads mid-stream. + dlCtx, dlCancel := common.CreateContextWithTimeout(domain.TimeoutDownload) + defer dlCancel() + reader, err := client.DownloadAttachment(dlCtx, grantID, messageID, attachmentID) if err != nil { return struct{}{}, common.WrapDownloadError("attachment", err) } @@ -181,10 +187,13 @@ func newAttachmentsDownloadCmd() *cobra.Command { if err != nil { return struct{}{}, common.WrapCreateError("output file", err) } + // Backstop close for early-error paths; CopyAndClose performs + // the error-checked close before success is reported. defer func() { _ = file.Close() }() - // Copy content - written, err := io.Copy(file, reader) + // Copy content and close, surfacing write errors that only + // appear at Close time on some filesystems. + written, err := common.CopyAndClose(file, reader) if err != nil { return struct{}{}, common.WrapWriteError("file", err) } diff --git a/internal/cli/email/drafts.go b/internal/cli/email/drafts.go index 2b12726..d8975fb 100644 --- a/internal/cli/email/drafts.go +++ b/internal/cli/email/drafts.go @@ -20,7 +20,9 @@ func newDraftsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "drafts", Short: "Manage email drafts", - Long: "List, create, edit, and send draft emails.", + Long: `List, create, edit, and send draft emails. + +API reference: https://developer.nylas.com/docs/reference/api/drafts/`, } cmd.AddCommand(newDraftsListCmd()) @@ -349,17 +351,13 @@ func newDraftsSendCmd() *cobra.Command { // Confirmation if !force { - fmt.Println("Send this draft?") fmt.Printf(" To: %s\n", common.FormatParticipants(draft.To)) fmt.Printf(" Subject: %s\n", draft.Subject) if signatureID != "" { fmt.Printf(" Signature: %s\n", signatureID) } - fmt.Print("\n[y/N]: ") - var confirm string - _, _ = fmt.Scanln(&confirm) // Ignore error - empty string treated as "no" - if confirm != "y" && confirm != "Y" && confirm != "yes" { + if !common.Confirm("\nSend this draft?", false) { fmt.Println("Cancelled.") return struct{}{}, nil } diff --git a/internal/cli/email/email.go b/internal/cli/email/email.go index 858a167..339774c 100644 --- a/internal/cli/email/email.go +++ b/internal/cli/email/email.go @@ -10,7 +10,9 @@ func NewEmailCmd() *cobra.Command { cmd := &cobra.Command{ Use: "email", Short: "Manage emails", - Long: "Commands for managing emails: list, read, send, search, and more.", + Long: `Commands for managing emails: list, read, send, search, and more. + +API reference: https://developer.nylas.com/docs/v3/email/`, } cmd.AddCommand(newListCmd()) diff --git a/internal/cli/email/folders.go b/internal/cli/email/folders.go index 2cc2814..7781a1e 100644 --- a/internal/cli/email/folders.go +++ b/internal/cli/email/folders.go @@ -14,7 +14,9 @@ func newFoldersCmd() *cobra.Command { cmd := &cobra.Command{ Use: "folders", Short: "Manage email folders/labels", - Long: "List, create, update, and delete email folders or labels.", + Long: `List, create, update, and delete email folders or labels. + +API reference: https://developer.nylas.com/docs/v3/email/folders/`, } cmd.AddCommand(newFoldersListCmd()) diff --git a/internal/cli/email/reply.go b/internal/cli/email/reply.go index 922eded..8b54863 100644 --- a/internal/cli/email/reply.go +++ b/internal/cli/email/reply.go @@ -69,11 +69,7 @@ groups with the original conversation in mail clients.`, printReplyPreview(req) if !noConfirm { - fmt.Print("\nSend this reply? [y/N]: ") - reader := bufio.NewReader(os.Stdin) - confirm, _ := reader.ReadString('\n') - confirm = strings.ToLower(strings.TrimSpace(confirm)) - if confirm != "y" && confirm != "yes" { + if !common.Confirm("\nSend this reply?", false) { fmt.Println("Cancelled.") return struct{}{}, nil } diff --git a/internal/cli/email/scheduled.go b/internal/cli/email/scheduled.go index 7db8f6a..db85bb7 100644 --- a/internal/cli/email/scheduled.go +++ b/internal/cli/email/scheduled.go @@ -14,7 +14,9 @@ func newScheduledCmd() *cobra.Command { cmd := &cobra.Command{ Use: "scheduled", Short: "Manage scheduled messages", - Long: "List, view, and cancel scheduled messages.", + Long: `List, view, and cancel scheduled messages. + +API reference: https://developer.nylas.com/docs/v3/email/scheduled-send/`, } cmd.AddCommand(newScheduledListCmd()) @@ -140,15 +142,11 @@ func newScheduledCancelCmd() *cobra.Command { closeTime := time.Unix(scheduled.CloseTime, 0) - fmt.Println("Cancel this scheduled message?") fmt.Printf(" Schedule ID: %s\n", scheduled.ScheduleID) fmt.Printf(" Status: %s\n", scheduled.Status) fmt.Printf(" Send at: %s\n", closeTime.Format(common.DisplayDateTime)) - fmt.Print("\n[y/N]: ") - var confirm string - _, _ = fmt.Scanln(&confirm) // Ignore error - empty string treated as "no" - if confirm != "y" && confirm != "Y" && confirm != "yes" { + if !common.Confirm("\nCancel this scheduled message?", false) { fmt.Println("Cancelled.") return struct{}{}, nil } diff --git a/internal/cli/email/send.go b/internal/cli/email/send.go index 8496166..b724626 100644 --- a/internal/cli/email/send.go +++ b/internal/cli/email/send.go @@ -325,16 +325,12 @@ Supports hosted templates: } if !noConfirm { - if scheduledTime.IsZero() { - fmt.Print("\nSend this email? [y/N]: ") - } else { - fmt.Print("\nSchedule this email? [y/N]: ") + prompt := "\nSend this email?" + if !scheduledTime.IsZero() { + prompt = "\nSchedule this email?" } - reader := bufio.NewReader(os.Stdin) - confirm, _ := reader.ReadString('\n') - confirm = strings.ToLower(strings.TrimSpace(confirm)) - if confirm != "y" && confirm != "yes" { + if !common.Confirm(prompt, false) { fmt.Println("Cancelled.") return struct{}{}, nil } diff --git a/internal/cli/email/signatures.go b/internal/cli/email/signatures.go index 9ec0fcc..0be7a85 100644 --- a/internal/cli/email/signatures.go +++ b/internal/cli/email/signatures.go @@ -205,10 +205,7 @@ func newSignaturesDeleteCmd() *cobra.Command { return struct{}{}, common.WrapGetError("signature", err) } if !yes { - fmt.Printf("Delete signature %q (%s)? [y/N]: ", signature.Name, signature.ID) - var confirm string - _, _ = fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" && confirm != "yes" { + if !common.Confirm(fmt.Sprintf("Delete signature %q (%s)?", signature.Name, signature.ID), false) { fmt.Println("Cancelled.") return struct{}{}, nil } diff --git a/internal/cli/email/templates_delete.go b/internal/cli/email/templates_delete.go index c71d331..1005eaa 100644 --- a/internal/cli/email/templates_delete.go +++ b/internal/cli/email/templates_delete.go @@ -1,10 +1,7 @@ package email import ( - "bufio" "fmt" - "os" - "strings" "github.com/nylas/cli/internal/adapters/templates" "github.com/nylas/cli/internal/cli/common" @@ -53,12 +50,8 @@ By default, you'll be prompted for confirmation. Use --force to skip the prompt. if tpl.UsageCount > 0 { _, _ = common.Yellow.Printf(" Used: %d time(s)\n", tpl.UsageCount) } - fmt.Print("\nAre you sure? [y/N]: ") - reader := bufio.NewReader(os.Stdin) - confirm, _ := reader.ReadString('\n') - confirm = strings.ToLower(strings.TrimSpace(confirm)) - if confirm != "y" && confirm != "yes" { + if !common.Confirm("\nAre you sure?", false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/email/templates_use.go b/internal/cli/email/templates_use.go index f18ddca..6ac2b79 100644 --- a/internal/cli/email/templates_use.go +++ b/internal/cli/email/templates_use.go @@ -1,10 +1,8 @@ package email import ( - "bufio" "context" "fmt" - "os" "strings" "github.com/nylas/cli/internal/adapters/templates" @@ -104,11 +102,7 @@ Use --preview to see the expanded template without sending.`, fmt.Printf(" Body: %s\n", common.Truncate(expandedBody, 50)) if !noConfirm { - fmt.Print("\nSend this email? [y/N]: ") - reader := bufio.NewReader(os.Stdin) - confirm, _ := reader.ReadString('\n') - confirm = strings.ToLower(strings.TrimSpace(confirm)) - if confirm != "y" && confirm != "yes" { + if !common.Confirm("\nSend this email?", false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/email/threads.go b/internal/cli/email/threads.go index b9194a5..6638466 100644 --- a/internal/cli/email/threads.go +++ b/internal/cli/email/threads.go @@ -14,7 +14,9 @@ func newThreadsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "threads", Short: "Manage email threads/conversations", - Long: "List, view, mark, and delete email threads (conversations).", + Long: `List, view, mark, and delete email threads (conversations). + +API reference: https://developer.nylas.com/docs/v3/email/threads/`, } cmd.AddCommand(newThreadsListCmd()) @@ -259,15 +261,11 @@ func newThreadsDeleteCmd() *cobra.Command { return struct{}{}, common.WrapGetError("thread", err) } - fmt.Println("Delete this thread?") fmt.Printf(" Subject: %s\n", thread.Subject) fmt.Printf(" Messages: %d\n", len(thread.MessageIDs)) fmt.Printf(" Participants: %s\n", common.FormatParticipants(thread.Participants)) - fmt.Print("\n[y/N]: ") - var confirm string - _, _ = fmt.Scanln(&confirm) // Ignore error - empty string treated as "no" - if confirm != "y" && confirm != "Y" && confirm != "yes" { + if !common.Confirm("\nDelete this thread?", false) { fmt.Println("Cancelled.") return struct{}{}, nil } diff --git a/internal/cli/integration/agent_policy_test.go b/internal/cli/integration/agent_policy_test.go index 68ce8e7..4043e59 100644 --- a/internal/cli/integration/agent_policy_test.go +++ b/internal/cli/integration/agent_policy_test.go @@ -83,9 +83,6 @@ func TestCLI_AgentPolicyLifecycle_CreateGetListUpdateDelete(t *testing.T) { if !strings.Contains(readTextStdout, "Limits:") { t.Fatalf("policy read text output should include limits section\noutput: %s", readTextStdout) } - if !strings.Contains(readTextStdout, "Options:") { - t.Fatalf("policy read text output should include options section\noutput: %s", readTextStdout) - } if !strings.Contains(readTextStdout, "Spam detection:") { t.Fatalf("policy read text output should include spam detection section\noutput: %s", readTextStdout) } diff --git a/internal/cli/integration/agent_rule_matrix_test.go b/internal/cli/integration/agent_rule_matrix_test.go index 99fa965..6549aac 100644 --- a/internal/cli/integration/agent_rule_matrix_test.go +++ b/internal/cli/integration/agent_rule_matrix_test.go @@ -50,12 +50,15 @@ func TestCLI_AgentRuleMatrix_CreateAllSupportedConditionsAndActions(t *testing.T skipIfMissingCreds(t) skipIfMissingAgentDomain(t) + // Provision lists before the scope so their LIFO cleanups run last, + // after the scope has deleted the rules that reference them. + listIDs := provisionRuleMatrixLists(t) scope := setupRuleMatrixScope(t, "rule-matrix-create") placeholder := createRuleForTest(t, getTestClient(), "it-rule-matrix-create-placeholder") scope.trackRule(placeholder.ID) attachRuleToWorkspaceForTest(t, getTestClient(), scope.workspaceID, placeholder.ID) - for _, tc := range buildRuleConditionMatrixCases() { + for _, tc := range buildRuleConditionMatrixCases(listIDs) { t.Run("create-"+tc.name, func(t *testing.T) { rule := runAgentRuleCreateJSON(t, scope.env, "--name", fmt.Sprintf("it-%s-%d", tc.name, time.Now().UnixNano()), @@ -64,7 +67,9 @@ func TestCLI_AgentRuleMatrix_CreateAllSupportedConditionsAndActions(t *testing.T "--condition", buildConditionArg(tc.field, tc.operator, tc.rawValue), "--action", "archive", ) - scope.trackRule(rule.ID) + // Delete this rule as soon as the subtest ends so the application + // stays under its per-plan rule cap across the whole matrix. + t.Cleanup(func() { scope.deleteRuleNow(t, rule.ID) }) assertRuleTrigger(t, rule, tc.trigger) assertRuleMatchOperator(t, rule, "all") assertRuleCondition(t, rule, tc.field, tc.operator, tc.expectedValue) @@ -80,7 +85,7 @@ func TestCLI_AgentRuleMatrix_CreateAllSupportedConditionsAndActions(t *testing.T "--condition", representativeCondition(tc.trigger), "--action", tc.actionArg, ) - scope.trackRule(rule.ID) + t.Cleanup(func() { scope.deleteRuleNow(t, rule.ID) }) assertRuleTrigger(t, rule, tc.trigger) assertRuleAction(t, rule, tc.expectedType, tc.expectedValue) }) @@ -124,6 +129,9 @@ func TestCLI_AgentRuleMatrix_UpdateAllSupportedConditionsAndActions(t *testing.T skipIfMissingCreds(t) skipIfMissingAgentDomain(t) + // Provision lists before the scope so their LIFO cleanups run last, + // after the scope has deleted the rules that reference them. + listIDs := provisionRuleMatrixLists(t) scope := setupRuleMatrixScope(t, "rule-matrix-update") client := getTestClient() @@ -139,7 +147,7 @@ func TestCLI_AgentRuleMatrix_UpdateAllSupportedConditionsAndActions(t *testing.T scope.trackRule(outboundBase.ID) attachRuleToWorkspaceForTest(t, client, scope.workspaceID, outboundBase.ID) - for _, tc := range buildRuleConditionMatrixCases() { + for _, tc := range buildRuleConditionMatrixCases(listIDs) { t.Run("update-condition-"+tc.name, func(t *testing.T) { ruleID := inboundBase.ID if tc.trigger == "outbound" { @@ -292,6 +300,27 @@ func (s *ruleMatrixScope) trackRule(ruleID string) { s.createdIDs = append(s.createdIDs, ruleID) } +// deleteRuleNow detaches a rule from the workspace and deletes it immediately. +// The create matrix exercises dozens of rules, but the application has a +// per-plan rule cap (free plan = 5), so each rule must be removed as soon as it +// has been asserted instead of accumulating until the final cleanup. +func (s *ruleMatrixScope) deleteRuleNow(t *testing.T, ruleID string) { + t.Helper() + if strings.TrimSpace(ruleID) == "" { + return + } + + client := getTestClient() + removeRuleFromWorkspaceForTest(t, client, s.workspaceID, ruleID) + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.DeleteRule(ctx, ruleID); err != nil { + t.Errorf("delete rule %s: %v", ruleID, err) + } +} + func runAgentRuleCreateJSON(t *testing.T, env map[string]string, args ...string) domain.Rule { t.Helper() @@ -390,10 +419,52 @@ func buildConditionArg(field, operator, rawValue string) string { return fmt.Sprintf("%s,%s,%s", field, operator, rawValue) } -func buildRuleConditionMatrixCases() []ruleConditionMatrixCase { +// provisionRuleMatrixLists creates two real lists per type via /v3/lists and +// returns type → list IDs. The API validates in_list condition values against +// existing lists (and type-matches them to the rule field), so the matrix +// cannot use fabricated IDs. Two lists per type exercise multi-list in_list +// conditions while staying under the per-plan cap of 10 lists (the rule cap +// of 5 is handled separately by deleteRuleNow). +func provisionRuleMatrixLists(t *testing.T) map[string][]string { + t.Helper() + + client := getTestClient() + listIDs := make(map[string][]string, len(domain.AgentListTypes)) + + for _, listType := range domain.AgentListTypes { + for n := 1; n <= 2; n++ { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + list, err := client.CreateList(ctx, map[string]any{ + "name": fmt.Sprintf("it-rule-matrix-%s-%d-%d", listType, n, time.Now().UnixNano()), + "type": listType, + }) + cancel() + if err != nil { + t.Fatalf("failed to create %s list for rule matrix: %v", listType, err) + } + listIDs[listType] = append(listIDs[listType], list.ID) + + listID := list.ID + t.Cleanup(func() { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := client.DeleteList(ctx, listID); err != nil { + t.Logf("cleanup delete list %s: %v", listID, err) + } + cancel() + }) + } + } + + return listIDs +} + +func buildRuleConditionMatrixCases(listIDs map[string][]string) []ruleConditionMatrixCase { cases := make([]ruleConditionMatrixCase, 0, 38) - appendStringFieldCases := func(trigger, field, exactValue, containsValue, listPrefix string) { + appendStringFieldCases := func(trigger, field, exactValue, containsValue, listType string) { + lists := listIDs[listType] cases = append(cases, ruleConditionMatrixCase{ name: fmt.Sprintf("%s-%s-is", trigger, strings.ReplaceAll(field, ".", "-")), @@ -424,22 +495,22 @@ func buildRuleConditionMatrixCases() []ruleConditionMatrixCase { trigger: trigger, field: field, operator: "in_list", - rawValue: fmt.Sprintf("%s-1,%s-2", listPrefix, listPrefix), - expectedValue: []string{fmt.Sprintf("%s-1", listPrefix), fmt.Sprintf("%s-2", listPrefix)}, + rawValue: strings.Join(lists, ","), + expectedValue: lists, }, ) } - appendStringFieldCases("inbound", "from.address", "sender@example.com", "sender@", "inbound-address-list") - appendStringFieldCases("inbound", "from.domain", "example.com", "ample", "inbound-domain-list") - appendStringFieldCases("inbound", "from.tld", "com", "o", "inbound-tld-list") + appendStringFieldCases("inbound", "from.address", "sender@example.com", "sender@", "address") + appendStringFieldCases("inbound", "from.domain", "example.com", "ample", "domain") + appendStringFieldCases("inbound", "from.tld", "com", "o", "tld") - appendStringFieldCases("outbound", "from.address", "sender@example.com", "sender@", "outbound-from-address-list") - appendStringFieldCases("outbound", "from.domain", "example.com", "ample", "outbound-from-domain-list") - appendStringFieldCases("outbound", "from.tld", "com", "o", "outbound-from-tld-list") - appendStringFieldCases("outbound", "recipient.address", "recipient@example.net", "recipient@", "outbound-recipient-address-list") - appendStringFieldCases("outbound", "recipient.domain", "example.net", "ample", "outbound-recipient-domain-list") - appendStringFieldCases("outbound", "recipient.tld", "net", "e", "outbound-recipient-tld-list") + appendStringFieldCases("outbound", "from.address", "sender@example.com", "sender@", "address") + appendStringFieldCases("outbound", "from.domain", "example.com", "ample", "domain") + appendStringFieldCases("outbound", "from.tld", "com", "o", "tld") + appendStringFieldCases("outbound", "recipient.address", "recipient@example.net", "recipient@", "address") + appendStringFieldCases("outbound", "recipient.domain", "example.net", "ample", "domain") + appendStringFieldCases("outbound", "recipient.tld", "net", "e", "tld") cases = append(cases, ruleConditionMatrixCase{ diff --git a/internal/cli/integration/local_regressions_test.go b/internal/cli/integration/local_regressions_test.go index f91b9ea..6afc519 100644 --- a/internal/cli/integration/local_regressions_test.go +++ b/internal/cli/integration/local_regressions_test.go @@ -255,7 +255,13 @@ func TestCLI_AuthList_DoesNotRequireFileStorePassphrase(t *testing.T) { configHome := filepath.Join(tempDir, "xdg") cacheHome := filepath.Join(tempDir, "cache") - stdout, stderr, err := runCLIWithOverrides(5*time.Second, map[string]string{ + // The API currently takes ~15s to return a 401 for an invalid key on + // GET /v3/grants (TTFB ~15s; connect/TLS are instant). The CLI waits for + // that response, so the wrapper timeout must exceed the server latency or + // the process is killed before the credential error is printed. Tracked as + // a server-side fix (invalid-key auth should fail fast); 25s leaves margin + // over the observed ~15s until then. + stdout, stderr, err := runCLIWithOverrides(25*time.Second, map[string]string{ "XDG_CONFIG_HOME": configHome, "XDG_CACHE_HOME": cacheHome, "HOME": tempDir, diff --git a/internal/cli/integration/test_setup.go b/internal/cli/integration/test_setup.go index f0bd9a0..ab493da 100644 --- a/internal/cli/integration/test_setup.go +++ b/internal/cli/integration/test_setup.go @@ -163,11 +163,14 @@ func checkOllamaAvailable() bool { for _, host := range hosts { resp, err := client.Get(host + "/api/tags") - if err == nil { - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode == http.StatusOK { - return true - } + if err != nil { + continue + } + ok := resp.StatusCode == http.StatusOK + // Close before the next iteration; a defer here would pile up until return. + _ = resp.Body.Close() + if ok { + return true } } diff --git a/internal/cli/notetaker/notetaker.go b/internal/cli/notetaker/notetaker.go index 5fa60e4..62af9fd 100644 --- a/internal/cli/notetaker/notetaker.go +++ b/internal/cli/notetaker/notetaker.go @@ -17,7 +17,9 @@ Notetaker bots can join video meetings (Zoom, Google Meet, Teams) to: - Generate transcripts - Provide meeting summaries -Use subcommands to create, list, show, delete notetakers and retrieve media.`, +Use subcommands to create, list, show, delete notetakers and retrieve media. + +API reference: https://developer.nylas.com/docs/v3/notetaker/`, Example: ` # List all notetakers nylas notetaker list diff --git a/internal/cli/otp/otp.go b/internal/cli/otp/otp.go index 5a9f907..8e072a8 100644 --- a/internal/cli/otp/otp.go +++ b/internal/cli/otp/otp.go @@ -16,7 +16,9 @@ Commands: get Get the latest OTP code watch Watch for new OTP codes list List configured accounts - messages Show recent messages (debug)`, + messages Show recent messages (debug) + +Guide: https://developer.nylas.com/docs/cookbook/cli/extract-otp-codes/`, } cmd.AddCommand(newGetCmd()) diff --git a/internal/cli/root.go b/internal/cli/root.go index cd6c12b..b8093c7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -21,7 +21,8 @@ var rootCmd = &cobra.Command{ nylas contacts list List contacts nylas commands --json Machine-readable command tree -Documentation: https://cli.nylas.com/`, +Documentation: https://cli.nylas.com/ +API reference: https://developer.nylas.com/`, SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cli/scheduler/bookings.go b/internal/cli/scheduler/bookings.go index eb417d8..8228bc0 100644 --- a/internal/cli/scheduler/bookings.go +++ b/internal/cli/scheduler/bookings.go @@ -17,7 +17,9 @@ func newBookingsCmd() *cobra.Command { Use: "bookings", Aliases: []string{"booking"}, Short: "Manage scheduler bookings", - Long: "Manage scheduler bookings (scheduled meetings).", + Long: `Manage scheduler bookings (scheduled meetings). + +API reference: https://developer.nylas.com/docs/reference/api/bookings/`, } cmd.AddCommand(newBookingShowCmd()) @@ -203,10 +205,7 @@ func newBookingCancelCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !yes { - fmt.Printf("Are you sure you want to cancel booking %s? (y/N): ", args[0]) - var confirm string - _, _ = fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" { + if !common.Confirm(fmt.Sprintf("Are you sure you want to cancel booking %s?", args[0]), false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/scheduler/configurations.go b/internal/cli/scheduler/configurations.go index 4b56013..af4d470 100644 --- a/internal/cli/scheduler/configurations.go +++ b/internal/cli/scheduler/configurations.go @@ -16,7 +16,9 @@ func newConfigurationsCmd() *cobra.Command { Use: "configurations", Aliases: []string{"config", "configs"}, Short: "Manage scheduler configurations", - Long: "Manage scheduler configurations (meeting types) for scheduling workflows.", + Long: `Manage scheduler configurations (meeting types) for scheduling workflows. + +API reference: https://developer.nylas.com/docs/reference/api/configurations/`, } cmd.AddCommand(newConfigListCmd()) @@ -269,10 +271,7 @@ func newConfigDeleteCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !yes { - fmt.Printf("Are you sure you want to delete configuration %s? (y/N): ", args[0]) - var confirm string - _, _ = fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" { + if !common.Confirm(fmt.Sprintf("Are you sure you want to delete configuration %s?", args[0]), false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/scheduler/scheduler.go b/internal/cli/scheduler/scheduler.go index a39a3c9..7616327 100644 --- a/internal/cli/scheduler/scheduler.go +++ b/internal/cli/scheduler/scheduler.go @@ -14,7 +14,9 @@ func NewSchedulerCmd() *cobra.Command { Long: `Manage Nylas Scheduler configurations, sessions, and bookings. The Nylas Scheduler allows you to create meeting booking workflows, -manage availability, and handle scheduling sessions.`, +manage availability, and handle scheduling sessions. + +API reference: https://developer.nylas.com/docs/v3/scheduler/`, } cmd.AddCommand(newConfigurationsCmd()) diff --git a/internal/cli/scheduler/sessions.go b/internal/cli/scheduler/sessions.go index ace87e4..f820cd6 100644 --- a/internal/cli/scheduler/sessions.go +++ b/internal/cli/scheduler/sessions.go @@ -16,7 +16,9 @@ func newSessionsCmd() *cobra.Command { Use: "sessions", Aliases: []string{"session"}, Short: "Manage scheduler sessions", - Long: "Manage scheduler sessions for booking workflows.", + Long: `Manage scheduler sessions for booking workflows. + +API reference: https://developer.nylas.com/docs/reference/api/sessions/`, } cmd.AddCommand(newSessionCreateCmd()) diff --git a/internal/cli/slack/auth.go b/internal/cli/slack/auth.go index 7540cad..f1cb68e 100644 --- a/internal/cli/slack/auth.go +++ b/internal/cli/slack/auth.go @@ -132,10 +132,7 @@ func newAuthRemoveCmd() *cobra.Command { Short: "Remove stored Slack token", RunE: func(cmd *cobra.Command, args []string) error { if !force { - fmt.Print("Remove Slack authentication? [y/N]: ") - var confirm string - _, _ = fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" && confirm != "yes" { + if !common.Confirm("Remove Slack authentication?", false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/slack/files.go b/internal/cli/slack/files.go index 2bb5f00..a785bd4 100644 --- a/internal/cli/slack/files.go +++ b/internal/cli/slack/files.go @@ -4,7 +4,6 @@ package slack import ( "fmt" - "io" "os" "path/filepath" "strings" @@ -331,8 +330,12 @@ Examples: return common.NewInputError(fmt.Sprintf("output path is a directory: %s", outputPath)) } - // Download the file - reader, err := client.DownloadFile(ctx, file.DownloadURL) + // Download the file with a dedicated long timeout: the command + // context carries the default API timeout, which would cut off + // large downloads mid-stream. + dlCtx, dlCancel := common.CreateContextWithTimeout(domain.TimeoutDownload) + defer dlCancel() + reader, err := client.DownloadFile(dlCtx, file.DownloadURL) if err != nil { return common.WrapDownloadError("file", err) } @@ -343,10 +346,13 @@ Examples: if err != nil { return common.WrapCreateError("output file", err) } + // Backstop close for early-error paths; CopyAndClose performs + // the error-checked close before success is reported. defer func() { _ = outFile.Close() }() - // Copy content - written, err := io.Copy(outFile, reader) + // Copy content and close, surfacing write errors that only + // appear at Close time on some filesystems. + written, err := common.CopyAndClose(outFile, reader) if err != nil { return common.WrapWriteError("file", err) } diff --git a/internal/cli/slack/send.go b/internal/cli/slack/send.go index 011e8ce..33a1f86 100644 --- a/internal/cli/slack/send.go +++ b/internal/cli/slack/send.go @@ -3,10 +3,7 @@ package slack import ( - "bufio" "fmt" - "os" - "strings" "github.com/spf13/cobra" @@ -64,14 +61,9 @@ Examples: if !noConfirm { fmt.Printf("Channel: %s\n", common.Cyan.Sprint(channelName)) - fmt.Printf("Message: %s\n\n", text) - fmt.Print("Send this message? [y/N]: ") + fmt.Printf("Message: %s\n", text) - reader := bufio.NewReader(os.Stdin) - confirm, _ := reader.ReadString('\n') - confirm = strings.TrimSpace(strings.ToLower(confirm)) - - if confirm != "y" && confirm != "yes" { + if !common.Confirm("\nSend this message?", false) { fmt.Println("Cancelled.") return nil } @@ -159,13 +151,8 @@ Examples: if broadcast { fmt.Println("(Also posting to channel)") } - fmt.Print("\nSend this reply? [y/N]: ") - - reader := bufio.NewReader(os.Stdin) - confirm, _ := reader.ReadString('\n') - confirm = strings.TrimSpace(strings.ToLower(confirm)) - if confirm != "y" && confirm != "yes" { + if !common.Confirm("\nSend this reply?", false) { fmt.Println("Cancelled.") return nil } diff --git a/internal/cli/templatecmd/template.go b/internal/cli/templatecmd/template.go index 5dfd03e..b79ad13 100644 --- a/internal/cli/templatecmd/template.go +++ b/internal/cli/templatecmd/template.go @@ -18,7 +18,9 @@ func NewTemplateCmd() *cobra.Command { Long: `Manage Nylas-hosted templates at the application or grant scope. Use --scope app for application-level templates and --scope grant to target -templates attached to a specific grant.`, +templates attached to a specific grant. + +API reference: https://developer.nylas.com/docs/reference/api/application-level-templates/`, } common.AddOutputFlags(cmd) diff --git a/internal/cli/update/update.go b/internal/cli/update/update.go index 126f5d4..44909be 100644 --- a/internal/cli/update/update.go +++ b/internal/cli/update/update.go @@ -1,12 +1,10 @@ package update import ( - "bufio" "context" "fmt" "os" "runtime" - "strings" "github.com/spf13/cobra" @@ -97,14 +95,7 @@ func runUpdate(ctx context.Context, checkOnly, force, yes bool) error { // Confirm update if !yes { - fmt.Print("\nDo you want to update? [y/N]: ") - reader := bufio.NewReader(os.Stdin) - response, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("read input: %w", err) - } - response = strings.TrimSpace(strings.ToLower(response)) - if response != "y" && response != "yes" { + if !common.Confirm("\nDo you want to update?", false) { fmt.Println("Update cancelled.") return nil } diff --git a/internal/cli/webhook/pubsub.go b/internal/cli/webhook/pubsub.go index 31b52c8..c52ee8a 100644 --- a/internal/cli/webhook/pubsub.go +++ b/internal/cli/webhook/pubsub.go @@ -13,7 +13,9 @@ func newPubSubCmd() *cobra.Command { Long: `Manage Nylas Pub/Sub notification channels for queue-based event delivery. Pub/Sub channels deliver notifications to Google Cloud Pub/Sub topics and are -useful for higher-volume or latency-sensitive event processing.`, +useful for higher-volume or latency-sensitive event processing. + +API reference: https://developer.nylas.com/docs/v3/notifications/pubsub-channel/`, } common.AddOutputFlags(cmd) diff --git a/internal/cli/webhook/webhook.go b/internal/cli/webhook/webhook.go index e128c09..3c55910 100644 --- a/internal/cli/webhook/webhook.go +++ b/internal/cli/webhook/webhook.go @@ -16,7 +16,9 @@ func NewWebhookCmd() *cobra.Command { Use webhooks for direct HTTPS push delivery, or Pub/Sub channels for high-volume queue-based notification delivery. -Note: Notification destination management requires an API key (admin-level access).`, +Note: Notification destination management requires an API key (admin-level access). + +API reference: https://developer.nylas.com/docs/v3/notifications/`, } cmd.AddCommand(newListCmd()) diff --git a/internal/cli/workflow/workflow.go b/internal/cli/workflow/workflow.go index 3850ad9..aa1254f 100644 --- a/internal/cli/workflow/workflow.go +++ b/internal/cli/workflow/workflow.go @@ -17,7 +17,9 @@ func NewWorkflowCmd() *cobra.Command { Short: "Manage hosted workflows", Long: `Manage Nylas-hosted workflows at the application or grant scope. -Workflows connect booking events to hosted templates.`, +Workflows connect booking events to hosted templates. + +API reference: https://developer.nylas.com/docs/reference/api/application-level-workflows/`, } common.AddOutputFlags(cmd) diff --git a/internal/domain/config.go b/internal/domain/config.go index 4fe1aa9..3949712 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -30,6 +30,10 @@ const ( // all Slack messages or channels (10m). TimeoutBulkOperation = 10 * time.Minute + // TimeoutDownload is the timeout for streamed file/attachment downloads (15m). + // Large or slow downloads would be cut off mid-stream by TimeoutAPI. + TimeoutDownload = 15 * time.Minute + // TimeoutQuickCheck is the timeout for quick checks like version checking (5s). TimeoutQuickCheck = 5 * time.Second diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 7d7fab1..a572c0f 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -62,6 +62,7 @@ var ( ErrApplicationNotFound = errors.New("application not found") ErrPolicyNotFound = errors.New("policy not found") ErrRuleNotFound = errors.New("rule not found") + ErrListNotFound = errors.New("list not found") ErrCallbackURINotFound = errors.New("callback URI not found") ErrConnectorNotFound = errors.New("connector not found") ErrCredentialNotFound = errors.New("credential not found") diff --git a/internal/domain/list.go b/internal/domain/list.go new file mode 100644 index 0000000..36b2d7b --- /dev/null +++ b/internal/domain/list.go @@ -0,0 +1,18 @@ +package domain + +// AgentList represents a /v3/lists resource. Lists hold normalized values +// (domains, TLDs, or email addresses) referenced by rule in_list conditions. +type AgentList struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + ItemsCount int `json:"items_count"` + ApplicationID string `json:"application_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + CreatedAt UnixTime `json:"created_at,omitzero"` + UpdatedAt UnixTime `json:"updated_at,omitzero"` +} + +// AgentListTypes enumerates the immutable list types accepted by /v3/lists. +var AgentListTypes = []string{"domain", "tld", "address"} diff --git a/internal/domain/policy.go b/internal/domain/policy.go index 6f64eba..75379dd 100644 --- a/internal/domain/policy.go +++ b/internal/domain/policy.go @@ -8,7 +8,6 @@ type Policy struct { OrganizationID string `json:"organization_id,omitempty"` Rules []string `json:"rules,omitempty"` Limits *PolicyLimits `json:"limits,omitempty"` - Options *PolicyOptions `json:"options,omitempty"` SpamDetection *PolicySpamDetection `json:"spam_detection,omitempty"` CreatedAt UnixTime `json:"created_at,omitempty"` UpdatedAt UnixTime `json:"updated_at,omitempty"` @@ -26,12 +25,6 @@ type PolicyLimits struct { LimitSpamRetentionPeriodInDays *int `json:"limit_spam_retention_period,omitempty"` } -// PolicyOptions contains option settings for a policy. -type PolicyOptions struct { - AdditionalFolders *[]string `json:"additional_folders,omitempty"` - UseCidrAliasing *bool `json:"use_cidr_aliasing,omitempty"` -} - // PolicySpamDetection contains spam detection settings for a policy. type PolicySpamDetection struct { UseListDNSBL *bool `json:"use_list_dnsbl,omitempty"` diff --git a/internal/domain/workspace.go b/internal/domain/workspace.go index 84817f3..45139af 100644 --- a/internal/domain/workspace.go +++ b/internal/domain/workspace.go @@ -9,7 +9,7 @@ type Workspace struct { Domain *string `json:"domain,omitempty"` AutoGroup bool `json:"auto_group,omitempty"` PolicyID string `json:"policy_id,omitempty"` - RulesIDs []string `json:"rules_ids,omitempty"` + RulesIDs []string `json:"rule_ids,omitempty"` CreatedAt UnixTime `json:"created_at,omitempty"` UpdatedAt UnixTime `json:"updated_at,omitempty"` } @@ -20,11 +20,11 @@ type CreateWorkspaceRequest struct { Domain string `json:"domain,omitempty"` AutoGroup *bool `json:"auto_group,omitempty"` PolicyID string `json:"policy_id,omitempty"` - RulesIDs []string `json:"rules_ids,omitempty"` + RulesIDs []string `json:"rule_ids,omitempty"` } // UpdateWorkspaceRequest updates workspace policy/rule attachments. type UpdateWorkspaceRequest struct { PolicyID *string `json:"policy_id,omitempty"` - RulesIDs *[]string `json:"rules_ids,omitempty"` + RulesIDs *[]string `json:"rule_ids,omitempty"` } diff --git a/internal/domain/workspace_test.go b/internal/domain/workspace_test.go new file mode 100644 index 0000000..dcb2fa3 --- /dev/null +++ b/internal/domain/workspace_test.go @@ -0,0 +1,45 @@ +package domain + +import ( + "encoding/json" + "testing" +) + +// The Nylas v3 API and the workspace service (uas) use the JSON field name +// "rule_ids" for workspace rule attachments. These tests pin the wire format so +// the CLI's rule-attach PATCH is actually read by the server — a mismatch +// silently no-ops the attach (the PATCH succeeds but the rule is never linked). + +func TestUpdateWorkspaceRequest_RuleIDsWireName(t *testing.T) { + ids := []string{"rule-1", "rule-2"} + req := UpdateWorkspaceRequest{RulesIDs: &ids} + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := raw["rule_ids"]; !ok { + t.Fatalf("UpdateWorkspaceRequest must serialize rule attachments as \"rule_ids\"; got %s", data) + } + if _, ok := raw["rules_ids"]; ok { + t.Fatalf("UpdateWorkspaceRequest must not use \"rules_ids\" (server reads \"rule_ids\"); got %s", data) + } +} + +func TestWorkspace_RuleIDsWireName(t *testing.T) { + // The server returns "rule_ids"; it must populate RulesIDs on read. + const body = `{"workspace_id":"ws-1","rule_ids":["rule-a","rule-b"]}` + + var ws Workspace + if err := json.Unmarshal([]byte(body), &ws); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(ws.RulesIDs) != 2 { + t.Fatalf("Workspace must decode \"rule_ids\" into RulesIDs; got %#v", ws.RulesIDs) + } +} diff --git a/internal/ports/list.go b/internal/ports/list.go new file mode 100644 index 0000000..01280a0 --- /dev/null +++ b/internal/ports/list.go @@ -0,0 +1,19 @@ +package ports + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// ListClient defines list management operations for rule in_list conditions. +type ListClient interface { + ListLists(ctx context.Context) ([]domain.AgentList, error) + GetList(ctx context.Context, listID string) (*domain.AgentList, error) + CreateList(ctx context.Context, payload map[string]any) (*domain.AgentList, error) + UpdateList(ctx context.Context, listID string, payload map[string]any) (*domain.AgentList, error) + DeleteList(ctx context.Context, listID string) error + GetListItems(ctx context.Context, listID string) ([]string, error) + AddListItems(ctx context.Context, listID string, items []string) (*domain.AgentList, error) + RemoveListItems(ctx context.Context, listID string, items []string) (*domain.AgentList, error) +} diff --git a/internal/ports/nylas.go b/internal/ports/nylas.go index dcb597d..1598fe3 100644 --- a/internal/ports/nylas.go +++ b/internal/ports/nylas.go @@ -20,6 +20,7 @@ type NylasClient interface { AgentClient PolicyClient RuleClient + ListClient SchedulerClient AdminClient TransactionalClient diff --git a/internal/tui/app_control.go b/internal/tui/app_control.go index 13c6b39..22122af 100644 --- a/internal/tui/app_control.go +++ b/internal/tui/app_control.go @@ -48,8 +48,16 @@ func (a *App) Stop() { } // Flash displays a temporary message. +// Safe to call from any goroutine, including the event loop — the status +// mutation is marshaled through QueueUpdateDraw from a new goroutine so +// callers never block the event loop waiting on itself. func (a *App) Flash(level FlashLevel, msg string, args ...any) { - a.status.Flash(level, fmt.Sprintf(msg, args...)) + text := fmt.Sprintf(msg, args...) + go func() { + a.QueueUpdateDraw(func() { + a.status.Flash(level, text) + }) + }() } // Styles returns the app styles. @@ -83,13 +91,11 @@ func (a *App) SwitchGrant(grantID, email, provider string) error { // Update status indicator a.status.UpdateGrant(email, provider, grantID) - // Refresh the current view to load data for the new grant + // Refresh the current view to load data for the new grant. + // SwitchGrant runs on the event loop; Refresh is non-blocking (views + // fetch in their own goroutine and apply via QueueUpdateDraw). if view := a.getCurrentView(); view != nil { - go func() { - a.QueueUpdateDraw(func() { - view.Refresh() - }) - }() + view.Refresh() } return nil @@ -100,6 +106,14 @@ func (a *App) CanSwitchGrant() bool { return a.config.GrantStore != nil } +// grantStillCurrent reports whether a grant ID snapshot taken when a fetch +// started still matches the active grant. Must be called from the event loop. +// Apply callbacks use it to drop in-flight results after SwitchGrant so a +// view never renders the previous grant's data. +func (a *App) grantStillCurrent(grantID string) bool { + return grantID == a.config.GrantID +} + // ============================================================================ // Vim-style Navigation Helpers // ============================================================================ diff --git a/internal/tui/app_init.go b/internal/tui/app_init.go index 270603e..0839386 100644 --- a/internal/tui/app_init.go +++ b/internal/tui/app_init.go @@ -155,10 +155,7 @@ func (a *App) setupKeys() { case 'r': // Refresh (lowercase only - uppercase R is for reply) if currentView != nil { - go func() { - currentView.Refresh() - a.QueueUpdateDraw(func() {}) - }() + currentView.Refresh() } return nil diff --git a/internal/tui/app_ui.go b/internal/tui/app_ui.go index 8edb586..2d3bfee 100644 --- a/internal/tui/app_ui.go +++ b/internal/tui/app_ui.go @@ -135,10 +135,7 @@ func (a *App) onCommand(cmd string) { // View commands case "refresh", "reload": if view := a.getCurrentView(); view != nil { - go func() { - view.Refresh() - a.QueueUpdateDraw(func() {}) - }() + view.Refresh() } case "top", "first", "gg": a.goToTop() @@ -174,10 +171,7 @@ func (a *App) onFilter(filter string) { a.hidePrompt() if view := a.getCurrentView(); view != nil { view.Filter(filter) - go func() { - view.Refresh() - a.QueueUpdateDraw(func() {}) - }() + view.Refresh() } } @@ -196,11 +190,9 @@ func (a *App) navigateTo(name string) { a.menu.SetHints(view.Hints()) a.SetFocus(view.Primitive()) - // Load data asynchronously - go func() { - view.Load() - a.QueueUpdateDraw(func() {}) - }() + // Load data. Load is non-blocking: views fetch in their own goroutine + // and apply results via QueueUpdateDraw. + view.Load() } func (a *App) goBack() *tcell.EventKey { diff --git a/internal/tui/availability_actions.go b/internal/tui/availability_actions.go index be8b814..839a126 100644 --- a/internal/tui/availability_actions.go +++ b/internal/tui/availability_actions.go @@ -22,44 +22,52 @@ func (v *AvailabilityView) fetchAvailability() { v.timeline.SetText("[gray]Loading availability...[-]") v.slotsList.Clear() + // Snapshot view state on the event loop; the goroutine below must not + // read fields that the event loop may mutate concurrently. + participants := make([]domain.AvailabilityParticipant, len(v.participants)) + for i, email := range v.participants { + participants[i] = domain.AvailabilityParticipant{ + Email: email, + } + } + + req := &domain.AvailabilityRequest{ + StartTime: v.startDate.Unix(), + EndTime: v.endDate.Add(24 * time.Hour).Unix(), // Include full end day + DurationMinutes: v.duration, + Participants: participants, + IntervalMinutes: 15, + } + + // Also fetch free/busy for timeline visualization + freeBusyReq := &domain.FreeBusyRequest{ + StartTime: v.startDate.Unix(), + EndTime: v.endDate.Add(24 * time.Hour).Unix(), + Emails: append([]string(nil), v.participants...), + } + grantID := v.app.config.GrantID + go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // Build participants list - participants := make([]domain.AvailabilityParticipant, len(v.participants)) - for i, email := range v.participants { - participants[i] = domain.AvailabilityParticipant{ - Email: email, - } - } - - req := &domain.AvailabilityRequest{ - StartTime: v.startDate.Unix(), - EndTime: v.endDate.Add(24 * time.Hour).Unix(), // Include full end day - DurationMinutes: v.duration, - Participants: participants, - IntervalMinutes: 15, - } - resp, err := v.app.config.Client.GetAvailability(ctx, req) if err != nil { v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale result + } v.timeline.SetText(fmt.Sprintf("[red]Failed to load availability: %v[-]", err)) }) return } - // Also fetch free/busy for timeline visualization - freeBusyReq := &domain.FreeBusyRequest{ - StartTime: v.startDate.Unix(), - EndTime: v.endDate.Add(24 * time.Hour).Unix(), - Emails: v.participants, - } - - freeBusyResp, _ := v.app.config.Client.GetFreeBusy(ctx, v.app.config.GrantID, freeBusyReq) + freeBusyResp, _ := v.app.config.Client.GetFreeBusy(ctx, grantID, freeBusyReq) v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } v.slots = resp.Data.TimeSlots if freeBusyResp != nil { v.freeBusy = freeBusyResp.Data diff --git a/internal/tui/availability_base.go b/internal/tui/availability_base.go index b2cc12a..cc89eaa 100644 --- a/internal/tui/availability_base.go +++ b/internal/tui/availability_base.go @@ -117,52 +117,82 @@ func (v *AvailabilityView) Hints() []Hint { } } +// Load resolves the current user, calendars, and availability. Network +// fetches run in background goroutines and results are applied on the event +// loop via QueueUpdateDraw. Must be called from the event loop; it is +// non-blocking. func (v *AvailabilityView) Load() { - // Add current user as first participant if empty - if len(v.participants) == 0 { - // Try to get current user's email from config - if v.app.config.GrantID != "" { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - grant, err := v.app.config.Client.GetGrant(ctx, v.app.config.GrantID) - if err == nil && grant.Email != "" { - v.participants = append(v.participants, grant.Email) - } - } - } - // Load calendars for event creation v.loadCalendars() + // Render the current (possibly empty) state synchronously so the view is + // never blank while background fetches are in flight. v.renderParticipants() v.updateInfoPanel() + + // Add current user as first participant if empty. fetchAvailability is + // skipped here: with no participants it makes no network call and would + // only show the "add participants" hint while the user is being resolved. + if len(v.participants) == 0 && v.app.config.GrantID != "" { + grantID := v.app.config.GrantID + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + grant, err := v.app.config.Client.GetGrant(ctx, grantID) + v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } + if err == nil && grant.Email != "" { + v.participants = append(v.participants, grant.Email) + } + v.renderParticipants() + v.updateInfoPanel() + v.fetchAvailability() + }) + }() + return + } + v.fetchAvailability() } +// loadCalendars fetches calendars in a background goroutine and applies the +// results on the event loop via QueueUpdateDraw. Must be called from the +// event loop; it is non-blocking. func (v *AvailabilityView) loadCalendars() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - calendars, err := v.app.config.Client.GetCalendars(ctx, v.app.config.GrantID) - if err != nil { - v.app.FlashLoadError("Failed to load calendars", err) - return - } + grantID := v.app.config.GrantID - v.calendars = calendars + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() - // Select primary calendar by default - for _, cal := range calendars { - if cal.IsPrimary { - v.selectedCalendarID = cal.ID + calendars, err := v.app.config.Client.GetCalendars(ctx, grantID) + if err != nil { + v.app.FlashLoadError("Failed to load calendars", err) return } - } - // Fall back to first calendar - if len(calendars) > 0 { - v.selectedCalendarID = calendars[0].ID - } + v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } + v.calendars = calendars + + // Select primary calendar by default + for _, cal := range calendars { + if cal.IsPrimary { + v.selectedCalendarID = cal.ID + return + } + } + + // Fall back to first calendar + if len(calendars) > 0 { + v.selectedCalendarID = calendars[0].ID + } + }) + }() } func (v *AvailabilityView) Refresh() { diff --git a/internal/tui/compose_actions.go b/internal/tui/compose_actions.go index affd0cb..62525cd 100644 --- a/internal/tui/compose_actions.go +++ b/internal/tui/compose_actions.go @@ -118,6 +118,7 @@ func (c *ComposeView) send() { // Send asynchronously c.app.Flash(FlashInfo, "Sending message...") + grantID := c.app.config.GrantID go func() { // Email send operations should complete within 30 seconds ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -133,15 +134,13 @@ func (c *ComposeView) send() { Subject: subject, Body: htmlBody, } - _, err = c.app.config.Client.UpdateDraft(ctx, c.app.config.GrantID, c.draft.ID, updateReq) + _, err = c.app.config.Client.UpdateDraft(ctx, grantID, c.draft.ID, updateReq) if err != nil { - c.app.QueueUpdateDraw(func() { - c.app.Flash(FlashError, "Failed to update draft: %v", err) - }) + c.app.Flash(FlashError, "Failed to update draft: %v", err) return } // Then send the draft - _, err = c.app.config.Client.SendDraft(ctx, c.app.config.GrantID, c.draft.ID, nil) + _, err = c.app.config.Client.SendDraft(ctx, grantID, c.draft.ID, nil) } else { // Normal send flow req := &domain.SendMessageRequest{ @@ -156,13 +155,11 @@ func (c *ComposeView) send() { req.ReplyToMsgID = c.replyToMsg.ID } - _, err = c.app.config.Client.SendMessage(ctx, c.app.config.GrantID, req) + _, err = c.app.config.Client.SendMessage(ctx, grantID, req) } if err != nil { - c.app.QueueUpdateDraw(func() { - c.app.Flash(FlashError, "Failed to send: %v", err) - }) + c.app.Flash(FlashError, "Failed to send: %v", err) return } @@ -294,6 +291,7 @@ func (c *ComposeView) saveDraft() { c.app.Flash(FlashInfo, "Saving draft...") + grantID := c.app.config.GrantID go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -301,16 +299,14 @@ func (c *ComposeView) saveDraft() { var err error if c.mode == ComposeModeDraft && c.draft != nil { // Update existing draft - _, err = c.app.config.Client.UpdateDraft(ctx, c.app.config.GrantID, c.draft.ID, req) + _, err = c.app.config.Client.UpdateDraft(ctx, grantID, c.draft.ID, req) } else { // Create new draft - _, err = c.app.config.Client.CreateDraft(ctx, c.app.config.GrantID, req) + _, err = c.app.config.Client.CreateDraft(ctx, grantID, req) } if err != nil { - c.app.QueueUpdateDraw(func() { - c.app.Flash(FlashError, "Failed to save draft: %v", err) - }) + c.app.Flash(FlashError, "Failed to save draft: %v", err) return } diff --git a/internal/tui/drafts.go b/internal/tui/drafts.go index 9c79b11..0e68e59 100644 --- a/internal/tui/drafts.go +++ b/internal/tui/drafts.go @@ -48,17 +48,29 @@ func NewDraftsView(app *App) *DraftsView { return v } +// Load fetches drafts in a background goroutine and applies the results on +// the event loop via QueueUpdateDraw. Must be called from the event loop; +// it is non-blocking. func (v *DraftsView) Load() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + grantID := v.app.config.GrantID - drafts, err := v.app.config.Client.GetDrafts(ctx, v.app.config.GrantID, 50) - if err != nil { - v.app.FlashLoadError("Failed to load drafts", err) - return - } - v.drafts = drafts - v.render() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + drafts, err := v.app.config.Client.GetDrafts(ctx, grantID, 50) + if err != nil { + v.app.FlashLoadError("Failed to load drafts", err) + return + } + v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } + v.drafts = drafts + v.render() + }) + }() } func (v *DraftsView) Refresh() { diff --git a/internal/tui/event_loop_safety_test.go b/internal/tui/event_loop_safety_test.go new file mode 100644 index 0000000..2c53bf4 --- /dev/null +++ b/internal/tui/event_loop_safety_test.go @@ -0,0 +1,474 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// startTestAppRunning starts the app's event loop on a simulation screen and +// registers cleanup. Tests use it to exercise code paths that marshal work +// through QueueUpdateDraw. +func startTestAppRunning(t *testing.T, app *App) { + t.Helper() + + screen := tcell.NewSimulationScreen("") + screen.SetSize(80, 24) + app.SetScreen(screen) + + runErr := make(chan error, 1) + go func() { + runErr <- app.Run() + }() + t.Cleanup(func() { + app.Stop() + select { + case err := <-runErr: + if err != nil { + t.Errorf("app.Run() returned error: %v", err) + } + case <-time.After(time.Second): + t.Log("app did not stop before cleanup timeout") + } + }) + + // Wait for the event loop to start processing updates. + ready := make(chan struct{}) + go func() { + app.QueueUpdateDraw(func() { close(ready) }) + }() + select { + case <-ready: + case <-time.After(time.Second): + t.Fatal("timed out waiting for TUI event loop") + } +} + +// runOnEventLoop runs fn on the event loop and waits for it to complete. +func runOnEventLoop(t *testing.T, app *App, fn func()) { + t.Helper() + + done := make(chan struct{}) + go func() { + app.QueueUpdateDraw(func() { + fn() + close(done) + }) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timed out waiting for event loop update") + } +} + +// TestFlashFromBackgroundGoroutines verifies App.Flash is safe to call bare +// from background goroutines: the status mutation must be marshaled onto the +// event loop instead of racing the status ticker and input handlers. +func TestFlashFromBackgroundGoroutines(t *testing.T) { + app := createTestApp(t) + startTestAppRunning(t, app) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + app.Flash(FlashInfo, "background flash %d", n) + }(i) + } + wg.Wait() + + deadline := time.After(time.Second) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-deadline: + t.Fatal("expected background flash to be visible in status bar") + case <-ticker.C: + if strings.Contains(readStatusText(t, app), "background flash") { + return + } + } + } +} + +// TestFlashFromEventLoopDoesNotDeadlock guards the QueueUpdateDraw +// re-entrancy hazard: tview's QueueUpdate blocks until the event loop runs +// the update, so Flash must not call it synchronously from event-loop +// callbacks (input handlers, queued updates). +func TestFlashFromEventLoopDoesNotDeadlock(t *testing.T) { + app := createTestApp(t) + startTestAppRunning(t, app) + + runOnEventLoop(t, app, func() { + app.Flash(FlashWarn, "flash from event loop") + }) +} + +// TestWebhookServerViewRecordEventConcurrent verifies webhook events arriving +// on server goroutines are applied on the event loop: concurrent recordEvent +// calls must not race the renderers and the buffer must stay capped at +// maxEvents. +func TestWebhookServerViewRecordEventConcurrent(t *testing.T) { + app := createTestApp(t) + startTestAppRunning(t, app) + + view := NewWebhookServerView(app) + + total := view.maxEvents + 10 // exceed maxEvents to exercise truncation + var wg sync.WaitGroup + for i := 0; i < total; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + view.recordEvent(&ports.WebhookEvent{ + Type: fmt.Sprintf("message.created.%d", n), + ReceivedAt: time.Now(), + }) + }(i) + } + wg.Wait() + + var got int + runOnEventLoop(t, app, func() { got = len(view.events) }) + if got != view.maxEvents { + t.Fatalf("events length = %d, want %d (capped at maxEvents)", got, view.maxEvents) + } +} + +// TestMessagesViewLoadSnapshotsGrantID verifies the fetch/apply split +// snapshots app.config.GrantID on the event loop before spawning the fetch +// goroutine. A regression that reads v.app.config.GrantID inside the +// goroutine would race a concurrent grant switch (caught by -race) and could +// fetch with the wrong grant (caught by the assertion below). +func TestMessagesViewLoadSnapshotsGrantID(t *testing.T) { + app := createTestApp(t) + mock, ok := app.config.Client.(*nylas.MockClient) + if !ok { + t.Fatal("test app client is not a MockClient") + } + + gotGrant := make(chan string, 1) + mock.GetThreadsFunc = func(ctx context.Context, grantID string, params *domain.ThreadQueryParams) ([]domain.Thread, error) { + gotGrant <- grantID + return nil, nil + } + + view := NewMessagesView(app) + view.folderPanel.folders = []domain.Folder{{ID: "INBOX", Name: "Inbox"}} + + startTestAppRunning(t, app) + + // Call Load and switch the grant in the SAME event-loop callback: the + // snapshot is taken synchronously inside Load, so the fetch must still + // use grant-A even though the config changed before the goroutine ran. + runOnEventLoop(t, app, func() { + app.config.GrantID = "grant-A" + view.Load() + app.config.GrantID = "grant-B" + }) + + select { + case grantID := <-gotGrant: + if grantID != "grant-A" { + t.Fatalf("fetch used grant %q, want snapshot %q taken when Load was called", grantID, "grant-A") + } + case <-time.After(time.Second): + t.Fatal("GetThreads was never called") + } +} + +// TestMessagesViewLoadDropsStaleGrantData verifies the apply callback drops +// in-flight results after a grant switch: if the user switches grants while a +// fetch is in flight, the old grant's data must never be written into the +// view (cross-account data leak). +func TestMessagesViewLoadDropsStaleGrantData(t *testing.T) { + app := createTestApp(t) + mock, ok := app.config.Client.(*nylas.MockClient) + if !ok { + t.Fatal("test app client is not a MockClient") + } + + release := make(chan struct{}) + returned := make(chan struct{}) + mock.GetThreadsFunc = func(ctx context.Context, grantID string, params *domain.ThreadQueryParams) ([]domain.Thread, error) { + <-release // hold the fetch in flight until the grant switch happened + defer close(returned) + return []domain.Thread{{ID: "thread-A", Subject: "Grant A secret"}}, nil + } + + view := NewMessagesView(app) + view.folderPanel.folders = []domain.Folder{{ID: "INBOX", Name: "Inbox"}} + + startTestAppRunning(t, app) + + // Start a load for grant-A. + runOnEventLoop(t, app, func() { + app.config.GrantID = "grant-A" + view.Load() + }) + + // Switch to grant-B while the grant-A fetch is still in flight, then let + // the fetch complete. + runOnEventLoop(t, app, func() { app.config.GrantID = "grant-B" }) + close(release) + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("GetThreads never returned") + } + + // The apply callback is queued right after GetThreads returns. Poll a few + // event-loop turns and assert grant-A's data is never rendered. + deadline := time.After(300 * time.Millisecond) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + var threadCount, rowCount int + runOnEventLoop(t, app, func() { + threadCount = len(view.threads) + rowCount = view.table.GetRowCount() + }) + if threadCount != 0 { + t.Fatalf("view shows %d threads from grant-A after switching to grant-B; stale apply must be dropped", threadCount) + } + if rowCount > 1 { // header row only + t.Fatalf("table rendered %d rows with grant-A data after switching to grant-B", rowCount) + } + + select { + case <-deadline: + return + case <-ticker.C: + } + } +} + +// TestMessagesViewLoadDropsStaleFolderData verifies the apply callback drops +// in-flight results after a folder switch: selecting folders quickly must +// never show one folder's threads under another folder's title. +func TestMessagesViewLoadDropsStaleFolderData(t *testing.T) { + app := createTestApp(t) + mock, ok := app.config.Client.(*nylas.MockClient) + if !ok { + t.Fatal("test app client is not a MockClient") + } + + release := make(chan struct{}) + returned := make(chan struct{}) + mock.GetThreadsFunc = func(ctx context.Context, grantID string, params *domain.ThreadQueryParams) ([]domain.Thread, error) { + <-release // hold folder-A's fetch in flight until the folder switch happened + defer close(returned) + return []domain.Thread{{ID: "thread-A", Subject: "Folder A thread"}}, nil + } + + view := NewMessagesView(app) + view.folderPanel.folders = []domain.Folder{ + {ID: "folder-A", Name: "Folder A"}, + {ID: "folder-B", Name: "Folder B"}, + } + + startTestAppRunning(t, app) + + // Start a load for folder-A. + runOnEventLoop(t, app, func() { + view.currentFolderID = "folder-A" + view.Load() + }) + + // Select folder-B while folder-A's fetch is still in flight, then let + // folder-A's fetch complete. + runOnEventLoop(t, app, func() { view.currentFolderID = "folder-B" }) + close(release) + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("GetThreads never returned") + } + + // The stale apply callback is queued right after GetThreads returns. + // Poll a few event-loop turns and assert folder-A's threads are never + // rendered. + deadline := time.After(300 * time.Millisecond) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + var threadCount int + runOnEventLoop(t, app, func() { threadCount = len(view.threads) }) + if threadCount != 0 { + t.Fatalf("view shows %d threads from folder-A after switching to folder-B; stale apply must be dropped", threadCount) + } + + select { + case <-deadline: + return + case <-ticker.C: + } + } +} + +// TestEventsViewLoadDropsStaleCalendarData verifies the apply callback drops +// in-flight results after a calendar switch: paging quickly through calendars +// must never let an earlier slow fetch overwrite the newly selected +// calendar's events. +func TestEventsViewLoadDropsStaleCalendarData(t *testing.T) { + app := createTestApp(t) + mock, ok := app.config.Client.(*nylas.MockClient) + if !ok { + t.Fatal("test app client is not a MockClient") + } + + release := make(chan struct{}) + returned := make(chan struct{}) + mock.GetEventsFunc = func(ctx context.Context, grantID, calendarID string, params *domain.EventQueryParams) ([]domain.Event, error) { + if calendarID != "cal-A" { + return nil, nil // cal-B load completes immediately + } + <-release // hold cal-A's fetch in flight until the calendar switch happened + defer close(returned) + return []domain.Event{{ID: "event-A", CalendarID: "cal-A", Title: "Calendar A event"}}, nil + } + + view := NewEventsView(app) + + startTestAppRunning(t, app) + + // Seed calendars and start a load for cal-A. + runOnEventLoop(t, app, func() { + view.calendar.SetCalendars([]domain.Calendar{{ID: "cal-A", Name: "A"}, {ID: "cal-B", Name: "B"}}) + view.loadEventsForCalendar("cal-A") + }) + + // Switch to cal-B while cal-A's fetch is still in flight (NextCalendar + // kicks off cal-B's load, which returns immediately), then let cal-A's + // fetch complete. + runOnEventLoop(t, app, func() { view.calendar.NextCalendar() }) + close(release) + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("GetEvents never returned") + } + + // The stale apply callback is queued right after GetEvents returns. Poll + // a few event-loop turns and assert cal-A's events are never rendered. + deadline := time.After(300 * time.Millisecond) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + var eventCount, widgetCount int + runOnEventLoop(t, app, func() { + eventCount = len(view.events) + widgetCount = len(view.calendar.events) + }) + if eventCount != 0 { + t.Fatalf("view shows %d events from cal-A after switching to cal-B; stale apply must be dropped", eventCount) + } + if widgetCount != 0 { + t.Fatalf("calendar widget shows %d events from cal-A after switching to cal-B; stale apply must be dropped", widgetCount) + } + + select { + case <-deadline: + return + case <-ticker.C: + } + } +} + +// TestAvailabilityViewLoadRendersBeforeGrantFetch verifies Load renders the +// current (empty) state synchronously when it spawns the current-user grant +// fetch: the info panel must reflect the view's settings immediately instead +// of staying stale/blank until the fetch returns. +func TestAvailabilityViewLoadRendersBeforeGrantFetch(t *testing.T) { + app := createTestApp(t) + mock, ok := app.config.Client.(*nylas.MockClient) + if !ok { + t.Fatal("test app client is not a MockClient") + } + + release := make(chan struct{}) + defer close(release) + mock.GetGrantFunc = func(ctx context.Context, grantID string) (*domain.Grant, error) { + <-release // keep the grant fetch in flight for the whole test + return nil, context.Canceled + } + + view := NewAvailabilityView(app) + + startTestAppRunning(t, app) + + // Change a setting and Load with no participants: the synchronous render + // must pick up the new duration even though the grant fetch never returns. + runOnEventLoop(t, app, func() { + view.duration = 45 + view.Load() + }) + + var infoText string + runOnEventLoop(t, app, func() { infoText = view.infoPanel.GetText(true) }) + if !strings.Contains(infoText, "45 min") { + t.Fatalf("info panel = %q, want it rendered synchronously with duration 45 min", infoText) + } +} + +// TestMessagesViewLoadAppliesThreadsViaEventLoop verifies the Load split: the +// network fetch runs off the event loop (Load returns immediately) and the +// fetched threads are applied through QueueUpdateDraw so the view state is +// only mutated on the event loop. +func TestMessagesViewLoadAppliesThreadsViaEventLoop(t *testing.T) { + app := createTestApp(t) + mock, ok := app.config.Client.(*nylas.MockClient) + if !ok { + t.Fatal("test app client is not a MockClient") + } + mock.GetThreadsFunc = func(ctx context.Context, grantID string, params *domain.ThreadQueryParams) ([]domain.Thread, error) { + return []domain.Thread{ + {ID: "thread-1", Subject: "First"}, + {ID: "thread-2", Subject: "Second"}, + }, nil + } + + view := NewMessagesView(app) + // Pre-populate folders so Load skips the folder fetch; the mock client + // records call metadata without locking, so the test keeps client calls + // sequential. + view.folderPanel.folders = []domain.Folder{{ID: "INBOX", Name: "Inbox"}} + + startTestAppRunning(t, app) + + runOnEventLoop(t, app, func() { view.Load() }) + + deadline := time.After(time.Second) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + var threadCount, rowCount int + runOnEventLoop(t, app, func() { + threadCount = len(view.threads) + rowCount = view.table.GetRowCount() + }) + if threadCount == 2 { + if rowCount < 2 { // both fetched threads rendered + t.Fatalf("table row count = %d, want >= 2 after render", rowCount) + } + return + } + + select { + case <-deadline: + t.Fatalf("threads not applied on event loop: got %d, want 2", threadCount) + case <-ticker.C: + } + } +} diff --git a/internal/tui/flash_helpers.go b/internal/tui/flash_helpers.go index 71a161c..7e98837 100644 --- a/internal/tui/flash_helpers.go +++ b/internal/tui/flash_helpers.go @@ -1,16 +1,9 @@ package tui -import "fmt" - // FlashLoadError flashes an error from a Load() call in the status bar. // Safe to call from any goroutine, including from inside a QueueUpdateDraw -// callback — the inner QueueUpdateDraw is dispatched in a new goroutine -// so callers never block the event loop waiting on itself. +// callback — Flash dispatches the status mutation in a new goroutine so +// callers never block the event loop waiting on itself. func (a *App) FlashLoadError(fallback string, err error) { - msg := fmt.Sprintf("%s: %v", fallback, err) - go func() { - a.QueueUpdateDraw(func() { - a.status.Flash(FlashError, msg) - }) - }() + a.Flash(FlashError, "%s: %v", fallback, err) } diff --git a/internal/tui/folder_panel.go b/internal/tui/folder_panel.go index 27d3f72..6dfceae 100644 --- a/internal/tui/folder_panel.go +++ b/internal/tui/folder_panel.go @@ -100,19 +100,30 @@ func (p *FolderPanel) handleInput(event *tcell.EventKey) *tcell.EventKey { return event } -// Load fetches folders from the API. +// Load fetches folders from the API in a background goroutine and applies +// the results on the event loop via QueueUpdateDraw. Must be called from +// the event loop; it is non-blocking. func (p *FolderPanel) Load() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + grantID := p.app.config.GrantID - folders, err := p.app.config.Client.GetFolders(ctx, p.app.config.GrantID) - if err != nil { - p.app.FlashLoadError("Failed to load folders", err) - return - } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + folders, err := p.app.config.Client.GetFolders(ctx, grantID) + if err != nil { + p.app.FlashLoadError("Failed to load folders", err) + return + } - p.folders = folders - p.render() + p.app.QueueUpdateDraw(func() { + if !p.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } + p.folders = folders + p.render() + }) + }() } func (p *FolderPanel) render() { diff --git a/internal/tui/views_contacts.go b/internal/tui/views_contacts.go index 0cdf633..0f0e665 100644 --- a/internal/tui/views_contacts.go +++ b/internal/tui/views_contacts.go @@ -40,16 +40,28 @@ func NewContactsView(app *App) *ContactsView { return v } +// Load fetches contacts in a background goroutine and applies the results on +// the event loop via QueueUpdateDraw. Must be called from the event loop; +// it is non-blocking. func (v *ContactsView) Load() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - contacts, err := v.app.config.Client.GetContacts(ctx, v.app.config.GrantID, nil) - if err != nil { - v.app.FlashLoadError("Failed to load contacts", err) - return - } - v.contacts = contacts - v.render() + grantID := v.app.config.GrantID + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + contacts, err := v.app.config.Client.GetContacts(ctx, grantID, nil) + if err != nil { + v.app.FlashLoadError("Failed to load contacts", err) + return + } + v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } + v.contacts = contacts + v.render() + }) + }() } func (v *ContactsView) Refresh() { v.Load() } diff --git a/internal/tui/views_events.go b/internal/tui/views_events.go index f334597..3a7c2df 100644 --- a/internal/tui/views_events.go +++ b/internal/tui/views_events.go @@ -74,67 +74,91 @@ func (v *EventsView) Hints() []Hint { } } +// Load fetches calendars in a background goroutine and applies the results on +// the event loop via QueueUpdateDraw. Must be called from the event loop; +// it is non-blocking. func (v *EventsView) Load() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + grantID := v.app.config.GrantID - // Get calendars first - calendars, err := v.app.config.Client.GetCalendars(ctx, v.app.config.GrantID) - if err != nil { - v.app.FlashLoadError("Failed to load calendars", err) - return - } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Get calendars first + calendars, err := v.app.config.Client.GetCalendars(ctx, grantID) + if err != nil { + v.app.FlashLoadError("Failed to load calendars", err) + return + } - v.calendars = calendars - v.calendar.SetCalendars(calendars) + v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } + v.calendars = calendars + v.calendar.SetCalendars(calendars) - if len(calendars) == 0 { - v.app.Flash(FlashWarn, "No calendars found") - return - } + if len(calendars) == 0 { + v.app.Flash(FlashWarn, "No calendars found") + return + } - // Load events for the current calendar - v.loadEventsForCalendar(v.calendar.GetCurrentCalendarID()) + // Load events for the current calendar + v.loadEventsForCalendar(v.calendar.GetCurrentCalendarID()) + }) + }() } +// loadEventsForCalendar fetches events in a background goroutine and applies +// the results on the event loop via QueueUpdateDraw. Must be called from the +// event loop; it is non-blocking. func (v *EventsView) loadEventsForCalendar(calendarID string) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Get events from selected calendar (fetch 2 months range) - now := time.Now() - startTime := now.AddDate(0, -1, 0).Unix() - endTime := now.AddDate(0, 2, 0).Unix() - - events, err := v.app.config.Client.GetEvents(ctx, v.app.config.GrantID, calendarID, &domain.EventQueryParams{ - Start: startTime, - End: endTime, - ExpandRecurring: true, - Limit: 200, - }) - if err != nil { - v.app.FlashLoadError("Failed to load events", err) - return - } + grantID := v.app.config.GrantID - v.events = events - v.calendar.SetEvents(events) - v.updateEventsList(v.calendar.GetSelectedDate()) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Get events from selected calendar (fetch 2 months range) + now := time.Now() + startTime := now.AddDate(0, -1, 0).Unix() + endTime := now.AddDate(0, 2, 0).Unix() + + events, err := v.app.config.Client.GetEvents(ctx, grantID, calendarID, &domain.EventQueryParams{ + Start: startTime, + End: endTime, + ExpandRecurring: true, + Limit: 200, + }) + if err != nil { + v.app.FlashLoadError("Failed to load events", err) + return + } - // Show calendar name in flash - if cal := v.calendar.GetCurrentCalendar(); cal != nil { - v.app.Flash(FlashInfo, "Calendar: %s (%d events)", cal.Name, len(events)) - } + v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } + if v.calendar.GetCurrentCalendarID() != calendarID { + return // calendar switched while fetch was in flight; drop stale data + } + v.events = events + v.calendar.SetEvents(events) + v.updateEventsList(v.calendar.GetSelectedDate()) + + // Show calendar name in flash + if cal := v.calendar.GetCurrentCalendar(); cal != nil { + v.app.Flash(FlashInfo, "Calendar: %s (%d events)", cal.Name, len(events)) + } + }) + }() } func (v *EventsView) Refresh() { v.Load() } func (v *EventsView) onCalendarChange(calendarID string) { - // Reload events for the new calendar - go func() { - v.loadEventsForCalendar(calendarID) - v.app.QueueUpdateDraw(func() {}) - }() + // Reload events for the new calendar (non-blocking) + v.loadEventsForCalendar(calendarID) } func (v *EventsView) onDateSelect(date time.Time) { @@ -332,10 +356,7 @@ func (v *EventsView) createNewEvent() { } v.app.ShowEventForm(calendarID, nil, func(event *domain.Event) { - // Refresh events after creation - go func() { - v.loadEventsForCalendar(calendarID) - v.app.QueueUpdateDraw(func() {}) - }() + // Refresh events after creation (non-blocking) + v.loadEventsForCalendar(calendarID) }) } diff --git a/internal/tui/views_events_detail.go b/internal/tui/views_events_detail.go index 3382c52..8c1a850 100644 --- a/internal/tui/views_events_detail.go +++ b/internal/tui/views_events_detail.go @@ -85,10 +85,7 @@ func (v *EventsView) showDayDetail() { } else { v.app.PopDetail() v.app.ShowEventForm(calendarID, &evt, func(updatedEvent *domain.Event) { - go func() { - v.loadEventsForCalendar(calendarID) - v.app.QueueUpdateDraw(func() {}) - }() + v.loadEventsForCalendar(calendarID) }) } } @@ -103,10 +100,7 @@ func (v *EventsView) showDayDetail() { } else { v.app.PopDetail() v.app.DeleteEvent(calendarID, &evt, func() { - go func() { - v.loadEventsForCalendar(calendarID) - v.app.QueueUpdateDraw(func() {}) - }() + v.loadEventsForCalendar(calendarID) }) } } diff --git a/internal/tui/views_events_recurring.go b/internal/tui/views_events_recurring.go index ca09bb3..8484daa 100644 --- a/internal/tui/views_events_recurring.go +++ b/internal/tui/views_events_recurring.go @@ -32,10 +32,7 @@ func (v *EventsView) showRecurringEventEditDialog(calendarID string, evt *domain // For editing a single occurrence, we pass the event as-is // The API will handle creating an exception v.app.ShowEventForm(calendarID, &eventCopy, func(updatedEvent *domain.Event) { - go func() { - v.loadEventsForCalendar(calendarID) - v.app.QueueUpdateDraw(func() {}) - }() + v.loadEventsForCalendar(calendarID) }) }) @@ -50,10 +47,7 @@ func (v *EventsView) showRecurringEventEditDialog(calendarID string, evt *domain v.app.Flash(FlashInfo, "Editing series from instance...") } v.app.ShowEventForm(calendarID, editEvt, func(updatedEvent *domain.Event) { - go func() { - v.loadEventsForCalendar(calendarID) - v.app.QueueUpdateDraw(func() {}) - }() + v.loadEventsForCalendar(calendarID) }) }) @@ -99,10 +93,7 @@ func (v *EventsView) showRecurringEventDeleteDialog(calendarID string, evt *doma fmt.Sprintf("Delete this occurrence of '%s'?", eventCopy.Title), func() { v.app.DeleteEvent(calendarID, &eventCopy, func() { - go func() { - v.loadEventsForCalendar(calendarID) - v.app.QueueUpdateDraw(func() {}) - }() + v.loadEventsForCalendar(calendarID) }) }) }) @@ -119,10 +110,7 @@ func (v *EventsView) showRecurringEventDeleteDialog(calendarID string, evt *doma deleteEvt = &domain.Event{ID: eventCopy.MasterEventID} } v.app.DeleteEvent(calendarID, deleteEvt, func() { - go func() { - v.loadEventsForCalendar(calendarID) - v.app.QueueUpdateDraw(func() {}) - }() + v.loadEventsForCalendar(calendarID) }) }) }) diff --git a/internal/tui/views_grants.go b/internal/tui/views_grants.go index a00e5b7..eab0d75 100644 --- a/internal/tui/views_grants.go +++ b/internal/tui/views_grants.go @@ -42,16 +42,23 @@ func NewGrantsView(app *App) *GrantsView { return v } +// Load fetches grants in a background goroutine and applies the results on +// the event loop via QueueUpdateDraw. Must be called from the event loop; +// it is non-blocking. func (v *GrantsView) Load() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - grants, err := v.app.config.Client.ListGrants(ctx) - if err != nil { - v.app.FlashLoadError("Failed to load grants", err) - return - } - v.grants = grants - v.render() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + grants, err := v.app.config.Client.ListGrants(ctx) + if err != nil { + v.app.FlashLoadError("Failed to load grants", err) + return + } + v.app.QueueUpdateDraw(func() { + v.grants = grants + v.render() + }) + }() } func (v *GrantsView) Refresh() { v.Load() } diff --git a/internal/tui/views_messages.go b/internal/tui/views_messages.go index 1b3be54..16e3333 100644 --- a/internal/tui/views_messages.go +++ b/internal/tui/views_messages.go @@ -85,34 +85,53 @@ func NewMessagesView(app *App) *MessagesView { return v } -func (v *MessagesView) Load() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() +// effectiveFolderID returns the folder used for thread queries; an empty +// selection means INBOX. +func (v *MessagesView) effectiveFolderID() string { + if v.currentFolderID == "" { + return "INBOX" + } + return v.currentFolderID +} +// Load fetches threads in a background goroutine and applies the results on +// the event loop via QueueUpdateDraw. Must be called from the event loop; +// it is non-blocking. +func (v *MessagesView) Load() { // Load folders if not already loaded if len(v.folderPanel.folders) == 0 { v.folderPanel.Load() } - // Build folder filter - use folder ID if set, otherwise default to INBOX - var folderFilter []string - if v.currentFolderID != "" { - folderFilter = []string{v.currentFolderID} - } else { - folderFilter = []string{"INBOX"} - } + // Snapshot the folder the request is for; an empty selection means INBOX + requestedFolderID := v.effectiveFolderID() + folderFilter := []string{requestedFolderID} + grantID := v.app.config.GrantID - params := &domain.ThreadQueryParams{ - Limit: 50, - In: folderFilter, - } - threads, err := v.app.config.Client.GetThreads(ctx, v.app.config.GrantID, params) - if err != nil { - v.app.FlashLoadError("Failed to load threads", err) - return - } - v.threads = threads - v.render() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + params := &domain.ThreadQueryParams{ + Limit: 50, + In: folderFilter, + } + threads, err := v.app.config.Client.GetThreads(ctx, grantID, params) + if err != nil { + v.app.FlashLoadError("Failed to load threads", err) + return + } + v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } + if v.effectiveFolderID() != requestedFolderID { + return // folder switched while fetch was in flight; drop stale data + } + v.threads = threads + v.render() + }) + }() } // updateLayout rebuilds the layout based on folder panel visibility. diff --git a/internal/tui/views_messages_actions.go b/internal/tui/views_messages_actions.go index 251bb41..f69ade8 100644 --- a/internal/tui/views_messages_actions.go +++ b/internal/tui/views_messages_actions.go @@ -26,11 +26,12 @@ func (v *MessagesView) toggleStar() { return } + grantID := v.app.config.GrantID go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() newStarred := !thread.Starred - _, err := v.app.config.Client.UpdateThread(ctx, v.app.config.GrantID, thread.ID, &domain.UpdateMessageRequest{ + _, err := v.app.config.Client.UpdateThread(ctx, grantID, thread.ID, &domain.UpdateMessageRequest{ Starred: &newStarred, }) if err != nil { @@ -55,11 +56,12 @@ func (v *MessagesView) markUnread() { return } + grantID := v.app.config.GrantID go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() unread := true - _, err := v.app.config.Client.UpdateThread(ctx, v.app.config.GrantID, thread.ID, &domain.UpdateMessageRequest{ + _, err := v.app.config.Client.UpdateThread(ctx, grantID, thread.ID, &domain.UpdateMessageRequest{ Unread: &unread, }) if err != nil { @@ -78,11 +80,8 @@ func (v *MessagesView) showCompose(mode ComposeMode, replyTo *domain.Message) { compose.SetOnSent(func() { v.app.PopDetail() - // Refresh messages to show the sent message - go func() { - v.Load() - v.app.QueueUpdateDraw(func() {}) - }() + // Refresh messages to show the sent message (non-blocking) + v.Load() }) compose.SetOnCancel(func() { @@ -157,15 +156,14 @@ func (v *MessagesView) showDownloadDialog() { func (v *MessagesView) downloadAttachment(messageID, attachmentID, filename string, displayNum int) { v.app.Flash(FlashInfo, "Downloading %s...", filename) + grantID := v.app.config.GrantID go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - reader, err := v.app.config.Client.DownloadAttachment(ctx, v.app.config.GrantID, messageID, attachmentID) + reader, err := v.app.config.Client.DownloadAttachment(ctx, grantID, messageID, attachmentID) if err != nil { - v.app.QueueUpdateDraw(func() { - v.app.Flash(FlashError, "Download failed: %v", err) - }) + v.app.Flash(FlashError, "Download failed: %v", err) return } defer func() { _ = reader.Close() }() @@ -173,35 +171,27 @@ func (v *MessagesView) downloadAttachment(messageID, attachmentID, filename stri // Get Downloads directory homeDir, err := os.UserHomeDir() if err != nil { - v.app.QueueUpdateDraw(func() { - v.app.Flash(FlashError, "Cannot find home directory: %v", err) - }) + v.app.Flash(FlashError, "Cannot find home directory: %v", err) return } downloadDir := filepath.Join(homeDir, "Downloads") // Ensure download directory exists if err := os.MkdirAll(downloadDir, 0750); err != nil { - v.app.QueueUpdateDraw(func() { - v.app.Flash(FlashError, "Cannot create Downloads directory: %v", err) - }) + v.app.Flash(FlashError, "Cannot create Downloads directory: %v", err) return } // Create file with unique name if exists destPath, err := safeAttachmentDownloadPath(downloadDir, filename) if err != nil { - v.app.QueueUpdateDraw(func() { - v.app.Flash(FlashError, "Cannot create file: %v", err) - }) + v.app.Flash(FlashError, "Cannot create file: %v", err) return } // #nosec G304 -- destPath is sanitized and constrained to downloadDir by safeAttachmentDownloadPath. file, destPath, err := v.createUniqueAttachmentFile(destPath) if err != nil { - v.app.QueueUpdateDraw(func() { - v.app.Flash(FlashError, "Cannot create file: %v", err) - }) + v.app.Flash(FlashError, "Cannot create file: %v", err) return } defer func() { _ = file.Close() }() @@ -209,9 +199,7 @@ func (v *MessagesView) downloadAttachment(messageID, attachmentID, filename stri // Copy content written, err := io.Copy(file, reader) if err != nil { - v.app.QueueUpdateDraw(func() { - v.app.Flash(FlashError, "Download failed: %v", err) - }) + v.app.Flash(FlashError, "Download failed: %v", err) return } diff --git a/internal/tui/views_messages_detail.go b/internal/tui/views_messages_detail.go index 8766689..eec9543 100644 --- a/internal/tui/views_messages_detail.go +++ b/internal/tui/views_messages_detail.go @@ -43,6 +43,7 @@ func (v *MessagesView) showDetail(thread *domain.Thread) { _, _ = fmt.Fprintf(detail, "[%s]Loading messages...[-]\n\n", muted) // Fetch all messages in the thread asynchronously + grantID := v.app.config.GrantID go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -50,13 +51,16 @@ func (v *MessagesView) showDetail(thread *domain.Thread) { // Fetch each message in the thread var messages []*domain.Message for _, msgID := range thread.MessageIDs { - msg, err := v.app.config.Client.GetMessage(ctx, v.app.config.GrantID, msgID) + msg, err := v.app.config.Client.GetMessage(ctx, grantID, msgID) if err == nil { messages = append(messages, msg) } } v.app.QueueUpdateDraw(func() { + if !v.app.grantStillCurrent(grantID) { + return // grant switched while fetch was in flight; drop stale data + } detail.Clear() // Clear attachments list diff --git a/internal/tui/views_webhooks.go b/internal/tui/views_webhooks.go index 08ca278..671dfd0 100644 --- a/internal/tui/views_webhooks.go +++ b/internal/tui/views_webhooks.go @@ -42,16 +42,23 @@ func NewWebhooksView(app *App) *WebhooksView { return v } +// Load fetches webhooks in a background goroutine and applies the results on +// the event loop via QueueUpdateDraw. Must be called from the event loop; +// it is non-blocking. func (v *WebhooksView) Load() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - webhooks, err := v.app.config.Client.ListWebhooks(ctx) - if err != nil { - v.app.FlashLoadError("Failed to load webhooks", err) - return - } - v.webhooks = webhooks - v.render() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + webhooks, err := v.app.config.Client.ListWebhooks(ctx) + if err != nil { + v.app.FlashLoadError("Failed to load webhooks", err) + return + } + v.app.QueueUpdateDraw(func() { + v.webhooks = webhooks + v.render() + }) + }() } func (v *WebhooksView) Refresh() { v.Load() } diff --git a/internal/tui/webhook_server.go b/internal/tui/webhook_server.go index abe9c95..dec2a41 100644 --- a/internal/tui/webhook_server.go +++ b/internal/tui/webhook_server.go @@ -184,16 +184,7 @@ func (v *WebhookServerView) startServer() { v.cancelFunc = cancel // Set up event handler - v.server.OnEvent(func(event *ports.WebhookEvent) { - v.events = append([]*ports.WebhookEvent{event}, v.events...) - if len(v.events) > v.maxEvents { - v.events = v.events[:v.maxEvents] - } - v.app.QueueUpdateDraw(func() { - v.renderEvents() - v.renderStatus() - }) - }) + v.server.OnEvent(v.recordEvent) // Start server in goroutine go func() { @@ -216,6 +207,20 @@ func (v *WebhookServerView) startServer() { }() } +// recordEvent prepends a webhook event and re-renders. It is invoked from +// the webhook server goroutine, so the slice mutation and rendering are both +// marshaled onto the event loop via QueueUpdateDraw. +func (v *WebhookServerView) recordEvent(event *ports.WebhookEvent) { + v.app.QueueUpdateDraw(func() { + v.events = append([]*ports.WebhookEvent{event}, v.events...) + if len(v.events) > v.maxEvents { + v.events = v.events[:v.maxEvents] + } + v.renderEvents() + v.renderStatus() + }) +} + func (v *WebhookServerView) stopServer() { if !v.serverRunning || v.server == nil { return diff --git a/internal/ui/server.go b/internal/ui/server.go index 9620a8a..1bbbda9 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -94,11 +94,12 @@ func (s *Server) Start() error { // Template-rendered index page mux.HandleFunc("/", s.handleIndex) - // Wrap with loopback-only host validation and same-origin protection. - // Without this, any visited webpage could POST to /api/exec or - // /api/config/setup on the local UI port. + // Wrap with loopback-only host validation, same-origin protection and + // security headers (strict CSP). Without this, any visited webpage could + // POST to /api/exec or /api/config/setup on the local UI port. handler := webguard.HostValidationMiddleware( - webguard.OriginProtectionMiddleware(mux)) + webguard.OriginProtectionMiddleware( + webguard.SecurityHeadersMiddleware(mux))) server := &http.Server{ Addr: s.addr, diff --git a/internal/ui/server_handlers_test.go b/internal/ui/server_handlers_test.go index 0cff5e2..8fa6b82 100644 --- a/internal/ui/server_handlers_test.go +++ b/internal/ui/server_handlers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "html/template" "net/http" "net/http/httptest" "path/filepath" @@ -461,6 +462,34 @@ func TestHandleIndex_NotFoundForNonRoot(t *testing.T) { } } +// TestHandleIndex_TemplateErrorIsGeneric verifies that template execution +// failures do not leak the raw error (data field paths, template internals) +// to the client; the real error is logged server-side instead. +func TestHandleIndex_TemplateErrorIsGeneric(t *testing.T) { + t.Parallel() + + // A base template referencing a field that does not exist on PageData + // fails at execution time. + tmpl := template.Must(template.New("base").Parse(`{{.NoSuchField}}`)) + server := &Server{demoMode: true, templates: tmpl} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + server.handleIndex(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("Expected status %d, got %d", http.StatusInternalServerError, w.Code) + } + body := w.Body.String() + if strings.Contains(body, "NoSuchField") || strings.Contains(body, "Template error") { + t.Errorf("Raw template error leaked to client: %q", body) + } + if !strings.Contains(body, "Failed to render page") { + t.Errorf("Expected generic error message, got %q", body) + } +} + // ============================================================================= // WriteJSON Helper Tests // ============================================================================= diff --git a/internal/ui/server_index.go b/internal/ui/server_index.go index ea3a1bf..f651804 100644 --- a/internal/ui/server_index.go +++ b/internal/ui/server_index.go @@ -2,6 +2,7 @@ package ui import ( "io/fs" + "log/slog" "net/http" ) @@ -22,10 +23,13 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { // Build page data data := s.buildPageData() - // Render template + // Render template. Template errors can include data field paths and + // snippets of upstream content; log the raw error and return a + // generic message to the client. w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, "base", data); err != nil { - http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) + slog.Error("template render failed", "template", "base", "err", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) } } diff --git a/internal/ui/server_security_test.go b/internal/ui/server_security_test.go index 114a9da..25411c1 100644 --- a/internal/ui/server_security_test.go +++ b/internal/ui/server_security_test.go @@ -2,8 +2,11 @@ package ui import ( "encoding/json" + "net" + "net/http" "strings" "testing" + "time" ) // ============================================================================= @@ -232,3 +235,67 @@ func TestSafeJSJSON_HandlesError(t *testing.T) { } // ============================================================================= +// Security Headers (server wiring) Tests +// ============================================================================= + +// freeLoopbackAddr reserves a loopback port and returns it as host:port. +func freeLoopbackAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + addr := ln.Addr().String() + if err := ln.Close(); err != nil { + t.Fatalf("release port: %v", err) + } + return addr +} + +// TestServerStart_SecurityHeaders verifies the running UI server actually +// serves the webguard security headers (strict CSP). The middleware is unit +// tested in webguard; this guards the wiring in Start(), which a refactor +// could silently drop without any other test failing. +func TestServerStart_SecurityHeaders(t *testing.T) { + server := NewDemoServer(freeLoopbackAddr(t)) + + // Start blocks on ListenAndServe and the server has no shutdown seam, so + // it runs until the test binary exits. + go func() { _ = server.Start() }() + + url := "http://" + server.addr + "/" + var resp *http.Response + deadline := time.Now().Add(5 * time.Second) + for { + var err error + resp, err = http.Get(url) // #nosec G107 -- loopback test URL + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("server at %s did not come up: %v", url, err) + } + time.Sleep(20 * time.Millisecond) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET / status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + csp := resp.Header.Get("Content-Security-Policy") + if csp == "" { + t.Fatal("UI server response is missing the CSP header — SecurityHeadersMiddleware not wired in Start()") + } + if !strings.Contains(csp, "script-src 'self';") { + t.Errorf("CSP must keep strict script-src 'self', got %q", csp) + } + if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("X-Content-Type-Options = %q, want %q", got, "nosniff") + } + if got := resp.Header.Get("X-Frame-Options"); got != "SAMEORIGIN" { + t.Errorf("X-Frame-Options = %q, want %q", got, "SAMEORIGIN") + } +} + +// ============================================================================= diff --git a/internal/ui/server_templates_test.go b/internal/ui/server_templates_test.go index 810c868..312ebf2 100644 --- a/internal/ui/server_templates_test.go +++ b/internal/ui/server_templates_test.go @@ -2,6 +2,9 @@ package ui import ( "bytes" + "encoding/json" + "regexp" + "strings" "testing" ) @@ -65,6 +68,66 @@ func TestLoadTemplates_FunctionsAvailable(t *testing.T) { } } +// TestBaseTemplate_InitialStateIsParseableJSON verifies the CSP-safe +// `, + Region: "us", + HasAPIKey: true, + DefaultGrant: "grant-1", + Grants: []Grant{ + {ID: "grant-1", Email: `evil"@example.com`, Provider: "google"}, + }, + Commands: GetDefaultCommands(), + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil { + t.Fatalf("ExecuteTemplate(base) failed: %v", err) + } + html := buf.String() + + if strings.Contains(html, "window.__INITIAL_STATE__") { + t.Error("inline executable initial-state script still present (blocked by CSP)") + } + + re := regexp.MustCompile(`(?s)`) + m := re.FindStringSubmatch(html) + if m == nil { + t.Fatal("initial-state JSON data block not found in rendered page") + } + + var state struct { + Configured bool `json:"configured"` + ClientID string `json:"clientID"` + Region string `json:"region"` + DefaultGrant string `json:"defaultGrant"` + Grants []Grant `json:"grants"` + } + if err := json.Unmarshal([]byte(m[1]), &state); err != nil { + t.Fatalf("initial-state block is not valid JSON: %v\nblock: %s", err, m[1]) + } + + if !state.Configured || state.Region != "us" || state.DefaultGrant != "grant-1" { + t.Errorf("initial state round-trip mismatch: %+v", state) + } + if state.ClientID != data.ClientID { + t.Errorf("ClientID round-trip mismatch: got %q want %q", state.ClientID, data.ClientID) + } +} + // ============================================================================= // Template Function Tests // ============================================================================= diff --git a/internal/ui/static/app.js b/internal/ui/static/app.js index 00ddc73..c385287 100644 --- a/internal/ui/static/app.js +++ b/internal/ui/static/app.js @@ -2,6 +2,19 @@ // Nylas CLI - Dashboard (Main Entry Point) // ============================================================================= +// readInitialState parses the server-rendered + + + diff --git a/internal/ui/templates/pages/admin.gohtml b/internal/ui/templates/pages/admin.gohtml index 96b28db..aba8693 100644 --- a/internal/ui/templates/pages/admin.gohtml +++ b/internal/ui/templates/pages/admin.gohtml @@ -18,14 +18,14 @@
nylas admin
- - - - - - - - - - - - - - - - + {{end}}
{{else}} @@ -113,19 +113,19 @@

QUICK COMMANDS

-
+
nylas auth status Check authentication
-
+
nylas email list List recent emails
-
+
nylas calendar list List calendars
-
+
nylas calendar events View calendar events
diff --git a/internal/ui/templates/pages/scheduler.gohtml b/internal/ui/templates/pages/scheduler.gohtml index 6415012..062079e 100644 --- a/internal/ui/templates/pages/scheduler.gohtml +++ b/internal/ui/templates/pages/scheduler.gohtml @@ -18,14 +18,14 @@
nylas scheduler
- - - - - - - - - - - - - - - - - -