From 15dd79ba71d2df0d3e361ccef33fe3698f140375 Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 16 Apr 2026 20:59:01 -0400 Subject: [PATCH] Remove inbound command and inbox provider surface --- .claude/commands/run-tests.md | 6 - CLAUDE.md | 2 +- Makefile | 43 +- cmd/nylas/main.go | 2 - docs/ARCHITECTURE.md | 3 +- docs/COMMANDS.md | 27 +- docs/INDEX.md | 4 +- docs/commands/agent.md | 9 - docs/commands/email.md | 4 +- docs/commands/inbound.md | 314 --------- internal/adapters/nylas/README.md | 1 - internal/adapters/nylas/demo_inbound.go | 135 ---- internal/adapters/nylas/demo_productivity.go | 2 - internal/adapters/nylas/inbound.go | 86 --- internal/adapters/nylas/inbound_crud_test.go | 395 ------------ .../adapters/nylas/inbound_messages_test.go | 251 -------- .../adapters/nylas/integration_grants_test.go | 1 - internal/adapters/nylas/managed_grants.go | 36 -- internal/adapters/nylas/mock_productivity.go | 64 -- internal/adapters/nylas/transactional.go | 2 +- internal/air/provider_support_test.go | 4 +- internal/cli/admin/connectors.go | 8 + internal/cli/agent/agent_test.go | 60 +- internal/cli/agent/policy.go | 34 +- .../cli/agent/policy_create_update_delete.go | 15 +- internal/cli/agent/rule.go | 26 +- internal/cli/auth/auth_test.go | 4 + internal/cli/auth/login.go | 5 +- internal/cli/auth/providers.go | 3 +- internal/cli/common/connectors.go | 46 ++ internal/cli/common/connectors_test.go | 59 ++ internal/cli/email/send_managed_test.go | 95 +-- internal/cli/email/signatures_support.go | 2 +- internal/cli/email/signatures_test.go | 24 - internal/cli/inbound/create.go | 89 --- internal/cli/inbound/delete.go | 105 --- internal/cli/inbound/helpers.go | 115 ---- internal/cli/inbound/inbound.go | 49 -- internal/cli/inbound/inbound_test.go | 493 -------------- internal/cli/inbound/list.go | 70 -- internal/cli/inbound/messages.go | 116 ---- internal/cli/inbound/monitor.go | 324 ---------- internal/cli/inbound/show.go | 63 -- internal/cli/integration/INDEX.md | 2 +- internal/cli/integration/admin_test.go | 13 + internal/cli/integration/agent_policy_test.go | 140 ---- internal/cli/integration/agent_rule_test.go | 62 +- internal/cli/integration/agent_test.go | 75 +++ .../cli/integration/auth_enhancements_test.go | 8 + .../cli/integration/inbound_removed_test.go | 74 +++ internal/cli/integration/inbound_test.go | 607 ------------------ .../cli/integration/local_regressions_test.go | 179 ++++++ internal/cli/integration/test.go | 22 +- internal/domain/advanced_test.go | 162 ----- internal/domain/basic_test.go | 1 - internal/domain/inbound.go | 42 -- internal/domain/provider.go | 5 +- internal/ports/inbound.go | 25 - internal/ports/nylas.go | 1 - internal/ports/transactional.go | 4 +- internal/tui/app_ui.go | 4 - internal/tui/commands_definitions.go | 6 - internal/tui/formatting_helpers.go | 106 +++ internal/tui/views_dashboard.go | 1 - internal/tui/views_inbound.go | 413 ------------ internal/tui/views_inbound_test.go | 210 ------ internal/ui/server_command_validation_test.go | 8 - internal/ui/server_defaults_test.go | 9 - internal/ui/server_demo.go | 19 - internal/ui/server_demo_test.go | 1 - internal/ui/server_exec.go | 7 - internal/ui/static/js/commands-core.js | 44 +- internal/ui/static/js/commands-inbound.js | 107 --- internal/ui/static/js/commands.js | 1 - internal/ui/static/js/params.js | 6 - internal/ui/templates.go | 9 - internal/ui/templates/base.gohtml | 1 - internal/ui/templates/pages/inbound.gohtml | 75 --- internal/ui/templates/partials/content.gohtml | 7 - tests/shared/helpers/ui-selectors.js | 2 - tests/ui/e2e/command-list-tests.spec.js | 8 - tests/ui/e2e/command-pages.spec.js | 1 - 82 files changed, 741 insertions(+), 4922 deletions(-) delete mode 100644 docs/commands/inbound.md delete mode 100644 internal/adapters/nylas/demo_inbound.go delete mode 100644 internal/adapters/nylas/inbound.go delete mode 100644 internal/adapters/nylas/inbound_crud_test.go delete mode 100644 internal/adapters/nylas/inbound_messages_test.go create mode 100644 internal/cli/common/connectors.go create mode 100644 internal/cli/common/connectors_test.go delete mode 100644 internal/cli/inbound/create.go delete mode 100644 internal/cli/inbound/delete.go delete mode 100644 internal/cli/inbound/helpers.go delete mode 100644 internal/cli/inbound/inbound.go delete mode 100644 internal/cli/inbound/inbound_test.go delete mode 100644 internal/cli/inbound/list.go delete mode 100644 internal/cli/inbound/messages.go delete mode 100644 internal/cli/inbound/monitor.go delete mode 100644 internal/cli/inbound/show.go create mode 100644 internal/cli/integration/inbound_removed_test.go delete mode 100644 internal/cli/integration/inbound_test.go delete mode 100644 internal/domain/inbound.go delete mode 100644 internal/ports/inbound.go create mode 100644 internal/tui/formatting_helpers.go delete mode 100644 internal/tui/views_inbound.go delete mode 100644 internal/tui/views_inbound_test.go delete mode 100644 internal/ui/static/js/commands-inbound.js delete mode 100644 internal/ui/templates/pages/inbound.gohtml diff --git a/.claude/commands/run-tests.md b/.claude/commands/run-tests.md index bdce8fa..57b9457 100644 --- a/.claude/commands/run-tests.md +++ b/.claude/commands/run-tests.md @@ -48,10 +48,6 @@ export NYLAS_API_KEY="your-api-key" export NYLAS_GRANT_ID="your-grant-id" export NYLAS_TEST_BINARY="$(pwd)/bin/nylas" -# For inbound tests -export NYLAS_INBOUND_GRANT_ID="your-inbound-inbox-id" -export NYLAS_INBOUND_EMAIL="your-inbox@nylas.email" - # For email send tests export NYLAS_TEST_SEND_EMAIL="true" # Enable send email tests export NYLAS_TEST_EMAIL="test@example.com" # Recipient for send tests @@ -60,8 +56,6 @@ export NYLAS_TEST_CC_EMAIL="cc@example.com" # CC recipient (optional) # Optional - for destructive tests export NYLAS_TEST_DELETE="true" # Enable delete tests (contacts, folders, webhooks, drafts, calendars) export NYLAS_TEST_DELETE_MESSAGE="true" # Enable email message delete tests -export NYLAS_TEST_CREATE_INBOUND="true" # Enable inbound inbox create tests -export NYLAS_TEST_DELETE_INBOUND="true" # Enable inbound inbox delete tests ``` Build binary first: diff --git a/CLAUDE.md b/CLAUDE.md index d4e1768..01a79a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config **Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/`, Chat at `internal/chat/` -**CLI packages:** admin, ai, audit, auth, calendar, config, contacts, email, inbound, mcp, notetaker, otp, scheduler, setup, slack, timezone, webhook +**CLI packages:** admin, ai, audit, auth, calendar, config, contacts, email, mcp, notetaker, otp, scheduler, setup, slack, timezone, webhook **Additional packages:** - `internal/ports/output.go` - OutputWriter interface for pluggable formatting diff --git a/Makefile b/Makefile index 44dbb07..7fe7e89 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test-unit test-race test-integration test-integration-fast test-cleanup test-coverage test-air test-air-integration test-e2e test-e2e-air test-e2e-ui test-playwright test-playwright-air test-playwright-ui test-playwright-interactive test-playwright-headed clean clean-cache install fmt vet lint vuln deps security check-context ci ci-full help +.PHONY: build test-unit test-race test-integration test-integration-fast test-cli-regressions test-integration-agent test-cleanup test-coverage test-air test-air-integration test-e2e test-e2e-air test-e2e-ui test-playwright test-playwright-air test-playwright-ui test-playwright-interactive test-playwright-headed clean clean-cache install fmt vet lint vuln deps security check-context ci ci-full help # Disable parallel Make execution - prevents Go build cache corruption on btrfs (CachyOS) .NOTPARALLEL: @@ -15,7 +15,7 @@ export NYLAS_API_KEY := $(patsubst "%",%,$(patsubst '%',%,$(NYLAS_API_KEY))) NYLAS_GRANT_ID := $(patsubst "%",%,$(patsubst '%',%,$(NYLAS_GRANT_ID))) NYLAS_CLIENT_ID := $(patsubst "%",%,$(patsubst '%',%,$(NYLAS_CLIENT_ID))) -NYLAS_INBOUND_GRANT_ID := $(patsubst "%",%,$(patsubst '%',%,$(NYLAS_INBOUND_GRANT_ID))) +NYLAS_AGENT_DOMAIN := $(patsubst "%",%,$(patsubst '%',%,$(NYLAS_AGENT_DOMAIN))) # Rate limit defaults (can be overridden in .env) NYLAS_TEST_RATE_LIMIT_RPS ?= 1.0 @@ -147,6 +147,35 @@ test-integration-fast: -run "TestCLI_Admin|TestCLI_Timezone|TestCLI_AIConfig|TestCLI_AIProvider|TestCLI_CalendarAI_Basic|TestCLI_CalendarAI_Adapt|TestCLI_CalendarAI_Analyze_Respects|TestCLI_CalendarAI_Analyze_Default|TestCLI_CalendarAI_Analyze_Disabled|TestCLI_CalendarAI_Analyze_Focus|TestCLI_CalendarAI_Analyze_With" \ ' +# Focused CLI regression checks for command removals and agent behavior. +# This makes ci-full explicitly verify the removed inbound surface and auth-level provider rejection. +test-cli-regressions: build + @echo "=== Running CLI Regression Checks ===" + @go clean -testcache + go test ./internal/cli/agent -v + NYLAS_DISABLE_KEYRING=true \ + NYLAS_TEST_RATE_LIMIT_RPS=$(NYLAS_TEST_RATE_LIMIT_RPS) \ + NYLAS_TEST_RATE_LIMIT_BURST=$(NYLAS_TEST_RATE_LIMIT_BURST) \ + NYLAS_TEST_BINARY=$(CURDIR)/bin/nylas \ + go test ./internal/cli/integration/... -tags=integration -v -timeout 10m -p 1 \ + -run 'TestCLI_(InboundRemoved|InboxAliasRemoved|HelpOmitsInbound|AuthLoginRejectsInboxProvider|ConnectorSurfaces_HideInboxProvider|AdminConnectorsCreate_RejectsInboxProvider|AdminConnectorsShow_HidesInboxProvider)$$' + @echo "✓ CLI regression checks passed" + +# Agent integration checks require explicit credentials plus an agent domain so the lifecycle suites do not self-skip. +test-integration-agent: build + @echo "=== Running Agent Integration Checks ===" + @: "$${NYLAS_API_KEY:?NYLAS_API_KEY is required for agent integration tests}" + @: "$${NYLAS_GRANT_ID:?NYLAS_GRANT_ID is required for agent integration tests}" + @: "$${NYLAS_AGENT_DOMAIN:?NYLAS_AGENT_DOMAIN is required for agent integration tests}" + @go clean -testcache + NYLAS_DISABLE_KEYRING=true \ + NYLAS_TEST_RATE_LIMIT_RPS=$(NYLAS_TEST_RATE_LIMIT_RPS) \ + NYLAS_TEST_RATE_LIMIT_BURST=$(NYLAS_TEST_RATE_LIMIT_BURST) \ + NYLAS_TEST_BINARY=$(CURDIR)/bin/nylas \ + go test ./internal/cli/integration/... -tags=integration -v -timeout 10m -p 1 \ + -run 'TestCLI_Agent.*$$' + @echo "✓ Agent integration checks passed" + # Clean up test resources (virtual calendars, test grants, test events, test emails, etc.) test-cleanup: @echo "=== Cleaning up test resources ===" @@ -313,6 +342,16 @@ ci-full: $(MAKE) --no-print-directory ci; \ echo ""; \ echo "================================="; \ + echo "Running CLI Regression Checks..."; \ + echo "================================="; \ + $(MAKE) --no-print-directory test-cli-regressions; \ + echo ""; \ + echo "================================="; \ + echo "Running Agent Integration Checks..."; \ + echo "================================="; \ + $(MAKE) --no-print-directory test-integration-agent; \ + echo ""; \ + echo "================================="; \ echo "Running Integration Tests..."; \ echo "================================="; \ $(MAKE) --no-print-directory test-integration; \ diff --git a/cmd/nylas/main.go b/cmd/nylas/main.go index aa40ab1..eeabc67 100644 --- a/cmd/nylas/main.go +++ b/cmd/nylas/main.go @@ -19,7 +19,6 @@ import ( "github.com/nylas/cli/internal/cli/dashboard" "github.com/nylas/cli/internal/cli/demo" "github.com/nylas/cli/internal/cli/email" - "github.com/nylas/cli/internal/cli/inbound" "github.com/nylas/cli/internal/cli/mcp" "github.com/nylas/cli/internal/cli/notetaker" "github.com/nylas/cli/internal/cli/otp" @@ -55,7 +54,6 @@ func main() { rootCmd.AddCommand(admin.NewAdminCmd()) rootCmd.AddCommand(webhook.NewWebhookCmd()) rootCmd.AddCommand(notetaker.NewNotetakerCmd()) - rootCmd.AddCommand(inbound.NewInboundCmd()) rootCmd.AddCommand(timezone.NewTimezoneCmd()) rootCmd.AddCommand(mcp.NewMCPCmd()) rootCmd.AddCommand(slack.NewSlackCmd()) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2a5a704..5a9907b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,7 +34,6 @@ internal/ calendar/ # Calendar & events contacts/ # Contact management email/ # Email operations - inbound/ # Inbound email rules integration/ # CLI integration tests mcp/ # MCP server command notetaker/ # Meeting notetaker @@ -165,7 +164,7 @@ url := qb.BuildURL(baseURL) 1. **Domain** (`internal/domain/`) - 29 files - Pure business logic, no external dependencies - Core types: Message, Email, Calendar, Event, Contact, Grant, Webhook - - Feature types: AI, Analytics, Admin, Scheduler, Notetaker, Slack, Inbound + - Feature types: AI, Analytics, Admin, Scheduler, Notetaker, Slack, Agent - Support types: Config, Errors, Provider, Utilities - Shared interfaces: `interfaces.go` (Paginated, QueryParams, Resource, Timestamped, Validator) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 46eafe3..47ddc1d 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -218,8 +218,8 @@ nylas email read --decrypt --verify # Decrypt + verif ``` **Managed send behavior:** -- Grants with provider `inbox` and `nylas` use the managed transactional send path automatically. -- The sender address comes from the active grant email for those managed providers. +- Grants with provider `nylas` use the managed transactional send path automatically. +- The sender address comes from the active grant email for those managed accounts. - GPG signing/encryption and `--signature-id` are not supported for managed transactional sends. **AI features:** @@ -481,28 +481,6 @@ nylas agent status # Check connector + account statu --- -## Inbound Email - -Receive emails at managed addresses without OAuth or third-party mailbox connections. - -```bash -nylas inbound list # List inbound inboxes -nylas inbound create # Create inbox (e.g., support@yourapp.nylas.email) -nylas inbound show # Show inbox details -nylas inbound delete # Delete inbox -nylas inbound messages # List messages in inbox -nylas inbound monitor # Real-time message monitoring -``` - -**Real-time monitoring with tunnel:** -```bash -nylas inbound monitor --tunnel cloudflared -``` - -**Details:** `docs/commands/inbound.md` - ---- - ## Timezone Utilities All timezone commands work **100% offline** - no API key required. @@ -1014,7 +992,6 @@ All commands follow consistent pattern: - Calendar: `docs/commands/calendar.md` - Contacts: `docs/commands/contacts.md` - Webhooks: `docs/commands/webhooks.md` -- Inbound: `docs/commands/inbound.md` - Scheduler: `docs/commands/scheduler.md` - Admin: `docs/commands/admin.md` - Workflows: `docs/commands/workflows.md` (OTP, automation) diff --git a/docs/INDEX.md b/docs/INDEX.md index 1071e5b..e678df8 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -73,7 +73,6 @@ Quick navigation guide to find the right documentation for your needs. - **Contacts** → [commands/contacts.md](commands/contacts.md) - **Webhooks** → [commands/webhooks.md](commands/webhooks.md) - **Agent accounts** → [commands/agent.md](commands/agent.md) -- **Inbound email** → [commands/inbound.md](commands/inbound.md) - **Scheduler** → [commands/scheduler.md](commands/scheduler.md) - **Admin** → [commands/admin.md](commands/admin.md) - **Timezone** → [commands/timezone.md](commands/timezone.md) @@ -124,7 +123,7 @@ docs/ ├── ARCHITECTURE.md # System design ├── DEVELOPMENT.md # Development setup │ -├── commands/ # Detailed command guides (17 files) +├── commands/ # Detailed command guides (16 files) │ ├── ai.md # AI features │ ├── mcp.md # MCP integration │ ├── calendar.md # Calendar events @@ -134,7 +133,6 @@ docs/ │ ├── explain-gpg.md # GPG explained │ ├── contacts.md # Contact management │ ├── webhooks.md # Webhook setup -│ ├── inbound.md # Inbound email │ ├── scheduler.md # Booking pages │ ├── admin.md # API management │ ├── timezone.md # Timezone utilities diff --git a/docs/commands/agent.md b/docs/commands/agent.md index 8689938..3d8e454 100644 --- a/docs/commands/agent.md +++ b/docs/commands/agent.md @@ -176,14 +176,6 @@ Summary: **Details:** [Agent rule reference](agent-rule.md) -## Relationship to Inbound - -`nylas agent` and `nylas inbound` are different features: -- `nylas agent` creates managed agent accounts backed by provider `nylas` -- `nylas inbound` creates inbound inboxes backed by provider `inbox` - -Both can use `@yourapp.nylas.email` addresses, but they are separate command groups and separate provider types. - Agent accounts can also expose the mailbox over IMAP and SMTP submission when an `app_password` is configured at creation time. ## Sending Email from Agent Accounts @@ -199,4 +191,3 @@ When the active grant is an agent account (`provider=nylas`): - [Agent policies](agent-policy.md) - [Agent rules](agent-rule.md) - [Email commands](email.md) -- [Inbound email](inbound.md) diff --git a/docs/commands/email.md b/docs/commands/email.md index 91f58cd..4f44bce 100644 --- a/docs/commands/email.md +++ b/docs/commands/email.md @@ -129,7 +129,7 @@ nylas email send --to "to@example.com" --subject "Hello" --body "Body" --yes - `--template-strict` - Fail if the hosted template references missing variables (default: true) - `--signature-id` - Append a stored signature when sending, creating a draft, or sending a draft -**Managed providers (`inbox`, `nylas`):** +**Managed provider (`nylas`):** - `nylas email send` uses the managed transactional send path automatically - the sender address is taken from the active grant email - `--signature-id` is not supported for managed transactional sends @@ -198,7 +198,7 @@ nylas email send --list-gpg-keys `--signature-id` can't be combined with `--sign` or `--encrypt`, because stored signatures are only supported on the standard JSON send and draft endpoints. -Managed transactional sends from `provider=inbox` or `provider=nylas` also do not support `--sign`, `--encrypt`, or `--signature-id`. +Managed transactional sends from `provider=nylas` also do not support `--sign`, `--encrypt`, or `--signature-id`. ### Signatures diff --git a/docs/commands/inbound.md b/docs/commands/inbound.md deleted file mode 100644 index 432e846..0000000 --- a/docs/commands/inbound.md +++ /dev/null @@ -1,314 +0,0 @@ -## Inbound Email Management - -Manage Nylas Inbound email inboxes for receiving emails at managed addresses. - -Nylas Inbound enables your application to receive emails at dedicated managed addresses (e.g., `support@yourapp.nylas.email`) without requiring OAuth authentication or third-party mailbox connections. - -**Use cases:** -- Capturing messages sent to specific addresses (intake@, leads@, tickets@) -- Triggering automated workflows from incoming mail -- Real-time message delivery to workers, LLMs, or downstream systems - -**Aliases:** `nylas inbox` - ---- - -### List Inbound Inboxes - -```bash -nylas inbound list -nylas inbound list --json -``` - -**Example output:** -```bash -$ nylas inbound list - -Inbound Inboxes (3) - -1. support@yourapp.nylas.email - ID: inbox_abc123 - Status: active - Created: 2024-12-01T10:00:00Z - -2. leads@yourapp.nylas.email - ID: inbox_def456 - Status: active - Created: 2024-12-10T14:30:00Z - -3. tickets@yourapp.nylas.email - ID: inbox_ghi789 - Status: active - Created: 2024-12-15T09:15:00Z - -Use 'nylas inbound messages [inbox-id]' to view messages -``` - ---- - -### Show Inbound Inbox - -```bash -nylas inbound show -nylas inbound show --json - -# Use environment variable for inbox ID -export NYLAS_INBOUND_GRANT_ID=inbox_abc123 -nylas inbound show -``` - -**Example output:** -```bash -$ nylas inbound show inbox_abc123 - -Inbound Inbox: inbox_abc123 -──────────────────────────────────────────────────────────── -Email: support@yourapp.nylas.email -ID: inbox_abc123 -Status: active -Created: 2024-12-01T10:00:00Z -Updated: 2024-12-15T14:30:00Z - -Configuration: - Domain: yourapp.nylas.email - Prefix: support - Forwarding: enabled - -Statistics: - Messages Received: 142 - Last Message: 2024-12-20T16:45:00Z -``` - ---- - -### Create Inbound Inbox - -```bash -# Create a support inbox -nylas inbound create support -# Creates: support@yourapp.nylas.email - -# Create a leads inbox -nylas inbound create leads -# Creates: leads@yourapp.nylas.email - -# Create and output as JSON -nylas inbound create tickets --json -``` - -**Example output:** -```bash -$ nylas inbound create support - -✓ Inbound inbox created successfully! - -Inbound Inbox: inbox_new_123 -──────────────────────────────────────────────────────────── -Email: support@yourapp.nylas.email -ID: inbox_new_123 -Status: active -Created: 2024-12-20T10:30:00Z - -Next steps: - 1. Set up a webhook: nylas webhooks create --url --triggers message.created - 2. View messages: nylas inbound messages inbox_new_123 - 3. Monitor in real-time: nylas inbound monitor inbox_new_123 -``` - ---- - -### Delete Inbound Inbox - -```bash -# Delete with confirmation -nylas inbound delete - -# Delete without confirmation -nylas inbound delete --yes -nylas inbound delete --force - -# Use environment variable for inbox ID -export NYLAS_INBOUND_GRANT_ID=inbox_abc123 -nylas inbound delete --yes -``` - -**Example output:** -```bash -$ nylas inbound delete inbox_abc123 - -You are about to delete the inbound inbox: - Email: support@yourapp.nylas.email - ID: inbox_abc123 - -This action cannot be undone. All messages in this inbox will be deleted. - -Type 'delete' to confirm: delete -✓ Inbox support@yourapp.nylas.email deleted successfully! -``` - ---- - -### List Messages - -```bash -# List messages for an inbox -nylas inbound messages - -# List only unread messages -nylas inbound messages --unread - -# Limit to 5 messages -nylas inbound messages --limit 5 - -# Output as JSON -nylas inbound messages --json - -# Use environment variable for inbox ID -export NYLAS_INBOUND_GRANT_ID=inbox_abc123 -nylas inbound messages -``` - -**Example output:** -```bash -$ nylas inbound messages inbox_abc123 - -Messages (10 total, 3 unread) - -1. [NEW] Feature Request: Dark Mode - From: alice@example.com - Date: 2024-12-20 16:45 - Preview: Hi team, I'd like to request a dark mode feature for the app... - ID: msg_xyz123 - -2. [NEW] Bug Report: Login Issue - From: bob@company.com - Date: 2024-12-20 15:30 - Preview: I'm experiencing issues logging in on mobile devices... - ID: msg_abc456 - -3. Integration Question - From: charlie@startup.io - Date: 2024-12-20 14:15 - Preview: Does your API support webhook retries? We need to ensure... - ID: msg_def789 - -Use 'nylas email read [inbox-id]' to view full message -``` - ---- - -### Monitor for New Messages - -Start a local webhook server to receive real-time notifications when new emails arrive. - -```bash -# Start monitoring with default settings -nylas inbound monitor - -# Monitor with cloudflared tunnel (for public access) -nylas inbound monitor --tunnel cloudflared - -# Monitor on custom port -nylas inbound monitor --port 8080 - -# Output events as JSON -nylas inbound monitor --tunnel cloudflared --json - -# Quiet mode (only show events) -nylas inbound monitor --quiet - -# Use environment variable for inbox ID -export NYLAS_INBOUND_GRANT_ID=inbox_abc123 -nylas inbound monitor --tunnel cloudflared -``` - -**Example output:** -```bash -$ nylas inbound monitor inbox_abc123 --tunnel cloudflared - -╔══════════════════════════════════════════════════════════════╗ -║ Nylas Inbound Monitor ║ -╚══════════════════════════════════════════════════════════════╝ - -Monitoring: support@yourapp.nylas.email - -Starting tunnel... -✓ Monitor started successfully! - - Local URL: http://localhost:3000/webhook - Public URL: https://random-words.trycloudflare.com/webhook - - Tunnel: cloudflared (connected) - -To receive events, register this webhook URL with Nylas: - nylas webhooks create --url https://random-words.trycloudflare.com/webhook --triggers message.created - -Press Ctrl+C to stop - -───────────────────────────────────────────────────────────────── -Incoming Messages: - -[16:45:32] NEW MESSAGE [verified] - Subject: Feature Request: Dark Mode - From: Alice Smith - Preview: Hi team, I'd like to request a dark mode feature... - ID: msg_xyz123 - -[16:48:15] NEW MESSAGE [verified] - Subject: Urgent: Production Issue - From: bob@company.com - Preview: We're seeing errors in production... - ID: msg_abc456 -``` - -**Tunnel providers:** -- `cloudflared` - Cloudflare Tunnel (requires `cloudflared` installed) - -**Flags:** -| Flag | Short | Description | -|------|-------|-------------| -| `--port` | `-p` | Port to listen on (default: 3000) | -| `--tunnel` | `-t` | Tunnel provider (cloudflared) | -| `--secret` | `-s` | Webhook secret for signature verification | -| `--json` | | Output events as JSON | -| `--quiet` | `-q` | Suppress startup messages, only show events | - ---- - -### Environment Variables - -| Variable | Description | -|----------|-------------| -| `NYLAS_INBOUND_GRANT_ID` | Default inbox ID for commands | - ---- - -### Workflow Example - -Complete workflow for setting up inbound email processing: - -```bash -# 1. Create an inbound inbox -nylas inbound create support -# → Creates support@yourapp.nylas.email - -# 2. Start monitoring (in another terminal) -nylas inbound monitor inbox_abc123 --tunnel cloudflared -# → Provides public webhook URL - -# 3. Register the webhook URL -nylas webhook create --url https://random-words.trycloudflare.com/webhook \ - --triggers message.created - -# 4. Send a test email to support@yourapp.nylas.email -# → Watch the monitor for incoming message - -# 5. View messages -nylas inbound messages inbox_abc123 - -# 6. Read a specific message -nylas email read msg_xyz123 inbox_abc123 -``` - ---- - diff --git a/internal/adapters/nylas/README.md b/internal/adapters/nylas/README.md index c2c1f45..8909a6f 100644 --- a/internal/adapters/nylas/README.md +++ b/internal/adapters/nylas/README.md @@ -20,7 +20,6 @@ This package implements the `ports.NylasClient` interface for the Nylas v3 API. | `scheduler.go` | Scheduling pages and bookings | | `notetakers.go` | Meeting notetaker (Nylas Notetaker API) | | `admin.go` | Admin operations (applications, grants) | -| `inbound.go` | Inbound email parsing | ## Special Files diff --git a/internal/adapters/nylas/demo_inbound.go b/internal/adapters/nylas/demo_inbound.go deleted file mode 100644 index 667b78b..0000000 --- a/internal/adapters/nylas/demo_inbound.go +++ /dev/null @@ -1,135 +0,0 @@ -package nylas - -import ( - "context" - "time" - - "github.com/nylas/cli/internal/domain" -) - -func (d *DemoClient) ListInboundInboxes(ctx context.Context) ([]domain.InboundInbox, error) { - now := time.Now() - return []domain.InboundInbox{ - { - ID: "inbox-demo-001", - Email: "support@demo-app.nylas.email", - GrantStatus: "valid", - CreatedAt: domain.UnixTime{Time: now.Add(-30 * 24 * time.Hour)}, - UpdatedAt: domain.UnixTime{Time: now.Add(-1 * time.Hour)}, - }, - { - ID: "inbox-demo-002", - Email: "sales@demo-app.nylas.email", - GrantStatus: "valid", - CreatedAt: domain.UnixTime{Time: now.Add(-14 * 24 * time.Hour)}, - UpdatedAt: domain.UnixTime{Time: now.Add(-2 * time.Hour)}, - }, - { - ID: "inbox-demo-003", - Email: "info@demo-app.nylas.email", - GrantStatus: "valid", - CreatedAt: domain.UnixTime{Time: now.Add(-7 * 24 * time.Hour)}, - UpdatedAt: domain.UnixTime{Time: now.Add(-30 * time.Minute)}, - }, - }, nil -} - -// GetInboundInbox returns a demo inbound inbox. -func (d *DemoClient) GetInboundInbox(ctx context.Context, grantID string) (*domain.InboundInbox, error) { - inboxes, _ := d.ListInboundInboxes(ctx) - for _, inbox := range inboxes { - if inbox.ID == grantID { - return &inbox, nil - } - } - return &inboxes[0], nil -} - -// CreateInboundInbox simulates creating an inbound inbox. -func (d *DemoClient) CreateInboundInbox(ctx context.Context, email string) (*domain.InboundInbox, error) { - now := time.Now() - return &domain.InboundInbox{ - ID: "inbox-new", - Email: email + "@demo-app.nylas.email", - GrantStatus: "valid", - CreatedAt: domain.UnixTime{Time: now}, - UpdatedAt: domain.UnixTime{Time: now}, - }, nil -} - -// DeleteInboundInbox simulates deleting an inbound inbox. -func (d *DemoClient) DeleteInboundInbox(ctx context.Context, grantID string) error { - return nil -} - -// GetInboundMessages returns demo inbound messages. -func (d *DemoClient) GetInboundMessages(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.InboundMessage, error) { - now := time.Now() - return []domain.InboundMessage{ - { - ID: "inbound-001", - GrantID: grantID, - Subject: "New Lead: Enterprise Plan Inquiry", - From: []domain.EmailParticipant{{Name: "John Smith", Email: "john.smith@bigcorp.com"}}, - To: []domain.EmailParticipant{{Name: "Sales", Email: "sales@demo-app.nylas.email"}}, - Date: now.Add(-10 * time.Minute), - Unread: true, - Starred: true, - Snippet: "Hi, I'm interested in learning more about your enterprise plan...", - Body: "Hi,\n\nI'm interested in learning more about your enterprise plan. Our company has about 500 employees and we're looking for a solution that can scale with our growth.\n\nCan we schedule a call this week?\n\nBest,\nJohn Smith\nVP of Engineering\nBigCorp Inc.", - ThreadID: "inbound-thread-001", - }, - { - ID: "inbound-002", - GrantID: grantID, - Subject: "Support Request: Integration Help", - From: []domain.EmailParticipant{{Name: "Sarah Johnson", Email: "sarah@startup.io"}}, - To: []domain.EmailParticipant{{Name: "Support", Email: "support@demo-app.nylas.email"}}, - Date: now.Add(-1 * time.Hour), - Unread: true, - Starred: false, - Snippet: "We're having trouble connecting our calendar integration...", - Body: "Hello,\n\nWe're having trouble connecting our calendar integration. The OAuth flow completes but we're not seeing any events sync.\n\nCan you help troubleshoot?\n\nThanks,\nSarah", - ThreadID: "inbound-thread-002", - }, - { - ID: "inbound-003", - GrantID: grantID, - Subject: "Partnership Opportunity", - From: []domain.EmailParticipant{{Name: "Mike Chen", Email: "mike@partner-company.com"}}, - To: []domain.EmailParticipant{{Name: "Info", Email: "info@demo-app.nylas.email"}}, - Date: now.Add(-3 * time.Hour), - Unread: false, - Starred: true, - Snippet: "We're a SaaS company looking for email integration partners...", - Body: "Hi there,\n\nWe're a SaaS company serving the healthcare industry and we're looking for email integration partners.\n\nWould love to explore a potential partnership.\n\nBest,\nMike Chen\nBusiness Development", - ThreadID: "inbound-thread-003", - }, - { - ID: "inbound-004", - GrantID: grantID, - Subject: "Billing Question", - From: []domain.EmailParticipant{{Name: "Lisa Park", Email: "lisa@customer.com"}}, - To: []domain.EmailParticipant{{Name: "Support", Email: "support@demo-app.nylas.email"}}, - Date: now.Add(-1 * 24 * time.Hour), - Unread: false, - Starred: false, - Snippet: "I have a question about my latest invoice...", - Body: "Hi,\n\nI have a question about my latest invoice. It seems like I was charged for 15 seats but we only have 10 active users.\n\nCan you look into this?\n\nThanks,\nLisa", - ThreadID: "inbound-thread-004", - }, - { - ID: "inbound-005", - GrantID: grantID, - Subject: "Feature Request: Dark Mode", - From: []domain.EmailParticipant{{Name: "Alex Rivera", Email: "alex@user.org"}}, - To: []domain.EmailParticipant{{Name: "Info", Email: "info@demo-app.nylas.email"}}, - Date: now.Add(-2 * 24 * time.Hour), - Unread: false, - Starred: false, - Snippet: "Would love to see dark mode support in the dashboard...", - Body: "Hello,\n\nI'm a happy user of your product but I work late hours and would really appreciate dark mode support.\n\nIs this on your roadmap?\n\nThanks,\nAlex", - ThreadID: "inbound-thread-005", - }, - }, nil -} diff --git a/internal/adapters/nylas/demo_productivity.go b/internal/adapters/nylas/demo_productivity.go index 29ef11e..838f988 100644 --- a/internal/adapters/nylas/demo_productivity.go +++ b/internal/adapters/nylas/demo_productivity.go @@ -179,5 +179,3 @@ func (d *DemoClient) GetNotetakerMedia(ctx context.Context, grantID, notetakerID }, }, nil } - -// ListInboundInboxes returns demo inbound inboxes. diff --git a/internal/adapters/nylas/inbound.go b/internal/adapters/nylas/inbound.go deleted file mode 100644 index ee005b1..0000000 --- a/internal/adapters/nylas/inbound.go +++ /dev/null @@ -1,86 +0,0 @@ -package nylas - -import ( - "context" - "fmt" - - "github.com/nylas/cli/internal/domain" -) - -// ListInboundInboxes lists all inbound inboxes (grants with provider=inbox). -func (c *HTTPClient) ListInboundInboxes(ctx context.Context) ([]domain.InboundInbox, error) { - grants, err := c.listManagedGrants(ctx, domain.ProviderInbox) - if err != nil { - return nil, err - } - - inboxes := make([]domain.InboundInbox, 0, len(grants)) - for _, grant := range grants { - inboxes = append(inboxes, convertManagedGrantToInboundInbox(grant)) - } - - return inboxes, nil -} - -// GetInboundInbox retrieves a specific inbound inbox by grant ID. -func (c *HTTPClient) GetInboundInbox(ctx context.Context, grantID string) (*domain.InboundInbox, error) { - grant, err := c.getManagedGrant(ctx, grantID) - if err != nil { - return nil, err - } - - if grant.Provider != domain.ProviderInbox { - return nil, fmt.Errorf("%w: grant is not an inbound inbox (provider=%s)", domain.ErrInvalidGrant, grant.Provider) - } - - inbox := convertManagedGrantToInboundInbox(*grant) - return &inbox, nil -} - -// CreateInboundInbox creates a new inbound inbox with the given email address. -// The email parameter is the local part (e.g., "support" for support@app.nylas.email). -func (c *HTTPClient) CreateInboundInbox(ctx context.Context, email string) (*domain.InboundInbox, error) { - grant, err := c.createManagedGrant(ctx, domain.ProviderInbox, email) - if err != nil { - return nil, err - } - - inbox := convertManagedGrantToInboundInbox(*grant) - return &inbox, nil -} - -// DeleteInboundInbox deletes an inbound inbox by revoking its grant. -func (c *HTTPClient) DeleteInboundInbox(ctx context.Context, grantID string) error { - return c.deleteManagedGrant(ctx, grantID, domain.ProviderInbox) -} - -// GetInboundMessages retrieves messages for an inbound inbox. -func (c *HTTPClient) GetInboundMessages(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.InboundMessage, error) { - if params == nil { - params = &domain.MessageQueryParams{Limit: 10} - } - if params.Limit <= 0 { - params.Limit = 10 - } - - baseURL := fmt.Sprintf("%s/v3/grants/%s/messages", c.baseURL, grantID) - queryURL := NewQueryBuilder(). - AddInt("limit", params.Limit). - Add("page_token", params.PageToken). - AddInt("offset", params.Offset). - Add("subject", params.Subject). - Add("from", params.From). - AddBoolPtr("unread", params.Unread). - AddInt64("received_before", params.ReceivedBefore). - AddInt64("received_after", params.ReceivedAfter). - BuildURL(baseURL) - - var result struct { - Data []messageResponse `json:"data"` - } - if err := c.doGet(ctx, queryURL, &result); err != nil { - return nil, err - } - - return convertMessages(result.Data), nil -} diff --git a/internal/adapters/nylas/inbound_crud_test.go b/internal/adapters/nylas/inbound_crud_test.go deleted file mode 100644 index e56da22..0000000 --- a/internal/adapters/nylas/inbound_crud_test.go +++ /dev/null @@ -1,395 +0,0 @@ -//go:build !integration -// +build !integration - -package nylas - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// ============================================================================= -// LIST INBOUND INBOXES TESTS -// ============================================================================= - -func TestListInboundInboxes(t *testing.T) { - t.Run("successful_list", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/v3/grants", r.URL.Path) - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "inbox", r.URL.Query().Get("provider")) - - response := map[string]any{ - "data": []map[string]any{ - { - "id": "inbox-001", - "email": "support@app.nylas.email", - "provider": "inbox", - "settings": map[string]any{ - "policy_id": "policy-001", - }, - "grant_status": "valid", - "created_at": time.Now().Add(-24 * time.Hour).Unix(), - "updated_at": time.Now().Unix(), - }, - { - "id": "inbox-002", - "email": "sales@app.nylas.email", - "provider": "inbox", - "grant_status": "valid", - "created_at": time.Now().Add(-48 * time.Hour).Unix(), - "updated_at": time.Now().Unix(), - }, - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - inboxes, err := client.ListInboundInboxes(context.Background()) - - require.NoError(t, err) - assert.Len(t, inboxes, 2) - assert.Equal(t, "inbox-001", inboxes[0].ID) - assert.Equal(t, "support@app.nylas.email", inboxes[0].Email) - assert.Equal(t, "policy-001", inboxes[0].PolicyID) - assert.Equal(t, "valid", inboxes[0].GrantStatus) - assert.Equal(t, "inbox-002", inboxes[1].ID) - }) - - t.Run("filters_by_inbox_provider", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Return mix of inbox and non-inbox providers - response := map[string]any{ - "data": []map[string]any{ - { - "id": "inbox-001", - "email": "support@app.nylas.email", - "provider": "inbox", - "grant_status": "valid", - "created_at": time.Now().Unix(), - }, - { - "id": "inbox-002", - "email": "user@gmail.com", - "provider": "google", // Should be filtered out - "grant_status": "valid", - "created_at": time.Now().Unix(), - }, - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - inboxes, err := client.ListInboundInboxes(context.Background()) - - require.NoError(t, err) - assert.Len(t, inboxes, 1) - assert.Equal(t, "support@app.nylas.email", inboxes[0].Email) - }) - - t.Run("handles_empty_response", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := map[string]any{ - "data": []map[string]any{}, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - inboxes, err := client.ListInboundInboxes(context.Background()) - - require.NoError(t, err) - assert.Empty(t, inboxes) - }) - - t.Run("handles_api_error", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": map[string]any{ - "message": "Invalid API key", - }, - }) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "invalid-key") - - _, err := client.ListInboundInboxes(context.Background()) - - assert.Error(t, err) - }) -} - -// ============================================================================= -// GET INBOUND INBOX TESTS -// ============================================================================= - -func TestGetInboundInbox(t *testing.T) { - t.Run("successful_get", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/v3/grants/inbox-001", r.URL.Path) - assert.Equal(t, http.MethodGet, r.Method) - - response := map[string]any{ - "data": map[string]any{ - "id": "inbox-001", - "email": "support@app.nylas.email", - "grant_status": "valid", - "provider": "inbox", - "created_at": time.Now().Add(-24 * time.Hour).Unix(), - "updated_at": time.Now().Unix(), - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - inbox, err := client.GetInboundInbox(context.Background(), "inbox-001") - - require.NoError(t, err) - assert.Equal(t, "inbox-001", inbox.ID) - assert.Equal(t, "support@app.nylas.email", inbox.Email) - assert.Equal(t, "valid", inbox.GrantStatus) - }) - - t.Run("validates_inbox_provider", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := map[string]any{ - "data": map[string]any{ - "id": "inbox-001", - "email": "user@gmail.com", - "grant_status": "valid", - "provider": "google", // Not inbox provider - "created_at": time.Now().Unix(), - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - _, err := client.GetInboundInbox(context.Background(), "inbox-001") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "not an inbound inbox") - }) - - t.Run("handles_not_found", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": map[string]any{ - "message": "Grant not found", - }, - }) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - _, err := client.GetInboundInbox(context.Background(), "nonexistent") - - assert.Error(t, err) - }) -} - -// ============================================================================= -// CREATE INBOUND INBOX TESTS -// ============================================================================= - -func TestCreateInboundInbox(t *testing.T) { - t.Run("successful_create", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/v3/grants", r.URL.Path) - assert.Equal(t, http.MethodPost, r.Method) - - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - assert.Equal(t, "inbox", body["provider"]) - settings, _ := body["settings"].(map[string]any) - assert.Equal(t, "support", settings["email"]) - - response := map[string]any{ - "data": map[string]any{ - "id": "new-inbox-001", - "email": "support@app.nylas.email", - "grant_status": "valid", - "provider": "virtual", - "created_at": time.Now().Unix(), - "updated_at": time.Now().Unix(), - }, - } - - w.WriteHeader(http.StatusCreated) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - inbox, err := client.CreateInboundInbox(context.Background(), "support") - - require.NoError(t, err) - assert.Equal(t, "new-inbox-001", inbox.ID) - assert.Equal(t, "support@app.nylas.email", inbox.Email) - assert.Equal(t, "valid", inbox.GrantStatus) - }) - - t.Run("handles_conflict_error", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusConflict) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": map[string]any{ - "message": "Email already exists", - }, - }) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - _, err := client.CreateInboundInbox(context.Background(), "existing") - - assert.Error(t, err) - }) - - t.Run("handles_validation_error", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": map[string]any{ - "message": "Invalid email prefix", - }, - }) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - _, err := client.CreateInboundInbox(context.Background(), "invalid@prefix") - - assert.Error(t, err) - }) -} - -// ============================================================================= -// DELETE INBOUND INBOX TESTS -// ============================================================================= - -func TestDeleteInboundInbox(t *testing.T) { - t.Run("successful_delete", func(t *testing.T) { - callCount := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - if callCount == 1 { - // First call: GetInboundInbox to verify it's an inbox provider - assert.Equal(t, "/v3/grants/inbox-001", r.URL.Path) - assert.Equal(t, http.MethodGet, r.Method) - - response := map[string]any{ - "data": map[string]any{ - "id": "inbox-001", - "email": "support@app.nylas.email", - "grant_status": "valid", - "provider": "inbox", - "created_at": time.Now().Unix(), - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) // Test helper, encode error not actionable - } else { - // Second call: RevokeGrant (DELETE) - assert.Equal(t, "/v3/grants/inbox-001", r.URL.Path) - assert.Equal(t, http.MethodDelete, r.Method) - - w.WriteHeader(http.StatusNoContent) - } - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - err := client.DeleteInboundInbox(context.Background(), "inbox-001") - - assert.NoError(t, err) - assert.Equal(t, 2, callCount, "Expected 2 API calls (get + delete)") - }) - - t.Run("handles_not_found", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": map[string]any{ - "message": "Grant not found", - }, - }) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - err := client.DeleteInboundInbox(context.Background(), "nonexistent") - - assert.Error(t, err) - }) -} diff --git a/internal/adapters/nylas/inbound_messages_test.go b/internal/adapters/nylas/inbound_messages_test.go deleted file mode 100644 index 427699e..0000000 --- a/internal/adapters/nylas/inbound_messages_test.go +++ /dev/null @@ -1,251 +0,0 @@ -//go:build !integration -// +build !integration - -package nylas - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/nylas/cli/internal/domain" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// ============================================================================= -// GET INBOUND MESSAGES TESTS -// ============================================================================= - -func TestGetInboundMessages(t *testing.T) { - t.Run("successful_get_messages", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/v3/grants/inbox-001/messages", r.URL.Path) - assert.Equal(t, http.MethodGet, r.Method) - - response := map[string]any{ - "data": []map[string]any{ - { - "id": "msg-001", - "grant_id": "inbox-001", - "subject": "Test Subject 1", - "from": []map[string]string{{"name": "John", "email": "john@example.com"}}, - "to": []map[string]string{{"name": "Support", "email": "support@app.nylas.email"}}, - "date": time.Now().Add(-1 * time.Hour).Unix(), - "unread": true, - "starred": false, - "snippet": "This is a test message...", - "body": "This is a test message body.", - "thread_id": "thread-001", - }, - { - "id": "msg-002", - "grant_id": "inbox-001", - "subject": "Test Subject 2", - "from": []map[string]string{{"name": "Jane", "email": "jane@example.com"}}, - "to": []map[string]string{{"name": "Support", "email": "support@app.nylas.email"}}, - "date": time.Now().Add(-2 * time.Hour).Unix(), - "unread": false, - "starred": true, - "snippet": "Another test message...", - "body": "Another test message body.", - "thread_id": "thread-002", - }, - }, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - messages, err := client.GetInboundMessages(context.Background(), "inbox-001", nil) - - require.NoError(t, err) - assert.Len(t, messages, 2) - assert.Equal(t, "msg-001", messages[0].ID) - assert.Equal(t, "Test Subject 1", messages[0].Subject) - assert.True(t, messages[0].Unread) - assert.Equal(t, "msg-002", messages[1].ID) - assert.True(t, messages[1].Starred) - }) - - t.Run("with_limit_param", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "5", r.URL.Query().Get("limit")) - - response := map[string]any{ - "data": []map[string]any{}, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - params := &domain.MessageQueryParams{Limit: 5} - _, err := client.GetInboundMessages(context.Background(), "inbox-001", params) - - assert.NoError(t, err) - }) - - t.Run("with_unread_param", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "true", r.URL.Query().Get("unread")) - - response := map[string]any{ - "data": []map[string]any{}, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - unread := true - params := &domain.MessageQueryParams{Unread: &unread} - _, err := client.GetInboundMessages(context.Background(), "inbox-001", params) - - assert.NoError(t, err) - }) - - t.Run("handles_empty_response", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := map[string]any{ - "data": []map[string]any{}, - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - messages, err := client.GetInboundMessages(context.Background(), "inbox-001", nil) - - require.NoError(t, err) - assert.Empty(t, messages) - }) - - t.Run("handles_api_error", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": map[string]any{ - "message": "Grant not found", - }, - }) - })) - defer server.Close() - - client := NewHTTPClient() - client.baseURL = server.URL - client.SetCredentials("", "", "test-api-key") - - _, err := client.GetInboundMessages(context.Background(), "nonexistent", nil) - - assert.Error(t, err) - }) -} - -// ============================================================================= -// MOCK CLIENT TESTS -// ============================================================================= - -func TestMockClient_InboundMethods(t *testing.T) { - mock := NewMockClient() - ctx := context.Background() - - t.Run("ListInboundInboxes", func(t *testing.T) { - inboxes, err := mock.ListInboundInboxes(ctx) - assert.NoError(t, err) - assert.NotEmpty(t, inboxes) - }) - - t.Run("GetInboundInbox", func(t *testing.T) { - inbox, err := mock.GetInboundInbox(ctx, "test-id") - assert.NoError(t, err) - assert.NotNil(t, inbox) - }) - - t.Run("CreateInboundInbox", func(t *testing.T) { - inbox, err := mock.CreateInboundInbox(ctx, "test") - assert.NoError(t, err) - assert.NotNil(t, inbox) - assert.Contains(t, inbox.Email, "test") - }) - - t.Run("DeleteInboundInbox", func(t *testing.T) { - err := mock.DeleteInboundInbox(ctx, "test-id") - assert.NoError(t, err) - }) - - t.Run("GetInboundMessages", func(t *testing.T) { - messages, err := mock.GetInboundMessages(ctx, "test-id", nil) - assert.NoError(t, err) - assert.NotEmpty(t, messages) - }) -} - -// ============================================================================= -// DEMO CLIENT TESTS -// ============================================================================= - -func TestDemoClient_InboundMethods(t *testing.T) { - demo := NewDemoClient() - ctx := context.Background() - - t.Run("ListInboundInboxes", func(t *testing.T) { - inboxes, err := demo.ListInboundInboxes(ctx) - assert.NoError(t, err) - assert.NotEmpty(t, inboxes) - // Should have realistic demo data - assert.Contains(t, inboxes[0].Email, "nylas.email") - }) - - t.Run("GetInboundInbox", func(t *testing.T) { - inbox, err := demo.GetInboundInbox(ctx, "inbox-demo-001") - assert.NoError(t, err) - assert.NotNil(t, inbox) - }) - - t.Run("CreateInboundInbox", func(t *testing.T) { - inbox, err := demo.CreateInboundInbox(ctx, "test") - assert.NoError(t, err) - assert.NotNil(t, inbox) - assert.Contains(t, inbox.Email, "nylas.email") - }) - - t.Run("DeleteInboundInbox", func(t *testing.T) { - err := demo.DeleteInboundInbox(ctx, "inbox-demo-001") - assert.NoError(t, err) - }) - - t.Run("GetInboundMessages", func(t *testing.T) { - messages, err := demo.GetInboundMessages(ctx, "inbox-demo-001", nil) - assert.NoError(t, err) - assert.NotEmpty(t, messages) - // Should have realistic demo data with various message types - assert.NotEmpty(t, messages[0].Subject) - assert.NotEmpty(t, messages[0].From) - }) -} diff --git a/internal/adapters/nylas/integration_grants_test.go b/internal/adapters/nylas/integration_grants_test.go index ed3d581..0cbaaf2 100644 --- a/internal/adapters/nylas/integration_grants_test.go +++ b/internal/adapters/nylas/integration_grants_test.go @@ -48,7 +48,6 @@ func TestIntegration_ListGrants_ValidatesProvider(t *testing.T) { "yahoo": true, "icloud": true, "ews": true, - "inbox": true, // Nylas Native Auth "nylas": true, // Nylas-managed agent account provider "virtual": true, "virtual-calendar": true, // Virtual calendar provider diff --git a/internal/adapters/nylas/managed_grants.go b/internal/adapters/nylas/managed_grants.go index cc26d7b..f493ef3 100644 --- a/internal/adapters/nylas/managed_grants.go +++ b/internal/adapters/nylas/managed_grants.go @@ -73,31 +73,6 @@ func (c *HTTPClient) getManagedGrant(ctx context.Context, grantID string) (*mana return &result.Data, nil } -func (c *HTTPClient) createManagedGrant(ctx context.Context, provider domain.Provider, email string) (*managedGrantResponse, error) { - queryURL := fmt.Sprintf("%s/v3/grants", c.baseURL) - - payload := map[string]any{ - "provider": string(provider), - "settings": map[string]string{ - "email": email, - }, - } - - resp, err := c.doJSONRequest(ctx, "POST", queryURL, payload) - if err != nil { - return nil, err - } - - var result struct { - Data managedGrantResponse `json:"data"` - } - if err := c.decodeJSONResponse(resp, &result); err != nil { - return nil, err - } - - return &result.Data, nil -} - func (c *HTTPClient) deleteManagedGrant(ctx context.Context, grantID string, expectedProvider domain.Provider) error { grant, err := c.getManagedGrant(ctx, grantID) if err != nil { @@ -113,17 +88,6 @@ func (c *HTTPClient) deleteManagedGrant(ctx context.Context, grantID string, exp return c.RevokeGrant(ctx, grantID) } -func convertManagedGrantToInboundInbox(grant managedGrantResponse) domain.InboundInbox { - return domain.InboundInbox{ - ID: grant.ID, - Email: grant.Email, - PolicyID: grant.Settings.PolicyID, - GrantStatus: grant.GrantStatus, - CreatedAt: grant.CreatedAt, - UpdatedAt: grant.UpdatedAt, - } -} - func convertManagedGrantToAgentAccount(grant managedGrantResponse) domain.AgentAccount { return domain.AgentAccount{ ID: grant.ID, diff --git a/internal/adapters/nylas/mock_productivity.go b/internal/adapters/nylas/mock_productivity.go index 847195b..dfbb343 100644 --- a/internal/adapters/nylas/mock_productivity.go +++ b/internal/adapters/nylas/mock_productivity.go @@ -63,68 +63,4 @@ func (m *MockClient) GetNotetakerMedia(ctx context.Context, grantID, notetakerID }, nil } -// ListInboundInboxes lists all inbound inboxes. -func (m *MockClient) ListInboundInboxes(ctx context.Context) ([]domain.InboundInbox, error) { - return []domain.InboundInbox{ - { - ID: "inbox-1", - Email: "support@app.nylas.email", - GrantStatus: "valid", - }, - { - ID: "inbox-2", - Email: "info@app.nylas.email", - GrantStatus: "valid", - }, - }, nil -} - -// GetInboundInbox retrieves a specific inbound inbox. -func (m *MockClient) GetInboundInbox(ctx context.Context, grantID string) (*domain.InboundInbox, error) { - m.LastGrantID = grantID - return &domain.InboundInbox{ - ID: grantID, - Email: "support@app.nylas.email", - GrantStatus: "valid", - }, nil -} - -// CreateInboundInbox creates a new inbound inbox. -func (m *MockClient) CreateInboundInbox(ctx context.Context, email string) (*domain.InboundInbox, error) { - return &domain.InboundInbox{ - ID: "new-inbox-id", - Email: email + "@app.nylas.email", - GrantStatus: "valid", - }, nil -} - -// DeleteInboundInbox deletes an inbound inbox. -func (m *MockClient) DeleteInboundInbox(ctx context.Context, grantID string) error { - m.LastGrantID = grantID - return nil -} - -// GetInboundMessages retrieves messages for an inbound inbox. -func (m *MockClient) GetInboundMessages(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.InboundMessage, error) { - m.LastGrantID = grantID - return []domain.InboundMessage{ - { - ID: "inbound-msg-1", - GrantID: grantID, - Subject: "New Lead Submission", - From: []domain.EmailParticipant{{Name: "John Doe", Email: "john@example.com"}}, - Snippet: "Hi, I'm interested in your services...", - Unread: true, - }, - { - ID: "inbound-msg-2", - GrantID: grantID, - Subject: "Support Request #12345", - From: []domain.EmailParticipant{{Name: "Jane Smith", Email: "jane@example.com"}}, - Snippet: "I need help with my account...", - Unread: false, - }, - }, nil -} - // Scheduler Mock Implementations diff --git a/internal/adapters/nylas/transactional.go b/internal/adapters/nylas/transactional.go index 22cc559..3f9bea0 100644 --- a/internal/adapters/nylas/transactional.go +++ b/internal/adapters/nylas/transactional.go @@ -22,7 +22,7 @@ func buildTransactionalSendPayload(req *domain.SendMessageRequest) map[string]an } // SendTransactionalMessage sends an email via the domain-based transactional endpoint. -// Used for Inbox provider grants: POST /v3/domains/{domain}/messages/send +// Used for managed Nylas grants: POST /v3/domains/{domain}/messages/send func (c *HTTPClient) SendTransactionalMessage(ctx context.Context, domainName string, req *domain.SendMessageRequest) (*domain.Message, error) { queryURL := fmt.Sprintf("%s/v3/domains/%s/messages/send", c.baseURL, domainName) diff --git a/internal/air/provider_support_test.go b/internal/air/provider_support_test.go index 849eaf3..dec4b51 100644 --- a/internal/air/provider_support_test.go +++ b/internal/air/provider_support_test.go @@ -117,7 +117,7 @@ func TestHandleCacheSync_FiltersSupportedProviders(t *testing.T) { }, { name: "does not sync requested unsupported provider", - query: "/api/cache/sync?email=inbox@example.com", + query: "/api/cache/sync?email=virtual@example.com", wantCount: 0, }, } @@ -140,7 +140,7 @@ func TestHandleCacheSync_FiltersSupportedProviders(t *testing.T) { grants: []domain.GrantInfo{ {ID: "grant-google", Email: "google@example.com", Provider: domain.ProviderGoogle}, {ID: "grant-nylas", Email: "nylas@example.com", Provider: domain.ProviderNylas}, - {ID: "grant-inbox", Email: "inbox@example.com", Provider: domain.ProviderInbox}, + {ID: "grant-virtual", Email: "virtual@example.com", Provider: domain.ProviderVirtual}, {ID: "grant-imap", Email: "imap@example.com", Provider: domain.ProviderIMAP}, }, defaultGrant: "grant-google", diff --git a/internal/cli/admin/connectors.go b/internal/cli/admin/connectors.go index 27bcf5c..68d2278 100644 --- a/internal/cli/admin/connectors.go +++ b/internal/cli/admin/connectors.go @@ -42,6 +42,7 @@ func newConnectorListCmd() *cobra.Command { if err != nil { return struct{}{}, common.WrapListError("connectors", err) } + connectors = common.FilterVisibleConnectors(connectors) if jsonOutput { return struct{}{}, json.NewEncoder(cmd.OutOrStdout()).Encode(connectors) @@ -87,6 +88,9 @@ func newConnectorShowCmd() *cobra.Command { if err != nil { return struct{}{}, common.WrapGetError("connector", err) } + if common.IsDeprecatedConnectorProvider(connector.Provider) { + return struct{}{}, common.NewUserError("connector not found", "The inbox connector is no longer supported") + } if jsonOutput { return struct{}{}, json.NewEncoder(cmd.OutOrStdout()).Encode(connector) @@ -148,6 +152,10 @@ func newConnectorCreateCmd() *cobra.Command { Short: "Create a connector", Long: "Create a new email provider connector.", RunE: func(cmd *cobra.Command, args []string) error { + if err := common.ValidateSupportedConnectorProvider(provider); err != nil { + return err + } + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { req := &domain.CreateConnectorRequest{ Name: name, diff --git a/internal/cli/agent/agent_test.go b/internal/cli/agent/agent_test.go index daceb78..566aec4 100644 --- a/internal/cli/agent/agent_test.go +++ b/internal/cli/agent/agent_test.go @@ -340,42 +340,17 @@ func TestFilterPoliciesWithAgentAccounts(t *testing.T) { } } -func TestBuildNonAgentPolicyIDs(t *testing.T) { - policyIDs := buildNonAgentPolicyIDs([]domain.InboundInbox{ - {ID: "inbox-1", Email: "support@example.com", PolicyID: "policy-2"}, - {ID: "inbox-2", Email: "sales@example.com", PolicyID: "policy-2"}, - {ID: "inbox-3", Email: "info@example.com", PolicyID: "policy-3"}, - {ID: "inbox-4", Email: "empty@example.com"}, - }) - - assert.Len(t, policyIDs, 2) - _, ok := policyIDs["policy-2"] - assert.True(t, ok) - _, ok = policyIDs["policy-3"] - assert.True(t, ok) -} - func TestResolvePolicyForAgentOps(t *testing.T) { scope := &agentPolicyScope{ AllPolicies: []domain.Policy{ {ID: "policy-agent", Name: "Agent"}, - {ID: "policy-mixed", Name: "Mixed"}, {ID: "policy-unattached", Name: "Unattached"}, - {ID: "policy-inbound", Name: "Inbound"}, }, PolicyRefsByID: map[string][]policyAgentAccountRef{ "policy-agent": {{ GrantID: "grant-agent", Email: "agent@example.com", }}, - "policy-mixed": {{ - GrantID: "grant-mixed", - Email: "mixed@example.com", - }}, - }, - NonAgentPolicyIDs: map[string]struct{}{ - "policy-mixed": {}, - "policy-inbound": {}, }, } @@ -383,29 +358,13 @@ func TestResolvePolicyForAgentOps(t *testing.T) { if assert.NoError(t, err) { assert.Equal(t, "policy-agent", resolved.Policy.ID) assert.True(t, resolved.AttachedToAgent) - assert.False(t, resolved.AttachedToNonAgent) } resolved, err = resolvePolicyForAgentOps(scope, "policy-unattached") if assert.NoError(t, err) { assert.Equal(t, "policy-unattached", resolved.Policy.ID) assert.False(t, resolved.AttachedToAgent) - assert.False(t, resolved.AttachedToNonAgent) } - - resolved, err = resolvePolicyForAgentOps(scope, "policy-mixed") - if assert.NoError(t, err) { - assert.Equal(t, "policy-mixed", resolved.Policy.ID) - assert.True(t, resolved.AttachedToAgent) - assert.True(t, resolved.AttachedToNonAgent) - if assert.Len(t, resolved.AgentAccounts, 1) { - assert.Equal(t, "mixed@example.com", resolved.AgentAccounts[0].Email) - } - } - - _, err = resolvePolicyForAgentOps(scope, "policy-inbound") - assert.Error(t, err) - assert.Contains(t, err.Error(), "outside the nylas agent scope") } func TestBuildRuleRefsByID(t *testing.T) { @@ -443,24 +402,9 @@ func TestRuleReferencedOutsideAgentScope(t *testing.T) { agentPolicies := []domain.Policy{ {ID: "policy-agent", Rules: []string{"rule-1"}}, } - nonAgentPolicyIDs := map[string]struct{}{ - "policy-other": {}, - } - - assert.True(t, ruleReferencedOutsideAgentScope(allPolicies, agentPolicies, nonAgentPolicyIDs, "rule-1")) - assert.False(t, ruleReferencedOutsideAgentScope(allPolicies, agentPolicies, nonAgentPolicyIDs, "rule-2")) - - mixedUsePolicies := []domain.Policy{ - {ID: "policy-mixed", Rules: []string{"rule-3"}}, - } - mixedUseAgentPolicies := []domain.Policy{ - {ID: "policy-mixed", Rules: []string{"rule-3"}}, - } - mixedUseNonAgentPolicyIDs := map[string]struct{}{ - "policy-mixed": {}, - } - assert.True(t, ruleReferencedOutsideAgentScope(mixedUsePolicies, mixedUseAgentPolicies, mixedUseNonAgentPolicyIDs, "rule-3")) + assert.True(t, ruleReferencedOutsideAgentScope(allPolicies, agentPolicies, "rule-1")) + assert.False(t, ruleReferencedOutsideAgentScope(allPolicies, agentPolicies, "rule-2")) } func TestPoliciesLeftEmptyByRuleRemoval(t *testing.T) { diff --git a/internal/cli/agent/policy.go b/internal/cli/agent/policy.go index a3ffde5..42fb0e4 100644 --- a/internal/cli/agent/policy.go +++ b/internal/cli/agent/policy.go @@ -17,10 +17,9 @@ type policyAgentAccountRef struct { } type resolvedPolicyScope struct { - Policy *domain.Policy - AgentAccounts []policyAgentAccountRef - AttachedToNonAgent bool - AttachedToAgent bool + Policy *domain.Policy + AgentAccounts []policyAgentAccountRef + AttachedToAgent bool } func newPolicyCmd() *cobra.Command { @@ -77,18 +76,6 @@ func buildPolicyAccountRefs(accounts []domain.AgentAccount) map[string][]policyA return refsByPolicyID } -func buildNonAgentPolicyIDs(inboxes []domain.InboundInbox) map[string]struct{} { - policyIDs := make(map[string]struct{}, len(inboxes)) - for _, inbox := range inboxes { - policyID := strings.TrimSpace(inbox.PolicyID) - if policyID == "" { - continue - } - policyIDs[policyID] = struct{}{} - } - return policyIDs -} - func filterPoliciesWithAgentAccounts(policies []domain.Policy, refsByPolicyID map[string][]policyAgentAccountRef) []domain.Policy { filtered := make([]domain.Policy, 0, len(policies)) for _, policy := range policies { @@ -111,20 +98,11 @@ func resolvePolicyForAgentOps(scope *agentPolicyScope, policyID string) (*resolv } agentAccounts := scope.PolicyRefsByID[policyID] - _, attachedToNonAgent := scope.NonAgentPolicyIDs[policyID] - - if len(agentAccounts) == 0 && attachedToNonAgent { - return nil, common.NewUserError( - "policy is attached outside the nylas agent scope", - "Use inbound policy management for provider=inbox policies, or attach this policy to a provider=nylas account first", - ) - } return &resolvedPolicyScope{ - Policy: policy, - AgentAccounts: agentAccounts, - AttachedToNonAgent: attachedToNonAgent, - AttachedToAgent: len(agentAccounts) > 0, + Policy: policy, + AgentAccounts: agentAccounts, + AttachedToAgent: len(agentAccounts) > 0, }, nil } diff --git a/internal/cli/agent/policy_create_update_delete.go b/internal/cli/agent/policy_create_update_delete.go index b83b493..6e6bdbe 100644 --- a/internal/cli/agent/policy_create_update_delete.go +++ b/internal/cli/agent/policy_create_update_delete.go @@ -114,16 +114,9 @@ func runPolicyUpdate(policyID string, payload map[string]any, jsonOutput bool) e return struct{}{}, err } - resolved, err := resolvePolicyForAgentOps(scope, policyID) - if err != nil { + if _, err := resolvePolicyForAgentOps(scope, policyID); err != nil { return struct{}{}, err } - if resolved.AttachedToAgent && resolved.AttachedToNonAgent { - return struct{}{}, common.NewUserError( - "policy is shared with non-agent accounts", - "Use a policy attached only to provider=nylas accounts, or manage the shared policy outside the agent namespace", - ) - } policy, err := client.UpdatePolicy(ctx, policyID, payload) if err != nil { @@ -187,12 +180,6 @@ func runPolicyDelete(policyID string) error { fmt.Sprintf("Detach or move the listed accounts to another policy before deleting %q", policyID), ) } - if resolved.AttachedToNonAgent { - return struct{}{}, common.NewUserError( - "policy is attached outside the nylas agent scope", - fmt.Sprintf("Delete or detach the non-agent attachments before deleting %q from the agent namespace", policyID), - ) - } if err := client.DeletePolicy(ctx, policyID); err != nil { return struct{}{}, common.WrapDeleteError("policy", err) diff --git a/internal/cli/agent/rule.go b/internal/cli/agent/rule.go index bb6412e..eb32552 100644 --- a/internal/cli/agent/rule.go +++ b/internal/cli/agent/rule.go @@ -21,10 +21,9 @@ type rulePolicyRef struct { } type agentPolicyScope struct { - AllPolicies []domain.Policy - AgentPolicies []domain.Policy - PolicyRefsByID map[string][]policyAgentAccountRef - NonAgentPolicyIDs map[string]struct{} + AllPolicies []domain.Policy + AgentPolicies []domain.Policy + PolicyRefsByID map[string][]policyAgentAccountRef } type resolvedRuleScope struct { @@ -77,16 +76,10 @@ func loadAgentPolicyScope(ctx context.Context, client ports.NylasClient) (*agent refsByPolicyID := buildPolicyAccountRefs(accounts) agentPolicies := filterPoliciesWithAgentAccounts(policies, refsByPolicyID) - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - return nil, common.WrapListError("inbound inboxes", err) - } - return &agentPolicyScope{ - AllPolicies: policies, - AgentPolicies: agentPolicies, - PolicyRefsByID: refsByPolicyID, - NonAgentPolicyIDs: buildNonAgentPolicyIDs(inboxes), + AllPolicies: policies, + AgentPolicies: agentPolicies, + PolicyRefsByID: refsByPolicyID, }, nil } @@ -257,7 +250,7 @@ func resolveScopedRule(ctx context.Context, client ports.NylasClient, ruleID, po SelectedRefs: selectedRefs, AllAgentRefs: allRefs, AllAgentPolicies: scope.AgentPolicies, - SharedOutsideAgent: ruleReferencedOutsideAgentScope(scope.AllPolicies, scope.AgentPolicies, scope.NonAgentPolicyIDs, ruleID), + SharedOutsideAgent: ruleReferencedOutsideAgentScope(scope.AllPolicies, scope.AgentPolicies, ruleID), }, nil } @@ -271,7 +264,7 @@ func filterRuleRefsByPolicyID(refs []rulePolicyRef, policyID string) []rulePolic return filtered } -func ruleReferencedOutsideAgentScope(allPolicies, agentPolicies []domain.Policy, nonAgentPolicyIDs map[string]struct{}, ruleID string) bool { +func ruleReferencedOutsideAgentScope(allPolicies, agentPolicies []domain.Policy, ruleID string) bool { agentPolicyIDs := make(map[string]struct{}, len(agentPolicies)) for _, policy := range agentPolicies { agentPolicyIDs[policy.ID] = struct{}{} @@ -284,9 +277,6 @@ func ruleReferencedOutsideAgentScope(allPolicies, agentPolicies []domain.Policy, if _, ok := agentPolicyIDs[policy.ID]; !ok { return true } - if _, ok := nonAgentPolicyIDs[policy.ID]; ok { - return true - } } return false diff --git a/internal/cli/auth/auth_test.go b/internal/cli/auth/auth_test.go index aede280..8d13aef 100644 --- a/internal/cli/auth/auth_test.go +++ b/internal/cli/auth/auth_test.go @@ -2,6 +2,7 @@ package auth import ( "bytes" + "strings" "testing" "github.com/nylas/cli/internal/cli/testutil" @@ -169,6 +170,9 @@ func TestParseLoginProvider(t *testing.T) { if err == nil { t.Fatalf("parseLoginProvider(%q) expected error", tt.input) } + if tt.input == "inbox" && !strings.Contains(err.Error(), "use 'google' or 'microsoft'") { + t.Fatalf("parseLoginProvider(%q) error = %q, want provider guidance", tt.input, err.Error()) + } return } if err != nil { diff --git a/internal/cli/auth/login.go b/internal/cli/auth/login.go index 1fa2d4c..821d403 100644 --- a/internal/cli/auth/login.go +++ b/internal/cli/auth/login.go @@ -83,6 +83,9 @@ func parseLoginProvider(provider string) (domain.Provider, error) { case string(domain.ProviderMicrosoft): return domain.ProviderMicrosoft, nil default: - return "", common.NewUserError(fmt.Sprintf("invalid provider: %s", provider), "use 'google' or 'microsoft'") + return "", common.NewUserError( + fmt.Sprintf("invalid provider: %s (use 'google' or 'microsoft')", provider), + "use 'google' or 'microsoft'", + ) } } diff --git a/internal/cli/auth/providers.go b/internal/cli/auth/providers.go index b74b718..25e3531 100644 --- a/internal/cli/auth/providers.go +++ b/internal/cli/auth/providers.go @@ -46,6 +46,7 @@ This command shows connectors configured for your Nylas application.`, if err != nil { return common.WrapFetchError("providers", err) } + connectors = common.FilterVisibleConnectors(connectors) if outputJSON { enc := json.NewEncoder(cmd.OutOrStdout()) @@ -106,8 +107,6 @@ func providerDisplayName(provider string) string { return "iCloud" case "ews": return "EWS" - case "inbox": - return "Inbox" case "virtual-calendar": return "Virtual Calendar" default: diff --git a/internal/cli/common/connectors.go b/internal/cli/common/connectors.go new file mode 100644 index 0000000..a0d2333 --- /dev/null +++ b/internal/cli/common/connectors.go @@ -0,0 +1,46 @@ +package common + +import ( + "strings" + + "github.com/nylas/cli/internal/domain" +) + +const deprecatedConnectorProviderInbox = "inbox" + +// IsDeprecatedConnectorProvider reports whether the CLI should hide or reject a +// connector provider that is no longer supported. +func IsDeprecatedConnectorProvider(provider string) bool { + return strings.EqualFold(strings.TrimSpace(provider), deprecatedConnectorProviderInbox) +} + +// FilterVisibleConnectors removes deprecated connector providers from CLI-facing +// listings while leaving the backend API surface unchanged. +func FilterVisibleConnectors(connectors []domain.Connector) []domain.Connector { + if len(connectors) == 0 { + return connectors + } + + filtered := make([]domain.Connector, 0, len(connectors)) + for _, connector := range connectors { + if IsDeprecatedConnectorProvider(connector.Provider) { + continue + } + filtered = append(filtered, connector) + } + + return filtered +} + +// ValidateSupportedConnectorProvider rejects connector providers that are no +// longer supported by the CLI. +func ValidateSupportedConnectorProvider(provider string) error { + if !IsDeprecatedConnectorProvider(provider) { + return nil + } + + return NewUserError( + "invalid provider: inbox", + "The inbox connector is no longer supported", + ) +} diff --git a/internal/cli/common/connectors_test.go b/internal/cli/common/connectors_test.go new file mode 100644 index 0000000..c6dbdf5 --- /dev/null +++ b/internal/cli/common/connectors_test.go @@ -0,0 +1,59 @@ +package common + +import ( + "testing" + + "github.com/nylas/cli/internal/domain" +) + +func TestIsDeprecatedConnectorProvider(t *testing.T) { + tests := []struct { + name string + provider string + want bool + }{ + {name: "matches inbox", provider: "inbox", want: true}, + {name: "matches inbox case insensitive", provider: "Inbox", want: true}, + {name: "ignores whitespace", provider: " inbox ", want: true}, + {name: "allows google", provider: "google", want: false}, + {name: "allows empty", provider: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsDeprecatedConnectorProvider(tt.provider); got != tt.want { + t.Fatalf("IsDeprecatedConnectorProvider(%q) = %t, want %t", tt.provider, got, tt.want) + } + }) + } +} + +func TestFilterVisibleConnectors(t *testing.T) { + connectors := []domain.Connector{ + {Provider: "google", Name: "Google"}, + {Provider: "inbox", Name: "Inbox"}, + {Provider: "microsoft", Name: "Microsoft"}, + } + + filtered := FilterVisibleConnectors(connectors) + if len(filtered) != 2 { + t.Fatalf("FilterVisibleConnectors() returned %d connectors, want 2", len(filtered)) + } + if filtered[0].Provider != "google" || filtered[1].Provider != "microsoft" { + t.Fatalf("FilterVisibleConnectors() returned %#v", filtered) + } +} + +func TestValidateSupportedConnectorProvider(t *testing.T) { + if err := ValidateSupportedConnectorProvider("google"); err != nil { + t.Fatalf("ValidateSupportedConnectorProvider(google) returned error: %v", err) + } + + err := ValidateSupportedConnectorProvider("inbox") + if err == nil { + t.Fatal("ValidateSupportedConnectorProvider(inbox) returned nil, want error") + } + if err.Error() != "invalid provider: inbox" { + t.Fatalf("ValidateSupportedConnectorProvider(inbox) error = %q, want %q", err.Error(), "invalid provider: inbox") + } +} diff --git a/internal/cli/email/send_managed_test.go b/internal/cli/email/send_managed_test.go index 07b2c31..a39be5e 100644 --- a/internal/cli/email/send_managed_test.go +++ b/internal/cli/email/send_managed_test.go @@ -12,66 +12,45 @@ import ( ) func TestSendMessageForGrant_UsesTransactionalSendForManagedProviders(t *testing.T) { - tests := []struct { - name string - provider domain.Provider - email string - }{ - { - name: "inbox grant uses transactional send", - provider: domain.ProviderInbox, - email: "info@qasim.nylas.email", - }, - { - name: "nylas grant uses transactional send", - provider: domain.ProviderNylas, - email: "xyz@qasim.nylas.email", - }, + client := nylas.NewMockClient() + client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { + t.Fatalf("SendMessage should not be called for managed provider %s", domain.ProviderNylas) + return nil, nil } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := nylas.NewMockClient() - client.SendMessageFunc = func(ctx context.Context, grantID string, req *domain.SendMessageRequest) (*domain.Message, error) { - t.Fatalf("SendMessage should not be called for managed provider %s", tt.provider) - return nil, nil - } + var gotDomain string + var gotFrom []domain.EmailParticipant + nylas.SendTransactionalMessageFunc = func(ctx context.Context, domainName string, req *domain.SendMessageRequest) (*domain.Message, error) { + gotDomain = domainName + gotFrom = append([]domain.EmailParticipant(nil), req.From...) + return &domain.Message{ID: "txn-msg-id", Subject: req.Subject}, nil + } + t.Cleanup(func() { + nylas.SendTransactionalMessageFunc = nil + }) - var gotDomain string - var gotFrom []domain.EmailParticipant - nylas.SendTransactionalMessageFunc = func(ctx context.Context, domainName string, req *domain.SendMessageRequest) (*domain.Message, error) { - gotDomain = domainName - gotFrom = append([]domain.EmailParticipant(nil), req.From...) - return &domain.Message{ID: "txn-msg-id", Subject: req.Subject}, nil - } - t.Cleanup(func() { - nylas.SendTransactionalMessageFunc = nil - }) - - req := &domain.SendMessageRequest{ - Subject: "Hello", - Body: "Body", - To: []domain.EmailParticipant{{Email: "to@example.com"}}, - } - grant := &domain.Grant{ - ID: "grant-123", - Provider: tt.provider, - Email: tt.email, - } + req := &domain.SendMessageRequest{ + Subject: "Hello", + Body: "Body", + To: []domain.EmailParticipant{{Email: "to@example.com"}}, + } + grant := &domain.Grant{ + ID: "grant-123", + Provider: domain.ProviderNylas, + Email: "xyz@qasim.nylas.email", + } - msg, err := sendMessageForGrant(context.Background(), client, "grant-123", grant, req) + msg, err := sendMessageForGrant(context.Background(), client, "grant-123", grant, req) - require.NoError(t, err) - require.NotNil(t, msg) - assert.Equal(t, "txn-msg-id", msg.ID) - assert.False(t, client.SendMessageCalled) - assert.Equal(t, "qasim.nylas.email", gotDomain) - require.Len(t, gotFrom, 1) - assert.Equal(t, tt.email, gotFrom[0].Email) - require.Len(t, req.From, 1) - assert.Equal(t, tt.email, req.From[0].Email) - }) - } + require.NoError(t, err) + require.NotNil(t, msg) + assert.Equal(t, "txn-msg-id", msg.ID) + assert.False(t, client.SendMessageCalled) + assert.Equal(t, "qasim.nylas.email", gotDomain) + require.Len(t, gotFrom, 1) + assert.Equal(t, grant.Email, gotFrom[0].Email) + require.Len(t, req.From, 1) + assert.Equal(t, grant.Email, req.From[0].Email) } func TestSendMessageForGrant_UsesGrantSendForStandardProviders(t *testing.T) { @@ -128,12 +107,6 @@ func TestValidateManagedSecureSendSupport(t *testing.T) { grant: &domain.Grant{Provider: domain.ProviderNylas}, wantError: true, }, - { - name: "managed inbox encrypt is rejected", - encrypt: true, - grant: &domain.Grant{Provider: domain.ProviderInbox}, - wantError: true, - }, { name: "standard provider sign is allowed", sign: true, diff --git a/internal/cli/email/signatures_support.go b/internal/cli/email/signatures_support.go index 0a64bbb..1b4a605 100644 --- a/internal/cli/email/signatures_support.go +++ b/internal/cli/email/signatures_support.go @@ -11,7 +11,7 @@ import ( ) func isManagedTransactionalGrant(grant *domain.Grant) bool { - return grant != nil && (grant.Provider == domain.ProviderInbox || grant.Provider == domain.ProviderNylas) + return grant != nil && grant.Provider == domain.ProviderNylas } func validateSendSignatureSupport(signatureID string, sign, encrypt bool, grant *domain.Grant) error { diff --git a/internal/cli/email/signatures_test.go b/internal/cli/email/signatures_test.go index 52f4361..c7ff38a 100644 --- a/internal/cli/email/signatures_test.go +++ b/internal/cli/email/signatures_test.go @@ -65,12 +65,6 @@ func TestValidateSendSignatureSupport(t *testing.T) { encrypt: true, wantErr: true, }, - { - name: "inbox provider rejects stored signatures", - signatureID: "sig-123", - grant: &domain.Grant{Provider: domain.ProviderInbox}, - wantErr: true, - }, { name: "standard provider allows stored signatures", signatureID: "sig-123", @@ -128,24 +122,6 @@ func TestValidateSignatureSelection(t *testing.T) { assert.True(t, mock.GetSignaturesCalled) }) - t.Run("rejects inbox grants before listing signatures", func(t *testing.T) { - mock := nylas.NewMockClient() - - signatures, err := validateSignatureSelection( - ctx, - mock, - "grant-123", - "sig-123", - &domain.Grant{Provider: domain.ProviderInbox}, - ) - - require.Error(t, err) - assert.Nil(t, signatures) - assert.ErrorContains(t, err, "`--signature-id` is not supported for managed transactional sends") - assert.False(t, mock.GetGrantCalled) - assert.False(t, mock.GetSignaturesCalled) - }) - t.Run("rejects nylas grants before listing signatures", func(t *testing.T) { mock := nylas.NewMockClient() diff --git a/internal/cli/inbound/create.go b/internal/cli/inbound/create.go deleted file mode 100644 index fbd4afe..0000000 --- a/internal/cli/inbound/create.go +++ /dev/null @@ -1,89 +0,0 @@ -package inbound - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/ports" - "github.com/spf13/cobra" -) - -func newCreateCmd() *cobra.Command { - var jsonOutput bool - - cmd := &cobra.Command{ - Use: "create ", - Short: "Create a new inbound inbox", - Long: `Create a new inbound inbox with a managed email address. - -You can provide either a full email address or just the local part (prefix). -Wildcards (*) are supported for catch-all patterns. - -Examples: - # Create with full email address - nylas inbound create support@yourapp.nylas.email - - # Create with just the prefix (domain added by API) - nylas inbound create support - - # Create a wildcard catch-all inbox - nylas inbound create "e2e-*@yourapp.nylas.email" - - # Create and output as JSON - nylas inbound create tickets --json`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runCreate(args[0], jsonOutput) - }, - } - - cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") - - return cmd -} - -func runCreate(email string, jsonOutput bool) error { - email = strings.TrimSpace(email) - if email == "" { - printError("Email address cannot be empty") - return common.NewInputError("email address cannot be empty") - } - - if strings.Contains(email, " ") { - printError("Email address should not contain spaces") - return common.NewInputError("invalid email address - should not contain spaces") - } - - _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - inbox, err := client.CreateInboundInbox(ctx, email) - if err != nil { - return struct{}{}, common.WrapCreateError("inbound inbox", err) - } - - // Save the new grant to local store so it appears in `nylas auth list` - saveGrantLocally(inbox.ID, inbox.Email) - - if jsonOutput { - data, _ := json.MarshalIndent(inbox, "", " ") - fmt.Println(string(data)) - return struct{}{}, nil - } - - printSuccess("Inbound inbox created successfully!") - fmt.Println() - printInboxDetails(*inbox) - - fmt.Println() - _, _ = common.Dim.Println("Next steps:") - _, _ = common.Dim.Printf(" 1. Set up a webhook: nylas webhooks create --url --triggers message.created\n") - _, _ = common.Dim.Printf(" 2. View messages: nylas inbound messages %s\n", inbox.ID) - _, _ = common.Dim.Printf(" 3. Monitor in real-time: nylas inbound monitor %s\n", inbox.ID) - - return struct{}{}, nil - }) - - return err -} diff --git a/internal/cli/inbound/delete.go b/internal/cli/inbound/delete.go deleted file mode 100644 index 74ac0b8..0000000 --- a/internal/cli/inbound/delete.go +++ /dev/null @@ -1,105 +0,0 @@ -package inbound - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/nylas/cli/internal/cli/common" - "github.com/spf13/cobra" -) - -func newDeleteCmd() *cobra.Command { - var ( - force bool - yes bool - ) - - cmd := &cobra.Command{ - Use: "delete ", - Short: "Delete an inbound inbox", - Long: `Delete an inbound inbox. - -This will permanently delete the inbox and all associated messages. -This action cannot be undone. - -Examples: - # Delete an inbox (with confirmation) - nylas inbound delete abc123 - - # Delete without confirmation - nylas inbound delete abc123 --yes - - # Use environment variable for inbox ID - export NYLAS_INBOUND_GRANT_ID=abc123 - nylas inbound delete --yes`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - inboxID, err := getInboxID(args) - if err != nil { - return err - } - - client, err := common.GetNylasClient() - if err != nil { - return err - } - - skipConfirm := yes || force - - // Get inbox details first for confirmation - ctx, cancel := common.CreateContext() - inbox, err := client.GetInboundInbox(ctx, inboxID) - cancel() - - if err != nil { - return common.WrapGetError("inbox", err) - } - - // Confirm deletion unless --yes flag is set - // Use stronger confirmation for destructive action - if !skipConfirm { - fmt.Printf("You are about to delete the inbound inbox:\n") - fmt.Printf(" Email: %s\n", common.Cyan.Sprint(inbox.Email)) - fmt.Printf(" ID: %s\n", inbox.ID) - fmt.Println() - _, _ = common.Yellow.Println("This action cannot be undone. All messages in this inbox will be deleted.") - fmt.Println() - - fmt.Print("Type 'delete' to confirm: ") - reader := bufio.NewReader(os.Stdin) - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - - if input != "delete" { - fmt.Println("Deletion cancelled.") - return nil - } - } - - // Delete the inbox - ctx2, cancel2 := common.CreateContext() - defer cancel2() - - err = common.RunWithSpinner("Deleting inbox...", func() error { - return client.DeleteInboundInbox(ctx2, inboxID) - }) - if err != nil { - return common.WrapDeleteError("inbox", err) - } - - // Remove from local grant store - removeGrantLocally(inboxID) - - printSuccess("Inbox %s deleted successfully!", inbox.Email) - - return nil - }, - } - - cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") - cmd.Flags().BoolVarP(&force, "force", "f", false, "Force delete without confirmation (alias for --yes)") - - return cmd -} diff --git a/internal/cli/inbound/helpers.go b/internal/cli/inbound/helpers.go deleted file mode 100644 index 3790508..0000000 --- a/internal/cli/inbound/helpers.go +++ /dev/null @@ -1,115 +0,0 @@ -package inbound - -import ( - "fmt" - "os" - "strings" - - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/domain" -) - -// getInboxID gets the inbox ID from args or environment variable. -func getInboxID(args []string) (string, error) { - if len(args) > 0 { - return args[0], nil - } - - // Try to get from environment variable - if envID := os.Getenv("NYLAS_INBOUND_GRANT_ID"); envID != "" { - return envID, nil - } - - return "", common.NewUserError("inbox ID required", "Provide as argument or set NYLAS_INBOUND_GRANT_ID environment variable") -} - -// printError prints an error message in red. -// Delegates to common.PrintError for consistent error formatting. -func printError(format string, args ...any) { - common.PrintError(format, args...) -} - -// printSuccess prints a success message in green. -// Delegates to common.PrintSuccess for consistent success formatting. -func printSuccess(format string, args ...any) { - common.PrintSuccess(format, args...) -} - -// printInboxSummary prints a single-line inbox summary. -func printInboxSummary(inbox domain.InboundInbox, index int) { - status := common.Green.Sprint("active") - if inbox.GrantStatus != "valid" { - status = common.Yellow.Sprint(inbox.GrantStatus) - } - - createdStr := common.FormatTimeAgo(inbox.CreatedAt.Time) - - fmt.Printf("%d. %-40s %s %s\n", - index+1, - common.Cyan.Sprint(inbox.Email), - common.Dim.Sprint(createdStr), - status, - ) - _, _ = common.Dim.Printf(" ID: %s\n", inbox.ID) -} - -// printInboxDetails prints detailed inbox information. -func printInboxDetails(inbox domain.InboundInbox) { - fmt.Println(strings.Repeat("─", 60)) - _, _ = common.BoldWhite.Printf("Inbox: %s\n", inbox.Email) - fmt.Println(strings.Repeat("─", 60)) - fmt.Printf("ID: %s\n", inbox.ID) - fmt.Printf("Email: %s\n", inbox.Email) - fmt.Printf("Status: %s\n", formatStatus(inbox.GrantStatus)) - fmt.Printf("Created: %s (%s)\n", inbox.CreatedAt.Format(common.DisplayDateTime), common.FormatTimeAgo(inbox.CreatedAt.Time)) - if !inbox.UpdatedAt.IsZero() { - fmt.Printf("Updated: %s (%s)\n", inbox.UpdatedAt.Format(common.DisplayDateTime), common.FormatTimeAgo(inbox.UpdatedAt.Time)) - } - fmt.Println() -} - -// formatStatus formats the grant status with color. -func formatStatus(status string) string { - return common.FormatGrantStatus(status) -} - -// saveGrantLocally saves the inbound inbox grant to the local keyring store. -func saveGrantLocally(grantID, email string) { - common.SaveGrantLocally(grantID, email, domain.ProviderInbox) -} - -// removeGrantLocally removes the inbound inbox grant from the local keyring store. -func removeGrantLocally(grantID string) { - common.RemoveGrantLocally(grantID) -} - -// printInboundMessageSummary prints an inbound message summary. -func printInboundMessageSummary(msg domain.InboundMessage, _ int) { - status := " " - if msg.Unread { - status = common.Cyan.Sprint("●") - } - - star := " " - if msg.Starred { - star = common.Yellow.Sprint("★") - } - - from := common.FormatParticipants(msg.From) - if len(from) > 20 { - from = from[:17] + "..." - } - - subject := msg.Subject - if len(subject) > 40 { - subject = subject[:37] + "..." - } - - dateStr := common.FormatTimeAgo(msg.Date) - if len(dateStr) > 12 { - dateStr = msg.Date.Format("Jan 2") - } - - fmt.Printf("%s %s %-20s %-40s %s\n", status, star, from, subject, common.Dim.Sprint(dateStr)) - _, _ = common.Dim.Printf(" ID: %s\n", msg.ID) -} diff --git a/internal/cli/inbound/inbound.go b/internal/cli/inbound/inbound.go deleted file mode 100644 index e37a166..0000000 --- a/internal/cli/inbound/inbound.go +++ /dev/null @@ -1,49 +0,0 @@ -// Package inbound provides CLI commands for Nylas Inbound email functionality. -// Nylas Inbound allows applications to receive emails at managed addresses -// without building OAuth flows or connecting to third-party mailboxes. -package inbound - -import ( - "github.com/spf13/cobra" -) - -// NewInboundCmd creates the inbound command group. -func NewInboundCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "inbound", - Aliases: []string{"inbox"}, - Short: "Manage inbound email inboxes", - Long: `Manage Nylas Inbound email inboxes. - -Nylas Inbound enables your application to receive emails at dedicated managed -addresses (e.g., support@yourapp.nylas.email) and process them via webhooks. - -Use cases: - - Capturing messages sent to specific addresses (intake@, leads@, tickets@) - - Triggering automated workflows from incoming mail - - Real-time message delivery to workers, LLMs, or downstream systems - -Examples: - # List all inbound inboxes - nylas inbound list - - # Create a new inbound inbox - nylas inbound create support - - # View messages for an inbound inbox - nylas inbound messages - - # Monitor for new inbound messages in real-time - nylas inbound monitor `, - } - - // Add subcommands - cmd.AddCommand(newListCmd()) - cmd.AddCommand(newShowCmd()) - cmd.AddCommand(newCreateCmd()) - cmd.AddCommand(newDeleteCmd()) - cmd.AddCommand(newMessagesCmd()) - cmd.AddCommand(newMonitorCmd()) - - return cmd -} diff --git a/internal/cli/inbound/inbound_test.go b/internal/cli/inbound/inbound_test.go deleted file mode 100644 index 4f95a42..0000000 --- a/internal/cli/inbound/inbound_test.go +++ /dev/null @@ -1,493 +0,0 @@ -package inbound - -import ( - "testing" - - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/cli/testutil" - "github.com/nylas/cli/internal/domain" - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" -) - -// executeCommand executes a command and captures its output. -func executeCommand(root *cobra.Command, args ...string) (string, string, error) { - return testutil.ExecuteCommand(root, args...) -} - -// ============================================================================= -// MAIN COMMAND TESTS -// ============================================================================= - -func TestNewInboundCmd(t *testing.T) { - cmd := NewInboundCmd() - - t.Run("command_name", func(t *testing.T) { - assert.Equal(t, "inbound", cmd.Use) - }) - - t.Run("has_inbox_alias", func(t *testing.T) { - assert.Contains(t, cmd.Aliases, "inbox") - }) - - t.Run("has_short_description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "inbound") - }) - - t.Run("has_long_description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Long) - assert.Contains(t, cmd.Long, "Nylas Inbound") - }) - - t.Run("has_subcommands", func(t *testing.T) { - subcommands := cmd.Commands() - assert.NotEmpty(t, subcommands) - }) - - t.Run("has_required_subcommands", func(t *testing.T) { - expectedCmds := []string{"list", "show", "create", "delete", "messages", "monitor"} - - cmdMap := make(map[string]bool) - for _, sub := range cmd.Commands() { - cmdMap[sub.Name()] = true - } - - for _, expected := range expectedCmds { - assert.True(t, cmdMap[expected], "Missing expected subcommand: %s", expected) - } - }) -} - -// ============================================================================= -// LIST COMMAND TESTS -// ============================================================================= - -func TestListCommand(t *testing.T) { - cmd := newListCmd() - - t.Run("command_name", func(t *testing.T) { - assert.Equal(t, "list", cmd.Use) - }) - - t.Run("has_short_description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "List") - }) - - t.Run("has_json_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) - }) - - t.Run("has_long_description_with_examples", func(t *testing.T) { - assert.Contains(t, cmd.Long, "Examples") - }) -} - -// ============================================================================= -// SHOW COMMAND TESTS -// ============================================================================= - -func TestShowCommand(t *testing.T) { - cmd := newShowCmd() - - t.Run("command_name", func(t *testing.T) { - assert.Equal(t, "show ", cmd.Use) - }) - - t.Run("has_short_description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "Show") - }) - - t.Run("has_json_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) - }) - - t.Run("has_long_description_with_examples", func(t *testing.T) { - assert.Contains(t, cmd.Long, "Examples") - assert.Contains(t, cmd.Long, "NYLAS_INBOUND_GRANT_ID") - }) -} - -// ============================================================================= -// CREATE COMMAND TESTS -// ============================================================================= - -func TestCreateCommand(t *testing.T) { - cmd := newCreateCmd() - - t.Run("command_name", func(t *testing.T) { - assert.Equal(t, "create ", cmd.Use) - }) - - t.Run("has_short_description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "Create") - }) - - t.Run("has_json_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) - }) - - t.Run("requires_one_argument", func(t *testing.T) { - assert.Contains(t, cmd.Use, "") - }) - - t.Run("has_long_description_with_examples", func(t *testing.T) { - assert.Contains(t, cmd.Long, "Examples") - assert.Contains(t, cmd.Long, "support") - assert.Contains(t, cmd.Long, "nylas.email") - }) -} - -// ============================================================================= -// DELETE COMMAND TESTS -// ============================================================================= - -func TestDeleteCommand(t *testing.T) { - cmd := newDeleteCmd() - - t.Run("command_name", func(t *testing.T) { - assert.Equal(t, "delete ", cmd.Use) - }) - - t.Run("has_short_description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "Delete") - }) - - t.Run("has_yes_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("yes") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) - }) - - t.Run("has_yes_shorthand", func(t *testing.T) { - flag := cmd.Flags().ShorthandLookup("y") - assert.NotNil(t, flag) - assert.Equal(t, "yes", flag.Name) - }) - - t.Run("has_force_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("force") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) - }) - - t.Run("has_force_shorthand", func(t *testing.T) { - flag := cmd.Flags().ShorthandLookup("f") - assert.NotNil(t, flag) - assert.Equal(t, "force", flag.Name) - }) - - t.Run("has_long_description_with_examples", func(t *testing.T) { - assert.Contains(t, cmd.Long, "Examples") - assert.Contains(t, cmd.Long, "--yes") - }) -} - -// ============================================================================= -// MESSAGES COMMAND TESTS -// ============================================================================= - -func TestMessagesCommand(t *testing.T) { - cmd := newMessagesCmd() - - t.Run("command_name", func(t *testing.T) { - assert.Equal(t, "messages [inbox-id]", cmd.Use) - }) - - t.Run("has_short_description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "messages") - }) - - t.Run("has_limit_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("limit") - assert.NotNil(t, flag) - assert.Equal(t, "10", flag.DefValue) - }) - - t.Run("has_limit_shorthand", func(t *testing.T) { - flag := cmd.Flags().ShorthandLookup("l") - assert.NotNil(t, flag) - assert.Equal(t, "limit", flag.Name) - }) - - t.Run("has_unread_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("unread") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) - }) - - t.Run("has_unread_shorthand", func(t *testing.T) { - flag := cmd.Flags().ShorthandLookup("u") - assert.NotNil(t, flag) - assert.Equal(t, "unread", flag.Name) - }) - - t.Run("has_json_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) - }) - - t.Run("has_long_description_with_examples", func(t *testing.T) { - assert.Contains(t, cmd.Long, "Examples") - assert.Contains(t, cmd.Long, "NYLAS_INBOUND_GRANT_ID") - }) -} - -// ============================================================================= -// MONITOR COMMAND TESTS -// ============================================================================= - -func TestMonitorCommand(t *testing.T) { - cmd := newMonitorCmd() - - t.Run("command_name", func(t *testing.T) { - assert.Equal(t, "monitor [inbox-id]", cmd.Use) - }) - - t.Run("has_short_description", func(t *testing.T) { - assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "Monitor") - }) - - t.Run("has_port_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("port") - assert.NotNil(t, flag) - assert.Equal(t, "3000", flag.DefValue) - }) - - t.Run("has_port_shorthand", func(t *testing.T) { - flag := cmd.Flags().ShorthandLookup("p") - assert.NotNil(t, flag) - assert.Equal(t, "port", flag.Name) - }) - - t.Run("has_tunnel_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("tunnel") - assert.NotNil(t, flag) - }) - - t.Run("has_tunnel_shorthand", func(t *testing.T) { - flag := cmd.Flags().ShorthandLookup("t") - assert.NotNil(t, flag) - assert.Equal(t, "tunnel", flag.Name) - }) - - t.Run("has_secret_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("secret") - assert.NotNil(t, flag) - }) - - t.Run("has_secret_shorthand", func(t *testing.T) { - flag := cmd.Flags().ShorthandLookup("s") - assert.NotNil(t, flag) - assert.Equal(t, "secret", flag.Name) - }) - - t.Run("has_json_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("json") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) - }) - - t.Run("has_quiet_flag", func(t *testing.T) { - flag := cmd.Flags().Lookup("quiet") - assert.NotNil(t, flag) - assert.Equal(t, "false", flag.DefValue) - }) - - t.Run("has_quiet_shorthand", func(t *testing.T) { - flag := cmd.Flags().ShorthandLookup("q") - assert.NotNil(t, flag) - assert.Equal(t, "quiet", flag.Name) - }) - - t.Run("has_long_description_with_examples", func(t *testing.T) { - assert.Contains(t, cmd.Long, "Examples") - assert.Contains(t, cmd.Long, "cloudflared") - assert.Contains(t, cmd.Long, "Ctrl+C") - }) -} - -// ============================================================================= -// HELPER FUNCTION TESTS -// ============================================================================= - -func TestGetInboxID(t *testing.T) { - t.Run("returns_first_arg_when_provided", func(t *testing.T) { - id, err := getInboxID([]string{"test-inbox-id"}) - assert.NoError(t, err) - assert.Equal(t, "test-inbox-id", id) - }) - - t.Run("returns_error_when_no_args_and_no_env", func(t *testing.T) { - // Ensure env var is not set - t.Setenv("NYLAS_INBOUND_GRANT_ID", "") - _, err := getInboxID([]string{}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "inbox ID required") - }) - - t.Run("returns_env_var_when_no_args", func(t *testing.T) { - t.Setenv("NYLAS_INBOUND_GRANT_ID", "env-inbox-id") - id, err := getInboxID([]string{}) - assert.NoError(t, err) - assert.Equal(t, "env-inbox-id", id) - }) - - t.Run("prefers_arg_over_env_var", func(t *testing.T) { - t.Setenv("NYLAS_INBOUND_GRANT_ID", "env-inbox-id") - id, err := getInboxID([]string{"arg-inbox-id"}) - assert.NoError(t, err) - assert.Equal(t, "arg-inbox-id", id) - }) -} - -func TestTruncate(t *testing.T) { - tests := []struct { - input string - maxLen int - expected string - }{ - {"hello", 10, "hello"}, - {"hello world", 8, "hello..."}, - {"short", 5, "short"}, - {"exactly10!", 10, "exactly10!"}, - {"longer than max", 10, "longer ..."}, - } - - for _, tt := range tests { - got := common.Truncate(tt.input, tt.maxLen) - assert.Equal(t, tt.expected, got) - } -} - -func TestFormatParticipant(t *testing.T) { - tests := []struct { - contact domain.EmailParticipant - expected string - }{ - {domain.EmailParticipant{Name: "John", Email: "john@example.com"}, "John"}, - {domain.EmailParticipant{Name: "", Email: "jane@example.com"}, "jane@example.com"}, - {domain.EmailParticipant{Name: "Alice", Email: ""}, "Alice"}, - } - - for _, tt := range tests { - got := common.FormatParticipant(tt.contact) - assert.Equal(t, tt.expected, got) - } -} - -func TestFormatParticipants(t *testing.T) { - contacts := []domain.EmailParticipant{ - {Name: "John", Email: "john@example.com"}, - {Name: "", Email: "jane@example.com"}, - } - got := common.FormatParticipants(contacts) - assert.Equal(t, "John, jane@example.com", got) -} - -func TestFormatStatus(t *testing.T) { - tests := []struct { - status string - contains string - }{ - {"valid", "active"}, - {"invalid", "invalid"}, - {"pending", "pending"}, - } - - for _, tt := range tests { - got := formatStatus(tt.status) - assert.Contains(t, got, tt.contains) - } -} - -// ============================================================================= -// HELP OUTPUT TESTS -// ============================================================================= - -func TestInboundCommandHelp(t *testing.T) { - cmd := NewInboundCmd() - stdout, _, err := executeCommand(cmd, "--help") - - assert.NoError(t, err) - - expectedStrings := []string{ - "inbound", - "list", - "show", - "create", - "delete", - "messages", - "monitor", - } - - for _, expected := range expectedStrings { - assert.Contains(t, stdout, expected, "Help output should contain %q", expected) - } -} - -func TestInboundListHelp(t *testing.T) { - cmd := NewInboundCmd() - stdout, _, err := executeCommand(cmd, "list", "--help") - - assert.NoError(t, err) - assert.Contains(t, stdout, "list") - assert.Contains(t, stdout, "--json") -} - -func TestInboundCreateHelp(t *testing.T) { - cmd := NewInboundCmd() - stdout, _, err := executeCommand(cmd, "create", "--help") - - assert.NoError(t, err) - assert.Contains(t, stdout, "create") - assert.Contains(t, stdout, "--json") - assert.Contains(t, stdout, "email") -} - -func TestInboundDeleteHelp(t *testing.T) { - cmd := NewInboundCmd() - stdout, _, err := executeCommand(cmd, "delete", "--help") - - assert.NoError(t, err) - assert.Contains(t, stdout, "delete") - assert.Contains(t, stdout, "--yes") - assert.Contains(t, stdout, "--force") -} - -func TestInboundMessagesHelp(t *testing.T) { - cmd := NewInboundCmd() - stdout, _, err := executeCommand(cmd, "messages", "--help") - - assert.NoError(t, err) - assert.Contains(t, stdout, "messages") - assert.Contains(t, stdout, "--limit") - assert.Contains(t, stdout, "--unread") - assert.Contains(t, stdout, "--json") -} - -func TestInboundMonitorHelp(t *testing.T) { - cmd := NewInboundCmd() - stdout, _, err := executeCommand(cmd, "monitor", "--help") - - assert.NoError(t, err) - assert.Contains(t, stdout, "monitor") - assert.Contains(t, stdout, "--port") - assert.Contains(t, stdout, "--tunnel") - assert.Contains(t, stdout, "--secret") - assert.Contains(t, stdout, "--json") - assert.Contains(t, stdout, "--quiet") -} diff --git a/internal/cli/inbound/list.go b/internal/cli/inbound/list.go deleted file mode 100644 index 8d36156..0000000 --- a/internal/cli/inbound/list.go +++ /dev/null @@ -1,70 +0,0 @@ -package inbound - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/ports" - "github.com/spf13/cobra" -) - -func newListCmd() *cobra.Command { - var jsonOutput bool - - cmd := &cobra.Command{ - Use: "list", - Short: "List all inbound inboxes", - Long: `List all inbound inboxes for your Nylas application. - -Inbound inboxes are managed email addresses that can receive emails without -requiring OAuth authentication. - -Examples: - # List all inbound inboxes - nylas inbound list - - # List inboxes as JSON - nylas inbound list --json`, - RunE: func(cmd *cobra.Command, args []string) error { - return runList(jsonOutput) - }, - } - - cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") - - return cmd -} - -func runList(jsonOutput bool) error { - _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - return struct{}{}, common.WrapListError("inboxes", err) - } - - if jsonOutput { - data, _ := json.MarshalIndent(inboxes, "", " ") - fmt.Println(string(data)) - return struct{}{}, nil - } - - if len(inboxes) == 0 { - common.PrintEmptyStateWithHint("inboxes", "Create one with: nylas inbound create ") - return struct{}{}, nil - } - - _, _ = common.BoldWhite.Printf("Inbound Inboxes (%d)\n\n", len(inboxes)) - - for i, inbox := range inboxes { - printInboxSummary(inbox, i) - } - - fmt.Println() - _, _ = common.Dim.Println("Use 'nylas inbound messages [inbox-id]' to view messages") - - return struct{}{}, nil - }) - return err -} diff --git a/internal/cli/inbound/messages.go b/internal/cli/inbound/messages.go deleted file mode 100644 index 506c335..0000000 --- a/internal/cli/inbound/messages.go +++ /dev/null @@ -1,116 +0,0 @@ -package inbound - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/domain" - "github.com/nylas/cli/internal/ports" - "github.com/spf13/cobra" -) - -func newMessagesCmd() *cobra.Command { - var ( - limit int - unread bool - jsonOutput bool - ) - - cmd := &cobra.Command{ - Use: "messages [inbox-id]", - Short: "List messages in an inbound inbox", - Long: `List messages received at an inbound inbox. - -Examples: - # List messages for an inbox - nylas inbound messages abc123 - - # List only unread messages - nylas inbound messages abc123 --unread - - # Limit to 5 messages - nylas inbound messages abc123 --limit 5 - - # Output as JSON - nylas inbound messages abc123 --json - - # Use environment variable for inbox ID - export NYLAS_INBOUND_GRANT_ID=abc123 - nylas inbound messages`, - RunE: func(cmd *cobra.Command, args []string) error { - return runMessages(args, limit, unread, jsonOutput) - }, - } - - cmd.Flags().IntVarP(&limit, "limit", "l", 10, "Maximum number of messages to show") - cmd.Flags().BoolVarP(&unread, "unread", "u", false, "Show only unread messages") - cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") - - return cmd -} - -func runMessages(args []string, limit int, unread bool, jsonOutput bool) error { - inboxID, err := getInboxID(args) - if err != nil { - printError("%v", err) - return err - } - - _, err = common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - // Build query params - params := &domain.MessageQueryParams{ - Limit: limit, - } - if unread { - unreadVal := true - params.Unread = &unreadVal - } - - messages, err := client.GetInboundMessages(ctx, inboxID, params) - if err != nil { - return struct{}{}, common.WrapListError("messages", err) - } - - if jsonOutput { - data, _ := json.MarshalIndent(messages, "", " ") - fmt.Println(string(data)) - return struct{}{}, nil - } - - if len(messages) == 0 { - if unread { - common.PrintEmptyState("unread messages") - } else { - common.PrintEmptyStateWithHint("messages", "Send an email to the inbox address to receive messages here") - } - return struct{}{}, nil - } - - // Count unread - unreadCount := 0 - for _, msg := range messages { - if msg.Unread { - unreadCount++ - } - } - - if unread { - _, _ = common.BoldWhite.Printf("Unread Messages (%d)\n\n", len(messages)) - } else { - _, _ = common.BoldWhite.Printf("Messages (%d total, %d unread)\n\n", len(messages), unreadCount) - } - - for i, msg := range messages { - printInboundMessageSummary(msg, i) - } - - fmt.Println() - _, _ = common.Dim.Println("Use 'nylas email read [inbox-id]' to view full message") - - return struct{}{}, nil - }) - - return err -} diff --git a/internal/cli/inbound/monitor.go b/internal/cli/inbound/monitor.go deleted file mode 100644 index 5d6f6e7..0000000 --- a/internal/cli/inbound/monitor.go +++ /dev/null @@ -1,324 +0,0 @@ -package inbound - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/signal" - "strings" - "syscall" - - "github.com/nylas/cli/internal/adapters/tunnel" - "github.com/nylas/cli/internal/adapters/webhookserver" - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/domain" - "github.com/nylas/cli/internal/ports" - "github.com/spf13/cobra" -) - -func newMonitorCmd() *cobra.Command { - var ( - port int - tunnelType string - webhookSecret string - jsonOutput bool - quiet bool - ) - - cmd := &cobra.Command{ - Use: "monitor [inbox-id]", - Short: "Monitor an inbound inbox for new messages in real-time", - Long: `Monitor an inbound inbox for new messages via webhooks. - -This starts a local webhook server to receive real-time notifications -when new emails arrive at your inbound inbox. The server can optionally -be exposed via a tunnel for receiving webhooks from the internet. - -Examples: - # Start monitoring with default settings - nylas inbound monitor abc123 - - # Monitor with cloudflared tunnel (for public access) - nylas inbound monitor abc123 --tunnel cloudflared - - # Monitor on custom port - nylas inbound monitor abc123 --port 8080 - - # Output events as JSON - nylas inbound monitor abc123 --tunnel cloudflared --json - - # Use environment variable for inbox ID - export NYLAS_INBOUND_GRANT_ID=abc123 - nylas inbound monitor --tunnel cloudflared - -Press Ctrl+C to stop monitoring.`, - RunE: func(cmd *cobra.Command, args []string) error { - return runMonitor(args, port, tunnelType, webhookSecret, jsonOutput, quiet) - }, - } - - cmd.Flags().IntVarP(&port, "port", "p", 3000, "Port to listen on") - cmd.Flags().StringVarP(&tunnelType, "tunnel", "t", "", "Tunnel provider (cloudflared)") - cmd.Flags().StringVarP(&webhookSecret, "secret", "s", "", "Webhook secret for signature verification") - cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output events as JSON") - cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Suppress startup messages, only show events") - - return cmd -} - -func runMonitor(args []string, port int, tunnelType, webhookSecret string, jsonOutput, quiet bool) error { - inboxID, err := getInboxID(args) - if err != nil { - printError("%v", err) - return err - } - - // Get inbox details using WithClientNoGrant - inbox, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (*domain.InboundInbox, error) { - inbox, err := client.GetInboundInbox(ctx, inboxID) - if err != nil { - return nil, common.WrapGetError("inbox", err) - } - return inbox, nil - }) - if err != nil { - return err - } - - // Create server config - config := ports.WebhookServerConfig{ - Port: port, - Path: "/webhook", - WebhookSecret: webhookSecret, - TunnelProvider: tunnelType, - } - - // Create webhook server - server := webhookserver.NewServer(config) - - // Set up tunnel if requested - if tunnelType != "" { - switch strings.ToLower(tunnelType) { - case "cloudflared", "cloudflare", "cf": - if !tunnel.IsCloudflaredInstalled() { - return common.NewUserError( - "cloudflared is not installed", - "Install it with: brew install cloudflared (macOS) or see https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/", - ) - } - localURL := fmt.Sprintf("http://localhost:%d", port) - t := tunnel.NewCloudflaredTunnel(localURL) - server.SetTunnel(t) - default: - return common.NewUserError( - fmt.Sprintf("unsupported tunnel provider: %s", tunnelType), - "Supported providers: cloudflared", - ) - } - } - - // Set up context with cancellation - serverCtx, serverCancel := context.WithCancel(context.Background()) - defer serverCancel() - - // Handle interrupt signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - // Print startup banner - if !quiet { - printMonitorBanner(inbox.Email) - } - - // Start spinner while starting tunnel - var spinner *common.Spinner - if tunnelType != "" && !quiet { - spinner = common.NewSpinner("Starting tunnel...") - spinner.Start() - } - - // Start the server - if err := server.Start(serverCtx); err != nil { - if spinner != nil { - spinner.Stop() - } - return common.WrapError(err) - } - - if spinner != nil { - spinner.Stop() - } - - // Print server info - stats := server.GetStats() - if !quiet { - printMonitorInfo(stats, tunnelType, inbox.Email) - } - - // Event display loop - filter for inbound events - go func() { - for event := range server.Events() { - // Filter for message.created events from inbox source - if event.Type == "message.created" && event.Source == "inbox" { - // Only show events for our inbox - if event.GrantID == "" || event.GrantID == inboxID { - if jsonOutput { - printEventJSON(event) - } else { - printInboundEvent(event, quiet) - } - } - } else if strings.HasPrefix(event.Type, "message.") { - // Show all message events if no source filter - if jsonOutput { - printEventJSON(event) - } else { - printInboundEvent(event, quiet) - } - } - } - }() - - // Wait for interrupt - <-sigChan - - if !quiet { - fmt.Println("\n\nStopping monitor...") - } - - // Stop the server - if err := server.Stop(); err != nil { - return common.WrapError(err) - } - - if !quiet { - finalStats := server.GetStats() - fmt.Printf("Monitor stopped. Total events received: %d\n", finalStats.EventsReceived) - } - - return nil -} - -func printMonitorBanner(email string) { - fmt.Println() - _, _ = common.Cyan.Println("╔══════════════════════════════════════════════════════════════╗") - _, _ = common.Cyan.Print("║") - fmt.Print(" ") - _, _ = common.BoldWhite.Print("Nylas Inbound Monitor") - fmt.Print(" ") - _, _ = common.Cyan.Println("║") - _, _ = common.Cyan.Println("╚══════════════════════════════════════════════════════════════╝") - fmt.Println() - fmt.Printf("Monitoring: %s\n", common.Cyan.Sprint(email)) - fmt.Println() -} - -func printMonitorInfo(stats ports.WebhookServerStats, tunnelType, _ string) { - _, _ = common.Green.Println("Monitor started successfully!") - fmt.Println() - - _, _ = common.BoldWhite.Print(" Local URL: ") - fmt.Println(stats.LocalURL) - - if stats.PublicURL != "" { - _, _ = common.BoldWhite.Print(" Public URL: ") - _, _ = common.Green.Println(stats.PublicURL) - fmt.Println() - _, _ = common.BoldWhite.Print(" Tunnel: ") - fmt.Printf("%s (%s)\n", tunnelType, stats.TunnelStatus) - } - - fmt.Println() - _, _ = common.Yellow.Println("To receive events, register this webhook URL with Nylas:") - webhookURL := stats.LocalURL - if stats.PublicURL != "" { - webhookURL = stats.PublicURL - } - fmt.Printf(" nylas webhooks create --url %s --triggers message.created\n", webhookURL) - fmt.Println() - _, _ = common.Dim.Println("Press Ctrl+C to stop") - fmt.Println() - _, _ = common.Cyan.Println("─────────────────────────────────────────────────────────────────") - _, _ = common.BoldWhite.Println("Incoming Messages:") - fmt.Println() -} - -func printEventJSON(event *ports.WebhookEvent) { - data, _ := json.Marshal(event) - fmt.Println(string(data)) -} - -func printInboundEvent(event *ports.WebhookEvent, quiet bool) { - timestamp := event.ReceivedAt.Format("15:04:05") - - // Determine verification status - verifyIcon := "" - if event.Signature != "" { - if event.Verified { - verifyIcon = common.Green.Sprint(" [verified]") - } else { - verifyIcon = common.Red.Sprint(" [unverified]") - } - } - - // Event type coloring - var typeStr string - switch event.Type { - case "message.created": - typeStr = common.Green.Sprint("NEW MESSAGE") - case "message.updated": - typeStr = common.Cyan.Sprint("UPDATED") - case "message.opened": - typeStr = common.Yellow.Sprint("OPENED") - default: - typeStr = common.Dim.Sprint(event.Type) - } - - fmt.Printf("%s %s%s\n", - common.Dim.Sprintf("[%s]", timestamp), - typeStr, - verifyIcon, - ) - - if !quiet { - // Print message details - if event.Body != nil { - if data, ok := event.Body["data"].(map[string]any); ok { - if obj, ok := data["object"].(map[string]any); ok { - // Print subject - if subject, ok := obj["subject"].(string); ok { - fmt.Printf(" %s %s\n", common.Dim.Sprint("Subject:"), common.Truncate(subject, 60)) - } - // Print from - if from, ok := obj["from"].([]any); ok && len(from) > 0 { - if fromObj, ok := from[0].(map[string]any); ok { - email := "" - name := "" - if e, ok := fromObj["email"].(string); ok { - email = e - } - if n, ok := fromObj["name"].(string); ok { - name = n - } - if name != "" { - fmt.Printf(" %s %s <%s>\n", common.Dim.Sprint("From:"), name, email) - } else { - fmt.Printf(" %s %s\n", common.Dim.Sprint("From:"), email) - } - } - } - // Print snippet - if snippet, ok := obj["snippet"].(string); ok { - fmt.Printf(" %s %s\n", common.Dim.Sprint("Preview:"), common.Truncate(snippet, 60)) - } - // Print message ID - if id, ok := obj["id"].(string); ok { - _, _ = common.Dim.Printf(" ID: %s\n", id) - } - } - } - } - fmt.Println() - } -} diff --git a/internal/cli/inbound/show.go b/internal/cli/inbound/show.go deleted file mode 100644 index 977d6e7..0000000 --- a/internal/cli/inbound/show.go +++ /dev/null @@ -1,63 +0,0 @@ -package inbound - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/ports" - "github.com/spf13/cobra" -) - -func newShowCmd() *cobra.Command { - var jsonOutput bool - - cmd := &cobra.Command{ - Use: "show ", - Short: "Show details of an inbound inbox", - Long: `Show detailed information about a specific inbound inbox. - -Examples: - # Show inbox details - nylas inbound show abc123 - - # Show as JSON - nylas inbound show abc123 --json - - # Use environment variable for inbox ID - export NYLAS_INBOUND_GRANT_ID=abc123 - nylas inbound show`, - RunE: func(cmd *cobra.Command, args []string) error { - return runShow(args, jsonOutput) - }, - } - - cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") - - return cmd -} - -func runShow(args []string, jsonOutput bool) error { - inboxID, err := getInboxID(args) - if err != nil { - return err - } - - _, err = common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - inbox, err := client.GetInboundInbox(ctx, inboxID) - if err != nil { - return struct{}{}, common.WrapGetError("inbox", err) - } - - if jsonOutput { - data, _ := json.MarshalIndent(inbox, "", " ") - fmt.Println(string(data)) - return struct{}{}, nil - } - - printInboxDetails(*inbox) - return struct{}{}, nil - }) - return err -} diff --git a/internal/cli/integration/INDEX.md b/internal/cli/integration/INDEX.md index 1d9ce2f..93efc82 100644 --- a/internal/cli/integration/INDEX.md +++ b/internal/cli/integration/INDEX.md @@ -24,7 +24,7 @@ Quick reference for finding integration tests by feature. | `drafts_test.go` | Draft operations | `nylas drafts` | | `email_test.go` | Email operations | `nylas email` (list, send, search) | | `folders_test.go` | Folder operations | `nylas folders` | -| `inbound_test.go` | Inbound email | `nylas inbound` (managed inboxes) | +| `inbound_removed_test.go` | Removed inbound command | Unknown-command behavior and help omission | | `metadata_test.go` | Metadata operations | Email/event metadata | | `misc_test.go` | Miscellaneous | Version, help, config | | `notetaker_test.go` | Notetaker operations | `nylas notetaker` | diff --git a/internal/cli/integration/admin_test.go b/internal/cli/integration/admin_test.go index a6df9eb..2a24fac 100644 --- a/internal/cli/integration/admin_test.go +++ b/internal/cli/integration/admin_test.go @@ -3,6 +3,7 @@ package integration import ( + "encoding/json" "strings" "testing" ) @@ -123,6 +124,9 @@ func TestCLI_AdminConnectorsList(t *testing.T) { if !strings.Contains(stdout, "Found") && !strings.Contains(stdout, "No connectors found") { t.Errorf("Expected connectors list output, got: %s", stdout) } + if strings.Contains(stdout, "inbox") { + t.Fatalf("admin connectors list still exposes removed inbox provider: %s", stdout) + } t.Logf("admin connectors list output:\n%s", stdout) } @@ -142,6 +146,15 @@ func TestCLI_AdminConnectorsListJSON(t *testing.T) { if len(trimmed) > 0 && !strings.HasPrefix(trimmed, "[") { t.Errorf("Expected JSON array output, got: %s", stdout) } + var connectors []map[string]any + if err := json.Unmarshal([]byte(stdout), &connectors); err != nil { + t.Fatalf("failed to parse admin connectors list JSON: %v\noutput: %s", err, stdout) + } + for _, connector := range connectors { + if provider, _ := connector["provider"].(string); strings.EqualFold(provider, "inbox") { + t.Fatalf("admin connectors list --json still exposes removed inbox provider: %s", stdout) + } + } t.Logf("admin connectors list --json output:\n%s", stdout) } diff --git a/internal/cli/integration/agent_policy_test.go b/internal/cli/integration/agent_policy_test.go index c6df4c9..88b78ae 100644 --- a/internal/cli/integration/agent_policy_test.go +++ b/internal/cli/integration/agent_policy_test.go @@ -311,103 +311,6 @@ func TestCLI_AgentPolicyDelete_RejectsAttachedPolicy(t *testing.T) { } } -func TestCLI_AgentPolicyCommands_RejectNonAgentOnlyPolicy(t *testing.T) { - skipIfMissingCreds(t) - - env := newAgentSandboxEnv(t) - client := getTestClient() - policyID := findNonAgentOnlyPolicyIDForTest(t, client) - if policyID == "" { - t.Skip("no non-agent-only policy available in this environment") - } - - testCases := []struct { - name string - args []string - }{ - { - name: "get", - args: []string{"agent", "policy", "get", policyID, "--json"}, - }, - { - name: "update", - args: []string{"agent", "policy", "update", policyID, "--name", newPolicyTestName("reject"), "--json"}, - }, - { - name: "delete", - args: []string{"agent", "policy", "delete", policyID, "--yes"}, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, tc.args...) - if err == nil { - t.Fatalf("expected %s to fail for non-agent-only policy\nstdout: %s\nstderr: %s", tc.name, stdout, stderr) - } - if !strings.Contains(strings.ToLower(stderr), "outside the nylas agent scope") { - t.Fatalf("expected agent scope rejection, got stderr: %s", stderr) - } - }) - } -} - -func TestCLI_AgentPolicyCommands_RejectMixedScopePolicyMutation(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingAgentDomain(t) - - env := newAgentSandboxEnv(t) - client := getTestClient() - policyID := findNonAgentOnlyPolicyIDForTest(t, client) - if policyID == "" { - t.Skip("no non-agent-only policy available to build mixed scope in this environment") - } - - email := newAgentTestEmail(t, "policy-mixed") - createdAccount := createAgentWithPolicyForTest(t, email, policyID) - t.Cleanup(func() { - if createdAccount == nil || createdAccount.ID == "" { - return - } - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = client.DeleteAgentAccount(ctx, createdAccount.ID) - }) - if exists, _ := waitForAgentByEmail(t, client, email, true); !exists { - t.Fatalf("created agent account %q did not appear in list", email) - } - - testCases := []struct { - name string - args []string - want string - }{ - { - name: "update", - args: []string{"agent", "policy", "update", policyID, "--name", newPolicyTestName("mixed-reject"), "--json"}, - want: "shared with non-agent accounts", - }, - { - name: "delete", - args: []string{"agent", "policy", "delete", policyID, "--yes"}, - want: "attached to agent accounts", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, tc.args...) - if err == nil { - t.Fatalf("expected %s to fail for mixed-scope policy\nstdout: %s\nstderr: %s", tc.name, stdout, stderr) - } - if !strings.Contains(strings.ToLower(stderr), tc.want) { - t.Fatalf("expected %q in stderr, got: %s", tc.want, stderr) - } - }) - } -} - func createAgentWithPolicyForTest(t *testing.T, email, policyID string) *domain.AgentAccount { t.Helper() @@ -423,49 +326,6 @@ func createAgentWithPolicyForTest(t *testing.T, email, policyID string) *domain. return account } -func findNonAgentOnlyPolicyIDForTest(t *testing.T, client interface { - ListInboundInboxes(context.Context) ([]domain.InboundInbox, error) - ListAgentAccounts(context.Context) ([]domain.AgentAccount, error) -}) string { - t.Helper() - - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - t.Fatalf("failed to list inbound inboxes: %v", err) - } - - accounts, err := client.ListAgentAccounts(ctx) - if err != nil { - t.Fatalf("failed to list agent accounts: %v", err) - } - - agentPolicyIDs := make(map[string]struct{}, len(accounts)) - for _, account := range accounts { - policyID := strings.TrimSpace(account.Settings.PolicyID) - if policyID == "" { - continue - } - agentPolicyIDs[policyID] = struct{}{} - } - - for _, inbox := range inboxes { - policyID := strings.TrimSpace(inbox.PolicyID) - if policyID == "" { - continue - } - if _, ok := agentPolicyIDs[policyID]; ok { - continue - } - return policyID - } - - return "" -} - func newPolicyTestName(prefix string) string { return fmt.Sprintf("it-policy-%s-%d", prefix, time.Now().UnixNano()) } diff --git a/internal/cli/integration/agent_rule_test.go b/internal/cli/integration/agent_rule_test.go index d0dc154..b716de7 100644 --- a/internal/cli/integration/agent_rule_test.go +++ b/internal/cli/integration/agent_rule_test.go @@ -332,20 +332,48 @@ func TestCLI_AgentRuleCommands_RejectMixedScopeRule(t *testing.T) { env := newAgentSandboxEnv(t) client := getTestClient() - sharedPolicyID := findNonAgentOnlyPolicyIDForTest(t, client) - if sharedPolicyID == "" { - t.Skip("no non-agent-only policy available to build mixed scope in this environment") + email := newAgentTestEmail(t, "rule-mixed") + agentPolicyName := newPolicyTestName("rule-mixed-agent") + detachedPolicyName := newPolicyTestName("rule-mixed-detached") + + var agentPolicy *domain.Policy + var detachedPolicy *domain.Policy + var createdAccount *domain.AgentAccount + var createdRule *domain.Rule + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + policy, err := client.CreatePolicy(ctx, map[string]any{"name": agentPolicyName}) + cancel() + if err != nil { + t.Fatalf("failed to create agent policy for mixed-scope rule test: %v", err) } + agentPolicy = policy - email := newAgentTestEmail(t, "rule-mixed") - createdAccount := createAgentWithPolicyForTest(t, email, sharedPolicyID) - createdRule := createRuleForTest(t, client, fmt.Sprintf("it-rule-mixed-%d", time.Now().UnixNano())) - attachRuleToPolicyForTest(t, client, sharedPolicyID, createdRule.ID) - assertPolicyContainsRuleForTest(t, client, sharedPolicyID, createdRule.ID) + acquireRateLimit(t) + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + policy, err = client.CreatePolicy(ctx, map[string]any{"name": detachedPolicyName}) + cancel() + if err != nil { + t.Fatalf("failed to create detached policy for mixed-scope rule test: %v", err) + } + detachedPolicy = policy + + createdAccount = createAgentWithPolicyForTest(t, email, agentPolicy.ID) + createdRule = createRuleForTest(t, client, fmt.Sprintf("it-rule-mixed-%d", time.Now().UnixNano())) + attachRuleToPolicyForTest(t, client, agentPolicy.ID, createdRule.ID) + attachRuleToPolicyForTest(t, client, detachedPolicy.ID, createdRule.ID) + assertPolicyContainsRuleForTest(t, client, agentPolicy.ID, createdRule.ID) + assertPolicyContainsRuleForTest(t, client, detachedPolicy.ID, createdRule.ID) t.Cleanup(func() { if createdRule != nil && createdRule.ID != "" { - removeRuleFromPolicyForTest(t, client, sharedPolicyID, createdRule.ID) + if agentPolicy != nil && agentPolicy.ID != "" { + removeRuleFromPolicyForTest(t, client, agentPolicy.ID, createdRule.ID) + } + if detachedPolicy != nil && detachedPolicy.ID != "" { + removeRuleFromPolicyForTest(t, client, detachedPolicy.ID, createdRule.ID) + } } if createdRule != nil && createdRule.ID != "" { acquireRateLimit(t) @@ -359,6 +387,18 @@ func TestCLI_AgentRuleCommands_RejectMixedScopeRule(t *testing.T) { defer cancel() _ = client.DeleteAgentAccount(ctx, createdAccount.ID) } + if detachedPolicy != nil && detachedPolicy.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeletePolicy(ctx, detachedPolicy.ID) + } + if agentPolicy != nil && agentPolicy.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeletePolicy(ctx, agentPolicy.ID) + } }) if exists, _ := waitForAgentByEmail(t, client, email, true); !exists { t.Fatalf("created agent account %q did not appear in list", email) @@ -372,7 +412,7 @@ func TestCLI_AgentRuleCommands_RejectMixedScopeRule(t *testing.T) { "rule", "update", createdRule.ID, - "--policy-id", sharedPolicyID, + "--policy-id", agentPolicy.ID, "--name", fmt.Sprintf("reject-mixed-%d", time.Now().UnixNano()), "--json", ) @@ -391,7 +431,7 @@ func TestCLI_AgentRuleCommands_RejectMixedScopeRule(t *testing.T) { "rule", "delete", createdRule.ID, - "--policy-id", sharedPolicyID, + "--policy-id", agentPolicy.ID, "--yes", ) if err == nil { diff --git a/internal/cli/integration/agent_test.go b/internal/cli/integration/agent_test.go index 44d61b8..bc767d7 100644 --- a/internal/cli/integration/agent_test.go +++ b/internal/cli/integration/agent_test.go @@ -99,6 +99,81 @@ func TestCLI_AgentLifecycle_CreateListDeleteByEmail(t *testing.T) { created = nil } +func TestCLI_AgentStatus(t *testing.T) { + skipIfMissingCreds(t) + + env := newAgentSandboxEnv(t) + client := getTestClient() + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + connectors, err := client.ListConnectors(ctx) + cancel() + if err != nil { + t.Fatalf("failed to list connectors for status test: %v", err) + } + + expectedConfigured := false + expectedConnectorID := "" + for _, connector := range connectors { + if connector.Provider == string(domain.ProviderNylas) { + expectedConfigured = true + expectedConnectorID = connector.ID + break + } + } + + acquireRateLimit(t) + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + accounts, err := client.ListAgentAccounts(ctx) + cancel() + if err != nil { + t.Fatalf("failed to list agent accounts for status test: %v", err) + } + + jsonStdout, jsonStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "status", "--json") + if err != nil { + t.Fatalf("agent status --json failed: %v\nstdout: %s\nstderr: %s", err, jsonStdout, jsonStderr) + } + + var result struct { + ConnectorConfigured bool `json:"connector_configured"` + ConnectorID string `json:"connector_id"` + AccountCount int `json:"account_count"` + Accounts []domain.AgentAccount `json:"accounts"` + } + if err := json.Unmarshal([]byte(jsonStdout), &result); err != nil { + t.Fatalf("failed to parse agent status JSON: %v\noutput: %s", err, jsonStdout) + } + + if result.ConnectorConfigured != expectedConfigured { + t.Fatalf("connector_configured = %t, want %t", result.ConnectorConfigured, expectedConfigured) + } + if expectedConfigured && result.ConnectorID != expectedConnectorID { + t.Fatalf("connector_id = %q, want %q", result.ConnectorID, expectedConnectorID) + } + if result.AccountCount != len(accounts) { + t.Fatalf("account_count = %d, want %d", result.AccountCount, len(accounts)) + } + if len(result.Accounts) != len(accounts) { + t.Fatalf("accounts length = %d, want %d", len(result.Accounts), len(accounts)) + } + + textStdout, textStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "status") + if err != nil { + t.Fatalf("agent status failed: %v\nstdout: %s\nstderr: %s", err, textStdout, textStderr) + } + if !strings.Contains(textStdout, "Agent Status") { + t.Fatalf("expected status heading, got: %s", textStdout) + } + if !strings.Contains(textStdout, "Connector:") { + t.Fatalf("expected connector line, got: %s", textStdout) + } + if !strings.Contains(textStdout, "Accounts:") { + t.Fatalf("expected accounts line, got: %s", textStdout) + } +} + func TestCLI_AgentCreate_WithPolicyID(t *testing.T) { skipIfMissingCreds(t) skipIfMissingAgentDomain(t) diff --git a/internal/cli/integration/auth_enhancements_test.go b/internal/cli/integration/auth_enhancements_test.go index 8c549de..a718d17 100644 --- a/internal/cli/integration/auth_enhancements_test.go +++ b/internal/cli/integration/auth_enhancements_test.go @@ -48,6 +48,9 @@ func TestCLI_AuthProvidersList(t *testing.T) { if !strings.Contains(stdout, "Available Authentication Providers") { t.Errorf("Expected providers list header, got: %s", stdout) } + if strings.Contains(stdout, "Provider: inbox") { + t.Fatalf("auth providers output still exposes removed inbox provider: %s", stdout) + } t.Logf("auth providers output:\n%s", stdout) } @@ -71,6 +74,11 @@ func TestCLI_AuthProvidersListJSON(t *testing.T) { if err := json.Unmarshal([]byte(stdout), &connectors); err != nil { t.Fatalf("Failed to parse JSON output: %v\noutput: %s", err, stdout) } + for _, connector := range connectors { + if provider, _ := connector["provider"].(string); strings.EqualFold(provider, "inbox") { + t.Fatalf("auth providers --json still exposes removed inbox provider: %s", stdout) + } + } t.Logf("auth providers --json output: %d connectors", len(connectors)) } diff --git a/internal/cli/integration/inbound_removed_test.go b/internal/cli/integration/inbound_removed_test.go new file mode 100644 index 0000000..d8db40b --- /dev/null +++ b/internal/cli/integration/inbound_removed_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package integration + +import ( + "strings" + "testing" +) + +func TestCLI_InboundRemoved(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found - run 'go build -o bin/nylas ./cmd/nylas' first") + } + + stdout, stderr, err := runCLI("inbound", "list") + if err == nil { + t.Fatal("expected inbound command to fail") + } + + output := strings.ToLower(stdout + stderr) + if !strings.Contains(output, `unknown command "inbound"`) { + t.Fatalf("expected unknown command error, got stdout=%q stderr=%q", stdout, stderr) + } +} + +func TestCLI_InboxAliasRemoved(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found - run 'go build -o bin/nylas ./cmd/nylas' first") + } + + stdout, stderr, err := runCLI("inbox", "list") + if err == nil { + t.Fatal("expected inbox alias to fail") + } + + output := strings.ToLower(stdout + stderr) + if !strings.Contains(output, `unknown command "inbox"`) { + t.Fatalf("expected unknown command error, got stdout=%q stderr=%q", stdout, stderr) + } +} + +func TestCLI_HelpOmitsInbound(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found - run 'go build -o bin/nylas ./cmd/nylas' first") + } + + stdout, stderr, err := runCLI("--help") + if err != nil { + t.Fatalf("--help failed: %v\nstderr: %s", err, stderr) + } + + if strings.Contains(stdout, "\ninbound") || strings.Contains(stdout, "\n inbound") { + t.Fatalf("expected root help to omit inbound command, got: %s", stdout) + } +} + +func TestCLI_AuthLoginRejectsInboxProvider(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found - run 'go build -o bin/nylas ./cmd/nylas' first") + } + + stdout, stderr, err := runCLI("auth", "login", "--provider", "inbox") + if err == nil { + t.Fatal("expected auth login with provider inbox to fail") + } + + output := strings.ToLower(stdout + stderr) + if !strings.Contains(output, "invalid provider: inbox") { + t.Fatalf("expected invalid provider error, got stdout=%q stderr=%q", stdout, stderr) + } + if !strings.Contains(output, "use 'google' or 'microsoft'") { + t.Fatalf("expected provider guidance, got stdout=%q stderr=%q", stdout, stderr) + } +} diff --git a/internal/cli/integration/inbound_test.go b/internal/cli/integration/inbound_test.go deleted file mode 100644 index fb72db5..0000000 --- a/internal/cli/integration/inbound_test.go +++ /dev/null @@ -1,607 +0,0 @@ -//go:build integration - -package integration - -import ( - "context" - "os" - "strings" - "testing" - "time" -) - -// ============================================================================= -// INBOUND LIST COMMAND TESTS -// ============================================================================= - -func TestCLI_InboundList(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - stdout, stderr, err := runCLI("inbound", "list") - - if err != nil { - // Skip if inbound is not enabled for this account - if strings.Contains(stderr, "not found") || strings.Contains(stderr, "unauthorized") { - t.Skip("Inbound not enabled for this account") - } - t.Fatalf("inbound list failed: %v\nstderr: %s", err, stderr) - } - - // Should show inboxes or "No inboxes found" - if !strings.Contains(stdout, "Found") && !strings.Contains(stdout, "No inbound inboxes found") && !strings.Contains(stdout, "nylas.email") { - t.Errorf("Expected inbox list output, got: %s", stdout) - } - - t.Logf("inbound list output:\n%s", stdout) -} - -func TestCLI_InboundList_JSON(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - stdout, stderr, err := runCLI("inbound", "list", "--json") - - if err != nil { - if strings.Contains(stderr, "not found") || strings.Contains(stderr, "unauthorized") { - t.Skip("Inbound not enabled for this account") - } - t.Fatalf("inbound list --json failed: %v\nstderr: %s", err, stderr) - } - - // Should be valid JSON output - if !strings.HasPrefix(strings.TrimSpace(stdout), "[") && !strings.HasPrefix(strings.TrimSpace(stdout), "null") { - t.Errorf("Expected JSON array output, got: %s", stdout) - } - - t.Logf("inbound list --json output:\n%s", stdout) -} - -func TestCLI_InboundList_InboxAlias(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - // Test the 'inbox' alias for 'inbound' command - stdout, stderr, err := runCLI("inbox", "list") - - if err != nil { - if strings.Contains(stderr, "not found") || strings.Contains(stderr, "unauthorized") { - t.Skip("Inbound not enabled for this account") - } - t.Fatalf("inbox list (alias) failed: %v\nstderr: %s", err, stderr) - } - - // Should show same output as 'inbound list' - if !strings.Contains(stdout, "Found") && !strings.Contains(stdout, "No inbound inboxes found") && !strings.Contains(stdout, "nylas.email") { - t.Errorf("Expected inbox list output, got: %s", stdout) - } - - t.Logf("inbox list (alias) output:\n%s", stdout) -} - -// ============================================================================= -// INBOUND SHOW COMMAND TESTS -// ============================================================================= - -func TestCLI_InboundShow(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - // First get an inbox ID - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - t.Skipf("Failed to list inboxes: %v", err) - } - if len(inboxes) == 0 { - t.Skip("No inbound inboxes available for show test") - } - - inboxID := inboxes[0].ID - - stdout, stderr, err := runCLI("inbound", "show", inboxID) - - if err != nil { - t.Fatalf("inbound show failed: %v\nstderr: %s", err, stderr) - } - - // Should show inbox details - if !strings.Contains(stdout, "ID:") && !strings.Contains(stdout, "Email:") { - t.Errorf("Expected inbox details in output, got: %s", stdout) - } - - t.Logf("inbound show output:\n%s", stdout) -} - -func TestCLI_InboundShow_JSON(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - // First get an inbox ID - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - t.Skipf("Failed to list inboxes: %v", err) - } - if len(inboxes) == 0 { - t.Skip("No inbound inboxes available for show test") - } - - inboxID := inboxes[0].ID - - stdout, stderr, err := runCLI("inbound", "show", inboxID, "--json") - - if err != nil { - t.Fatalf("inbound show --json failed: %v\nstderr: %s", err, stderr) - } - - // Should be valid JSON with expected fields - if !strings.Contains(stdout, `"id":`) { - t.Errorf("Expected '\"id\":' in JSON output, got: %s", stdout) - } - if !strings.Contains(stdout, `"email":`) { - t.Errorf("Expected '\"email\":' in JSON output, got: %s", stdout) - } - - t.Logf("inbound show --json output:\n%s", stdout) -} - -func TestCLI_InboundShow_InvalidID(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - _, stderr, err := runCLI("inbound", "show", "invalid-inbox-id") - - if err == nil { - t.Error("Expected error for invalid inbox ID, but command succeeded") - } - - t.Logf("inbound show invalid ID error: %s", stderr) -} - -// ============================================================================= -// INBOUND MESSAGES COMMAND TESTS -// ============================================================================= - -func TestCLI_InboundMessages(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - // First get an inbox ID - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - t.Skipf("Failed to list inboxes: %v", err) - } - if len(inboxes) == 0 { - t.Skip("No inbound inboxes available for messages test") - } - - inboxID := inboxes[0].ID - - stdout, stderr, err := runCLI("inbound", "messages", inboxID, "--limit", "5") - - if err != nil { - t.Fatalf("inbound messages failed: %v\nstderr: %s", err, stderr) - } - - // Should show messages or "No messages found" - if !strings.Contains(stdout, "Messages (") && !strings.Contains(stdout, "No messages found") && !strings.Contains(stdout, "Unread Messages") { - t.Errorf("Expected messages output, got: %s", stdout) - } - - t.Logf("inbound messages output:\n%s", stdout) -} - -func TestCLI_InboundMessages_WithLimit(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - t.Skipf("Failed to list inboxes: %v", err) - } - if len(inboxes) == 0 { - t.Skip("No inbound inboxes available for messages test") - } - - inboxID := inboxes[0].ID - - stdout, stderr, err := runCLI("inbound", "messages", inboxID, "--limit", "2") - - if err != nil { - t.Fatalf("inbound messages --limit failed: %v\nstderr: %s", err, stderr) - } - - t.Logf("inbound messages --limit output:\n%s", stdout) -} - -func TestCLI_InboundMessages_UnreadOnly(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - t.Skipf("Failed to list inboxes: %v", err) - } - if len(inboxes) == 0 { - t.Skip("No inbound inboxes available for messages test") - } - - inboxID := inboxes[0].ID - - stdout, stderr, err := runCLI("inbound", "messages", inboxID, "--unread") - - if err != nil { - t.Fatalf("inbound messages --unread failed: %v\nstderr: %s", err, stderr) - } - - t.Logf("inbound messages --unread output:\n%s", stdout) -} - -func TestCLI_InboundMessages_JSON(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - t.Skipf("Failed to list inboxes: %v", err) - } - if len(inboxes) == 0 { - t.Skip("No inbound inboxes available for messages test") - } - - inboxID := inboxes[0].ID - - stdout, stderr, err := runCLI("inbound", "messages", inboxID, "--json", "--limit", "3") - - if err != nil { - t.Fatalf("inbound messages --json failed: %v\nstderr: %s", err, stderr) - } - - // Should be valid JSON output - if !strings.HasPrefix(strings.TrimSpace(stdout), "[") && !strings.HasPrefix(strings.TrimSpace(stdout), "null") { - t.Errorf("Expected JSON array output, got: %s", stdout) - } - - t.Logf("inbound messages --json output:\n%s", stdout) -} - -// ============================================================================= -// INBOUND CREATE COMMAND TESTS -// ============================================================================= - -func TestCLI_InboundCreate(t *testing.T) { - if os.Getenv("NYLAS_TEST_CREATE_INBOUND") != "true" { - t.Skip("Skipping create test - set NYLAS_TEST_CREATE_INBOUND=true to enable") - } - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - // Generate a unique email prefix - prefix := "test-" + time.Now().Format("20060102150405") - - stdout, stderr, err := runCLI("inbound", "create", prefix) - - if err != nil { - // Skip if inbound is not enabled for this account or if there are validation errors - if strings.Contains(stderr, "not found") || - strings.Contains(stderr, "unauthorized") || - strings.Contains(stderr, "not enabled") || - strings.Contains(stderr, "invalid 'email'") || - strings.Contains(stderr, "invalid email") { - t.Skip("Inbound not enabled or email validation failed for this account") - } - t.Fatalf("inbound create failed: %v\nstderr: %s", err, stderr) - } - - // Should show created inbox details - if !strings.Contains(stdout, "Created") || !strings.Contains(stdout, prefix) { - t.Errorf("Expected created confirmation with prefix %s, got: %s", prefix, stdout) - } - - t.Logf("inbound create output:\n%s", stdout) - - // Cleanup: Extract inbox ID and delete it - // Look for the inbox we just created and delete it - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - inboxes, err := client.ListInboundInboxes(ctx) - if err == nil { - for _, inbox := range inboxes { - if strings.Contains(inbox.Email, prefix) { - t.Logf("Cleaning up test inbox: %s", inbox.ID) - _ = client.DeleteInboundInbox(ctx, inbox.ID) - break - } - } - } -} - -func TestCLI_InboundCreate_JSON(t *testing.T) { - if os.Getenv("NYLAS_TEST_CREATE_INBOUND") != "true" { - t.Skip("Skipping create test - set NYLAS_TEST_CREATE_INBOUND=true to enable") - } - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - // Generate a unique email prefix - prefix := "testjson-" + time.Now().Format("20060102150405") - - stdout, stderr, err := runCLI("inbound", "create", prefix, "--json") - - if err != nil { - // Skip if inbound is not enabled for this account or if there are validation errors - if strings.Contains(stderr, "not found") || - strings.Contains(stderr, "unauthorized") || - strings.Contains(stderr, "not enabled") || - strings.Contains(stderr, "invalid 'email'") || - strings.Contains(stderr, "invalid email") { - t.Skip("Inbound not enabled or email validation failed for this account") - } - t.Fatalf("inbound create --json failed: %v\nstderr: %s", err, stderr) - } - - // Should be valid JSON with expected fields - if !strings.Contains(stdout, `"id":`) { - t.Errorf("Expected '\"id\":' in JSON output, got: %s", stdout) - } - if !strings.Contains(stdout, `"email":`) { - t.Errorf("Expected '\"email\":' in JSON output, got: %s", stdout) - } - - t.Logf("inbound create --json output:\n%s", stdout) - - // Cleanup: Extract inbox ID and delete it - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - inboxes, err := client.ListInboundInboxes(ctx) - if err == nil { - for _, inbox := range inboxes { - if strings.Contains(inbox.Email, prefix) { - t.Logf("Cleaning up test inbox: %s", inbox.ID) - _ = client.DeleteInboundInbox(ctx, inbox.ID) - break - } - } - } -} - -func TestCLI_InboundCreate_NoPrefix(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - _, stderr, err := runCLI("inbound", "create") - - if err == nil { - t.Error("Expected error when no prefix provided, but command succeeded") - } - - // Should show error about missing argument - if !strings.Contains(stderr, "argument") && !strings.Contains(stderr, "required") { - t.Logf("Expected argument error in stderr: %s", stderr) - } -} - -// ============================================================================= -// INBOUND DELETE COMMAND TESTS -// ============================================================================= - -func TestCLI_InboundDelete(t *testing.T) { - if os.Getenv("NYLAS_TEST_DELETE_INBOUND") != "true" { - t.Skip("Skipping delete test - set NYLAS_TEST_DELETE_INBOUND=true to enable") - } - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - // First create an inbox to delete - prefix := "todelete-" + time.Now().Format("20060102150405") - - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - inbox, err := client.CreateInboundInbox(ctx, prefix) - if err != nil { - t.Skipf("Failed to create inbox for delete test: %v", err) - } - - // Wait for creation to propagate - time.Sleep(1 * time.Second) - - stdout, stderr, err := runCLI("inbound", "delete", inbox.ID, "--yes") - - if err != nil { - t.Fatalf("inbound delete failed: %v\nstderr: %s", err, stderr) - } - - // Should show deleted confirmation - if !strings.Contains(stdout, "Deleted") && !strings.Contains(stdout, "deleted") { - t.Errorf("Expected deleted confirmation, got: %s", stdout) - } - - t.Logf("inbound delete output:\n%s", stdout) -} - -func TestCLI_InboundDelete_InvalidID(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - _, stderr, err := runCLI("inbound", "delete", "invalid-inbox-id", "--yes") - - if err == nil { - t.Error("Expected error for invalid inbox ID, but command succeeded") - } - - t.Logf("inbound delete invalid ID error: %s", stderr) -} - -func TestCLI_InboundDelete_NoConfirm(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - // Without --yes flag, should require confirmation - // Since we can't provide interactive input, this should fail or prompt - stdout, stderr, err := runCLI("inbound", "delete", "some-inbox-id") - - // Should either fail or show confirmation prompt - t.Logf("inbound delete without --yes: stdout=%s stderr=%s err=%v", stdout, stderr, err) -} - -// ============================================================================= -// INBOUND HELP TESTS -// ============================================================================= - -func TestCLI_InboundHelp(t *testing.T) { - skipIfMissingCreds(t) - - stdout, stderr, err := runCLI("inbound", "--help") - - if err != nil { - t.Fatalf("inbound --help failed: %v\nstderr: %s", err, stderr) - } - - // Should show inbound subcommands - expectedCommands := []string{"list", "show", "create", "delete", "messages", "monitor"} - for _, cmd := range expectedCommands { - if !strings.Contains(stdout, cmd) { - t.Errorf("Expected '%s' in inbound help, got: %s", cmd, stdout) - } - } - - t.Logf("inbound help output:\n%s", stdout) -} - -func TestCLI_InboundListHelp(t *testing.T) { - skipIfMissingCreds(t) - - stdout, stderr, err := runCLI("inbound", "list", "--help") - - if err != nil { - t.Fatalf("inbound list --help failed: %v\nstderr: %s", err, stderr) - } - - if !strings.Contains(stdout, "--json") { - t.Errorf("Expected '--json' flag in help, got: %s", stdout) - } - - t.Logf("inbound list help output:\n%s", stdout) -} - -func TestCLI_InboundMessagesHelp(t *testing.T) { - skipIfMissingCreds(t) - - stdout, stderr, err := runCLI("inbound", "messages", "--help") - - if err != nil { - t.Fatalf("inbound messages --help failed: %v\nstderr: %s", err, stderr) - } - - if !strings.Contains(stdout, "--limit") { - t.Errorf("Expected '--limit' flag in help, got: %s", stdout) - } - if !strings.Contains(stdout, "--unread") { - t.Errorf("Expected '--unread' flag in help, got: %s", stdout) - } - if !strings.Contains(stdout, "--json") { - t.Errorf("Expected '--json' flag in help, got: %s", stdout) - } - - t.Logf("inbound messages help output:\n%s", stdout) -} - -func TestCLI_InboundMonitorHelp(t *testing.T) { - skipIfMissingCreds(t) - - stdout, stderr, err := runCLI("inbound", "monitor", "--help") - - if err != nil { - t.Fatalf("inbound monitor --help failed: %v\nstderr: %s", err, stderr) - } - - if !strings.Contains(stdout, "--port") { - t.Errorf("Expected '--port' flag in help, got: %s", stdout) - } - if !strings.Contains(stdout, "--tunnel") { - t.Errorf("Expected '--tunnel' flag in help, got: %s", stdout) - } - if !strings.Contains(stdout, "cloudflared") { - t.Errorf("Expected 'cloudflared' in help, got: %s", stdout) - } - - t.Logf("inbound monitor help output:\n%s", stdout) -} - -// ============================================================================= -// ENVIRONMENT VARIABLE TESTS -// ============================================================================= - -func TestCLI_InboundWithEnvVar(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingInboundCreds(t) - - // First get an inbox ID - client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - inboxes, err := client.ListInboundInboxes(ctx) - if err != nil { - t.Skipf("Failed to list inboxes: %v", err) - } - if len(inboxes) == 0 { - t.Skip("No inbound inboxes available") - } - - inboxID := inboxes[0].ID - - // Run with NYLAS_INBOUND_GRANT_ID set - stdout, stderr, err := runCLIWithEnv( - map[string]string{"NYLAS_INBOUND_GRANT_ID": inboxID}, - "inbound", "messages", "--limit", "2", - ) - - if err != nil { - t.Fatalf("inbound messages with env var failed: %v\nstderr: %s", err, stderr) - } - - t.Logf("inbound messages with env var output:\n%s", stdout) -} - -// runCLIWithEnv executes a CLI command with additional environment variables -func runCLIWithEnv(env map[string]string, args ...string) (string, string, error) { - return runCLIWithEnvImpl(env, args...) -} - -func runCLIWithEnvImpl(env map[string]string, args ...string) (string, string, error) { - // This is a simplified implementation that sets env vars before running - // For full implementation, we'd need to modify runCLI to accept env vars - for k, v := range env { - os.Setenv(k, v) - defer os.Unsetenv(k) - } - return runCLI(args...) -} diff --git a/internal/cli/integration/local_regressions_test.go b/internal/cli/integration/local_regressions_test.go index 951700c..9421d01 100644 --- a/internal/cli/integration/local_regressions_test.go +++ b/internal/cli/integration/local_regressions_test.go @@ -8,6 +8,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" "net" "net/http" "net/http/httptest" @@ -296,6 +297,89 @@ func TestCLI_AuthProviders_RequiresFileStorePassphrase(t *testing.T) { } } +func TestCLI_ConnectorSurfaces_HideInboxProvider(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/connectors" { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[ + {"id":"conn-inbox-1","name":"Inbox","provider":"inbox"}, + {"id":"conn-google-1","name":"","provider":"google","settings":{"client_id":"google-client-id"}}, + {"id":"conn-imap-1","name":"Custom IMAP","provider":"imap","scopes":["mail.read_only","mail.send"]} + ]}`)) + })) + defer server.Close() + + tempDir := t.TempDir() + configHome := filepath.Join(tempDir, "xdg") + configPath := filepath.Join(configHome, "nylas", "config.yaml") + configStore := config.NewFileStore(configPath) + if err := configStore.Save(&domain.Config{ + Region: "us", + API: &domain.APIConfig{BaseURL: server.URL}, + }); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + overrides := map[string]string{ + "XDG_CONFIG_HOME": configHome, + "HOME": tempDir, + "NYLAS_API_KEY": "test-api-key", + "NYLAS_CLIENT_ID": "", + "NYLAS_CLIENT_SECRET": "", + "NYLAS_GRANT_ID": "", + "NYLAS_DISABLE_KEYRING": "true", + "NYLAS_FILE_STORE_PASSPHRASE": "integration-test-file-store-passphrase", + } + + stdout, stderr, err := runCLIWithOverrides(30*time.Second, overrides, "auth", "providers") + if err != nil { + t.Fatalf("auth providers failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + for _, unwanted := range []string{"Provider: inbox", "\n Inbox\n"} { + if strings.Contains(stdout, unwanted) { + t.Fatalf("auth providers unexpectedly exposed inbox connector: %s", stdout) + } + } + for _, wanted := range []string{"Provider: google", "Provider: imap"} { + if !strings.Contains(stdout, wanted) { + t.Fatalf("auth providers output %q does not contain %q", stdout, wanted) + } + } + + stdout, stderr, err = runCLIWithOverrides(30*time.Second, overrides, "auth", "providers", "--json") + if err != nil { + t.Fatalf("auth providers --json failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + assertNoInboxConnector(t, stdout) + + stdout, stderr, err = runCLIWithOverrides(30*time.Second, overrides, "admin", "connectors", "list") + if err != nil { + t.Fatalf("admin connectors list failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + if strings.Contains(stdout, "inbox") { + t.Fatalf("admin connectors list unexpectedly exposed inbox connector: %s", stdout) + } + for _, wanted := range []string{"google", "imap"} { + if !strings.Contains(stdout, wanted) { + t.Fatalf("admin connectors list output %q does not contain %q", stdout, wanted) + } + } + + stdout, stderr, err = runCLIWithOverrides(30*time.Second, overrides, "admin", "connectors", "list", "--json") + if err != nil { + t.Fatalf("admin connectors list --json failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + assertNoInboxConnector(t, stdout) +} + func TestCLI_AuthProviders_HidesEmptyConnectorFields(t *testing.T) { if testBinary == "" { t.Skip("CLI binary not found") @@ -363,6 +447,101 @@ func TestCLI_AuthProviders_HidesEmptyConnectorFields(t *testing.T) { } } +func TestCLI_AdminConnectorsCreate_RejectsInboxProvider(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + tempDir := t.TempDir() + configHome := filepath.Join(tempDir, "xdg") + configPath := filepath.Join(configHome, "nylas", "config.yaml") + configStore := config.NewFileStore(configPath) + if err := configStore.Save(&domain.Config{Region: "us"}); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + stdout, stderr, err := runCLIWithOverrides(30*time.Second, map[string]string{ + "XDG_CONFIG_HOME": configHome, + "HOME": tempDir, + "NYLAS_API_KEY": "test-api-key", + "NYLAS_DISABLE_KEYRING": "true", + "NYLAS_FILE_STORE_PASSPHRASE": "integration-test-file-store-passphrase", + }, "admin", "connectors", "create", "--name", "Removed Inbox", "--provider", "inbox") + if err == nil { + t.Fatalf("expected admin connectors create --provider inbox to fail\nstdout: %s\nstderr: %s", stdout, stderr) + } + if !strings.Contains(stderr, "invalid provider: inbox") { + t.Fatalf("stderr %q does not mention rejected inbox provider", stderr) + } +} + +func TestCLI_AdminConnectorsShow_HidesInboxProvider(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/connectors/conn-inbox-1" { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"id":"conn-inbox-1","name":"Inbox","provider":"inbox"}}`)) + })) + defer server.Close() + + tempDir := t.TempDir() + configHome := filepath.Join(tempDir, "xdg") + configPath := filepath.Join(configHome, "nylas", "config.yaml") + configStore := config.NewFileStore(configPath) + if err := configStore.Save(&domain.Config{ + Region: "us", + API: &domain.APIConfig{BaseURL: server.URL}, + }); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + overrides := map[string]string{ + "XDG_CONFIG_HOME": configHome, + "HOME": tempDir, + "NYLAS_API_KEY": "test-api-key", + "NYLAS_DISABLE_KEYRING": "true", + "NYLAS_FILE_STORE_PASSPHRASE": "integration-test-file-store-passphrase", + } + + for _, args := range [][]string{ + {"admin", "connectors", "show", "conn-inbox-1"}, + {"admin", "connectors", "show", "conn-inbox-1", "--json"}, + } { + stdout, stderr, err := runCLIWithOverrides(30*time.Second, overrides, args...) + if err == nil { + t.Fatalf("expected %v to fail\nstdout: %s\nstderr: %s", args, stdout, stderr) + } + if !strings.Contains(stderr, "connector not found") { + t.Fatalf("stderr %q does not report connector not found for %v", stderr, args) + } + if strings.Contains(stdout, "provider") || strings.Contains(stdout, "inbox") { + t.Fatalf("stdout %q unexpectedly exposed inbox connector for %v", stdout, args) + } + } +} + +func assertNoInboxConnector(t *testing.T, stdout string) { + t.Helper() + + var connectors []map[string]any + if err := json.Unmarshal([]byte(stdout), &connectors); err != nil { + t.Fatalf("failed to parse connectors JSON: %v\noutput: %s", err, stdout) + } + + for _, connector := range connectors { + if provider, _ := connector["provider"].(string); strings.EqualFold(provider, "inbox") { + t.Fatalf("JSON output still exposed removed inbox provider: %s", stdout) + } + } +} + func TestCLI_MCPServe_RequiresFileStorePassphrase(t *testing.T) { if testBinary == "" { t.Skip("CLI binary not found") diff --git a/internal/cli/integration/test.go b/internal/cli/integration/test.go index 5a28a4e..07d3f90 100644 --- a/internal/cli/integration/test.go +++ b/internal/cli/integration/test.go @@ -15,7 +15,6 @@ // - NYLAS_TEST_AUTH_LOGOUT: Set to "true" to enable auth logout tests // - NYLAS_TEST_RATE_LIMIT_RPS: API rate limit (requests/sec, default: 2.0) // - NYLAS_TEST_RATE_LIMIT_BURST: API rate limit burst capacity (default: 5) -// - NYLAS_INBOUND_GRANT_ID: Grant ID for inbound inbox tests (skips inbound tests if not set) // - NYLAS_FILE_STORE_PASSPHRASE: Passphrase for the encrypted file secret-store fallback // // Parallel Testing: @@ -75,12 +74,11 @@ import ( // Test configuration loaded from environment var ( - testAPIKey string - testGrantID string - testClientID string - testEmail string - testBinary string - testInboundGrantID string + testAPIKey string + testGrantID string + testClientID string + testEmail string + testBinary string ) // Rate limiter for API calls to prevent hitting Nylas rate limits @@ -97,7 +95,6 @@ func init() { testGrantID = os.Getenv("NYLAS_GRANT_ID") testClientID = os.Getenv("NYLAS_CLIENT_ID") testEmail = os.Getenv("NYLAS_TEST_EMAIL") - testInboundGrantID = os.Getenv("NYLAS_INBOUND_GRANT_ID") // Configure rate limiter from environment if rpsStr := os.Getenv("NYLAS_TEST_RATE_LIMIT_RPS"); rpsStr != "" { @@ -183,15 +180,6 @@ func skipIfKeyringDisabled(t *testing.T) { } } -// skipIfMissingInboundCreds skips the test if NYLAS_INBOUND_GRANT_ID is not set. -// Call this at the start of inbound inbox tests. -func skipIfMissingInboundCreds(t *testing.T) { - t.Helper() - if testInboundGrantID == "" { - t.Skip("NYLAS_INBOUND_GRANT_ID not set - skipping inbound tests") - } -} - // runCLI executes a CLI command and returns stdout, stderr, and error. // NOTE: This does NOT apply rate limiting. For tests that make API calls, // either call acquireRateLimit(t) before this, or use runCLIWithRateLimit. diff --git a/internal/domain/advanced_test.go b/internal/domain/advanced_test.go index 3bdf8b9..0d018b2 100644 --- a/internal/domain/advanced_test.go +++ b/internal/domain/advanced_test.go @@ -54,168 +54,6 @@ func TestErrors(t *testing.T) { } } -// ============================================================================= -// INBOUND INBOX TESTS -// ============================================================================= - -func TestInboundInbox(t *testing.T) { - t.Run("IsValid_returns_true_for_valid_status", func(t *testing.T) { - inbox := InboundInbox{ - ID: "inbox-001", - Email: "support@app.nylas.email", - GrantStatus: "valid", - } - if !inbox.IsValid() { - t.Error("Expected IsValid() to return true for valid status") - } - }) - - t.Run("IsValid_returns_false_for_invalid_status", func(t *testing.T) { - inbox := InboundInbox{ - ID: "inbox-001", - Email: "support@app.nylas.email", - GrantStatus: "invalid", - } - if inbox.IsValid() { - t.Error("Expected IsValid() to return false for invalid status") - } - }) - - t.Run("IsValid_returns_false_for_empty_status", func(t *testing.T) { - inbox := InboundInbox{ - ID: "inbox-001", - Email: "support@app.nylas.email", - GrantStatus: "", - } - if inbox.IsValid() { - t.Error("Expected IsValid() to return false for empty status") - } - }) - - t.Run("IsValid_returns_false_for_other_statuses", func(t *testing.T) { - statuses := []string{"pending", "error", "suspended", "VALID", "Valid"} - for _, status := range statuses { - inbox := InboundInbox{GrantStatus: status} - if inbox.IsValid() { - t.Errorf("Expected IsValid() to return false for status %q", status) - } - } - }) - - t.Run("inbox_creation", func(t *testing.T) { - inbox := InboundInbox{ - ID: "inbox-001", - Email: "support@app.nylas.email", - GrantStatus: "valid", - } - - if inbox.ID != "inbox-001" { - t.Errorf("InboundInbox.ID = %q, want %q", inbox.ID, "inbox-001") - } - if inbox.Email != "support@app.nylas.email" { - t.Errorf("InboundInbox.Email = %q, want %q", inbox.Email, "support@app.nylas.email") - } - }) -} - -// ============================================================================= -// INBOUND WEBHOOK EVENT TESTS -// ============================================================================= - -func TestInboundWebhookEvent(t *testing.T) { - t.Run("IsInboundEvent_returns_true_for_inbox_source", func(t *testing.T) { - event := InboundWebhookEvent{ - Type: "message.created", - Source: "inbox", - GrantID: "inbox-001", - MessageID: "msg-001", - } - if !event.IsInboundEvent() { - t.Error("Expected IsInboundEvent() to return true for source 'inbox'") - } - }) - - t.Run("IsInboundEvent_returns_false_for_other_sources", func(t *testing.T) { - sources := []string{"", "email", "calendar", "Inbox", "INBOX", "imap"} - for _, source := range sources { - event := InboundWebhookEvent{ - Type: "message.created", - Source: source, - } - if event.IsInboundEvent() { - t.Errorf("Expected IsInboundEvent() to return false for source %q", source) - } - } - }) - - t.Run("event_creation_with_message", func(t *testing.T) { - msg := &Message{ - ID: "msg-001", - Subject: "Test Subject", - } - event := InboundWebhookEvent{ - Type: "message.created", - Source: "inbox", - GrantID: "inbox-001", - MessageID: "msg-001", - Message: msg, - } - - if event.Type != "message.created" { - t.Errorf("InboundWebhookEvent.Type = %q, want %q", event.Type, "message.created") - } - if event.Message == nil { - t.Error("InboundWebhookEvent.Message should not be nil") - } - if event.Message.ID != "msg-001" { - t.Errorf("InboundWebhookEvent.Message.ID = %q, want %q", event.Message.ID, "msg-001") - } - }) - - t.Run("event_creation_without_message", func(t *testing.T) { - event := InboundWebhookEvent{ - Type: "message.created", - Source: "inbox", - GrantID: "inbox-001", - MessageID: "msg-001", - Message: nil, - } - - if event.Message != nil { - t.Error("InboundWebhookEvent.Message should be nil") - } - if event.MessageID != "msg-001" { - t.Errorf("InboundWebhookEvent.MessageID = %q, want %q", event.MessageID, "msg-001") - } - }) -} - -// ============================================================================= -// CREATE INBOUND INBOX REQUEST TESTS -// ============================================================================= - -func TestCreateInboundInboxRequest(t *testing.T) { - t.Run("request_creation", func(t *testing.T) { - req := CreateInboundInboxRequest{ - Email: "support", - } - - if req.Email != "support" { - t.Errorf("CreateInboundInboxRequest.Email = %q, want %q", req.Email, "support") - } - }) - - t.Run("request_with_various_prefixes", func(t *testing.T) { - prefixes := []string{"support", "sales", "info", "help-desk", "team123"} - for _, prefix := range prefixes { - req := CreateInboundInboxRequest{Email: prefix} - if req.Email != prefix { - t.Errorf("CreateInboundInboxRequest.Email = %q, want %q", req.Email, prefix) - } - } - }) -} - // ============================================================================= // WEBHOOK TRIGGER TYPES TESTS // ============================================================================= diff --git a/internal/domain/basic_test.go b/internal/domain/basic_test.go index aeb5a70..21c2ff6 100644 --- a/internal/domain/basic_test.go +++ b/internal/domain/basic_test.go @@ -59,7 +59,6 @@ func TestProvider(t *testing.T) { {ProviderMicrosoft, true}, {ProviderIMAP, false}, {ProviderVirtual, false}, - {ProviderInbox, false}, {ProviderNylas, true}, {Provider("unknown"), false}, } diff --git a/internal/domain/inbound.go b/internal/domain/inbound.go deleted file mode 100644 index 31b649d..0000000 --- a/internal/domain/inbound.go +++ /dev/null @@ -1,42 +0,0 @@ -package domain - -// InboundInbox represents a Nylas Inbound inbox (a grant with provider=inbox). -// Inbound inboxes receive emails at managed addresses without OAuth. -type InboundInbox struct { - ID string `json:"id"` // Grant ID - Email string `json:"email"` // Full email address (e.g., info@app.nylas.email) - PolicyID string `json:"policy_id,omitempty"` - GrantStatus string `json:"grant_status"` // Status of the inbox - CreatedAt UnixTime `json:"created_at"` - UpdatedAt UnixTime `json:"updated_at"` -} - -// IsValid returns true if the inbound inbox is in a valid state. -func (i *InboundInbox) IsValid() bool { - return i.GrantStatus == "valid" -} - -// CreateInboundInboxRequest represents a request to create a new inbound inbox. -type CreateInboundInboxRequest struct { - // Email is the local part of the email address (before @). - // The full address will be: {email}@{your-app}.nylas.email - Email string `json:"email"` -} - -// InboundMessage represents an email received at an inbound inbox. -// This is an alias for Message but provides semantic clarity. -type InboundMessage = Message - -// InboundWebhookEvent contains metadata specific to inbound webhook events. -type InboundWebhookEvent struct { - Type string `json:"type"` // e.g., "message.created" - Source string `json:"source"` // "inbox" for inbound emails - GrantID string `json:"grant_id"` // The inbound inbox grant ID - MessageID string `json:"message_id"` // The message ID - Message *InboundMessage `json:"message"` // The message object (if included) -} - -// IsInboundEvent returns true if the event is from an inbound inbox. -func (e *InboundWebhookEvent) IsInboundEvent() bool { - return e.Source == "inbox" -} diff --git a/internal/domain/provider.go b/internal/domain/provider.go index c4dd613..5d90ab3 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -10,7 +10,6 @@ const ( ProviderMicrosoft Provider = "microsoft" ProviderIMAP Provider = "imap" ProviderVirtual Provider = "virtual" - ProviderInbox Provider = "inbox" // Nylas Native Auth ProviderNylas Provider = "nylas" ) @@ -28,8 +27,6 @@ func (p Provider) DisplayName() string { return "IMAP" case ProviderVirtual: return "Virtual" - case ProviderInbox: - return "Inbox" case ProviderNylas: return "Nylas" default: @@ -40,7 +37,7 @@ func (p Provider) DisplayName() string { // IsValid checks if the provider is a known type. func (p Provider) IsValid() bool { switch p { - case ProviderGoogle, ProviderMicrosoft, ProviderIMAP, ProviderVirtual, ProviderInbox, ProviderNylas: + case ProviderGoogle, ProviderMicrosoft, ProviderIMAP, ProviderVirtual, ProviderNylas: return true default: return false diff --git a/internal/ports/inbound.go b/internal/ports/inbound.go deleted file mode 100644 index f518dec..0000000 --- a/internal/ports/inbound.go +++ /dev/null @@ -1,25 +0,0 @@ -package ports - -import ( - "context" - - "github.com/nylas/cli/internal/domain" -) - -// InboundClient defines the interface for inbound inbox operations. -type InboundClient interface { - // ListInboundInboxes retrieves all inbound inboxes. - ListInboundInboxes(ctx context.Context) ([]domain.InboundInbox, error) - - // GetInboundInbox retrieves a specific inbound inbox. - GetInboundInbox(ctx context.Context, grantID string) (*domain.InboundInbox, error) - - // CreateInboundInbox creates a new inbound inbox. - CreateInboundInbox(ctx context.Context, email string) (*domain.InboundInbox, error) - - // DeleteInboundInbox deletes an inbound inbox. - DeleteInboundInbox(ctx context.Context, grantID string) error - - // GetInboundMessages retrieves inbound messages with query parameters. - GetInboundMessages(ctx context.Context, grantID string, params *domain.MessageQueryParams) ([]domain.InboundMessage, error) -} diff --git a/internal/ports/nylas.go b/internal/ports/nylas.go index c49f26a..c4c0329 100644 --- a/internal/ports/nylas.go +++ b/internal/ports/nylas.go @@ -17,7 +17,6 @@ type NylasClient interface { WebhookClient PubSubClient NotetakerClient - InboundClient AgentClient PolicyClient RuleClient diff --git a/internal/ports/transactional.go b/internal/ports/transactional.go index 442e278..a140c12 100644 --- a/internal/ports/transactional.go +++ b/internal/ports/transactional.go @@ -7,9 +7,9 @@ import ( ) // TransactionalClient defines the interface for domain-based transactional email operations. -// This is used for Nylas Inbox provider grants which use domain-based endpoints instead of grant-based. +// This is used for managed Nylas grants which use domain-based endpoints instead of grant-based. type TransactionalClient interface { // SendTransactionalMessage sends an email via the domain-based transactional endpoint. - // Used for Inbox provider grants: POST /v3/domains/{domain}/messages/send + // Used for managed Nylas grants: POST /v3/domains/{domain}/messages/send SendTransactionalMessage(ctx context.Context, domainName string, req *domain.SendMessageRequest) (*domain.Message, error) } diff --git a/internal/tui/app_ui.go b/internal/tui/app_ui.go index b347e4f..8edb586 100644 --- a/internal/tui/app_ui.go +++ b/internal/tui/app_ui.go @@ -95,8 +95,6 @@ func (a *App) onCommand(cmd string) { a.navigateTo("webhook-server") case "g", "grants", "gr": a.navigateTo("grants") - case "i", "in", "inbound", "inbox": - a.navigateTo("inbound") case "d", "dashboard", "dash", "home": a.navigateTo("dashboard") @@ -243,8 +241,6 @@ func (a *App) createView(name string) ResourceView { return NewWebhookServerView(a) case "grants": return NewGrantsView(a) - case "inbound": - return NewInboundView(a) default: return NewDashboardView(a) } diff --git a/internal/tui/commands_definitions.go b/internal/tui/commands_definitions.go index e622e82..b4e9213 100644 --- a/internal/tui/commands_definitions.go +++ b/internal/tui/commands_definitions.go @@ -41,12 +41,6 @@ func (r *CommandRegistry) registerAllCommands() { Description: "Go to grants/accounts view", Category: CategoryNavigation, }) - r.Register(Command{ - Name: "inbound", - Aliases: []string{"i", "in", "inbox"}, - Description: "Go to inbound inboxes view", - Category: CategoryNavigation, - }) r.Register(Command{ Name: "dashboard", Aliases: []string{"d", "dash", "home"}, diff --git a/internal/tui/formatting_helpers.go b/internal/tui/formatting_helpers.go new file mode 100644 index 0000000..093d3d5 --- /dev/null +++ b/internal/tui/formatting_helpers.go @@ -0,0 +1,106 @@ +package tui + +import ( + "html" + "strings" + "time" + + "github.com/nylas/cli/internal/cli/common" +) + +// formatDate formats a time for display in the UI. +func formatDate(t time.Time) string { + now := time.Now() + if t.Year() == now.Year() && t.YearDay() == now.YearDay() { + return t.Format("3:04 PM") + } + if t.Year() == now.Year() { + return t.Format("Jan 2") + } + return t.Format("Jan 2, 06") +} + +// formatFileSize formats a file size in bytes to a human-readable string. +func formatFileSize(size int64) string { + return common.FormatSize(size) +} + +// stripHTMLForTUI removes HTML tags from a string for terminal display. +func stripHTMLForTUI(s string) string { + // Remove style and script tags and their contents. + s = removeTagWithContent(s, "style") + s = removeTagWithContent(s, "script") + s = removeTagWithContent(s, "head") + + // Replace block-level elements with newlines before stripping tags. + blockTags := []string{"br", "p", "div", "tr", "li", "h1", "h2", "h3", "h4", "h5", "h6"} + for _, tag := range blockTags { + s = strings.ReplaceAll(s, "<"+tag+">", "\n") + s = strings.ReplaceAll(s, "<"+tag+"/>", "\n") + s = strings.ReplaceAll(s, "<"+tag+" />", "\n") + s = strings.ReplaceAll(s, "", "\n") + s = strings.ReplaceAll(s, "<"+strings.ToUpper(tag)+">", "\n") + s = strings.ReplaceAll(s, "", "\n") + } + + // Strip remaining HTML tags. + var result strings.Builder + inTag := false + for _, r := range s { + switch { + case r == '<': + inTag = true + case r == '>': + inTag = false + case !inTag: + result.WriteRune(r) + } + } + + // Decode HTML entities. + text := html.UnescapeString(result.String()) + + // Clean up whitespace. + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + + for strings.Contains(text, " ") { + text = strings.ReplaceAll(text, " ", " ") + } + + for strings.Contains(text, "\n\n\n") { + text = strings.ReplaceAll(text, "\n\n\n", "\n\n") + } + + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = strings.TrimSpace(line) + } + text = strings.Join(lines, "\n") + + return strings.TrimSpace(text) +} + +// removeTagWithContent removes an HTML tag and all its content. +func removeTagWithContent(s, tag string) string { + result := s + for { + lower := strings.ToLower(result) + startIdx := strings.Index(lower, "<"+tag) + if startIdx == -1 { + break + } + endTag := "" + endIdx := strings.Index(lower[startIdx:], endTag) + if endIdx == -1 { + closeIdx := strings.Index(result[startIdx:], ">") + if closeIdx == -1 { + break + } + result = result[:startIdx] + result[startIdx+closeIdx+1:] + } else { + result = result[:startIdx] + result[startIdx+endIdx+len(endTag):] + } + } + return result +} diff --git a/internal/tui/views_dashboard.go b/internal/tui/views_dashboard.go index 5bf7fbf..e7e32bc 100644 --- a/internal/tui/views_dashboard.go +++ b/internal/tui/views_dashboard.go @@ -67,7 +67,6 @@ func (v *DashboardView) Load() { {":m", "Messages", "Email messages"}, {":e", "Events", "Calendar events"}, {":c", "Contacts", "Contacts"}, - {":i", "Inbound", "Inbound inboxes"}, {":w", "Webhooks", "Webhooks"}, {":ws", "Server", "Webhook server (local)"}, {":g", "Grants", "Connected accounts"}, diff --git a/internal/tui/views_inbound.go b/internal/tui/views_inbound.go deleted file mode 100644 index bca3fb9..0000000 --- a/internal/tui/views_inbound.go +++ /dev/null @@ -1,413 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "html" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/domain" - "github.com/rivo/tview" -) - -// InboundView displays inbound inboxes and their messages. -type InboundView struct { - app *App - layout *tview.Flex - inboxList *Table - messageList *Table - inboxes []domain.InboundInbox - messages []domain.InboundMessage - selectedInbox *domain.InboundInbox - focusedPanel int // 0 = inbox list, 1 = message list - showingDetail bool - name string - title string -} - -// NewInboundView creates a new inbound view. -func NewInboundView(app *App) *InboundView { - v := &InboundView{ - app: app, - name: "inbound", - title: "Inbound", - } - - // Create inbox list table - v.inboxList = NewTable(app.styles) - v.inboxList.SetColumns([]Column{ - {Title: "", Width: 3}, - {Title: "EMAIL", Expand: true}, - {Title: "STATUS", Width: 10}, - {Title: "CREATED", Width: 15}, - }) - - // Create message list table - v.messageList = NewTable(app.styles) - v.messageList.SetColumns([]Column{ - {Title: "", Width: 3}, - {Title: "FROM", Width: 25}, - {Title: "SUBJECT", Expand: true}, - {Title: "DATE", Width: 12}, - }) - - // Create split layout: Inboxes (top) | Messages (bottom) - v.layout = tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(v.inboxList, 10, 0, true). - AddItem(v.messageList, 0, 1, false) - - // Set up selection callback for inbox list - v.inboxList.SetOnSelect(func(meta *RowMeta) { - if inbox, ok := meta.Data.(*domain.InboundInbox); ok { - v.selectedInbox = inbox - v.loadMessages(inbox.ID) - } - }) - - // Set up double-click to view message - v.messageList.SetOnDoubleClick(func(meta *RowMeta) { - if msg, ok := meta.Data.(*domain.InboundMessage); ok { - v.showMessageDetail(msg) - } - }) - - return v -} - -func (v *InboundView) Name() string { return v.name } -func (v *InboundView) Title() string { return v.title } -func (v *InboundView) Primitive() tview.Primitive { return v.layout } -func (v *InboundView) Filter(string) {} - -func (v *InboundView) Hints() []Hint { - return []Hint{ - {Key: "enter", Desc: "select/view"}, - {Key: "Tab", Desc: "switch panel"}, - {Key: "r", Desc: "refresh"}, - } -} - -func (v *InboundView) Load() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Load inbound inboxes - inboxes, err := v.app.config.Client.ListInboundInboxes(ctx) - if err != nil { - v.app.Flash(FlashError, "Failed to load inboxes: %v", err) - return - } - - v.inboxes = inboxes - v.renderInboxes() - - if len(inboxes) == 0 { - v.app.Flash(FlashInfo, "No inbound inboxes found. Create one with: nylas inbound create ") - return - } - - // Select first inbox and load its messages - v.selectedInbox = &inboxes[0] - v.loadMessages(inboxes[0].ID) - - v.app.Flash(FlashInfo, "Found %d inbound inbox(es)", len(inboxes)) -} - -func (v *InboundView) Refresh() { - v.Load() -} - -func (v *InboundView) renderInboxes() { - var data [][]string - var meta []RowMeta - - for _, inbox := range v.inboxes { - status := "active" - if inbox.GrantStatus != "valid" { - status = inbox.GrantStatus - } - - created := formatDate(inbox.CreatedAt.Time) - - // Mark selected inbox - marker := "" - if v.selectedInbox != nil && inbox.ID == v.selectedInbox.ID { - marker = ">" - } - - data = append(data, []string{ - marker, - inbox.Email, - status, - created, - }) - - // Create a copy for closure - i := inbox - meta = append(meta, RowMeta{ - ID: inbox.ID, - Data: &i, - Error: inbox.GrantStatus != "valid", - }) - } - - v.inboxList.SetData(data, meta) -} - -func (v *InboundView) loadMessages(inboxID string) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - messages, err := v.app.config.Client.GetInboundMessages(ctx, inboxID, &domain.MessageQueryParams{Limit: 50}) - if err != nil { - v.app.Flash(FlashError, "Failed to load messages: %v", err) - return - } - - v.messages = messages - v.renderMessages() -} - -func (v *InboundView) renderMessages() { - var data [][]string - var meta []RowMeta - - for _, msg := range v.messages { - from := "" - if len(msg.From) > 0 { - from = msg.From[0].Name - if from == "" { - from = msg.From[0].Email - } - } - - date := formatDate(msg.Date) - - data = append(data, []string{ - "", - from, - msg.Subject, - date, - }) - - // Create a copy for closure - m := msg - meta = append(meta, RowMeta{ - ID: msg.ID, - Data: &m, - Unread: msg.Unread, - Starred: msg.Starred, - }) - } - - v.messageList.SetData(data, meta) -} - -func (v *InboundView) HandleKey(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEscape: - if v.showingDetail { - v.closeDetail() - return nil - } - return event - - case tcell.KeyTab: - // Switch focus between inbox list and message list - v.focusedPanel = (v.focusedPanel + 1) % 2 - if v.focusedPanel == 0 { - v.app.SetFocus(v.inboxList) - } else { - v.app.SetFocus(v.messageList) - } - return nil - - case tcell.KeyEnter: - if v.focusedPanel == 0 { - // Select inbox and load messages - if meta := v.inboxList.SelectedMeta(); meta != nil { - if inbox, ok := meta.Data.(*domain.InboundInbox); ok { - v.selectedInbox = inbox - v.renderInboxes() // Update marker - v.loadMessages(inbox.ID) - // Switch to message panel - v.focusedPanel = 1 - v.app.SetFocus(v.messageList) - } - } - } else { - // View message detail - if meta := v.messageList.SelectedMeta(); meta != nil { - if msg, ok := meta.Data.(*domain.InboundMessage); ok { - v.showMessageDetail(msg) - } - } - } - return nil - } - - return event -} - -func (v *InboundView) showMessageDetail(msg *domain.InboundMessage) { - detail := tview.NewTextView() - detail.SetDynamicColors(true) - detail.SetBackgroundColor(v.app.styles.BgColor) - detail.SetBorderPadding(1, 1, 2, 2) - detail.SetScrollable(true) - - // Use cached Hex() method - st := v.app.styles - title := st.Hex(st.TitleFg) - key := st.Hex(st.FgColor) - value := st.Hex(st.InfoSectionFg) - muted := st.Hex(st.BorderColor) - - // Format sender - from := "" - if len(msg.From) > 0 { - from = msg.From[0].String() - } - - // Format recipients - var to []string - for _, t := range msg.To { - to = append(to, t.String()) - } - - _, _ = fmt.Fprintf(detail, "[%s::b]%s[-::-]\n", title, msg.Subject) - _, _ = fmt.Fprintf(detail, "[%s]────────────────────────────────────────[-]\n", muted) - _, _ = fmt.Fprintf(detail, "[%s]From:[-] [%s]%s[-]\n", key, value, from) - if len(to) > 0 { - _, _ = fmt.Fprintf(detail, "[%s]To:[-] [%s]%s[-]\n", key, value, strings.Join(to, ", ")) - } - _, _ = fmt.Fprintf(detail, "[%s]Date:[-] [%s]%s[-]\n", key, value, msg.Date.Format(common.DisplayWeekdayComma)) - _, _ = fmt.Fprintf(detail, "[%s]ID:[-] [%s]%s[-]\n", key, value, msg.ID) - _, _ = fmt.Fprintf(detail, "[%s]────────────────────────────────────────[-]\n\n", muted) - - // Body - body := msg.Body - if body == "" { - body = msg.Snippet - } - body = stripHTMLForTUI(body) - _, _ = fmt.Fprintf(detail, "[%s]%s[-]\n\n", value, tview.Escape(body)) - - _, _ = fmt.Fprintf(detail, "[%s]Press Esc to go back[-]", muted) - - v.app.PushDetail("inbound-message-detail", detail) - v.showingDetail = true -} - -func (v *InboundView) closeDetail() { - v.app.PopDetail() - v.showingDetail = false - if v.focusedPanel == 0 { - v.app.SetFocus(v.inboxList) - } else { - v.app.SetFocus(v.messageList) - } -} - -// formatDate formats a time for display in the UI. -func formatDate(t time.Time) string { - now := time.Now() - if t.Year() == now.Year() && t.YearDay() == now.YearDay() { - return t.Format("3:04 PM") - } - if t.Year() == now.Year() { - return t.Format("Jan 2") - } - return t.Format("Jan 2, 06") -} - -// formatFileSize formats a file size in bytes to a human-readable string. -func formatFileSize(size int64) string { - return common.FormatSize(size) -} - -// stripHTMLForTUI removes HTML tags from a string for terminal display. -func stripHTMLForTUI(s string) string { - // Remove style and script tags and their contents - s = removeTagWithContent(s, "style") - s = removeTagWithContent(s, "script") - s = removeTagWithContent(s, "head") - - // Replace block-level elements with newlines before stripping tags - blockTags := []string{"br", "p", "div", "tr", "li", "h1", "h2", "h3", "h4", "h5", "h6"} - for _, tag := range blockTags { - s = strings.ReplaceAll(s, "<"+tag+">", "\n") - s = strings.ReplaceAll(s, "<"+tag+"/>", "\n") - s = strings.ReplaceAll(s, "<"+tag+" />", "\n") - s = strings.ReplaceAll(s, "", "\n") - s = strings.ReplaceAll(s, "<"+strings.ToUpper(tag)+">", "\n") - s = strings.ReplaceAll(s, "", "\n") - } - - // Strip remaining HTML tags - var result strings.Builder - inTag := false - for _, r := range s { - switch { - case r == '<': - inTag = true - case r == '>': - inTag = false - case !inTag: - result.WriteRune(r) - } - } - - // Decode HTML entities - text := html.UnescapeString(result.String()) - - // Clean up whitespace - text = strings.ReplaceAll(text, "\r\n", "\n") - text = strings.ReplaceAll(text, "\r", "\n") - - // Collapse multiple spaces - for strings.Contains(text, " ") { - text = strings.ReplaceAll(text, " ", " ") - } - - // Collapse multiple newlines - for strings.Contains(text, "\n\n\n") { - text = strings.ReplaceAll(text, "\n\n\n", "\n\n") - } - - // Trim spaces from each line - lines := strings.Split(text, "\n") - for i, line := range lines { - lines[i] = strings.TrimSpace(line) - } - text = strings.Join(lines, "\n") - - return strings.TrimSpace(text) -} - -// removeTagWithContent removes an HTML tag and all its content. -func removeTagWithContent(s, tag string) string { - result := s - for { - lower := strings.ToLower(result) - startIdx := strings.Index(lower, "<"+tag) - if startIdx == -1 { - break - } - endTag := "" - endIdx := strings.Index(lower[startIdx:], endTag) - if endIdx == -1 { - closeIdx := strings.Index(result[startIdx:], ">") - if closeIdx == -1 { - break - } - result = result[:startIdx] + result[startIdx+closeIdx+1:] - } else { - result = result[:startIdx] + result[startIdx+endIdx+len(endTag):] - } - } - return result -} diff --git a/internal/tui/views_inbound_test.go b/internal/tui/views_inbound_test.go deleted file mode 100644 index ef792c5..0000000 --- a/internal/tui/views_inbound_test.go +++ /dev/null @@ -1,210 +0,0 @@ -//go:build !integration - -package tui - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestStripHTMLForTUI(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - expected string - }{ - { - name: "plain text unchanged", - input: "Hello, World!", - expected: "Hello, World!", - }, - { - name: "basic HTML tags removed", - input: "

Hello

World

", - expected: "Hello\n\nWorld", - }, - { - name: "br tags become newlines", - input: "Line 1
Line 2
Line 3", - expected: "Line 1\nLine 2\nLine 3", - }, - { - name: "div tags become newlines", - input: "
Section 1
Section 2
", - expected: "Section 1\n\nSection 2", - }, - { - name: "inline tags removed", - input: "Bold and italic", - expected: "Bold and italic", - }, - { - name: "anchor tags removed", - input: "Visit example", - expected: "Visit example", - }, - { - name: "style tags and content removed", - input: "Hello", - expected: "Hello", - }, - { - name: "script tags and content removed", - input: "Safe content", - expected: "Safe content", - }, - { - name: "head tags and content removed", - input: "PageContent", - expected: "Content", - }, - { - name: "HTML entities decoded", - input: "<tag> & "quoted"", - expected: " & \"quoted\"", - }, - { - name: "multiple spaces collapsed", - input: "Hello World", - expected: "Hello World", - }, - { - name: "multiple newlines collapsed", - input: "Line 1\n\n\n\nLine 2", - expected: "Line 1\n\nLine 2", - }, - { - name: "windows newlines normalized", - input: "Line 1\r\nLine 2\rLine 3", - expected: "Line 1\nLine 2\nLine 3", - }, - { - name: "whitespace trimmed from lines", - input: " Hello \n World ", - expected: "Hello\nWorld", - }, - { - name: "uppercase HTML tags handled", - input: "

Paragraph


Line", - expected: "Paragraph\n\nLine", - }, - { - name: "list items become newlines", - input: "
  • Item 1
  • Item 2
", - expected: "Item 1\n\nItem 2", - }, - { - name: "table rows become newlines", - input: "
Cell 1
Cell 2
", - expected: "Cell 1\n\nCell 2", - }, - { - name: "headers become newlines", - input: "

Title

Subtitle

", - expected: "Title\n\nSubtitle", - }, - { - name: "empty input", - input: "", - expected: "", - }, - { - name: "only tags", - input: "
", - expected: "", - }, - { - name: "nested tags", - input: "

Deep

", - expected: "Deep", - }, - { - name: "self-closing br variants", - input: "A
B
C
D", - expected: "A\nB\nC\nD", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := stripHTMLForTUI(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestRemoveTagWithContent(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - tag string - expected string - }{ - { - name: "remove style tag", - input: "Content", - tag: "style", - expected: "Content", - }, - { - name: "remove script tag", - input: "BeforeAfter", - tag: "script", - expected: "BeforeAfter", - }, - { - name: "remove head tag", - input: "PageBody", - tag: "head", - expected: "Body", - }, - { - name: "remove multiple instances", - input: "MidEnd", - tag: "style", - expected: "MidEnd", - }, - { - name: "case insensitive", - input: "Content", - tag: "style", - expected: "Content", - }, - { - name: "tag not found", - input: "No tags here", - tag: "style", - expected: "No tags here", - }, - { - name: "empty input", - input: "", - tag: "style", - expected: "", - }, - { - name: "unclosed tag removed", - input: "Content", - tag: "style", - expected: "Content", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := removeTagWithContent(tt.input, tt.tag) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/internal/ui/server_command_validation_test.go b/internal/ui/server_command_validation_test.go index 30d95b6..a85e970 100644 --- a/internal/ui/server_command_validation_test.go +++ b/internal/ui/server_command_validation_test.go @@ -52,13 +52,6 @@ func TestAllowedCommands(t *testing.T) { {"contacts search", "contacts search", true}, {"contacts groups", "contacts groups", true}, - // Inbound commands - {"inbound list", "inbound list", true}, - {"inbound show", "inbound show", true}, - {"inbound create", "inbound create", true}, - {"inbound messages", "inbound messages", true}, - {"inbound monitor", "inbound monitor", true}, - // Scheduler commands {"scheduler configurations", "scheduler configurations", true}, {"scheduler sessions", "scheduler sessions", true}, @@ -183,7 +176,6 @@ func TestAllowedCommandsCompleteness(t *testing.T) { "email", "calendar", "contacts", - "inbound", "scheduler", "timezone", "webhook", diff --git a/internal/ui/server_defaults_test.go b/internal/ui/server_defaults_test.go index fc21454..67fefd1 100644 --- a/internal/ui/server_defaults_test.go +++ b/internal/ui/server_defaults_test.go @@ -30,9 +30,6 @@ func TestGetDefaultCommands(t *testing.T) { if len(cmds.Contacts) == 0 { t.Error("Contacts commands should not be empty") } - if len(cmds.Inbound) == 0 { - t.Error("Inbound commands should not be empty") - } if len(cmds.Scheduler) == 0 { t.Error("Scheduler commands should not be empty") } @@ -64,7 +61,6 @@ func TestGetDefaultCommands_RequiredFields(t *testing.T) { allCommands = append(allCommands, cmds.Email...) allCommands = append(allCommands, cmds.Calendar...) allCommands = append(allCommands, cmds.Contacts...) - allCommands = append(allCommands, cmds.Inbound...) allCommands = append(allCommands, cmds.Scheduler...) allCommands = append(allCommands, cmds.Timezone...) allCommands = append(allCommands, cmds.Webhook...) @@ -99,7 +95,6 @@ func TestGetDefaultCommands_ParamCommands(t *testing.T) { allCommands = append(allCommands, cmds.Email...) allCommands = append(allCommands, cmds.Calendar...) allCommands = append(allCommands, cmds.Contacts...) - allCommands = append(allCommands, cmds.Inbound...) allCommands = append(allCommands, cmds.Scheduler...) allCommands = append(allCommands, cmds.Timezone...) allCommands = append(allCommands, cmds.Webhook...) @@ -211,10 +206,6 @@ func TestGetDemoCommandOutput_AllCommands(t *testing.T) { {"contacts list --id", []string{"Demo Mode", "demo-contact-001"}}, {"contacts groups", []string{"Demo Mode", "Contact Groups", "Work"}}, - // Inbound commands - {"inbound list", []string{"Demo Mode", "Inbound Inboxes", "inbox-001"}}, - {"inbound messages", []string{"Demo Mode", "Inbound Messages", "billing"}}, - // Scheduler commands {"scheduler configurations", []string{"Demo Mode", "30-min Meeting", "DURATION"}}, {"scheduler bookings", []string{"Demo Mode", "Bookings", "UPCOMING"}}, diff --git a/internal/ui/server_demo.go b/internal/ui/server_demo.go index fcef8ae..79c544c 100644 --- a/internal/ui/server_demo.go +++ b/internal/ui/server_demo.go @@ -119,25 +119,6 @@ Showing 5 of 127 contacts` 4 groups found` - case "inbound list": - return `Demo Mode - Inbound Inboxes - - ID ADDRESS STATUS - inbox-001 support@yourapp.nylas.email Active - inbox-002 leads@yourapp.nylas.email Active - inbox-003 tickets@yourapp.nylas.email Active - -3 inbound inboxes` - - case "inbound messages": - return `Demo Mode - Inbound Messages - - ★ ● customer@email.com Need help with billing 5 min ago - ● lead@company.com Interested in your product 1 hour ago - partner@business.com Partnership inquiry 3 hours ago - -Showing 3 of 42 messages` - case "scheduler configurations": return `Demo Mode - Scheduler Configurations diff --git a/internal/ui/server_demo_test.go b/internal/ui/server_demo_test.go index b1e452d..df88ddd 100644 --- a/internal/ui/server_demo_test.go +++ b/internal/ui/server_demo_test.go @@ -456,7 +456,6 @@ func TestCommandsJSContainsNoDashboardOldURL(t *testing.T) { "static/js/commands-calendar.js", "static/js/commands-contacts.js", "static/js/commands-scheduler.js", - "static/js/commands-inbound.js", "static/js/commands-timezone.js", "static/js/commands-webhook.js", "static/js/commands-otp.js", diff --git a/internal/ui/server_exec.go b/internal/ui/server_exec.go index d2711a3..5ed1e28 100644 --- a/internal/ui/server_exec.go +++ b/internal/ui/server_exec.go @@ -147,13 +147,6 @@ var allowedCommands = map[string]bool{ "contacts groups show": true, "contacts groups create": true, "contacts groups delete": true, - // Inbound commands - "inbound list": true, - "inbound show": true, - "inbound create": true, - "inbound delete": true, - "inbound messages": true, - "inbound monitor": true, // Scheduler commands "scheduler configurations": true, "scheduler sessions": true, diff --git a/internal/ui/static/js/commands-core.js b/internal/ui/static/js/commands-core.js index b2b5582..c1695fc 100644 --- a/internal/ui/static/js/commands-core.js +++ b/internal/ui/static/js/commands-core.js @@ -11,7 +11,6 @@ let cachedCalendarIds = []; // [{id: "calendar-id", label: "Calendar Name"}, .. let cachedEventIds = []; // [{id: "event-id", label: "Event Title"}, ...] let cachedGrantIds = []; // [{id: "grant-id", label: "email@example.com (Provider)"}, ...] let cachedContactIds = []; // [{id: "contact-id", label: "John Doe (john@example.com)"}, ...] -let cachedInboxIds = []; // [{id: "inbox-id", label: "support@app.nylas.email"}, ...] let cachedWebhookIds = []; // [{id: "webhook-id", label: "https://example.com/webhook"}, ...] let cachedNotetakerIds = []; // [{id: "notetaker-id", label: "Team Standup"}, ...] @@ -310,43 +309,6 @@ function parseContactIdsFromOutput(output) { return ids; } -/** - * Parse inbound list output to extract inbox IDs. - */ -function parseInboxIdsFromOutput(output) { - const ids = []; - const lines = output.split('\n'); - let dataStarted = false; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - if (trimmed.startsWith('ID') && trimmed.includes('ADDRESS')) { - dataStarted = true; - continue; - } - - if (!dataStarted) continue; - if (trimmed.includes('───') || trimmed.includes('---') || trimmed.includes('inboxes')) continue; - - const parts = trimmed.split(/\s{2,}/); - if (parts.length >= 2) { - const id = parts[0].trim(); - const address = parts[1].trim(); - - if (id && id.length >= 5 && !id.includes('ID') && address.includes('@')) { - ids.push({ - id: id, - label: address - }); - } - } - } - - return ids; -} - /** * Parse webhook list output to extract webhook IDs. */ @@ -434,7 +396,6 @@ function getCachedCalendarIds() { return cachedCalendarIds; } function getCachedEventIds() { return cachedEventIds; } function getCachedGrantIds() { return cachedGrantIds; } function getCachedContactIds() { return cachedContactIds; } -function getCachedInboxIds() { return cachedInboxIds; } function getCachedWebhookIds() { return cachedWebhookIds; } function getCachedNotetakerIds() { return cachedNotetakerIds; } @@ -451,7 +412,6 @@ function clearAllCachedIds() { cachedEventIds = []; cachedGrantIds = []; cachedContactIds = []; - cachedInboxIds = []; cachedWebhookIds = []; cachedNotetakerIds = []; } @@ -459,7 +419,7 @@ function clearAllCachedIds() { function getTotalCachedCount() { return cachedMessageIds.length + cachedFolderIds.length + cachedScheduleIds.length + cachedThreadIds.length + cachedCalendarIds.length + cachedEventIds.length + - cachedGrantIds.length + cachedContactIds.length + cachedInboxIds.length + + cachedGrantIds.length + cachedContactIds.length + cachedWebhookIds.length + cachedNotetakerIds.length; } @@ -470,7 +430,6 @@ function updateCacheCountBadge() { 'calendar-cache-count-badge', 'auth-cache-count-badge', 'contacts-cache-count-badge', - 'inbound-cache-count-badge', 'webhook-cache-count-badge', 'notetaker-cache-count-badge' ]; @@ -505,7 +464,6 @@ function clearCacheAndNotify() { { current: 'currentCalendarCmd', commands: 'calendarCommands', prefix: 'calendar' }, { current: 'currentAuthCmd', commands: 'authCommands', prefix: 'auth' }, { current: 'currentContactsCmd', commands: 'contactsCommands', prefix: 'contacts' }, - { current: 'currentInboundCmd', commands: 'inboundCommands', prefix: 'inbound' }, { current: 'currentWebhookCmd', commands: 'webhookCommands', prefix: 'webhook' }, { current: 'currentNotetakerCmd', commands: 'notetakerCommands', prefix: 'notetaker' } ]; diff --git a/internal/ui/static/js/commands-inbound.js b/internal/ui/static/js/commands-inbound.js deleted file mode 100644 index ae023e8..0000000 --- a/internal/ui/static/js/commands-inbound.js +++ /dev/null @@ -1,107 +0,0 @@ -// ============================================================================= -// Inbound Commands -// ============================================================================= - -const inboundCommandSections = [ - { - title: 'Inboxes', - commands: { - 'list': { title: 'List', cmd: 'inbound list', desc: 'List all inbound inboxes' }, - 'show': { title: 'Show', cmd: 'inbound show', desc: 'Show inbox details', param: { name: 'inbox-id', placeholder: 'Enter inbox ID...' } }, - 'create': { title: 'Create', cmd: 'inbound create', desc: 'Create a new inbox', param: { name: 'name', placeholder: 'Enter inbox name (e.g., support)...' } }, - 'delete': { title: 'Delete', cmd: 'inbound delete', desc: 'Delete an inbox', param: { name: 'inbox-id', placeholder: 'Enter inbox ID...' } } - } - }, - { - title: 'Messages', - commands: { - 'messages': { title: 'Messages', cmd: 'inbound messages', desc: 'View inbox messages', param: { name: 'inbox-id', placeholder: 'Enter inbox ID...' } }, - 'monitor': { title: 'Monitor', cmd: 'inbound monitor', desc: 'Monitor for new messages', param: { name: 'inbox-id', placeholder: 'Enter inbox ID...' } } - } - } -]; - -const inboundCommands = {}; -inboundCommandSections.forEach(section => { - Object.assign(inboundCommands, section.commands); -}); - -let currentInboundCmd = ''; - -function showInboundCmd(cmd) { - const data = inboundCommands[cmd]; - if (!data) return; - - currentInboundCmd = cmd; - - document.querySelectorAll('#page-inbound .cmd-item').forEach(item => { - item.classList.toggle('active', item.dataset.cmd === cmd); - }); - - const detail = document.getElementById('inbound-detail'); - detail.querySelector('.detail-placeholder').style.display = 'none'; - detail.querySelector('.detail-content').style.display = 'block'; - - document.getElementById('inbound-detail-title').textContent = data.title; - document.getElementById('inbound-detail-cmd').textContent = 'nylas ' + data.cmd; - document.getElementById('inbound-detail-desc').textContent = data.desc || ''; - document.getElementById('inbound-output').textContent = 'Click "Run" to execute command...'; - document.getElementById('inbound-output').className = 'output-pre'; - - showParamInput('inbound', data.param, data.flags); -} - -async function runInboundCmd() { - if (!currentInboundCmd) return; - - const data = inboundCommands[currentInboundCmd]; - const output = document.getElementById('inbound-output'); - const btn = document.getElementById('inbound-run-btn'); - const fullCmd = buildCommand(data.cmd, 'inbound', data.flags); - - document.getElementById('inbound-detail-cmd').textContent = 'nylas ' + fullCmd; - - setButtonLoading(btn, true); - setOutputLoading(output); - - try { - const res = await fetch('/api/exec', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: fullCmd }) - }); - const result = await res.json(); - - if (result.error) { - setOutputError(output, result.error); - showToast('Command failed', 'error'); - } else { - setOutputSuccess(output, result.output); - showToast('Command completed', 'success'); - - if (result.output && currentInboundCmd === 'list') { - const ids = parseInboxIdsFromOutput(result.output); - if (ids.length > 0) { - cachedInboxIds = ids; - showToast('Cached ' + ids.length + ' inbox IDs for quick access', 'info'); - updateCacheCountBadge(); - } - } - } - - updateTimestamp('inbound'); - } catch (err) { - setOutputError(output, 'Failed to execute command: ' + err.message); - showToast('Connection error', 'error'); - } finally { - setButtonLoading(btn, false); - } -} - -function refreshInboundCmd() { - if (currentInboundCmd) runInboundCmd(); -} - -function renderInboundCommands() { - renderCommandSections('inbound-cmd-list', inboundCommandSections, 'showInboundCmd'); -} diff --git a/internal/ui/static/js/commands.js b/internal/ui/static/js/commands.js index e360475..2c06e9f 100644 --- a/internal/ui/static/js/commands.js +++ b/internal/ui/static/js/commands.js @@ -82,7 +82,6 @@ document.addEventListener('DOMContentLoaded', () => { renderEmailCommands(); renderCalendarCommands(); renderContactsCommands(); - renderInboundCommands(); renderSchedulerCommands(); renderTimezoneCommands(); renderWebhookCommands(); diff --git a/internal/ui/static/js/params.js b/internal/ui/static/js/params.js index 0d167c9..a6fdfd3 100644 --- a/internal/ui/static/js/params.js +++ b/internal/ui/static/js/params.js @@ -33,10 +33,6 @@ function getParamSuggestions(section, paramName) { 'contact-id': 'getCachedContactIds' // contacts show, update, delete }; - const inboundParamMap = { - 'inbox-id': 'getCachedInboxIds' // inbound show, delete, messages, monitor - }; - const webhookParamMap = { 'webhook-id': 'getCachedWebhookIds' // webhook show, update, delete }; @@ -55,8 +51,6 @@ function getParamSuggestions(section, paramName) { getterName = authParamMap[paramName]; } else if (section === 'contacts') { getterName = contactsParamMap[paramName]; - } else if (section === 'inbound') { - getterName = inboundParamMap[paramName]; } else if (section === 'webhook') { getterName = webhookParamMap[paramName]; } else if (section === 'notetaker') { diff --git a/internal/ui/templates.go b/internal/ui/templates.go index e08dfcf..207cd4a 100644 --- a/internal/ui/templates.go +++ b/internal/ui/templates.go @@ -26,7 +26,6 @@ type Commands struct { Email []Command `json:"email"` Calendar []Command `json:"calendar"` Contacts []Command `json:"contacts"` - Inbound []Command `json:"inbound"` Scheduler []Command `json:"scheduler"` Timezone []Command `json:"timezone"` Webhook []Command `json:"webhook"` @@ -160,14 +159,6 @@ func GetDefaultCommands() Commands { {Key: "photo-info", Title: "Photo Info", Cmd: "contacts photo info", Desc: "Show photo info", ParamName: "contact-id", Placeholder: "Enter contact ID..."}, {Key: "sync", Title: "Sync Info", Cmd: "contacts sync", Desc: "Contact sync info"}, }, - Inbound: []Command{ - {Key: "list", Title: "List", Cmd: "inbound list", Desc: "List inbound inboxes"}, - {Key: "show", Title: "Show", Cmd: "inbound show", Desc: "Show inbox details", ParamName: "inbox-id", Placeholder: "Enter inbox ID..."}, - {Key: "create", Title: "Create", Cmd: "inbound create", Desc: "Create a new inbox", ParamName: "name", Placeholder: "Enter inbox name..."}, - {Key: "delete", Title: "Delete", Cmd: "inbound delete", Desc: "Delete an inbox", ParamName: "inbox-id", Placeholder: "Enter inbox ID..."}, - {Key: "messages", Title: "Messages", Cmd: "inbound messages", Desc: "View inbox messages", ParamName: "inbox-id", Placeholder: "Enter inbox ID..."}, - {Key: "monitor", Title: "Monitor", Cmd: "inbound monitor", Desc: "Monitor for new messages", ParamName: "inbox-id", Placeholder: "Enter inbox ID..."}, - }, Scheduler: []Command{ {Key: "config-list", Title: "List Configs", Cmd: "scheduler configurations list", Desc: "List scheduler configurations"}, {Key: "config-create", Title: "Create Config", Cmd: "scheduler configurations create", Desc: "Create a scheduler configuration"}, diff --git a/internal/ui/templates/base.gohtml b/internal/ui/templates/base.gohtml index bc7e194..ffd8c4c 100644 --- a/internal/ui/templates/base.gohtml +++ b/internal/ui/templates/base.gohtml @@ -54,7 +54,6 @@ - diff --git a/internal/ui/templates/pages/inbound.gohtml b/internal/ui/templates/pages/inbound.gohtml deleted file mode 100644 index b8885ad..0000000 --- a/internal/ui/templates/pages/inbound.gohtml +++ /dev/null @@ -1,75 +0,0 @@ -{{define "page-inbound"}} -
-
-
-
-
- - - -

Inbound Commands

-

Select a command from the right to see details

-
- -
-
-
-
-
Inbound Commands
-
- -
-
-
-
-
-{{end}} diff --git a/internal/ui/templates/partials/content.gohtml b/internal/ui/templates/partials/content.gohtml index daa471a..f9a77a2 100644 --- a/internal/ui/templates/partials/content.gohtml +++ b/internal/ui/templates/partials/content.gohtml @@ -113,12 +113,6 @@ Email -