diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 85f3441..46eafe3 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -452,15 +452,32 @@ nylas webhook server --port 8080 --tunnel cloudflared # With public tunnel Create and manage Nylas-managed agent accounts backed by provider `nylas`. ```bash -nylas agent list # List agent accounts -nylas agent create # Create agent account -nylas agent create --app-password PW # Create account with IMAP/SMTP app password -nylas agent delete # Delete/revoke agent account -nylas agent delete --yes # Skip confirmation +nylas agent account list # List agent accounts +nylas agent account create # Create agent account +nylas agent account create --app-password PW # Create account with IMAP/SMTP app password +nylas agent account create --policy-id # Create account attached to a policy +nylas agent account get # Show one agent account +nylas agent account delete # Delete/revoke agent account +nylas agent account delete --yes # Skip confirmation +nylas agent policy list # List policy for default agent account +nylas agent policy list --all # List all policies attached to agent accounts +nylas agent policy create --name NAME # Create a policy +nylas agent policy get # Show one policy +nylas agent policy read # Read one policy +nylas agent policy update --name NAME # Update a policy +nylas agent policy delete --yes # Delete an unattached policy +nylas agent rule list # List rules for default agent policy +nylas agent rule list --all # List all rules attached to agent policies +nylas agent rule read # Read one rule +nylas agent rule get # Show one rule +nylas agent rule create --name NAME --condition from.domain,is,example.com --action mark_as_spam # Create a rule from common flags +nylas agent rule create --data-file rule.json # Create a rule from full JSON +nylas agent rule update --name NAME --description TEXT # Update a rule +nylas agent rule delete --yes # Delete a rule nylas agent status # Check connector + account status ``` -**Details:** `docs/commands/agent.md` +**Details:** `docs/commands/agent.md`, `docs/commands/agent-policy.md`, `docs/commands/agent-rule.md` --- diff --git a/docs/commands/agent-policy.md b/docs/commands/agent-policy.md new file mode 100644 index 0000000..7703397 --- /dev/null +++ b/docs/commands/agent-policy.md @@ -0,0 +1,194 @@ +# Agent Policies + +Detailed reference for `nylas agent policy`. + +Agent policies are filtered through `provider=nylas` agent accounts in the CLI, even though the underlying policy objects are application-level resources. + +## Commands + +```bash +nylas agent policy list +nylas agent policy list --all +nylas agent policy create --name "Strict Policy" +nylas agent policy create --data-file policy.json +nylas agent policy get +nylas agent policy read +nylas agent policy update --name "Updated Policy" +nylas agent policy update --data-file update.json +nylas agent policy delete --yes +``` + +## Scope Model + +The CLI intentionally treats policies as an agent-scoped surface: + +- `nylas agent policy list` shows only the policy attached to the current default `provider=nylas` grant +- `nylas agent policy list --all` shows only policies referenced by at least one `provider=nylas` agent account +- text output includes the attached agent email and grant ID so you can see which agent account uses which policy + +This means: + +- a policy can exist in the application but still not appear under `nylas agent policy` +- a policy with no attached `provider=nylas` account is hidden from the agent policy list + +## Listing Policies + +### Default Agent Policy + +```bash +nylas agent policy list +nylas agent policy list --json +``` + +Behavior: + +- resolves the current default local grant +- requires that default grant to be `provider=nylas` +- returns the single attached policy for that grant + +### All Agent Policies + +```bash +nylas agent policy list --all +nylas agent policy list --all --json +``` + +Behavior: + +- lists all policies referenced by at least one `provider=nylas` agent account +- text output includes one `Agent:` line per attached agent account + +## Reading Policies + +```bash +nylas agent policy get +nylas agent policy read +nylas agent policy read --json +``` + +Notes: + +- `get` and `read` are aliases +- text output expands the policy into readable sections for: + - rules + - limits + - options + - spam detection +- `--json` returns the raw API payload + +Use `--json` when you need the exact field names for automation or a follow-up update. + +## Creating Policies + +### Simple Create + +```bash +nylas agent policy create --name "Strict Policy" +nylas agent policy create --name "Strict Policy" --json +``` + +This is the fastest path when you only need a named policy object and will add rules or settings later. + +### Full JSON Create + +```bash +nylas agent policy create --data-file policy.json +nylas agent policy create --data '{"name":"Strict Policy","rules":["rule-123"]}' +``` + +Example payload: + +```json +{ + "name": "Strict Policy", + "rules": ["rule-123"], + "limits": { + "limit_attachment_size_limit": 50480000, + "limit_attachment_count_limit": 10, + "limit_count_daily_message_per_grant": 500, + "limit_inbox_retention_period": 30, + "limit_spam_retention_period": 7 + }, + "options": { + "additional_folders": [], + "use_cidr_aliasing": false + }, + "spam_detection": { + "use_list_dnsbl": false, + "use_header_anomaly_detection": false, + "spam_sensitivity": 1 + } +} +``` + +## Updating Policies + +### Simple Update + +```bash +nylas agent policy update --name "Updated Policy" +``` + +### Partial JSON Update + +```bash +nylas agent policy update --data-file update.json +nylas agent policy update --data '{"spam_detection":{"spam_sensitivity":0.8}}' +``` + +Behavior: + +- `--name` updates the policy name directly +- `--data` and `--data-file` send a partial JSON body +- if both are provided, the explicit flags win for overlapping top-level fields + +Recommended workflow: + +1. `nylas agent policy read --json` +2. edit the payload you need +3. `nylas agent policy update --data-file update.json` + +## Deleting Policies + +```bash +nylas agent policy delete --yes +``` + +Safety rule: + +- delete is rejected if any `provider=nylas` agent account still references the policy + +To remove a policy from active use: + +1. create or choose another policy +2. create future agent accounts with `--policy-id ` +3. remove or rotate away the attached agent accounts that still reference the old policy +4. delete the now-unattached policy + +## Relationship to Agent Accounts + +Policies are attached at agent account creation time: + +```bash +nylas agent account create me@yourapp.nylas.email --policy-id +``` + +There is currently no separate `agent account update` command, so the main CLI-managed attachment point is account creation. + +## Troubleshooting + +If `nylas agent policy list` returns nothing: + +- make sure your default local grant is a `provider=nylas` account +- verify the agent account actually has a `settings.policy_id` +- try `nylas auth list` to confirm which grant is marked default + +If `nylas agent policy delete` fails: + +- the policy is still attached to one or more `provider=nylas` agent accounts +- run `nylas agent policy list --all` to see the attached agent mappings + +## See Also + +- [Agent overview](agent.md) +- [Agent rules](agent-rule.md) diff --git a/docs/commands/agent-rule.md b/docs/commands/agent-rule.md new file mode 100644 index 0000000..bc2cd2f --- /dev/null +++ b/docs/commands/agent-rule.md @@ -0,0 +1,266 @@ +# Agent Rules + +Detailed reference for `nylas agent rule`. + +Agent rules are filtered through policies that are attached to `provider=nylas` agent accounts. The CLI hides rules that are outside that agent scope. + +## Commands + +```bash +nylas agent rule list +nylas agent rule list --policy-id +nylas agent rule list --all +nylas agent rule get +nylas agent rule read +nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action mark_as_spam +nylas agent rule create --data-file rule.json +nylas agent rule update --name "Updated Rule" +nylas agent rule delete --yes +``` + +## Scope Model + +The CLI resolves rules through agent policy attachment: + +- `nylas agent rule list` uses the policy attached to the current default `provider=nylas` grant +- `nylas agent rule list --policy-id ` uses that specific policy within the agent scope +- `nylas agent rule list --all` shows rules reachable from any policy attached to any `provider=nylas` agent account +- `get`, `read`, `update`, and `delete` validate that the rule is reachable from the selected agent scope before operating on it + +This prevents the agent command surface from mutating rules that are only in non-agent policy usage. + +## Listing Rules + +### Rules for the Default Agent Policy + +```bash +nylas agent rule list +nylas agent rule list --json +``` + +Behavior: + +- resolves the default local `provider=nylas` grant +- finds the policy attached to that grant +- returns the rules attached to that policy + +### Rules for a Specific Agent Policy + +```bash +nylas agent rule list --policy-id +``` + +Use this when you want to inspect one policy without changing your default grant. + +### All Agent Rules + +```bash +nylas agent rule list --all +nylas agent rule list --all --json +``` + +Behavior: + +- shows only rules referenced by policies attached to `provider=nylas` accounts +- text output includes policy and agent account references + +## Reading Rules + +```bash +nylas agent rule get +nylas agent rule read +nylas agent rule read --json +``` + +Notes: + +- `get` and `read` are aliases +- text output expands the rule into readable sections for: + - trigger + - match logic + - actions + - policy references + - agent account references +- `--json` returns the raw rule payload + +## Creating Rules + +You can create rules either from common-case flags or from raw JSON. + +### Common-Case Flags + +```bash +nylas agent rule create \ + --name "Block Example" \ + --condition from.domain,is,example.com \ + --action mark_as_spam +``` + +```bash +nylas agent rule create \ + --name "VIP sender" \ + --condition from.address,is,ceo@example.com \ + --action mark_as_read \ + --action mark_as_starred +``` + +Available common flags: + +- `--name` +- `--description` +- `--priority` +- `--enabled` +- `--disabled` +- `--trigger` +- `--policy-id` +- `--match-operator all|any` +- repeatable `--condition` +- repeatable `--action` + +Defaults when creating from flags: + +- `trigger=inbound` +- `enabled=true` +- `match.operator=all` + +### `--condition` + +Format: + +```bash +--condition ,, +``` + +Examples: + +```bash +--condition from.domain,is,example.com +--condition from.address,is,ceo@example.com +--condition subject.contains,is,invoice +``` + +Important: + +- condition values are treated as strings by default +- values like `true` and `123` stay strings +- there is no implicit JSON coercion for condition values + +### `--action` + +Formats: + +```bash +--action +--action = +``` + +Examples: + +```bash +--action mark_as_spam +--action mark_as_read +--action move_to_folder=vip +--action tag=security +``` + +Action values are also treated as strings by default. + +### Full JSON Create + +```bash +nylas agent rule create --data-file rule.json +nylas agent rule create --data '{"name":"Block Example","enabled":true,"trigger":"inbound","match":{"operator":"all","conditions":[{"field":"from.domain","operator":"is","value":"example.com"}]},"actions":[{"type":"mark_as_spam"}]}' +``` + +Use JSON when the rule structure is more complex than the common flags make comfortable. + +## Updating Rules + +### Simple Top-Level Updates + +```bash +nylas agent rule update --name "Updated Rule" +nylas agent rule update --description "Block example.org" +nylas agent rule update --priority 20 --enabled +``` + +### Replacing Conditions and Actions with Flags + +```bash +nylas agent rule update \ + --match-operator any \ + --condition from.domain,is,example.org \ + --condition from.tld,is,org \ + --action mark_as_spam +``` + +Behavior: + +- `--condition` replaces the rule's condition set +- `--action` replaces the rule's action set +- existing `match.operator` is preserved unless you explicitly pass `--match-operator` + +### Partial JSON Update + +```bash +nylas agent rule update --data-file update.json +nylas agent rule update --data '{"description":"Updated via JSON"}' +``` + +Recommended workflow: + +1. `nylas agent rule read --json` +2. edit the payload you need +3. `nylas agent rule update --data-file update.json` + +## Deleting Rules + +```bash +nylas agent rule delete --yes +``` + +Safety rules: + +- delete is rejected if the rule is referenced outside the current `provider=nylas` agent scope +- delete is rejected if removing the rule would leave an attached agent policy with zero rules + +These checks are there to prevent accidental breakage of active agent policy configuration. + +## Relationship to Policies + +Rules are attached to policies, and policies are attached to agent accounts. + +Practical flow: + +1. create or choose a policy +2. create a rule and attach it to that policy in the same command +3. create an agent account with that policy using `--policy-id` + +The CLI scope always follows that chain: + +- agent account +- policy +- rules reachable from that policy + +## Troubleshooting + +If `nylas agent rule list` returns nothing: + +- make sure your default grant is `provider=nylas` +- confirm that default agent account has a policy attached +- confirm the policy actually has rules attached + +If `nylas agent rule read` or `update` says the rule is not found: + +- the rule may exist in the application but outside the current agent scope +- try `nylas agent rule list --all` to see what is reachable from agent accounts + +If `nylas agent rule delete` is rejected: + +- the rule is shared outside the current agent scope, or +- deleting it would leave an attached policy with no remaining rules + +## See Also + +- [Agent overview](agent.md) +- [Agent policies](agent-policy.md) diff --git a/docs/commands/agent.md b/docs/commands/agent.md index e1bf6fb..8689938 100644 --- a/docs/commands/agent.md +++ b/docs/commands/agent.md @@ -1,29 +1,40 @@ -# Agent Accounts +# Agent -Manage Nylas agent accounts from the CLI. +Manage Nylas agent resources from the CLI. -Agent accounts are managed email identities backed by provider `nylas`. Unlike OAuth grants, they do not require a third-party mailbox connection. The CLI keeps connector setup automatic and always uses `provider=nylas` for this command group. +Agent accounts are managed email identities backed by provider `nylas`. Unlike OAuth grants, they do not require a third-party mailbox connection. Account operations live under `nylas agent account`, while `nylas agent status` reports connector and account readiness. ## Commands ```bash -nylas agent list -nylas agent create -nylas agent create --app-password -nylas agent delete +nylas agent account list +nylas agent account create +nylas agent account get +nylas agent account delete +nylas agent policy list +nylas agent policy create --name +nylas agent policy get +nylas agent policy read +nylas agent policy update --name +nylas agent policy delete +nylas agent rule list +nylas agent rule read +nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action mark_as_spam +nylas agent rule update --name "Updated Rule" --description "Block example.org" +nylas agent rule delete nylas agent status ``` ## List Agent Accounts ```bash -nylas agent list -nylas agent list --json +nylas agent account list +nylas agent account list --json ``` **Example output:** ```bash -$ nylas agent list +$ nylas agent account list Agent Accounts (2) @@ -37,9 +48,10 @@ Agent Accounts (2) ## Create Agent Account ```bash -nylas agent create me@yourapp.nylas.email -nylas agent create me@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' -nylas agent create support@yourapp.nylas.email --json +nylas agent account create me@yourapp.nylas.email +nylas agent account create me@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' +nylas agent account create me@yourapp.nylas.email --policy-id 12345678-1234-1234-1234-123456789012 +nylas agent account create support@yourapp.nylas.email --json ``` Behavior: @@ -47,10 +59,11 @@ Behavior: - automatically creates the `nylas` connector first if it does not exist - stores the created grant locally like other authenticated accounts - optionally sets `settings.app_password` on the grant for IMAP/SMTP mail client access +- optionally sets `settings.policy_id` on the grant so the new account starts with an attached policy **Example output:** ```bash -$ nylas agent create me@yourapp.nylas.email +$ nylas agent account create me@yourapp.nylas.email ✓ Agent account created successfully! @@ -64,7 +77,7 @@ Status: valid Use `--app-password` when you want the agent account to work with a standard mail client over IMAP/SMTP submission. ```bash -nylas agent create me@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' +nylas agent account create me@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' ``` Requirements: @@ -76,11 +89,29 @@ Requirements: When set, the agent account email becomes the mail-client username and the app password is used for IMAP/SMTP authentication. +### `--policy-id` + +Use `--policy-id` when you want the new agent account to start with a specific policy already attached. + +```bash +nylas agent account create me@yourapp.nylas.email --policy-id 12345678-1234-1234-1234-123456789012 +``` + +## Show Agent Account + +```bash +nylas agent account get 12345678-1234-1234-1234-123456789012 +nylas agent account get me@yourapp.nylas.email +nylas agent account get me@yourapp.nylas.email --json +``` + +You can look up an agent account by grant ID or by email address. + ## Delete Agent Account ```bash -nylas agent delete 12345678-1234-1234-1234-123456789012 -nylas agent delete me@yourapp.nylas.email --yes +nylas agent account delete 12345678-1234-1234-1234-123456789012 +nylas agent account delete me@yourapp.nylas.email --yes ``` Deleting an agent account revokes the underlying `provider=nylas` grant. @@ -97,6 +128,54 @@ This reports: - whether agent accounts already exist - which managed accounts are currently configured +## Policies + +```bash +nylas agent policy list +nylas agent policy list --all +nylas agent policy create --name "Strict Policy" +nylas agent policy create --data '{"name":"Strict Policy","rules":["rule-123"]}' +nylas agent policy create --data-file policy.json +nylas agent policy get 12345678-1234-1234-1234-123456789012 +nylas agent policy read 12345678-1234-1234-1234-123456789012 +nylas agent policy update 12345678-1234-1234-1234-123456789012 --name "Updated Policy" +nylas agent policy update 12345678-1234-1234-1234-123456789012 --data-file update.json +nylas agent policy delete 12345678-1234-1234-1234-123456789012 --yes +``` + +Summary: +- `list` resolves the default `provider=nylas` grant and shows its attached policy +- `list --all` shows only policies that are actually referenced by `provider=nylas` agent accounts +- `get` and `read` are aliases +- `delete` refuses to remove a policy that is still attached to any `provider=nylas` agent account + +**Details:** [Agent policy reference](agent-policy.md) + +## Rules + +```bash +nylas agent rule list +nylas agent rule list --policy-id +nylas agent rule list --all +nylas agent rule read +nylas agent rule get +nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action mark_as_spam +nylas agent rule create --name "VIP sender" --condition from.address,is,ceo@example.com --action mark_as_read --action mark_as_starred +nylas agent rule create --data-file rule.json +nylas agent rule update --name "Updated Rule" --description "Block example.org" +nylas agent rule update --condition from.domain,is,example.org --action mark_as_spam +nylas agent rule delete --yes +``` + +Summary: +- `list` uses the policy attached to the current default `provider=nylas` grant unless `--policy-id` is passed +- `list --all` shows only rules reachable from policies attached to `provider=nylas` accounts +- `create` supports common-case flags like `--name`, repeatable `--condition`, and repeatable `--action` +- `get` and `read` are aliases +- `update` and `delete` refuse to operate on rules that are outside the current `provider=nylas` agent scope + +**Details:** [Agent rule reference](agent-rule.md) + ## Relationship to Inbound `nylas agent` and `nylas inbound` are different features: @@ -117,5 +196,7 @@ When the active grant is an agent account (`provider=nylas`): ## See Also +- [Agent policies](agent-policy.md) +- [Agent rules](agent-rule.md) - [Email commands](email.md) - [Inbound email](inbound.md) diff --git a/internal/adapters/nylas/agent.go b/internal/adapters/nylas/agent.go index 296a840..f3aa2ac 100644 --- a/internal/adapters/nylas/agent.go +++ b/internal/adapters/nylas/agent.go @@ -41,7 +41,7 @@ func (c *HTTPClient) GetAgentAccount(ctx context.Context, grantID string) (*doma } // CreateAgentAccount creates a new managed agent account grant. -func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword string) (*domain.AgentAccount, error) { +func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { queryURL := fmt.Sprintf("%s/v3/connect/custom", c.baseURL) settings := map[string]any{ @@ -50,6 +50,9 @@ func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword if appPassword != "" { settings["app_password"] = appPassword } + if policyID != "" { + settings["policy_id"] = policyID + } payload := map[string]any{ "provider": string(domain.ProviderNylas), @@ -67,6 +70,9 @@ func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword } account := convertManagedGrantToAgentAccount(*grant) + if policyID != "" && account.Settings.PolicyID == "" { + account.Settings.PolicyID = policyID + } return &account, nil } diff --git a/internal/adapters/nylas/agent_test.go b/internal/adapters/nylas/agent_test.go index 1b778cf..ca81f7d 100644 --- a/internal/adapters/nylas/agent_test.go +++ b/internal/adapters/nylas/agent_test.go @@ -119,6 +119,7 @@ func TestCreateAgentAccount(t *testing.T) { require.True(t, ok) assert.Equal(t, "agent@example.com", settings["email"]) assert.Equal(t, "ValidAgentPass123ABC!", settings["app_password"]) + assert.Equal(t, "policy-123", settings["policy_id"]) response := map[string]any{ "data": map[string]any{ @@ -139,10 +140,11 @@ func TestCreateAgentAccount(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "ValidAgentPass123ABC!") + account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "ValidAgentPass123ABC!", "policy-123") require.NoError(t, err) assert.Equal(t, "agent-new", account.ID) assert.Equal(t, "agent@example.com", account.Email) + assert.Equal(t, "policy-123", account.Settings.PolicyID) } func TestCreateAgentAccount_DirectResponseFallback(t *testing.T) { @@ -164,7 +166,7 @@ func TestCreateAgentAccount_DirectResponseFallback(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "") + account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "", "") require.NoError(t, err) assert.Equal(t, "agent-direct", account.ID) assert.Equal(t, "agent@example.com", account.Email) diff --git a/internal/adapters/nylas/client_performance_test.go b/internal/adapters/nylas/client_performance_test.go index a7b4ed8..cfa6009 100644 --- a/internal/adapters/nylas/client_performance_test.go +++ b/internal/adapters/nylas/client_performance_test.go @@ -19,8 +19,12 @@ func TestContextTimeouts(t *testing.T) { t.Run("enforces_context_timeout", func(t *testing.T) { // Server that delays response beyond the context timeout server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(10 * time.Second) // Longer than our test timeout - w.WriteHeader(http.StatusOK) + select { + case <-time.After(10 * time.Second): // Longer than our test timeout + w.WriteHeader(http.StatusOK) + case <-r.Context().Done(): + return + } })) defer server.Close() @@ -45,8 +49,12 @@ func TestContextTimeouts(t *testing.T) { t.Run("respects_existing_context_timeout", func(t *testing.T) { // Server that delays briefly server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(3 * time.Second) - w.WriteHeader(http.StatusOK) + select { + case <-time.After(3 * time.Second): + w.WriteHeader(http.StatusOK) + case <-r.Context().Done(): + return + } })) defer server.Close() diff --git a/internal/adapters/nylas/demo_agent.go b/internal/adapters/nylas/demo_agent.go index 315dae7..8eb3ad1 100644 --- a/internal/adapters/nylas/demo_agent.go +++ b/internal/adapters/nylas/demo_agent.go @@ -13,6 +13,9 @@ func (d *DemoClient) ListAgentAccounts(ctx context.Context) ([]domain.AgentAccou Provider: domain.ProviderNylas, Email: "demo-agent@example.com", GrantStatus: "valid", + Settings: domain.AgentAccountSettings{ + PolicyID: "policy-demo-1", + }, }, }, nil } @@ -23,15 +26,21 @@ func (d *DemoClient) GetAgentAccount(ctx context.Context, grantID string) (*doma Provider: domain.ProviderNylas, Email: "demo-agent@example.com", GrantStatus: "valid", + Settings: domain.AgentAccountSettings{ + PolicyID: "policy-demo-1", + }, }, nil } -func (d *DemoClient) CreateAgentAccount(ctx context.Context, email, appPassword string) (*domain.AgentAccount, error) { +func (d *DemoClient) CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { return &domain.AgentAccount{ ID: "agent-demo-new", Provider: domain.ProviderNylas, Email: email, GrantStatus: "valid", + Settings: domain.AgentAccountSettings{ + PolicyID: policyID, + }, }, nil } diff --git a/internal/adapters/nylas/demo_policy.go b/internal/adapters/nylas/demo_policy.go new file mode 100644 index 0000000..a1043f7 --- /dev/null +++ b/internal/adapters/nylas/demo_policy.go @@ -0,0 +1,51 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +func (d *DemoClient) ListPolicies(ctx context.Context) ([]domain.Policy, error) { + return []domain.Policy{ + { + ID: "policy-demo-1", + Name: "Demo Policy", + ApplicationID: "app-demo", + OrganizationID: "org-demo", + }, + }, nil +} + +func (d *DemoClient) GetPolicy(ctx context.Context, policyID string) (*domain.Policy, error) { + return &domain.Policy{ + ID: policyID, + Name: "Demo Policy", + ApplicationID: "app-demo", + OrganizationID: "org-demo", + }, nil +} + +func (d *DemoClient) CreatePolicy(ctx context.Context, payload map[string]any) (*domain.Policy, error) { + name, _ := payload["name"].(string) + return &domain.Policy{ + ID: "policy-demo-new", + Name: name, + ApplicationID: "app-demo", + OrganizationID: "org-demo", + }, nil +} + +func (d *DemoClient) UpdatePolicy(ctx context.Context, policyID string, payload map[string]any) (*domain.Policy, error) { + name, _ := payload["name"].(string) + return &domain.Policy{ + ID: policyID, + Name: name, + ApplicationID: "app-demo", + OrganizationID: "org-demo", + }, nil +} + +func (d *DemoClient) DeletePolicy(ctx context.Context, policyID string) error { + return nil +} diff --git a/internal/adapters/nylas/demo_rule.go b/internal/adapters/nylas/demo_rule.go new file mode 100644 index 0000000..c8ea5a0 --- /dev/null +++ b/internal/adapters/nylas/demo_rule.go @@ -0,0 +1,85 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +func (d *DemoClient) ListRules(ctx context.Context) ([]domain.Rule, error) { + enabled := true + return []domain.Rule{ + { + ID: "rule-demo-1", + Name: "Demo Rule", + Enabled: &enabled, + Trigger: "inbound", + ApplicationID: "app-demo", + OrganizationID: "org-demo", + Match: &domain.RuleMatch{ + Operator: "all", + Conditions: []domain.RuleCondition{{ + Field: "from.domain", + Operator: "is", + Value: "example.com", + }}, + }, + Actions: []domain.RuleAction{{ + Type: "mark_as_spam", + }}, + }, + }, nil +} + +func (d *DemoClient) GetRule(ctx context.Context, ruleID string) (*domain.Rule, error) { + enabled := true + return &domain.Rule{ + ID: ruleID, + Name: "Demo Rule", + Enabled: &enabled, + Trigger: "inbound", + ApplicationID: "app-demo", + OrganizationID: "org-demo", + Match: &domain.RuleMatch{ + Operator: "all", + Conditions: []domain.RuleCondition{{ + Field: "from.domain", + Operator: "is", + Value: "example.com", + }}, + }, + Actions: []domain.RuleAction{{ + Type: "mark_as_spam", + }}, + }, nil +} + +func (d *DemoClient) CreateRule(ctx context.Context, payload map[string]any) (*domain.Rule, error) { + name, _ := payload["name"].(string) + enabled := true + return &domain.Rule{ + ID: "rule-demo-new", + Name: name, + Enabled: &enabled, + Trigger: "inbound", + ApplicationID: "app-demo", + OrganizationID: "org-demo", + }, nil +} + +func (d *DemoClient) UpdateRule(ctx context.Context, ruleID string, payload map[string]any) (*domain.Rule, error) { + name, _ := payload["name"].(string) + enabled := true + return &domain.Rule{ + ID: ruleID, + Name: name, + Enabled: &enabled, + Trigger: "inbound", + ApplicationID: "app-demo", + OrganizationID: "org-demo", + }, nil +} + +func (d *DemoClient) DeleteRule(ctx context.Context, ruleID string) error { + return nil +} diff --git a/internal/adapters/nylas/inbound_crud_test.go b/internal/adapters/nylas/inbound_crud_test.go index 7b494bc..e56da22 100644 --- a/internal/adapters/nylas/inbound_crud_test.go +++ b/internal/adapters/nylas/inbound_crud_test.go @@ -29,9 +29,12 @@ func TestListInboundInboxes(t *testing.T) { response := map[string]any{ "data": []map[string]any{ { - "id": "inbox-001", - "email": "support@app.nylas.email", - "provider": "inbox", + "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(), @@ -62,6 +65,7 @@ func TestListInboundInboxes(t *testing.T) { 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) }) diff --git a/internal/adapters/nylas/integration_base_test.go b/internal/adapters/nylas/integration_base_test.go index cf09232..0019b3a 100644 --- a/internal/adapters/nylas/integration_base_test.go +++ b/internal/adapters/nylas/integration_base_test.go @@ -64,8 +64,10 @@ func createTestContext() (context.Context, context.CancelFunc) { } // createLongTestContext creates a context with extended timeout for slower operations. +// Keep this aligned with the client default so live integration tests don't fail +// simply because the test context expires before an otherwise valid slow API call. func createLongTestContext() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), 60*time.Second) + return context.WithTimeout(context.Background(), domain.TimeoutAPI) } // rateLimitDelay adds a delay between API calls to avoid rate limiting. diff --git a/internal/adapters/nylas/integration_productivity_test.go b/internal/adapters/nylas/integration_productivity_test.go index 6f50c66..a8f6099 100644 --- a/internal/adapters/nylas/integration_productivity_test.go +++ b/internal/adapters/nylas/integration_productivity_test.go @@ -45,7 +45,7 @@ func TestIntegration_GetScheduledMessage_NotFound(t *testing.T) { func TestIntegration_ListNotetakers(t *testing.T) { client, grantID := getTestClient(t) - ctx, cancel := createTestContext() + ctx, cancel := createLongTestContext() defer cancel() notetakers, err := client.ListNotetakers(ctx, grantID, nil) @@ -61,7 +61,7 @@ func TestIntegration_ListNotetakers(t *testing.T) { func TestIntegration_ListNotetakers_WithParams(t *testing.T) { client, grantID := getTestClient(t) - ctx, cancel := createTestContext() + ctx, cancel := createLongTestContext() defer cancel() // Test with limit @@ -79,14 +79,15 @@ func TestIntegration_ListNotetakers_WithParams(t *testing.T) { func TestIntegration_ListNotetakers_ByState(t *testing.T) { client, grantID := getTestClient(t) - ctx, cancel := createTestContext() - defer cancel() // Only test with states known to be valid in the API states := []string{"scheduled", "attending", "media_processing"} for _, state := range states { t.Run("State_"+state, func(t *testing.T) { + ctx, cancel := createLongTestContext() + defer cancel() + params := &domain.NotetakerQueryParams{ Limit: 10, State: state, diff --git a/internal/adapters/nylas/managed_grants.go b/internal/adapters/nylas/managed_grants.go index 8152f6f..cc26d7b 100644 --- a/internal/adapters/nylas/managed_grants.go +++ b/internal/adapters/nylas/managed_grants.go @@ -117,6 +117,7 @@ func convertManagedGrantToInboundInbox(grant managedGrantResponse) domain.Inboun return domain.InboundInbox{ ID: grant.ID, Email: grant.Email, + PolicyID: grant.Settings.PolicyID, GrantStatus: grant.GrantStatus, CreatedAt: grant.CreatedAt, UpdatedAt: grant.UpdatedAt, diff --git a/internal/adapters/nylas/mock_agent.go b/internal/adapters/nylas/mock_agent.go index 4ca310e..f72578d 100644 --- a/internal/adapters/nylas/mock_agent.go +++ b/internal/adapters/nylas/mock_agent.go @@ -13,6 +13,9 @@ func (m *MockClient) ListAgentAccounts(ctx context.Context) ([]domain.AgentAccou Provider: domain.ProviderNylas, Email: "agent@example.com", GrantStatus: "valid", + Settings: domain.AgentAccountSettings{ + PolicyID: "policy-1", + }, }, }, nil } @@ -23,15 +26,21 @@ func (m *MockClient) GetAgentAccount(ctx context.Context, grantID string) (*doma Provider: domain.ProviderNylas, Email: "agent@example.com", GrantStatus: "valid", + Settings: domain.AgentAccountSettings{ + PolicyID: "policy-1", + }, }, nil } -func (m *MockClient) CreateAgentAccount(ctx context.Context, email, appPassword string) (*domain.AgentAccount, error) { +func (m *MockClient) CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { return &domain.AgentAccount{ ID: "agent-new", Provider: domain.ProviderNylas, Email: email, GrantStatus: "valid", + Settings: domain.AgentAccountSettings{ + PolicyID: policyID, + }, }, nil } diff --git a/internal/adapters/nylas/mock_policy.go b/internal/adapters/nylas/mock_policy.go new file mode 100644 index 0000000..1e2e628 --- /dev/null +++ b/internal/adapters/nylas/mock_policy.go @@ -0,0 +1,51 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +func (m *MockClient) ListPolicies(ctx context.Context) ([]domain.Policy, error) { + return []domain.Policy{ + { + ID: "policy-1", + Name: "Default Policy", + ApplicationID: "app-123", + OrganizationID: "org-123", + }, + }, nil +} + +func (m *MockClient) GetPolicy(ctx context.Context, policyID string) (*domain.Policy, error) { + return &domain.Policy{ + ID: policyID, + Name: "Default Policy", + ApplicationID: "app-123", + OrganizationID: "org-123", + }, nil +} + +func (m *MockClient) CreatePolicy(ctx context.Context, payload map[string]any) (*domain.Policy, error) { + name, _ := payload["name"].(string) + return &domain.Policy{ + ID: "policy-new", + Name: name, + ApplicationID: "app-123", + OrganizationID: "org-123", + }, nil +} + +func (m *MockClient) UpdatePolicy(ctx context.Context, policyID string, payload map[string]any) (*domain.Policy, error) { + name, _ := payload["name"].(string) + return &domain.Policy{ + ID: policyID, + Name: name, + ApplicationID: "app-123", + OrganizationID: "org-123", + }, nil +} + +func (m *MockClient) DeletePolicy(ctx context.Context, policyID string) error { + return nil +} diff --git a/internal/adapters/nylas/mock_rule.go b/internal/adapters/nylas/mock_rule.go new file mode 100644 index 0000000..35959c3 --- /dev/null +++ b/internal/adapters/nylas/mock_rule.go @@ -0,0 +1,85 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +func (m *MockClient) ListRules(ctx context.Context) ([]domain.Rule, error) { + enabled := true + return []domain.Rule{ + { + ID: "rule-1", + Name: "Default Rule", + Enabled: &enabled, + Trigger: "inbound", + ApplicationID: "app-123", + OrganizationID: "org-123", + Match: &domain.RuleMatch{ + Operator: "all", + Conditions: []domain.RuleCondition{{ + Field: "from.domain", + Operator: "is", + Value: "example.com", + }}, + }, + Actions: []domain.RuleAction{{ + Type: "mark_as_spam", + }}, + }, + }, nil +} + +func (m *MockClient) GetRule(ctx context.Context, ruleID string) (*domain.Rule, error) { + enabled := true + return &domain.Rule{ + ID: ruleID, + Name: "Default Rule", + Enabled: &enabled, + Trigger: "inbound", + ApplicationID: "app-123", + OrganizationID: "org-123", + Match: &domain.RuleMatch{ + Operator: "all", + Conditions: []domain.RuleCondition{{ + Field: "from.domain", + Operator: "is", + Value: "example.com", + }}, + }, + Actions: []domain.RuleAction{{ + Type: "mark_as_spam", + }}, + }, nil +} + +func (m *MockClient) CreateRule(ctx context.Context, payload map[string]any) (*domain.Rule, error) { + name, _ := payload["name"].(string) + enabled := true + return &domain.Rule{ + ID: "rule-new", + Name: name, + Enabled: &enabled, + Trigger: "inbound", + ApplicationID: "app-123", + OrganizationID: "org-123", + }, nil +} + +func (m *MockClient) UpdateRule(ctx context.Context, ruleID string, payload map[string]any) (*domain.Rule, error) { + name, _ := payload["name"].(string) + enabled := true + return &domain.Rule{ + ID: ruleID, + Name: name, + Enabled: &enabled, + Trigger: "inbound", + ApplicationID: "app-123", + OrganizationID: "org-123", + }, nil +} + +func (m *MockClient) DeleteRule(ctx context.Context, ruleID string) error { + return nil +} diff --git a/internal/adapters/nylas/policy.go b/internal/adapters/nylas/policy.go new file mode 100644 index 0000000..c9aa01b --- /dev/null +++ b/internal/adapters/nylas/policy.go @@ -0,0 +1,105 @@ +package nylas + +import ( + "context" + "fmt" + "net/url" + + "github.com/nylas/cli/internal/domain" +) + +type policyListResponse struct { + Data []domain.Policy `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` +} + +type policyResponse struct { + Data domain.Policy `json:"data"` +} + +// ListPolicies lists all policies available to the authenticated application. +func (c *HTTPClient) ListPolicies(ctx context.Context) ([]domain.Policy, error) { + baseURL := fmt.Sprintf("%s/v3/policies", c.baseURL) + pageToken := "" + policies := make([]domain.Policy, 0) + + for { + queryBuilder := NewQueryBuilder() + if pageToken != "" { + queryBuilder.Add("page_token", pageToken) + } + queryURL := queryBuilder.BuildURL(baseURL) + + var result policyListResponse + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + + policies = append(policies, result.Data...) + + if result.NextCursor == "" { + break + } + if result.NextCursor == pageToken { + return nil, fmt.Errorf("failed to paginate policies: repeated cursor %q", result.NextCursor) + } + pageToken = result.NextCursor + } + + return policies, nil +} + +// GetPolicy retrieves a policy by ID. +func (c *HTTPClient) GetPolicy(ctx context.Context, policyID string) (*domain.Policy, error) { + queryURL := fmt.Sprintf("%s/v3/policies/%s", c.baseURL, url.PathEscape(policyID)) + + var result policyResponse + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrPolicyNotFound); err != nil { + return nil, err + } + + return &result.Data, nil +} + +// CreatePolicy creates a new policy. +func (c *HTTPClient) CreatePolicy(ctx context.Context, payload map[string]any) (*domain.Policy, error) { + queryURL := fmt.Sprintf("%s/v3/policies", c.baseURL) + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, payload) + if err != nil { + return nil, err + } + + var result policyResponse + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + return &result.Data, nil +} + +// UpdatePolicy updates an existing policy. +func (c *HTTPClient) UpdatePolicy(ctx context.Context, policyID string, payload map[string]any) (*domain.Policy, error) { + queryURL := fmt.Sprintf("%s/v3/policies/%s", c.baseURL, url.PathEscape(policyID)) + + resp, err := c.doJSONRequest(ctx, "PUT", queryURL, payload) + if err != nil { + return nil, err + } + + var result policyResponse + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + if result.Data.ID == "" { + result.Data.ID = policyID + } + + return &result.Data, nil +} + +// DeletePolicy deletes a policy by ID. +func (c *HTTPClient) DeletePolicy(ctx context.Context, policyID string) error { + queryURL := fmt.Sprintf("%s/v3/policies/%s", c.baseURL, url.PathEscape(policyID)) + return c.doDelete(ctx, queryURL) +} diff --git a/internal/adapters/nylas/policy_test.go b/internal/adapters/nylas/policy_test.go new file mode 100644 index 0000000..3c7a3c0 --- /dev/null +++ b/internal/adapters/nylas/policy_test.go @@ -0,0 +1,210 @@ +//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" +) + +func TestListPolicies(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/policies", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + requests++ + var response map[string]any + if requests == 1 { + _, ok := r.URL.Query()["page_token"] + assert.False(t, ok) + response = map[string]any{ + "data": []map[string]any{ + { + "id": "policy-001", + "name": "Default Policy", + "application_id": "app-123", + "organization_id": "org-123", + "created_at": time.Now().Unix(), + "updated_at": time.Now().Unix(), + }, + }, + "next_cursor": "cursor-2", + } + } else { + assert.Equal(t, "cursor-2", r.URL.Query().Get("page_token")) + response = map[string]any{ + "data": []map[string]any{ + { + "id": "policy-002", + "name": "Strict Policy", + "application_id": "app-123", + "organization_id": "org-123", + "created_at": time.Now().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") + + policies, err := client.ListPolicies(context.Background()) + require.NoError(t, err) + require.Len(t, policies, 2) + assert.Equal(t, 2, requests) + assert.Equal(t, "policy-001", policies[0].ID) + assert.Equal(t, "policy-002", policies[1].ID) +} + +func TestGetPolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/policies/policy/001", r.URL.Path) + assert.Equal(t, "/v3/policies/policy%2F001", r.RequestURI) + assert.Equal(t, http.MethodGet, r.Method) + + response := map[string]any{ + "data": map[string]any{ + "id": "policy/001", + "name": "Default Policy", + "application_id": "app-123", + "organization_id": "org-123", + "created_at": time.Now().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") + + policy, err := client.GetPolicy(context.Background(), "policy/001") + require.NoError(t, err) + assert.Equal(t, "policy/001", policy.ID) + assert.Equal(t, "Default Policy", policy.Name) +} + +func TestGetPolicyNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "api_error", + "message": "not found", + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + _, err := client.GetPolicy(context.Background(), "missing") + require.Error(t, err) + assert.ErrorIs(t, err, domain.ErrPolicyNotFound) +} + +func TestCreatePolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/policies", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "New Policy", payload["name"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "policy-new", + "name": "New Policy", + "application_id": "app-123", + "organization_id": "org-123", + "created_at": time.Now().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") + + policy, err := client.CreatePolicy(context.Background(), map[string]any{"name": "New Policy"}) + require.NoError(t, err) + assert.Equal(t, "policy-new", policy.ID) + assert.Equal(t, "New Policy", policy.Name) +} + +func TestUpdatePolicyAssignsRequestedIDWhenResponseOmitsIt(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/policies/policy/001", r.URL.Path) + assert.Equal(t, "/v3/policies/policy%2F001", r.RequestURI) + assert.Equal(t, http.MethodPut, r.Method) + + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "Updated Policy", payload["name"]) + + response := map[string]any{ + "data": map[string]any{ + "name": "Updated Policy", + "application_id": "app-123", + "organization_id": "org-123", + "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") + + policy, err := client.UpdatePolicy(context.Background(), "policy/001", map[string]any{"name": "Updated Policy"}) + require.NoError(t, err) + assert.Equal(t, "policy/001", policy.ID) + assert.Equal(t, "Updated Policy", policy.Name) +} + +func TestDeletePolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/policies/policy/001", r.URL.Path) + assert.Equal(t, "/v3/policies/policy%2F001", r.RequestURI) + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + require.NoError(t, client.DeletePolicy(context.Background(), "policy/001")) +} diff --git a/internal/adapters/nylas/rule.go b/internal/adapters/nylas/rule.go new file mode 100644 index 0000000..1f8f4ab --- /dev/null +++ b/internal/adapters/nylas/rule.go @@ -0,0 +1,107 @@ +package nylas + +import ( + "context" + "fmt" + "net/url" + + "github.com/nylas/cli/internal/domain" +) + +type ruleListResponse struct { + Data struct { + Items []domain.Rule `json:"items"` + } `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` +} + +type ruleResponse struct { + Data domain.Rule `json:"data"` +} + +// ListRules lists all rules available to the authenticated application. +func (c *HTTPClient) ListRules(ctx context.Context) ([]domain.Rule, error) { + baseURL := fmt.Sprintf("%s/v3/rules", c.baseURL) + pageToken := "" + rules := make([]domain.Rule, 0) + + for { + queryBuilder := NewQueryBuilder() + if pageToken != "" { + queryBuilder.Add("page_token", pageToken) + } + queryURL := queryBuilder.BuildURL(baseURL) + + var result ruleListResponse + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + + rules = append(rules, result.Data.Items...) + + if result.NextCursor == "" { + break + } + if result.NextCursor == pageToken { + return nil, fmt.Errorf("failed to paginate rules: repeated cursor %q", result.NextCursor) + } + pageToken = result.NextCursor + } + + return rules, nil +} + +// GetRule retrieves a rule by ID. +func (c *HTTPClient) GetRule(ctx context.Context, ruleID string) (*domain.Rule, error) { + queryURL := fmt.Sprintf("%s/v3/rules/%s", c.baseURL, url.PathEscape(ruleID)) + + var result ruleResponse + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrRuleNotFound); err != nil { + return nil, err + } + + return &result.Data, nil +} + +// CreateRule creates a new rule. +func (c *HTTPClient) CreateRule(ctx context.Context, payload map[string]any) (*domain.Rule, error) { + queryURL := fmt.Sprintf("%s/v3/rules", c.baseURL) + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, payload) + if err != nil { + return nil, err + } + + var result ruleResponse + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + return &result.Data, nil +} + +// UpdateRule updates an existing rule. +func (c *HTTPClient) UpdateRule(ctx context.Context, ruleID string, payload map[string]any) (*domain.Rule, error) { + queryURL := fmt.Sprintf("%s/v3/rules/%s", c.baseURL, url.PathEscape(ruleID)) + + resp, err := c.doJSONRequest(ctx, "PUT", queryURL, payload) + if err != nil { + return nil, err + } + + var result ruleResponse + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + if result.Data.ID == "" { + result.Data.ID = ruleID + } + + return &result.Data, nil +} + +// DeleteRule deletes a rule by ID. +func (c *HTTPClient) DeleteRule(ctx context.Context, ruleID string) error { + queryURL := fmt.Sprintf("%s/v3/rules/%s", c.baseURL, url.PathEscape(ruleID)) + return c.doDelete(ctx, queryURL) +} diff --git a/internal/adapters/nylas/rule_test.go b/internal/adapters/nylas/rule_test.go new file mode 100644 index 0000000..82e0aa9 --- /dev/null +++ b/internal/adapters/nylas/rule_test.go @@ -0,0 +1,170 @@ +package nylas + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListRules(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/rules", r.URL.Path) + _, ok := r.URL.Query()["page_token"] + assert.False(t, ok) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "items": []map[string]any{ + { + "id": "rule-001", + "name": "Rule One", + "enabled": true, + "trigger": "inbound", + }, + { + "id": "rule-002", + "name": "Rule Two", + "enabled": false, + "trigger": "inbound", + }, + }, + }, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + rules, err := client.ListRules(context.Background()) + require.NoError(t, err) + require.Len(t, rules, 2) + assert.Equal(t, "rule-001", rules[0].ID) + assert.Equal(t, "rule-002", rules[1].ID) +} + +func TestGetRule(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/rules/rule/001", r.URL.Path) + assert.Equal(t, "/v3/rules/rule%2F001", r.RequestURI) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "id": "rule/001", + "name": "Rule One", + "enabled": true, + "trigger": "inbound", + }, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + rule, err := client.GetRule(context.Background(), "rule/001") + require.NoError(t, err) + assert.Equal(t, "rule/001", rule.ID) + assert.Equal(t, "Rule One", rule.Name) +} + +func TestCreateRule(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/rules", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "New Rule", payload["name"]) + + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "id": "rule-new", + "name": "New Rule", + "enabled": true, + "trigger": "inbound", + }, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + rule, err := client.CreateRule(context.Background(), map[string]any{"name": "New Rule"}) + require.NoError(t, err) + assert.Equal(t, "rule-new", rule.ID) + assert.Equal(t, "New Rule", rule.Name) +} + +func TestUpdateRuleAssignsRequestedIDWhenResponseOmitsIt(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/rules/rule/001", r.URL.Path) + assert.Equal(t, "/v3/rules/rule%2F001", r.RequestURI) + assert.Equal(t, http.MethodPut, r.Method) + + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "Updated Rule", payload["name"]) + + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "name": "Updated Rule", + "enabled": true, + "trigger": "inbound", + }, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + rule, err := client.UpdateRule(context.Background(), "rule/001", map[string]any{"name": "Updated Rule"}) + require.NoError(t, err) + assert.Equal(t, "rule/001", rule.ID) + assert.Equal(t, "Updated Rule", rule.Name) +} + +func TestDeleteRule(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/rules/rule/001", r.URL.Path) + assert.Equal(t, "/v3/rules/rule%2F001", r.RequestURI) + 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") + + require.NoError(t, client.DeleteRule(context.Background(), "rule/001")) +} + +func TestGetRuleNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "api_error", + "message": "not found", + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + _, err := client.GetRule(context.Background(), "missing") + require.Error(t, err) + assert.ErrorIs(t, err, domain.ErrRuleNotFound) +} diff --git a/internal/cli/agent/account.go b/internal/cli/agent/account.go new file mode 100644 index 0000000..701f889 --- /dev/null +++ b/internal/cli/agent/account.go @@ -0,0 +1,35 @@ +package agent + +import "github.com/spf13/cobra" + +func newAccountCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "account", + Short: "Manage agent accounts", + Long: `Manage Nylas agent accounts. + +Agent accounts are managed email identities backed by the Nylas provider. +This command always uses provider=nylas and keeps connector setup out of the +user's path. + +Examples: + # Create a new agent account + nylas agent account create me@yourapp.nylas.email + + # List agent accounts + nylas agent account list + + # Show one agent account + nylas agent account get + + # Delete an agent account + nylas agent account delete `, + } + + cmd.AddCommand(newCreateCmd()) + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newDeleteCmd()) + + return cmd +} diff --git a/internal/cli/agent/agent.go b/internal/cli/agent/agent.go index 85670cd..9b49a89 100644 --- a/internal/cli/agent/agent.go +++ b/internal/cli/agent/agent.go @@ -7,30 +7,36 @@ func NewAgentCmd() *cobra.Command { cmd := &cobra.Command{ Use: "agent", Aliases: []string{"agents"}, - Short: "Manage Nylas agent accounts", - Long: `Manage Nylas agent accounts. + Short: "Manage Nylas agent resources", + Long: `Manage Nylas agent resources. -Agent accounts are managed email identities backed by the Nylas provider. -This command always uses provider=nylas and keeps the connector setup out of -the user's path. +Agent account operations live under the account subcommand. Top-level status +reports the readiness of the nylas connector and the currently configured +managed accounts. Examples: # Create a new agent account - nylas agent create me@yourapp.nylas.email + nylas agent account create me@yourapp.nylas.email # List agent accounts - nylas agent list + nylas agent account list + + # List policies + nylas agent policy list + + # List rules + nylas agent rule list # Check connector and account status nylas agent status - # Delete an agent account - nylas agent delete `, + # Show an agent account + nylas agent account get `, } - cmd.AddCommand(newCreateCmd()) - cmd.AddCommand(newListCmd()) - cmd.AddCommand(newDeleteCmd()) + cmd.AddCommand(newAccountCmd()) + cmd.AddCommand(newPolicyCmd()) + cmd.AddCommand(newRuleCmd()) cmd.AddCommand(newStatusCmd()) return cmd diff --git a/internal/cli/agent/agent_payload_test.go b/internal/cli/agent/agent_payload_test.go new file mode 100644 index 0000000..929ebd2 --- /dev/null +++ b/internal/cli/agent/agent_payload_test.go @@ -0,0 +1,219 @@ +package agent + +import ( + "testing" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" +) + +func TestLoadRulePayload(t *testing.T) { + payload, err := loadRulePayload("", "", rulePayloadOptions{ + Name: "Block Example", + Conditions: []string{"from.domain,is,example.com"}, + Actions: []string{"mark_as_spam"}, + }, true) + if assert.NoError(t, err) { + assert.Equal(t, "Block Example", payload["name"]) + assert.Equal(t, true, payload["enabled"]) + assert.Equal(t, "inbound", payload["trigger"]) + + matchPayload, ok := payload["match"].(map[string]any) + if assert.True(t, ok) { + assert.Equal(t, "all", matchPayload["operator"]) + assert.Len(t, matchPayload["conditions"], 1) + conditions, ok := matchPayload["conditions"].([]domain.RuleCondition) + if assert.True(t, ok) && assert.Len(t, conditions, 1) { + assert.Equal(t, "example.com", conditions[0].Value) + } + } + + actions, ok := payload["actions"].([]domain.RuleAction) + if assert.True(t, ok) && assert.Len(t, actions, 1) { + assert.Equal(t, "mark_as_spam", actions[0].Type) + } + } + + payload, err = loadRulePayload(`{"name":"JSON Name","trigger":"inbound"}`, "", rulePayloadOptions{ + Name: "Flag Name", + MatchOperator: "any", + Conditions: []string{"from.address,is,ceo@example.com"}, + Actions: []string{"move_to_folder=vip"}, + }, true) + if assert.NoError(t, err) { + assert.Equal(t, "Flag Name", payload["name"]) + matchPayload, ok := payload["match"].(map[string]any) + if assert.True(t, ok) { + assert.Equal(t, "any", matchPayload["operator"]) + conditions, ok := matchPayload["conditions"].([]domain.RuleCondition) + if assert.True(t, ok) && assert.Len(t, conditions, 1) { + assert.Equal(t, "ceo@example.com", conditions[0].Value) + } + } + actions, ok := payload["actions"].([]domain.RuleAction) + if assert.True(t, ok) && assert.Len(t, actions, 1) { + assert.Equal(t, "move_to_folder", actions[0].Type) + assert.Equal(t, "vip", actions[0].Value) + } + } + + payload, err = loadRulePayload("", "", rulePayloadOptions{ + Name: "Preserve Strings", + Conditions: []string{"subject.contains,is,true", "from.tld,is,123"}, + Actions: []string{"move_to_folder=123", "tag=true"}, + }, true) + if assert.NoError(t, err) { + matchPayload, ok := payload["match"].(map[string]any) + if assert.True(t, ok) { + conditions, ok := matchPayload["conditions"].([]domain.RuleCondition) + if assert.True(t, ok) && assert.Len(t, conditions, 2) { + assert.Equal(t, "true", conditions[0].Value) + assert.Equal(t, "123", conditions[1].Value) + } + } + actions, ok := payload["actions"].([]domain.RuleAction) + if assert.True(t, ok) && assert.Len(t, actions, 2) { + assert.Equal(t, "123", actions[0].Value) + assert.Equal(t, "true", actions[1].Value) + } + } + + _, err = loadRulePayload("", "", rulePayloadOptions{}, true) + assert.EqualError(t, err, "rule create requires a rule definition") + + _, err = loadRulePayload("", "", rulePayloadOptions{ + Name: "Block Example", + Actions: []string{"mark_as_spam"}, + }, true) + assert.EqualError(t, err, "rule create is missing required fields") + + _, err = loadRulePayload("", "", rulePayloadOptions{ + Name: "Block Example", + Conditions: []string{"from.domain,is,example.com"}, + Actions: []string{"mark_as_spam"}, + EnabledSet: true, + DisabledSet: true, + }, true) + assert.EqualError(t, err, "cannot combine --enabled with --disabled") + + _, err = loadRulePayload("", "", rulePayloadOptions{ + Name: "Block Example", + Conditions: []string{"invalid"}, + Actions: []string{"mark_as_spam"}, + }, true) + assert.EqualError(t, err, "invalid --condition value") + + _, err = loadRulePayload("", "", rulePayloadOptions{ + Name: "Block Example", + Conditions: []string{"from.domain,is,example.com"}, + Actions: []string{"=broken"}, + }, true) + assert.EqualError(t, err, "invalid --action value") +} + +func TestPreserveRuleMatchOperator(t *testing.T) { + payload := map[string]any{ + "match": map[string]any{ + "conditions": []domain.RuleCondition{{ + Field: "from.domain", + Operator: "is", + Value: "example.com", + }}, + }, + } + + preserveRuleMatchOperator(payload, &domain.Rule{ + Match: &domain.RuleMatch{Operator: "any"}, + }) + + matchPayload, ok := payload["match"].(map[string]any) + if assert.True(t, ok) { + assert.Equal(t, "any", matchPayload["operator"]) + } +} + +func TestPreserveRuleMatchOperator_NoOverride(t *testing.T) { + payload := map[string]any{ + "match": map[string]any{ + "operator": "all", + "conditions": []domain.RuleCondition{{ + Field: "from.domain", + Operator: "is", + Value: "example.com", + }}, + }, + } + + preserveRuleMatchOperator(payload, &domain.Rule{ + Match: &domain.RuleMatch{Operator: "any"}, + }) + + matchPayload, ok := payload["match"].(map[string]any) + if assert.True(t, ok) { + assert.Equal(t, "all", matchPayload["operator"]) + } +} + +func TestRuleJSONPreservesZeroAndFalseValues(t *testing.T) { + priority := 0 + enabled := false + rule := domain.Rule{ + ID: "rule-123", + Priority: &priority, + Enabled: &enabled, + } + + output := captureStdout(t, func() { + assert.NoError(t, common.PrintJSON(rule)) + }) + + assert.Contains(t, output, `"priority": 0`) + assert.Contains(t, output, `"enabled": false`) +} + +func TestValidateAgentAppPassword(t *testing.T) { + assert.NoError(t, validateAgentAppPassword("")) + assert.NoError(t, validateAgentAppPassword("ValidAgentPass123ABC!")) + + assert.EqualError(t, validateAgentAppPassword("short"), "app password must be between 18 and 40 characters") + assert.EqualError(t, validateAgentAppPassword("Invalid Agent Pass123"), "app password must use printable ASCII characters only and cannot contain spaces") + assert.EqualError(t, validateAgentAppPassword("alllowercasepassword123"), "app password must include at least one uppercase letter, one lowercase letter, and one digit") +} + +func TestDeleteCmd(t *testing.T) { + cmd := newDeleteCmd() + + assert.Equal(t, "delete ", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("yes")) + assert.NotNil(t, cmd.Flags().Lookup("force")) +} + +func TestIsDeleteConfirmed(t *testing.T) { + assert.True(t, isDeleteConfirmed("y\n")) + assert.True(t, isDeleteConfirmed("yes\n")) + assert.True(t, isDeleteConfirmed("delete\n")) + assert.False(t, isDeleteConfirmed("n\n")) + assert.False(t, isDeleteConfirmed("\n")) +} + +func TestFindNylasConnector(t *testing.T) { + connectors := []domain.Connector{ + {Provider: "google", ID: "conn-google"}, + {Provider: "nylas"}, + } + + connector := findNylasConnector(connectors) + assert.NotNil(t, connector) + assert.Equal(t, "nylas", connector.Provider) + assert.Empty(t, connector.ID) + assert.Nil(t, findNylasConnector([]domain.Connector{{Provider: "google"}})) +} + +func TestFormatConnectorSummary(t *testing.T) { + assert.Equal(t, "nylas", formatConnectorSummary(domain.Connector{Provider: "nylas"})) + assert.Equal(t, "nylas (conn-nylas)", formatConnectorSummary(domain.Connector{ + Provider: "nylas", + ID: "conn-nylas", + })) +} diff --git a/internal/cli/agent/agent_test.go b/internal/cli/agent/agent_test.go index 2bc0121..daceb78 100644 --- a/internal/cli/agent/agent_test.go +++ b/internal/cli/agent/agent_test.go @@ -1,7 +1,10 @@ package agent import ( + "bytes" + "os" "testing" + "time" "github.com/nylas/cli/internal/domain" "github.com/stretchr/testify/assert" @@ -13,9 +16,9 @@ func TestNewAgentCmd(t *testing.T) { assert.Equal(t, "agent", cmd.Use) assert.Contains(t, cmd.Aliases, "agents") assert.Contains(t, cmd.Short, "agent") - assert.Contains(t, cmd.Long, "provider=nylas") + assert.Contains(t, cmd.Long, "account subcommand") - expected := []string{"create", "list", "delete", "status"} + expected := []string{"account", "policy", "rule", "status"} cmdMap := make(map[string]bool) for _, sub := range cmd.Commands() { cmdMap[sub.Name()] = true @@ -31,51 +34,468 @@ func TestCreateCmd(t *testing.T) { assert.Equal(t, "create ", cmd.Use) assert.NotNil(t, cmd.Flags().Lookup("json")) assert.NotNil(t, cmd.Flags().Lookup("app-password")) + assert.NotNil(t, cmd.Flags().Lookup("policy-id")) assert.Contains(t, cmd.Long, "provider=nylas") } -func TestValidateAgentAppPassword(t *testing.T) { - assert.NoError(t, validateAgentAppPassword("")) - assert.NoError(t, validateAgentAppPassword("ValidAgentPass123ABC!")) +func TestAccountCmd(t *testing.T) { + cmd := newAccountCmd() + + assert.Equal(t, "account", cmd.Use) + assert.Contains(t, cmd.Short, "accounts") - assert.EqualError(t, validateAgentAppPassword("short"), "app password must be between 18 and 40 characters") - assert.EqualError(t, validateAgentAppPassword("Invalid Agent Pass123"), "app password must use printable ASCII characters only and cannot contain spaces") - assert.EqualError(t, validateAgentAppPassword("alllowercasepassword123"), "app password must include at least one uppercase letter, one lowercase letter, and one digit") + expected := []string{"create", "list", "get", "delete"} + cmdMap := make(map[string]bool) + for _, sub := range cmd.Commands() { + cmdMap[sub.Name()] = true + } + for _, name := range expected { + assert.True(t, cmdMap[name], "missing subcommand %s", name) + } } -func TestDeleteCmd(t *testing.T) { - cmd := newDeleteCmd() +func TestGetCmd(t *testing.T) { + cmd := newGetCmd() - assert.Equal(t, "delete ", cmd.Use) - assert.NotNil(t, cmd.Flags().Lookup("yes")) - assert.NotNil(t, cmd.Flags().Lookup("force")) + assert.Equal(t, "get ", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("json")) + assert.Contains(t, cmd.Long, "grant ID or by email address") } -func TestIsDeleteConfirmed(t *testing.T) { - assert.True(t, isDeleteConfirmed("y\n")) - assert.True(t, isDeleteConfirmed("yes\n")) - assert.True(t, isDeleteConfirmed("delete\n")) - assert.False(t, isDeleteConfirmed("n\n")) - assert.False(t, isDeleteConfirmed("\n")) +func TestPolicyCmd(t *testing.T) { + cmd := newPolicyCmd() + + assert.Equal(t, "policy", cmd.Use) + assert.Contains(t, cmd.Short, "policies") + + expected := []string{"list", "get", "read", "create", "update", "delete"} + cmdMap := make(map[string]bool) + for _, sub := range cmd.Commands() { + cmdMap[sub.Name()] = true + } + for _, name := range expected { + assert.True(t, cmdMap[name], "missing subcommand %s", name) + } } -func TestFindNylasConnector(t *testing.T) { - connectors := []domain.Connector{ - {Provider: "google", ID: "conn-google"}, - {Provider: "nylas"}, +func TestRuleCmd(t *testing.T) { + cmd := newRuleCmd() + + assert.Equal(t, "rule", cmd.Use) + assert.Contains(t, cmd.Short, "rules") + + expected := []string{"list", "get", "read", "create", "update", "delete"} + cmdMap := make(map[string]bool) + for _, sub := range cmd.Commands() { + cmdMap[sub.Name()] = true } + for _, name := range expected { + assert.True(t, cmdMap[name], "missing subcommand %s", name) + } +} - connector := findNylasConnector(connectors) - assert.NotNil(t, connector) - assert.Equal(t, "nylas", connector.Provider) - assert.Empty(t, connector.ID) - assert.Nil(t, findNylasConnector([]domain.Connector{{Provider: "google"}})) +func TestLoadPolicyPayload(t *testing.T) { + payload, err := loadPolicyPayload("", "", "Test Policy", true) + assert.NoError(t, err) + assert.Equal(t, "Test Policy", payload["name"]) + + _, err = loadPolicyPayload("", "", "", true) + assert.EqualError(t, err, "policy name is required") +} + +func TestPolicyListCmd(t *testing.T) { + cmd := newPolicyListCmd() + + assert.Equal(t, "list", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("json")) + assert.NotNil(t, cmd.Flags().Lookup("all")) + assert.Contains(t, cmd.Long, "provider=nylas account") + assert.Contains(t, cmd.Flags().Lookup("all").Usage, "provider=nylas accounts") +} + +func TestPolicyReadCmd(t *testing.T) { + cmd := newPolicyReadCmd() + + assert.Equal(t, "read ", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("json")) + assert.Contains(t, cmd.Long, "Read details for a single policy") +} + +func TestRuleListCmd(t *testing.T) { + cmd := newRuleListCmd() + + assert.Equal(t, "list", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("json")) + assert.NotNil(t, cmd.Flags().Lookup("all")) + assert.NotNil(t, cmd.Flags().Lookup("policy-id")) + assert.Contains(t, cmd.Long, "default grant") +} + +func TestRuleReadCmd(t *testing.T) { + cmd := newRuleReadCmd() + + assert.Equal(t, "read ", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("json")) + assert.NotNil(t, cmd.Flags().Lookup("all")) + assert.NotNil(t, cmd.Flags().Lookup("policy-id")) + assert.Contains(t, cmd.Long, "Read details for a single rule") +} + +func TestRuleCreateCmd(t *testing.T) { + cmd := newRuleCreateCmd() + + assert.Equal(t, "create", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("name")) + assert.NotNil(t, cmd.Flags().Lookup("description")) + assert.NotNil(t, cmd.Flags().Lookup("priority")) + assert.NotNil(t, cmd.Flags().Lookup("enabled")) + assert.NotNil(t, cmd.Flags().Lookup("disabled")) + assert.NotNil(t, cmd.Flags().Lookup("condition")) + assert.NotNil(t, cmd.Flags().Lookup("action")) + assert.Contains(t, cmd.Long, "--condition") +} + +func TestRuleUpdateCmd(t *testing.T) { + cmd := newRuleUpdateCmd() + + assert.Equal(t, "update ", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("name")) + assert.NotNil(t, cmd.Flags().Lookup("description")) + assert.NotNil(t, cmd.Flags().Lookup("priority")) + assert.NotNil(t, cmd.Flags().Lookup("enabled")) + assert.NotNil(t, cmd.Flags().Lookup("disabled")) + assert.NotNil(t, cmd.Flags().Lookup("condition")) + assert.NotNil(t, cmd.Flags().Lookup("action")) + assert.Contains(t, cmd.Long, "--condition") } -func TestFormatConnectorSummary(t *testing.T) { - assert.Equal(t, "nylas", formatConnectorSummary(domain.Connector{Provider: "nylas"})) - assert.Equal(t, "nylas (conn-nylas)", formatConnectorSummary(domain.Connector{ - Provider: "nylas", - ID: "conn-nylas", - })) +func TestPrintPolicyDetails(t *testing.T) { + attachmentSize := int64(50480000) + attachmentCount := 10 + allowedTypes := []string{"application/pdf", "text/plain"} + totalMIME := int64(50480000) + dailyMessages := int64(500) + inboxRetention := 30 + spamRetention := 7 + additionalFolders := []string{"archive", "support"} + useCidrAliasing := false + useDNSBL := false + useHeaderAnomaly := true + spamSensitivity := 1.0 + + policy := domain.Policy{ + ID: "policy-123", + Name: "Default Policy", + ApplicationID: "app-123", + OrganizationID: "org-123", + Rules: []string{"rule-a", "rule-b"}, + Limits: &domain.PolicyLimits{ + LimitAttachmentSizeInBytes: &attachmentSize, + LimitAttachmentCount: &attachmentCount, + LimitAttachmentAllowedTypes: &allowedTypes, + LimitSizeTotalMimeInBytes: &totalMIME, + LimitCountDailyMessagePerGrant: &dailyMessages, + LimitInboxRetentionPeriodInDays: &inboxRetention, + LimitSpamRetentionPeriodInDays: &spamRetention, + }, + Options: &domain.PolicyOptions{ + AdditionalFolders: &additionalFolders, + UseCidrAliasing: &useCidrAliasing, + }, + SpamDetection: &domain.PolicySpamDetection{ + UseListDNSBL: &useDNSBL, + UseHeaderAnomalyDetection: &useHeaderAnomaly, + SpamSensitivity: &spamSensitivity, + }, + CreatedAt: domain.UnixTime{Time: time.Date(2026, time.April, 13, 16, 49, 44, 0, time.UTC)}, + UpdatedAt: domain.UnixTime{Time: time.Date(2026, time.April, 13, 16, 49, 44, 0, time.UTC)}, + } + + output := captureStdout(t, func() { + printPolicyDetails(policy) + }) + + assert.Contains(t, output, "Policy: Default Policy") + assert.Contains(t, output, "Rules:") + assert.Contains(t, output, "1. rule-a") + assert.Contains(t, output, "Limits:") + assert.Contains(t, output, "Attachment size:") + assert.Contains(t, output, "50480000 bytes") + assert.Contains(t, output, "Allowed types:") + assert.Contains(t, output, "application/pdf") + assert.Contains(t, output, "Options:") + assert.Contains(t, output, "Additional folders:") + assert.Contains(t, output, "archive") + assert.Contains(t, output, "CIDR aliasing:") + assert.Contains(t, output, "Spam detection:") + assert.Contains(t, output, "Use DNSBL:") + assert.Contains(t, output, "Header anomaly detection:") + assert.Contains(t, output, "Spam sensitivity:") +} + +func TestPrintRuleDetails(t *testing.T) { + priority := 10 + enabled := true + rule := domain.Rule{ + ID: "rule-123", + Name: "Block Example", + Description: "Blocks example.com", + Priority: &priority, + Enabled: &enabled, + Trigger: "inbound", + ApplicationID: "app-123", + OrganizationID: "org-123", + Match: &domain.RuleMatch{ + Operator: "all", + Conditions: []domain.RuleCondition{{ + Field: "from.domain", + Operator: "is", + Value: "example.com", + }}, + }, + Actions: []domain.RuleAction{{ + Type: "mark_as_spam", + }}, + CreatedAt: domain.UnixTime{Time: time.Date(2026, time.April, 13, 16, 49, 44, 0, time.UTC)}, + UpdatedAt: domain.UnixTime{Time: time.Date(2026, time.April, 13, 16, 49, 44, 0, time.UTC)}, + } + + output := captureStdout(t, func() { + printRuleDetails(rule, []rulePolicyRef{{ + PolicyID: "policy-123", + PolicyName: "Default Policy", + Accounts: []policyAgentAccountRef{{ + GrantID: "grant-123", + Email: "agent@example.com", + }}, + }}) + }) + + assert.Contains(t, output, "Rule: Block Example") + assert.Contains(t, output, "Policies:") + assert.Contains(t, output, "Default Policy") + assert.Contains(t, output, "agent@example.com") + assert.Contains(t, output, "Match:") + assert.Contains(t, output, "from.domain is example.com") + assert.Contains(t, output, "Actions:") + assert.Contains(t, output, "mark_as_spam") +} + +func TestBuildPolicyAccountRefs(t *testing.T) { + refsByPolicyID := buildPolicyAccountRefs([]domain.AgentAccount{ + { + ID: "grant-b", + Email: "beta@example.com", + Provider: domain.ProviderNylas, + Settings: domain.AgentAccountSettings{PolicyID: "policy-1"}, + }, + { + ID: "grant-a", + Email: "alpha@example.com", + Provider: domain.ProviderNylas, + Settings: domain.AgentAccountSettings{PolicyID: "policy-1"}, + }, + { + ID: "grant-empty", + Email: "empty@example.com", + Provider: domain.ProviderNylas, + }, + }) + + if assert.Len(t, refsByPolicyID["policy-1"], 2) { + assert.Equal(t, "alpha@example.com", refsByPolicyID["policy-1"][0].Email) + assert.Equal(t, "grant-a", refsByPolicyID["policy-1"][0].GrantID) + assert.Equal(t, "beta@example.com", refsByPolicyID["policy-1"][1].Email) + } + assert.Empty(t, refsByPolicyID[""]) +} + +func TestFormatPolicyAgentAccounts(t *testing.T) { + assert.Equal(t, "", formatPolicyAgentAccounts(nil)) + assert.Equal(t, + "alpha@example.com (grant-a), beta@example.com (grant-b)", + formatPolicyAgentAccounts([]policyAgentAccountRef{ + {GrantID: "grant-a", Email: "alpha@example.com"}, + {GrantID: "grant-b", Email: "beta@example.com"}, + }), + ) +} + +func TestFilterPoliciesWithAgentAccounts(t *testing.T) { + policies := filterPoliciesWithAgentAccounts( + []domain.Policy{ + {ID: "policy-1", Name: "Attached"}, + {ID: "policy-2", Name: "Unused"}, + }, + map[string][]policyAgentAccountRef{ + "policy-1": {{ + GrantID: "grant-1", + Email: "agent@example.com", + }}, + }, + ) + + if assert.Len(t, policies, 1) { + assert.Equal(t, "policy-1", policies[0].ID) + } +} + +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": {}, + }, + } + + resolved, err := resolvePolicyForAgentOps(scope, "policy-agent") + 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) { + refsByRuleID := buildRuleRefsByID( + []domain.Policy{ + {ID: "policy-b", Name: "Beta", Rules: []string{"rule-1"}}, + {ID: "policy-a", Name: "Alpha", Rules: []string{"rule-1", "rule-2", "rule-1"}}, + }, + map[string][]policyAgentAccountRef{ + "policy-a": {{ + GrantID: "grant-a", + Email: "alpha@example.com", + }}, + "policy-b": {{ + GrantID: "grant-b", + Email: "beta@example.com", + }}, + }, + ) + + if assert.Len(t, refsByRuleID["rule-1"], 2) { + assert.Equal(t, "Alpha", refsByRuleID["rule-1"][0].PolicyName) + assert.Equal(t, "Beta", refsByRuleID["rule-1"][1].PolicyName) + } + if assert.Len(t, refsByRuleID["rule-2"], 1) { + assert.Equal(t, "policy-a", refsByRuleID["rule-2"][0].PolicyID) + } +} + +func TestRuleReferencedOutsideAgentScope(t *testing.T) { + allPolicies := []domain.Policy{ + {ID: "policy-agent", Rules: []string{"rule-1"}}, + {ID: "policy-other", Rules: []string{"rule-1"}}, + } + 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")) +} + +func TestPoliciesLeftEmptyByRuleRemoval(t *testing.T) { + blocking := policiesLeftEmptyByRuleRemoval([]domain.Policy{ + {ID: "policy-last", Name: "Last Rule", Rules: []string{"rule-1"}}, + {ID: "policy-shared", Name: "Has Spare", Rules: []string{"rule-1", "rule-2"}}, + {ID: "policy-other", Name: "Other Rule", Rules: []string{"rule-3"}}, + }, "rule-1") + + if assert.Len(t, blocking, 1) { + assert.Equal(t, "policy-last", blocking[0].ID) + assert.Equal(t, "Last Rule", blocking[0].Name) + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stdout pipe: %v", err) + } + + os.Stdout = w + defer func() { + os.Stdout = oldStdout + }() + + fn() + + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + _ = r.Close() + + return buf.String() } diff --git a/internal/cli/agent/create.go b/internal/cli/agent/create.go index 1e84cf9..a850293 100644 --- a/internal/cli/agent/create.go +++ b/internal/cli/agent/create.go @@ -2,7 +2,6 @@ package agent import ( "context" - "encoding/json" "fmt" "strings" @@ -15,6 +14,7 @@ import ( func newCreateCmd() *cobra.Command { var jsonOutput bool var appPassword string + var policyID string cmd := &cobra.Command{ Use: "create ", @@ -25,22 +25,24 @@ This command always creates a provider=nylas grant. If the nylas connector does not exist yet, it will be created automatically first. Examples: - nylas agent create me@yourapp.nylas.email - nylas agent create support@yourapp.nylas.email --json - nylas agent create debug@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!'`, + nylas agent account create me@yourapp.nylas.email + nylas agent account create support@yourapp.nylas.email --json + nylas agent account create debug@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' + nylas agent account create routed@yourapp.nylas.email --policy-id `, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runCreate(args[0], appPassword, jsonOutput) + return runCreate(args[0], appPassword, policyID, jsonOutput) }, } cmd.Flags().StringVar(&appPassword, "app-password", "", "Optional IMAP/SMTP app password for mail-client access") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Optional policy ID to attach to the created agent account") cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") return cmd } -func runCreate(email, appPassword string, jsonOutput bool) error { +func runCreate(email, appPassword, policyID string, jsonOutput bool) error { email = strings.TrimSpace(email) if email == "" { printError("Email address cannot be empty") @@ -54,6 +56,7 @@ func runCreate(email, appPassword string, jsonOutput bool) error { printError(err.Error()) return err } + policyID = strings.TrimSpace(policyID) _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { connector, err := ensureNylasConnector(ctx, client) @@ -61,7 +64,7 @@ func runCreate(email, appPassword string, jsonOutput bool) error { return struct{}{}, common.WrapCreateError("nylas connector", err) } - account, err := client.CreateAgentAccount(ctx, email, appPassword) + account, err := client.CreateAgentAccount(ctx, email, appPassword, policyID) if err != nil { return struct{}{}, common.WrapCreateError("agent account", err) } @@ -69,9 +72,7 @@ func runCreate(email, appPassword string, jsonOutput bool) error { saveGrantLocally(account.ID, account.Email) if jsonOutput { - data, _ := json.MarshalIndent(account, "", " ") - fmt.Println(string(data)) - return struct{}{}, nil + return struct{}{}, common.PrintJSON(account) } printSuccess("Agent account created successfully!") @@ -83,9 +84,10 @@ func runCreate(email, appPassword string, jsonOutput bool) error { fmt.Println() _, _ = common.Dim.Println("Next steps:") - _, _ = common.Dim.Printf(" 1. List agent accounts: nylas agent list\n") + _, _ = common.Dim.Printf(" 1. List agent accounts: nylas agent account list\n") _, _ = common.Dim.Printf(" 2. Check connector status: nylas agent status\n") - _, _ = common.Dim.Printf(" 3. Delete this account: nylas agent delete %s\n", account.ID) + _, _ = common.Dim.Printf(" 3. Show this account: nylas agent account get %s\n", account.ID) + _, _ = common.Dim.Printf(" 4. Delete this account: nylas agent account delete %s\n", account.ID) return struct{}{}, nil }) diff --git a/internal/cli/agent/delete.go b/internal/cli/agent/delete.go index b5980e7..a13fb5b 100644 --- a/internal/cli/agent/delete.go +++ b/internal/cli/agent/delete.go @@ -24,8 +24,8 @@ func newDeleteCmd() *cobra.Command { This permanently revokes the provider=nylas grant. Examples: - nylas agent delete 123456 - nylas agent delete me@yourapp.nylas.email --yes`, + nylas agent account delete 123456 + nylas agent account delete me@yourapp.nylas.email --yes`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { identifier, err := getAgentIdentifier(args) diff --git a/internal/cli/agent/get.go b/internal/cli/agent/get.go new file mode 100644 index 0000000..0cb767d --- /dev/null +++ b/internal/cli/agent/get.go @@ -0,0 +1,61 @@ +package agent + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newGetCmd() *cobra.Command { + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "get ", + Short: "Show an agent account", + Long: `Show a Nylas agent account. + +You can look up an account by grant ID or by email address. + +Examples: + nylas agent account get 123456 + nylas agent account get me@yourapp.nylas.email + nylas agent account get me@yourapp.nylas.email --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + identifier, err := getAgentIdentifier(args) + if err != nil { + return err + } + return runGet(identifier, jsonOutput) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func runGet(identifier string, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + grantID, err := resolveAgentID(ctx, client, identifier) + if err != nil { + return struct{}{}, common.WrapGetError("agent account", err) + } + + account, err := client.GetAgentAccount(ctx, grantID) + if err != nil { + return struct{}{}, common.WrapGetError("agent account", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(account) + } + + printAgentDetails(*account) + return struct{}{}, nil + }) + + return err +} diff --git a/internal/cli/agent/list.go b/internal/cli/agent/list.go index 5cee9d8..9d369c4 100644 --- a/internal/cli/agent/list.go +++ b/internal/cli/agent/list.go @@ -2,7 +2,6 @@ package agent import ( "context" - "encoding/json" "fmt" "github.com/nylas/cli/internal/cli/common" @@ -21,8 +20,8 @@ func newListCmd() *cobra.Command { This command only shows grants created with provider=nylas. Examples: - nylas agent list - nylas agent list --json`, + nylas agent account list + nylas agent account list --json`, RunE: func(cmd *cobra.Command, args []string) error { return runList(jsonOutput) }, @@ -41,13 +40,11 @@ func runList(jsonOutput bool) error { } if jsonOutput { - data, _ := json.MarshalIndent(accounts, "", " ") - fmt.Println(string(data)) - return struct{}{}, nil + return struct{}{}, common.PrintJSON(accounts) } if len(accounts) == 0 { - common.PrintEmptyStateWithHint("agent accounts", "Create one with: nylas agent create ") + common.PrintEmptyStateWithHint("agent accounts", "Create one with: nylas agent account create ") return struct{}{}, nil } diff --git a/internal/cli/agent/policy.go b/internal/cli/agent/policy.go new file mode 100644 index 0000000..a3ffde5 --- /dev/null +++ b/internal/cli/agent/policy.go @@ -0,0 +1,340 @@ +package agent + +import ( + "cmp" + "fmt" + "slices" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/spf13/cobra" +) + +type policyAgentAccountRef struct { + GrantID string `json:"grant_id"` + Email string `json:"email"` +} + +type resolvedPolicyScope struct { + Policy *domain.Policy + AgentAccounts []policyAgentAccountRef + AttachedToNonAgent bool + AttachedToAgent bool +} + +func newPolicyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "policy", + Short: "Manage agent policies", + Long: `Manage policies used by agent accounts. + +Policies are backed by the /v3/policies API and can be attached to agent +accounts via policy_id in grant settings. + +Examples: + nylas agent policy list + nylas agent policy get + nylas agent policy read + nylas agent policy create --name "Strict Policy" + nylas agent policy update --name "Updated Policy" + nylas agent policy delete --yes`, + } + + cmd.AddCommand(newPolicyListCmd()) + cmd.AddCommand(newPolicyGetCmd()) + cmd.AddCommand(newPolicyReadCmd()) + cmd.AddCommand(newPolicyCreateCmd()) + cmd.AddCommand(newPolicyUpdateCmd()) + cmd.AddCommand(newPolicyDeleteCmd()) + + return cmd +} + +func buildPolicyAccountRefs(accounts []domain.AgentAccount) map[string][]policyAgentAccountRef { + refsByPolicyID := make(map[string][]policyAgentAccountRef, len(accounts)) + for _, account := range accounts { + policyID := strings.TrimSpace(account.Settings.PolicyID) + if policyID == "" { + continue + } + refsByPolicyID[policyID] = append(refsByPolicyID[policyID], policyAgentAccountRef{ + GrantID: account.ID, + Email: account.Email, + }) + } + + for policyID, refs := range refsByPolicyID { + slices.SortFunc(refs, func(a, b policyAgentAccountRef) int { + if c := cmp.Compare(strings.ToLower(a.Email), strings.ToLower(b.Email)); c != 0 { + return c + } + return cmp.Compare(a.GrantID, b.GrantID) + }) + refsByPolicyID[policyID] = refs + } + + 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 { + if len(refsByPolicyID[policy.ID]) == 0 { + continue + } + filtered = append(filtered, policy) + } + return filtered +} + +func resolvePolicyForAgentOps(scope *agentPolicyScope, policyID string) (*resolvedPolicyScope, error) { + policyID = strings.TrimSpace(policyID) + policy := findPolicyByID(scope.AllPolicies, policyID) + if policy == nil { + return nil, common.NewUserError( + "policy not found", + "Use 'nylas agent policy list --all' to inspect provider=nylas policies", + ) + } + + 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, + }, nil +} + +func formatPolicyAgentAccounts(accounts []policyAgentAccountRef) string { + if len(accounts) == 0 { + return "" + } + + formatted := make([]string, 0, len(accounts)) + for _, account := range accounts { + switch { + case account.Email != "" && account.GrantID != "": + formatted = append(formatted, fmt.Sprintf("%s (%s)", account.Email, account.GrantID)) + case account.Email != "": + formatted = append(formatted, account.Email) + case account.GrantID != "": + formatted = append(formatted, account.GrantID) + } + } + + return strings.Join(formatted, ", ") +} + +func printPolicySummary(policy domain.Policy, index int, accounts []policyAgentAccountRef) { + fmt.Printf("%d. %-32s %s\n", index+1, common.Cyan.Sprint(policy.Name), common.Dim.Sprint(policy.ID)) + if !policy.UpdatedAt.IsZero() { + _, _ = common.Dim.Printf(" Updated: %s\n", common.FormatTimeAgo(policy.UpdatedAt.Time)) + } + for _, account := range accounts { + _, _ = common.Dim.Printf(" Agent: %s (%s)\n", account.Email, account.GrantID) + } +} + +func printPolicyDetails(policy domain.Policy) { + fmt.Printf("Policy: %s\n", policy.Name) + fmt.Printf("ID: %s\n", policy.ID) + if policy.ApplicationID != "" { + fmt.Printf("Application: %s\n", policy.ApplicationID) + } + if policy.OrganizationID != "" { + fmt.Printf("Organization: %s\n", policy.OrganizationID) + } + fmt.Printf("Rules: %d\n", len(policy.Rules)) + if !policy.CreatedAt.IsZero() { + fmt.Printf("Created: %s (%s)\n", policy.CreatedAt.Format(common.DisplayDateTime), common.FormatTimeAgo(policy.CreatedAt.Time)) + } + if !policy.UpdatedAt.IsZero() { + fmt.Printf("Updated: %s (%s)\n", policy.UpdatedAt.Format(common.DisplayDateTime), common.FormatTimeAgo(policy.UpdatedAt.Time)) + } + + printPolicyStringListSection("Rules", policy.Rules) + printPolicyLimitsSection(policy.Limits) + printPolicyOptionsSection(policy.Options) + printPolicySpamDetectionSection(policy.SpamDetection) + fmt.Println() +} + +func printPolicySectionHeader(title string) { + fmt.Printf("\n%s:\n", title) +} + +func printPolicyField(label, value string) { + fmt.Printf(" %-25s %s\n", label+":", value) +} + +func printPolicyStringListSection(title string, values []string) { + printPolicySectionHeader(title) + if len(values) == 0 { + fmt.Println(" none") + return + } + + for i, value := range values { + fmt.Printf(" %d. %s\n", i+1, value) + } +} + +func printPolicyValueList(label string, values []string) { + if len(values) == 0 { + printPolicyField(label, "none") + return + } + + printPolicyField(label, fmt.Sprintf("%d", len(values))) + for _, value := range values { + fmt.Printf(" - %s\n", value) + } +} + +func printPolicyLimitsSection(limits *domain.PolicyLimits) { + printPolicySectionHeader("Limits") + if limits == nil { + fmt.Println(" none") + return + } + + printed := false + if limits.LimitAttachmentSizeInBytes != nil { + printPolicyField("Attachment size", formatPolicyBytes(*limits.LimitAttachmentSizeInBytes)) + printed = true + } + if limits.LimitAttachmentCount != nil { + printPolicyField("Attachment count", fmt.Sprintf("%d", *limits.LimitAttachmentCount)) + printed = true + } + if limits.LimitAttachmentAllowedTypes != nil { + printPolicyValueList("Allowed types", *limits.LimitAttachmentAllowedTypes) + printed = true + } + if limits.LimitSizeTotalMimeInBytes != nil { + printPolicyField("MIME size total", formatPolicyBytes(*limits.LimitSizeTotalMimeInBytes)) + printed = true + } + if limits.LimitStorageTotalInBytes != nil { + printPolicyField("Storage total", formatPolicyBytes(*limits.LimitStorageTotalInBytes)) + printed = true + } + if limits.LimitCountDailyMessagePerGrant != nil { + printPolicyField("Daily messages/grant", fmt.Sprintf("%d", *limits.LimitCountDailyMessagePerGrant)) + printed = true + } + if limits.LimitInboxRetentionPeriodInDays != nil { + printPolicyField("Inbox retention", formatPolicyDays(*limits.LimitInboxRetentionPeriodInDays)) + printed = true + } + if limits.LimitSpamRetentionPeriodInDays != nil { + printPolicyField("Spam retention", formatPolicyDays(*limits.LimitSpamRetentionPeriodInDays)) + printed = true + } + + if !printed { + fmt.Println(" none") + } +} + +func printPolicyOptionsSection(options *domain.PolicyOptions) { + printPolicySectionHeader("Options") + if options == nil { + fmt.Println(" none") + return + } + + printed := false + if options.AdditionalFolders != nil { + printPolicyValueList("Additional folders", *options.AdditionalFolders) + printed = true + } + if options.UseCidrAliasing != nil { + printPolicyField("CIDR aliasing", fmt.Sprintf("%t", *options.UseCidrAliasing)) + printed = true + } + + if !printed { + fmt.Println(" none") + } +} + +func printPolicySpamDetectionSection(spamDetection *domain.PolicySpamDetection) { + printPolicySectionHeader("Spam detection") + if spamDetection == nil { + fmt.Println(" none") + return + } + + printed := false + if spamDetection.UseListDNSBL != nil { + printPolicyField("Use DNSBL", fmt.Sprintf("%t", *spamDetection.UseListDNSBL)) + printed = true + } + if spamDetection.UseHeaderAnomalyDetection != nil { + printPolicyField("Header anomaly detection", fmt.Sprintf("%t", *spamDetection.UseHeaderAnomalyDetection)) + printed = true + } + if spamDetection.SpamSensitivity != nil { + printPolicyField("Spam sensitivity", fmt.Sprintf("%.2f", *spamDetection.SpamSensitivity)) + printed = true + } + + if !printed { + fmt.Println(" none") + } +} + +func formatPolicyBytes(size int64) string { + return fmt.Sprintf("%s (%d bytes)", common.FormatSize(size), size) +} + +func formatPolicyDays(days int) string { + if days == 1 { + return "1 day" + } + return fmt.Sprintf("%d days", days) +} + +func loadPolicyPayload(data, dataFile, name string, requireName bool) (map[string]any, error) { + payload, err := common.ReadJSONStringMap(data, dataFile) + if err != nil { + return nil, err + } + + if name != "" { + payload["name"] = name + } + + if requireName { + rawName, _ := payload["name"].(string) + if rawName == "" { + return nil, common.NewUserError("policy name is required", "Use --name or include a non-empty name in --data/--data-file") + } + } + + return payload, nil +} diff --git a/internal/cli/agent/policy_create_update_delete.go b/internal/cli/agent/policy_create_update_delete.go new file mode 100644 index 0000000..b83b493 --- /dev/null +++ b/internal/cli/agent/policy_create_update_delete.go @@ -0,0 +1,205 @@ +package agent + +import ( + "context" + "fmt" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newPolicyCreateCmd() *cobra.Command { + var ( + name string + data string + dataFile string + jsonOutput bool + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a policy", + Long: `Create a new policy. + +Use --name for a simple policy, or pass a full request body with --data or +--data-file to set limits, rules, options, and spam detection. + +Examples: + nylas agent policy create --name "Strict Policy" + nylas agent policy create --data '{"name":"Strict Policy","rules":["rule-123"]}' + nylas agent policy create --data-file policy.json`, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := loadPolicyPayload(data, dataFile, name, true) + if err != nil { + return err + } + return runPolicyCreate(payload, jsonOutput) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Policy name") + cmd.Flags().StringVar(&data, "data", "", "Inline JSON request body") + cmd.Flags().StringVar(&dataFile, "data-file", "", "Path to a JSON request body file") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func runPolicyCreate(payload map[string]any, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + policy, err := client.CreatePolicy(ctx, payload) + if err != nil { + return struct{}{}, common.WrapCreateError("policy", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(policy) + } + + printSuccess("Policy created successfully!") + fmt.Println() + printPolicyDetails(*policy) + return struct{}{}, nil + }) + + return err +} + +func newPolicyUpdateCmd() *cobra.Command { + var ( + name string + data string + dataFile string + jsonOutput bool + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a policy", + Long: `Update an existing policy. + +Use --name for a simple rename, or pass a partial JSON request body with +--data or --data-file to update nested fields. + +Examples: + nylas agent policy update --name "Updated Policy" + nylas agent policy update --data '{"spam_detection":{"spam_sensitivity":2}}' + nylas agent policy update --data-file update.json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := loadPolicyPayload(data, dataFile, name, false) + if err != nil { + return err + } + if len(payload) == 0 { + return common.NewUserError("policy update requires at least one field", "Use --name, --data, or --data-file") + } + return runPolicyUpdate(args[0], payload, jsonOutput) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Updated policy name") + cmd.Flags().StringVar(&data, "data", "", "Inline JSON request body") + cmd.Flags().StringVar(&dataFile, "data-file", "", "Path to a JSON request body file") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func runPolicyUpdate(policyID string, payload map[string]any, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + scope, err := loadAgentPolicyScope(ctx, client) + if err != nil { + return struct{}{}, err + } + + resolved, err := resolvePolicyForAgentOps(scope, policyID) + if 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 { + return struct{}{}, common.WrapUpdateError("policy", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(policy) + } + + common.PrintUpdateSuccess("policy", policy.Name) + fmt.Println() + printPolicyDetails(*policy) + return struct{}{}, nil + }) + + return err +} + +func newPolicyDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a policy", + Long: `Delete a policy permanently. + +Examples: + nylas agent policy delete --yes`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !yes { + return common.NewUserError("deletion requires confirmation", "Re-run with --yes to delete the policy") + } + return runPolicyDelete(args[0]) + }, + } + + common.AddYesFlag(cmd, &yes) + + return cmd +} + +func runPolicyDelete(policyID string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + scope, err := loadAgentPolicyScope(ctx, client) + if err != nil { + return struct{}{}, err + } + + resolved, err := resolvePolicyForAgentOps(scope, policyID) + if err != nil { + return struct{}{}, err + } + + attachedAccounts := resolved.AgentAccounts + if len(attachedAccounts) > 0 { + accountSummary := formatPolicyAgentAccounts(attachedAccounts) + return struct{}{}, common.NewUserError( + fmt.Sprintf("policy is attached to agent accounts: %s", accountSummary), + 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) + } + common.PrintSuccess("Policy deleted") + return struct{}{}, nil + }) + + return err +} diff --git a/internal/cli/agent/policy_list_get.go b/internal/cli/agent/policy_list_get.go new file mode 100644 index 0000000..fe58210 --- /dev/null +++ b/internal/cli/agent/policy_list_get.go @@ -0,0 +1,193 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newPolicyListCmd() *cobra.Command { + var ( + jsonOutput bool + allPolicies bool + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List policies for the default agent account", + Long: `List policies for the current default agent account. + +By default, this command resolves the current default grant and shows the +single policy attached to that provider=nylas account. Use --all to list every +policy referenced by a provider=nylas account. + +Examples: + nylas agent policy list + nylas agent policy list --all + nylas agent policy list --json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyList(jsonOutput, allPolicies) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + cmd.Flags().BoolVar(&allPolicies, "all", false, "List all policies referenced by provider=nylas accounts") + + return cmd +} + +func runPolicyList(jsonOutput, allPolicies bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + if allPolicies { + policies, err := client.ListPolicies(ctx) + if err != nil { + return struct{}{}, common.WrapListError("policies", err) + } + + accounts, err := client.ListAgentAccounts(ctx) + if err != nil { + return struct{}{}, common.WrapListError("agent accounts", err) + } + refsByPolicyID := buildPolicyAccountRefs(accounts) + policies = filterPoliciesWithAgentAccounts(policies, refsByPolicyID) + + if jsonOutput { + return struct{}{}, common.PrintJSON(policies) + } + + if len(policies) == 0 { + common.PrintEmptyStateWithHint("policies attached to nylas agent accounts", "Create or update a provider=nylas account with a policy_id to see it here") + return struct{}{}, nil + } + + _, _ = common.BoldWhite.Printf("Policies (%d)\n\n", len(policies)) + for i, policy := range policies { + printPolicySummary(policy, i, refsByPolicyID[policy.ID]) + } + fmt.Println() + return struct{}{}, nil + } + + grantID, err := common.GetGrantID(nil) + if err != nil { + return struct{}{}, common.WrapGetError("default grant", err) + } + + account, err := client.GetAgentAccount(ctx, grantID) + if err != nil { + if errors.Is(err, domain.ErrInvalidGrant) { + return struct{}{}, common.NewUserError( + "default grant is not a nylas agent account", + "Use 'nylas auth switch ' to select a provider=nylas account, or run 'nylas agent policy list --all'", + ) + } + return struct{}{}, common.WrapGetError("default agent account", err) + } + + policyID := strings.TrimSpace(account.Settings.PolicyID) + if policyID == "" { + if jsonOutput { + fmt.Println("[]") + return struct{}{}, nil + } + common.PrintEmptyStateWithHint( + "policy on the default agent account", + "Use 'nylas agent policy list --all' to inspect all agent-attached policies", + ) + return struct{}{}, nil + } + + policy, err := client.GetPolicy(ctx, policyID) + if err != nil { + return struct{}{}, common.WrapGetError("policy", err) + } + policies := []domain.Policy{*policy} + + if jsonOutput { + return struct{}{}, common.PrintJSON(policies) + } + + _, _ = common.BoldWhite.Printf("Policies (%d)\n\n", len(policies)) + printPolicySummary(*policy, 0, []policyAgentAccountRef{{ + GrantID: account.ID, + Email: account.Email, + }}) + fmt.Println() + return struct{}{}, nil + }) + + return err +} + +func newPolicyGetCmd() *cobra.Command { + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "get ", + Short: "Show a policy", + Long: `Show details for a single policy. + +Examples: + nylas agent policy get + nylas agent policy get --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyGet(args[0], jsonOutput) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func newPolicyReadCmd() *cobra.Command { + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "read ", + Short: "Read a policy", + Long: `Read details for a single policy. + +Examples: + nylas agent policy read + nylas agent policy read --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPolicyGet(args[0], jsonOutput) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func runPolicyGet(policyID string, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + scope, err := loadAgentPolicyScope(ctx, client) + if err != nil { + return struct{}{}, err + } + + resolved, err := resolvePolicyForAgentOps(scope, policyID) + if err != nil { + return struct{}{}, err + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(resolved.Policy) + } + + printPolicyDetails(*resolved.Policy) + return struct{}{}, nil + }) + + return err +} diff --git a/internal/cli/agent/rule.go b/internal/cli/agent/rule.go new file mode 100644 index 0000000..bb6412e --- /dev/null +++ b/internal/cli/agent/rule.go @@ -0,0 +1,551 @@ +package agent + +import ( + "cmp" + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +type rulePolicyRef struct { + PolicyID string + PolicyName string + Accounts []policyAgentAccountRef +} + +type agentPolicyScope struct { + AllPolicies []domain.Policy + AgentPolicies []domain.Policy + PolicyRefsByID map[string][]policyAgentAccountRef + NonAgentPolicyIDs map[string]struct{} +} + +type resolvedRuleScope struct { + Rule *domain.Rule + SelectedRefs []rulePolicyRef + AllAgentRefs []rulePolicyRef + AllAgentPolicies []domain.Policy + SharedOutsideAgent bool +} + +func newRuleCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rule", + Short: "Manage agent rules", + Long: `Manage rules used by policies attached to agent accounts. + +Rules are backed by the /v3/rules API. The agent namespace scopes them through +policies that are attached to provider=nylas accounts. + +Examples: + nylas agent rule list + nylas agent rule list --all + nylas agent rule read + nylas agent rule create --data-file rule.json + nylas agent rule update --name "Updated Rule" + nylas agent rule delete --yes`, + } + + cmd.AddCommand(newRuleListCmd()) + cmd.AddCommand(newRuleGetCmd()) + cmd.AddCommand(newRuleReadCmd()) + cmd.AddCommand(newRuleCreateCmd()) + cmd.AddCommand(newRuleUpdateCmd()) + cmd.AddCommand(newRuleDeleteCmd()) + + return cmd +} + +func loadAgentPolicyScope(ctx context.Context, client ports.NylasClient) (*agentPolicyScope, error) { + policies, err := client.ListPolicies(ctx) + if err != nil { + return nil, common.WrapListError("policies", err) + } + + accounts, err := client.ListAgentAccounts(ctx) + if err != nil { + return nil, common.WrapListError("agent accounts", err) + } + + 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), + }, nil +} + +func resolveDefaultAgentAccount(ctx context.Context, client ports.NylasClient) (*domain.AgentAccount, error) { + grantID, err := common.GetGrantID(nil) + if err != nil { + return nil, common.WrapGetError("default grant", err) + } + + account, err := client.GetAgentAccount(ctx, grantID) + if err != nil { + if errors.Is(err, domain.ErrInvalidGrant) { + return nil, common.NewUserError( + "default grant is not a nylas agent account", + "Use 'nylas auth switch ' to select a provider=nylas account", + ) + } + return nil, common.WrapGetError("default agent account", err) + } + + return account, nil +} + +func resolveAgentPolicy(ctx context.Context, client ports.NylasClient, policyID string) (*domain.Policy, []policyAgentAccountRef, error) { + policyID = strings.TrimSpace(policyID) + if policyID != "" { + scope, err := loadAgentPolicyScope(ctx, client) + if err != nil { + return nil, nil, err + } + + policy := findPolicyByID(scope.AgentPolicies, policyID) + if policy == nil { + return nil, nil, common.NewUserError( + "policy is not attached to a nylas agent account", + "Use 'nylas agent policy list --all' to inspect provider=nylas policies", + ) + } + + return policy, scope.PolicyRefsByID[policyID], nil + } + + account, err := resolveDefaultAgentAccount(ctx, client) + if err != nil { + return nil, nil, err + } + + defaultPolicyID := strings.TrimSpace(account.Settings.PolicyID) + if defaultPolicyID == "" { + return nil, nil, common.NewUserError( + "default agent account does not have a policy", + "Pass --policy-id or attach a policy to the active provider=nylas account first", + ) + } + + policy, err := client.GetPolicy(ctx, defaultPolicyID) + if err != nil { + return nil, nil, common.WrapGetError("policy", err) + } + + return policy, []policyAgentAccountRef{{ + GrantID: account.ID, + Email: account.Email, + }}, nil +} + +func findPolicyByID(policies []domain.Policy, policyID string) *domain.Policy { + for i := range policies { + if policies[i].ID == policyID { + return &policies[i] + } + } + return nil +} + +func buildRuleRefsByID(policies []domain.Policy, refsByPolicyID map[string][]policyAgentAccountRef) map[string][]rulePolicyRef { + refsByRuleID := make(map[string][]rulePolicyRef) + for _, policy := range policies { + accounts := refsByPolicyID[policy.ID] + if len(accounts) == 0 { + continue + } + + seen := make(map[string]struct{}, len(policy.Rules)) + for _, ruleID := range policy.Rules { + ruleID = strings.TrimSpace(ruleID) + if ruleID == "" { + continue + } + if _, ok := seen[ruleID]; ok { + continue + } + seen[ruleID] = struct{}{} + + accountRefs := make([]policyAgentAccountRef, len(accounts)) + copy(accountRefs, accounts) + + refsByRuleID[ruleID] = append(refsByRuleID[ruleID], rulePolicyRef{ + PolicyID: policy.ID, + PolicyName: policy.Name, + Accounts: accountRefs, + }) + } + } + + for ruleID, refs := range refsByRuleID { + slices.SortFunc(refs, func(a, b rulePolicyRef) int { + if c := cmp.Compare(strings.ToLower(a.PolicyName), strings.ToLower(b.PolicyName)); c != 0 { + return c + } + return cmp.Compare(a.PolicyID, b.PolicyID) + }) + refsByRuleID[ruleID] = refs + } + + return refsByRuleID +} + +func filterRulesWithAgentPolicies(rules []domain.Rule, refsByRuleID map[string][]rulePolicyRef) []domain.Rule { + filtered := make([]domain.Rule, 0, len(rules)) + for _, rule := range rules { + if len(refsByRuleID[rule.ID]) == 0 { + continue + } + filtered = append(filtered, rule) + } + return filtered +} + +func resolveScopedRule(ctx context.Context, client ports.NylasClient, ruleID, policyID string, all bool) (*resolvedRuleScope, error) { + scope, err := loadAgentPolicyScope(ctx, client) + if err != nil { + return nil, err + } + + refsByRuleID := buildRuleRefsByID(scope.AgentPolicies, scope.PolicyRefsByID) + allRefs := refsByRuleID[ruleID] + if len(allRefs) == 0 { + return nil, common.NewUserError( + "rule is not attached to a nylas agent policy", + "Use 'nylas agent rule list --all' to inspect provider=nylas rules", + ) + } + + selectedRefs := allRefs + if !all { + targetPolicy, _, err := resolveAgentPolicyFromScope(ctx, client, scope, policyID) + if err != nil { + return nil, err + } + + selectedRefs = filterRuleRefsByPolicyID(allRefs, targetPolicy.ID) + if len(selectedRefs) == 0 { + return nil, common.NewUserError( + "rule is not attached to the selected policy", + "Use 'nylas agent rule list --all' to inspect all agent-scoped rules", + ) + } + } + + rule, err := client.GetRule(ctx, ruleID) + if err != nil { + return nil, common.WrapGetError("rule", err) + } + + return &resolvedRuleScope{ + Rule: rule, + SelectedRefs: selectedRefs, + AllAgentRefs: allRefs, + AllAgentPolicies: scope.AgentPolicies, + SharedOutsideAgent: ruleReferencedOutsideAgentScope(scope.AllPolicies, scope.AgentPolicies, scope.NonAgentPolicyIDs, ruleID), + }, nil +} + +func filterRuleRefsByPolicyID(refs []rulePolicyRef, policyID string) []rulePolicyRef { + filtered := make([]rulePolicyRef, 0, len(refs)) + for _, ref := range refs { + if ref.PolicyID == policyID { + filtered = append(filtered, ref) + } + } + return filtered +} + +func ruleReferencedOutsideAgentScope(allPolicies, agentPolicies []domain.Policy, nonAgentPolicyIDs map[string]struct{}, ruleID string) bool { + agentPolicyIDs := make(map[string]struct{}, len(agentPolicies)) + for _, policy := range agentPolicies { + agentPolicyIDs[policy.ID] = struct{}{} + } + + for _, policy := range allPolicies { + if !policyContainsRule(policy, ruleID) { + continue + } + if _, ok := agentPolicyIDs[policy.ID]; !ok { + return true + } + if _, ok := nonAgentPolicyIDs[policy.ID]; ok { + return true + } + } + + return false +} + +func policyContainsRule(policy domain.Policy, ruleID string) bool { + for _, candidate := range policy.Rules { + if strings.TrimSpace(candidate) == ruleID { + return true + } + } + return false +} + +func appendUniqueString(items []string, value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return append([]string(nil), items...) + } + + updated := append([]string(nil), items...) + if !slices.Contains(updated, value) { + updated = append(updated, value) + } + return updated +} + +func removeString(items []string, value string) []string { + value = strings.TrimSpace(value) + filtered := make([]string, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item) == value { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func policiesLeftEmptyByRuleRemoval(policies []domain.Policy, ruleID string) []domain.Policy { + blocking := make([]domain.Policy, 0) + for _, policy := range policies { + if !policyContainsRule(policy, ruleID) { + continue + } + if len(removeString(policy.Rules, ruleID)) == 0 { + blocking = append(blocking, policy) + } + } + return blocking +} + +func attachRuleToPolicy(ctx context.Context, client ports.NylasClient, policy domain.Policy, ruleID string) error { + updatedRules := appendUniqueString(policy.Rules, ruleID) + if slices.Equal(updatedRules, policy.Rules) { + return nil + } + + _, err := client.UpdatePolicy(ctx, policy.ID, map[string]any{"rules": updatedRules}) + return err +} + +func detachRuleFromPolicies(ctx context.Context, client ports.NylasClient, policies []domain.Policy, ruleID string) (func(context.Context) error, error) { + originalRulesByPolicyID := make(map[string][]string) + updatedPolicyIDs := make([]string, 0) + + for _, policy := range policies { + if !policyContainsRule(policy, ruleID) { + continue + } + + originalRulesByPolicyID[policy.ID] = append([]string(nil), policy.Rules...) + updatedRules := removeString(policy.Rules, ruleID) + if _, err := client.UpdatePolicy(ctx, policy.ID, map[string]any{"rules": updatedRules}); err != nil { + if rollbackErr := rollbackPolicyRuleUpdates(ctx, client, originalRulesByPolicyID, updatedPolicyIDs); rollbackErr != nil { + return nil, fmt.Errorf("failed to detach rule from policy %s: %w (rollback failed: %v)", policy.ID, err, rollbackErr) + } + return nil, err + } + updatedPolicyIDs = append(updatedPolicyIDs, policy.ID) + } + + return func(ctx context.Context) error { + return rollbackPolicyRuleUpdates(ctx, client, originalRulesByPolicyID, updatedPolicyIDs) + }, nil +} + +func rollbackPolicyRuleUpdates(ctx context.Context, client ports.NylasClient, originalRulesByPolicyID map[string][]string, updatedPolicyIDs []string) error { + var failures []string + for _, policyID := range updatedPolicyIDs { + if _, err := client.UpdatePolicy(ctx, policyID, map[string]any{"rules": originalRulesByPolicyID[policyID]}); err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", policyID, err)) + } + } + + if len(failures) > 0 { + return fmt.Errorf("failed to rollback policy updates: %s", strings.Join(failures, "; ")) + } + return nil +} + +func printRuleSummary(rule domain.Rule, index int, refs []rulePolicyRef) { + fmt.Printf("%d. %-32s %s\n", index+1, common.Cyan.Sprint(rule.Name), common.Dim.Sprint(rule.ID)) + if !rule.UpdatedAt.IsZero() { + _, _ = common.Dim.Printf(" Updated: %s\n", common.FormatTimeAgo(rule.UpdatedAt.Time)) + } + for _, ref := range refs { + _, _ = common.Dim.Printf(" Policy: %s (%s)\n", ref.PolicyName, ref.PolicyID) + for _, account := range ref.Accounts { + _, _ = common.Dim.Printf(" Agent: %s (%s)\n", account.Email, account.GrantID) + } + } +} + +func printRuleDetails(rule domain.Rule, refs []rulePolicyRef) { + fmt.Printf("Rule: %s\n", rule.Name) + fmt.Printf("ID: %s\n", rule.ID) + if rule.Description != "" { + fmt.Printf("Description: %s\n", rule.Description) + } + if rule.Priority != nil { + fmt.Printf("Priority: %d\n", *rule.Priority) + } + if rule.Enabled != nil { + fmt.Printf("Enabled: %t\n", *rule.Enabled) + } + if rule.Trigger != "" { + fmt.Printf("Trigger: %s\n", rule.Trigger) + } + if rule.ApplicationID != "" { + fmt.Printf("Application: %s\n", rule.ApplicationID) + } + if rule.OrganizationID != "" { + fmt.Printf("Organization: %s\n", rule.OrganizationID) + } + if !rule.CreatedAt.IsZero() { + fmt.Printf("Created: %s (%s)\n", rule.CreatedAt.Format(common.DisplayDateTime), common.FormatTimeAgo(rule.CreatedAt.Time)) + } + if !rule.UpdatedAt.IsZero() { + fmt.Printf("Updated: %s (%s)\n", rule.UpdatedAt.Format(common.DisplayDateTime), common.FormatTimeAgo(rule.UpdatedAt.Time)) + } + + printRuleRefsSection(refs) + printRuleMatchSection(rule.Match) + printRuleActionsSection(rule.Actions) + fmt.Println() +} + +func printRuleRefsSection(refs []rulePolicyRef) { + printPolicySectionHeader("Policies") + if len(refs) == 0 { + fmt.Println(" none") + return + } + + for _, ref := range refs { + printPolicyField("Policy", fmt.Sprintf("%s (%s)", ref.PolicyName, ref.PolicyID)) + if len(ref.Accounts) == 0 { + continue + } + for _, account := range ref.Accounts { + printPolicyField("Agent", fmt.Sprintf("%s (%s)", account.Email, account.GrantID)) + } + } +} + +func printRuleMatchSection(match *domain.RuleMatch) { + printPolicySectionHeader("Match") + if match == nil { + fmt.Println(" none") + return + } + + if match.Operator != "" { + printPolicyField("Operator", match.Operator) + } + if len(match.Conditions) == 0 { + fmt.Println(" Conditions: none") + return + } + + fmt.Println(" Conditions:") + for i, condition := range match.Conditions { + fmt.Printf(" %d. %s %s %s\n", i+1, condition.Field, condition.Operator, formatRuleValue(condition.Value)) + } +} + +func printRuleActionsSection(actions []domain.RuleAction) { + printPolicySectionHeader("Actions") + if len(actions) == 0 { + fmt.Println(" none") + return + } + + for i, action := range actions { + if action.Value == nil { + fmt.Printf(" %d. %s\n", i+1, action.Type) + continue + } + fmt.Printf(" %d. %s => %s\n", i+1, action.Type, formatRuleValue(action.Value)) + } +} + +func formatRuleValue(value any) string { + switch v := value.(type) { + case nil: + return "none" + case string: + return v + case []string: + return strings.Join(v, ", ") + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + parts = append(parts, formatRuleValue(item)) + } + return strings.Join(parts, ", ") + default: + return fmt.Sprintf("%v", v) + } +} + +func resolveAgentPolicyFromScope(ctx context.Context, client ports.NylasClient, scope *agentPolicyScope, policyID string) (*domain.Policy, []policyAgentAccountRef, error) { + policyID = strings.TrimSpace(policyID) + if policyID != "" { + policy := findPolicyByID(scope.AgentPolicies, policyID) + if policy == nil { + return nil, nil, common.NewUserError( + "policy is not attached to a nylas agent account", + "Use 'nylas agent policy list --all' to inspect provider=nylas policies", + ) + } + + return policy, scope.PolicyRefsByID[policyID], nil + } + + account, err := resolveDefaultAgentAccount(ctx, client) + if err != nil { + return nil, nil, err + } + + defaultPolicyID := strings.TrimSpace(account.Settings.PolicyID) + if defaultPolicyID == "" { + return nil, nil, common.NewUserError( + "default agent account does not have a policy", + "Pass --policy-id or attach a policy to the active provider=nylas account first", + ) + } + + policy := findPolicyByID(scope.AgentPolicies, defaultPolicyID) + if policy == nil { + return nil, nil, common.NewUserError( + "default agent account policy is not attached to a nylas agent account", + "Use 'nylas agent policy list --all' to inspect provider=nylas policies", + ) + } + + return policy, []policyAgentAccountRef{{ + GrantID: account.ID, + Email: account.Email, + }}, nil +} diff --git a/internal/cli/agent/rule_create_update_delete.go b/internal/cli/agent/rule_create_update_delete.go new file mode 100644 index 0000000..b479018 --- /dev/null +++ b/internal/cli/agent/rule_create_update_delete.go @@ -0,0 +1,298 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newRuleCreateCmd() *cobra.Command { + var ( + data string + dataFile string + policyID string + jsonOutput bool + opts rulePayloadOptions + enableRule bool + disableRule bool + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a rule", + Long: `Create a new rule and attach it to an agent policy. + +Rules are created through /v3/rules, then attached to the selected policy. If +--policy-id is omitted, the CLI uses the policy attached to the current +default provider=nylas grant. + +Examples: + nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action mark_as_spam + nylas agent rule create --name "VIP sender" --condition from.address,is,ceo@example.com --action mark_as_read --action mark_as_starred + nylas agent rule create --data-file rule.json + nylas agent rule create --data-file rule.json --policy-id `, + RunE: func(cmd *cobra.Command, args []string) error { + opts.PrioritySet = cmd.Flags().Changed("priority") + if err := assignRuleStateFlags(cmd, enableRule, disableRule, &opts); err != nil { + return err + } + + payload, err := loadRulePayload(data, dataFile, opts, true) + if err != nil { + return err + } + return runRuleCreate(payload, policyID, jsonOutput) + }, + } + + cmd.Flags().StringVar(&opts.Name, "name", "", "Rule name") + cmd.Flags().StringVar(&opts.Description, "description", "", "Rule description") + cmd.Flags().IntVar(&opts.Priority, "priority", 0, "Rule priority") + cmd.Flags().BoolVar(&enableRule, "enabled", false, "Create the rule in an enabled state") + cmd.Flags().BoolVar(&disableRule, "disabled", false, "Create the rule in a disabled state") + cmd.Flags().StringVar(&opts.Trigger, "trigger", "", "Rule trigger (defaults to inbound when using flags)") + cmd.Flags().StringVar(&opts.MatchOperator, "match-operator", "", "Match operator for the supplied conditions") + cmd.Flags().StringArrayVar(&opts.Conditions, "condition", nil, "Match condition as field,operator,value (repeatable)") + cmd.Flags().StringArrayVar(&opts.Actions, "action", nil, "Rule action as type or type=value (repeatable)") + cmd.Flags().StringVar(&data, "data", "", "Inline JSON request body") + cmd.Flags().StringVar(&dataFile, "data-file", "", "Path to a JSON request body file") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to attach the created rule to") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func runRuleCreate(payload map[string]any, policyID string, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + policy, accounts, err := resolveAgentPolicy(ctx, client, policyID) + if err != nil { + return struct{}{}, err + } + + rule, err := client.CreateRule(ctx, payload) + if err != nil { + return struct{}{}, common.WrapCreateError("rule", err) + } + + if err := attachRuleToPolicy(ctx, client, *policy, rule.ID); err != nil { + cleanupErr := client.DeleteRule(ctx, rule.ID) + if cleanupErr != nil { + return struct{}{}, fmt.Errorf("failed to attach rule to policy: %w (cleanup failed: %v)", err, cleanupErr) + } + return struct{}{}, fmt.Errorf("failed to attach rule to policy: %w", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(rule) + } + + printSuccess("Rule created successfully!") + fmt.Println() + printRuleDetails(*rule, []rulePolicyRef{{ + PolicyID: policy.ID, + PolicyName: policy.Name, + Accounts: accounts, + }}) + return struct{}{}, nil + }) + + return err +} + +func newRuleUpdateCmd() *cobra.Command { + var ( + data string + dataFile string + policyID string + allRules bool + jsonOutput bool + opts rulePayloadOptions + enableRule bool + disableRule bool + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a rule", + Long: `Update an existing rule. + +By default, this validates that the rule belongs to the current default +provider=nylas policy. Use --policy-id to scope the validation to another +agent policy, or --all to search any agent policy. + +Examples: + nylas agent rule update --name "Updated Rule" + nylas agent rule update --description "Block example.org" --priority 20 + nylas agent rule update --condition from.domain,is,example.org --action mark_as_spam + nylas agent rule update --data-file update.json + nylas agent rule update --all --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if allRules && policyID != "" { + return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") + } + + opts.PrioritySet = cmd.Flags().Changed("priority") + if err := assignRuleStateFlags(cmd, enableRule, disableRule, &opts); err != nil { + return err + } + + payload, err := loadRulePayload(data, dataFile, opts, false) + if err != nil { + return err + } + if len(payload) == 0 { + return common.NewUserError( + "rule update requires at least one field", + "Use flags like --name/--condition/--action, or provide JSON with --data/--data-file", + ) + } + return runRuleUpdate(args[0], payload, policyID, allRules, jsonOutput) + }, + } + + cmd.Flags().StringVar(&opts.Name, "name", "", "Updated rule name") + cmd.Flags().StringVar(&opts.Description, "description", "", "Updated rule description") + cmd.Flags().IntVar(&opts.Priority, "priority", 0, "Updated rule priority") + cmd.Flags().BoolVar(&enableRule, "enabled", false, "Set the rule to enabled") + cmd.Flags().BoolVar(&disableRule, "disabled", false, "Set the rule to disabled") + cmd.Flags().StringVar(&opts.Trigger, "trigger", "", "Updated rule trigger") + cmd.Flags().StringVar(&opts.MatchOperator, "match-operator", "", "Updated match operator") + cmd.Flags().StringArrayVar(&opts.Conditions, "condition", nil, "Replace conditions with field,operator,value entries (repeatable)") + cmd.Flags().StringArrayVar(&opts.Actions, "action", nil, "Replace actions with type or type=value entries (repeatable)") + cmd.Flags().StringVar(&data, "data", "", "Inline JSON request body") + cmd.Flags().StringVar(&dataFile, "data-file", "", "Path to a JSON request body file") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the update to") + cmd.Flags().BoolVar(&allRules, "all", false, "Search across all provider=nylas policies") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func runRuleUpdate(ruleID string, payload map[string]any, policyID string, allRules, jsonOutput bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + scope, err := resolveScopedRule(ctx, client, ruleID, policyID, allRules) + if err != nil { + return struct{}{}, err + } + if scope.SharedOutsideAgent { + return struct{}{}, common.NewUserError( + "rule is shared with a non-agent policy", + "Use the generic policy/rule surface to modify shared rules safely", + ) + } + + preserveRuleMatchOperator(payload, scope.Rule) + + rule, err := client.UpdateRule(ctx, ruleID, payload) + if err != nil { + return struct{}{}, common.WrapUpdateError("rule", err) + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(rule) + } + + common.PrintUpdateSuccess("rule", rule.Name) + fmt.Println() + printRuleDetails(*rule, scope.SelectedRefs) + return struct{}{}, nil + }) + + return err +} + +func newRuleDeleteCmd() *cobra.Command { + var ( + yes bool + policyID string + allRules bool + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a rule", + Long: `Delete a rule and detach it from agent policies. + +Examples: + nylas agent rule delete --yes + nylas agent rule delete --all --yes`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !yes { + return common.NewUserError("deletion requires confirmation", "Re-run with --yes to delete the rule") + } + if allRules && policyID != "" { + return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") + } + return runRuleDelete(args[0], policyID, allRules) + }, + } + + common.AddYesFlag(cmd, &yes) + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the delete to") + cmd.Flags().BoolVar(&allRules, "all", false, "Search across all provider=nylas policies") + + return cmd +} + +func runRuleDelete(ruleID, policyID string, allRules bool) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + scope, err := resolveScopedRule(ctx, client, ruleID, policyID, allRules) + if err != nil { + return struct{}{}, err + } + if scope.SharedOutsideAgent { + return struct{}{}, common.NewUserError( + "rule is shared with a non-agent policy", + "Use the generic policy/rule surface to delete shared rules safely", + ) + } + if blockingPolicies := policiesLeftEmptyByRuleRemoval(scope.AllAgentPolicies, ruleID); len(blockingPolicies) > 0 { + policyNames := make([]string, 0, len(blockingPolicies)) + for _, policy := range blockingPolicies { + policyNames = append(policyNames, policy.Name) + } + return struct{}{}, common.NewUserError( + "cannot delete the last rule from an agent policy", + fmt.Sprintf("Attach another rule to %s before deleting %q", strings.Join(policyNames, ", "), scope.Rule.Name), + ) + } + + rollback, err := detachRuleFromPolicies(ctx, client, scope.AllAgentPolicies, ruleID) + if err != nil { + return struct{}{}, fmt.Errorf("failed to detach rule from agent policies: %w", err) + } + + if err := client.DeleteRule(ctx, ruleID); err != nil { + if rollbackErr := rollback(ctx); rollbackErr != nil { + return struct{}{}, fmt.Errorf("failed to delete rule: %w (rollback failed: %v)", err, rollbackErr) + } + return struct{}{}, common.WrapDeleteError("rule", err) + } + + common.PrintSuccess("Rule deleted") + return struct{}{}, nil + }) + + return err +} + +func assignRuleStateFlags(cmd *cobra.Command, enableRule, disableRule bool, opts *rulePayloadOptions) error { + enabledChanged := cmd.Flags().Changed("enabled") + disabledChanged := cmd.Flags().Changed("disabled") + if enabledChanged && !enableRule { + return common.NewUserError("invalid --enabled value", "Use --enabled or omit the flag") + } + if disabledChanged && !disableRule { + return common.NewUserError("invalid --disabled value", "Use --disabled or omit the flag") + } + + opts.EnabledSet = enableRule + opts.DisabledSet = disableRule + return nil +} diff --git a/internal/cli/agent/rule_list_get.go b/internal/cli/agent/rule_list_get.go new file mode 100644 index 0000000..1057ce5 --- /dev/null +++ b/internal/cli/agent/rule_list_get.go @@ -0,0 +1,240 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newRuleListCmd() *cobra.Command { + var ( + jsonOutput bool + allRules bool + policyID string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List rules for the default agent policy", + Long: `List rules for the current default agent policy. + +By default, this command resolves the current default grant and lists the rules +attached to that provider=nylas account's policy. Use --policy-id to inspect a +specific agent policy, or --all to list every rule reachable from any +provider=nylas account policy. + +Examples: + nylas agent rule list + nylas agent rule list --policy-id + nylas agent rule list --all + nylas agent rule list --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if allRules && policyID != "" { + return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") + } + return runRuleList(jsonOutput, allRules, policyID) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + cmd.Flags().BoolVar(&allRules, "all", false, "List all rules reachable from provider=nylas policies") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the rule list to") + + return cmd +} + +func runRuleList(jsonOutput, allRules bool, policyID string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + if allRules { + scope, err := loadAgentPolicyScope(ctx, client) + if err != nil { + return struct{}{}, err + } + + refsByRuleID := buildRuleRefsByID(scope.AgentPolicies, scope.PolicyRefsByID) + if len(refsByRuleID) == 0 { + if jsonOutput { + fmt.Println("[]") + return struct{}{}, nil + } + common.PrintEmptyStateWithHint("rules attached to nylas agent policies", "Create a rule and attach it to a provider=nylas policy to see it here") + return struct{}{}, nil + } + + rules, err := client.ListRules(ctx) + if err != nil { + return struct{}{}, common.WrapListError("rules", err) + } + rules = filterRulesWithAgentPolicies(rules, refsByRuleID) + + if jsonOutput { + return struct{}{}, common.PrintJSON(rules) + } + + _, _ = common.BoldWhite.Printf("Rules (%d)\n\n", len(rules)) + for i, rule := range rules { + printRuleSummary(rule, i, refsByRuleID[rule.ID]) + } + fmt.Println() + return struct{}{}, nil + } + + scope, err := loadAgentPolicyScope(ctx, client) + if err != nil { + return struct{}{}, err + } + + policy, accounts, err := resolveAgentPolicyFromScope(ctx, client, scope, policyID) + if err != nil { + return struct{}{}, err + } + + ruleIDs := make([]string, 0, len(policy.Rules)) + for _, ruleID := range policy.Rules { + ruleID = strings.TrimSpace(ruleID) + if ruleID == "" { + continue + } + ruleIDs = append(ruleIDs, ruleID) + } + + if len(ruleIDs) == 0 { + if jsonOutput { + fmt.Println("[]") + return struct{}{}, nil + } + common.PrintEmptyStateWithHint("rules on the selected agent policy", "Use 'nylas agent rule create --data-file rule.json' to add one") + return struct{}{}, nil + } + + allRulesList, err := client.ListRules(ctx) + if err != nil { + return struct{}{}, common.WrapListError("rules", err) + } + rulesByID := make(map[string]domain.Rule, len(allRulesList)) + for _, rule := range allRulesList { + rulesByID[rule.ID] = rule + } + + rules := make([]domain.Rule, 0, len(ruleIDs)) + ruleRefs := make(map[string][]rulePolicyRef, len(ruleIDs)) + for _, ruleID := range ruleIDs { + rule, ok := rulesByID[ruleID] + if !ok { + return struct{}{}, common.WrapGetError("rule", domain.ErrRuleNotFound) + } + rules = append(rules, rule) + ruleRefs[rule.ID] = []rulePolicyRef{{ + PolicyID: policy.ID, + PolicyName: policy.Name, + Accounts: accounts, + }} + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(rules) + } + + _, _ = common.BoldWhite.Printf("Rules (%d)\n\n", len(rules)) + for i, rule := range rules { + printRuleSummary(rule, i, ruleRefs[rule.ID]) + } + fmt.Println() + return struct{}{}, nil + }) + + return err +} + +func newRuleGetCmd() *cobra.Command { + var ( + jsonOutput bool + allRules bool + policyID string + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Show a rule", + Long: `Show details for a single rule. + +By default, this validates that the rule is attached to the current default +agent policy. Use --policy-id to scope the lookup to another provider=nylas +policy, or --all to search any provider=nylas policy. + +Examples: + nylas agent rule get + nylas agent rule get --policy-id + nylas agent rule get --all + nylas agent rule get --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if allRules && policyID != "" { + return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") + } + return runRuleGet(args[0], jsonOutput, allRules, policyID) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + cmd.Flags().BoolVar(&allRules, "all", false, "Search across all provider=nylas policies") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the rule lookup to") + + return cmd +} + +func newRuleReadCmd() *cobra.Command { + var ( + jsonOutput bool + allRules bool + policyID string + ) + + cmd := &cobra.Command{ + Use: "read ", + Short: "Read a rule", + Long: `Read details for a single rule. + +Examples: + nylas agent rule read + nylas agent rule read --policy-id + nylas agent rule read --all + nylas agent rule read --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if allRules && policyID != "" { + return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") + } + return runRuleGet(args[0], jsonOutput, allRules, policyID) + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + cmd.Flags().BoolVar(&allRules, "all", false, "Search across all provider=nylas policies") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the rule lookup to") + + return cmd +} + +func runRuleGet(ruleID string, jsonOutput, allRules bool, policyID string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + scope, err := resolveScopedRule(ctx, client, ruleID, policyID, allRules) + if err != nil { + return struct{}{}, err + } + + if jsonOutput { + return struct{}{}, common.PrintJSON(scope.Rule) + } + + printRuleDetails(*scope.Rule, scope.SelectedRefs) + return struct{}{}, nil + }) + + return err +} diff --git a/internal/cli/agent/rule_payload.go b/internal/cli/agent/rule_payload.go new file mode 100644 index 0000000..f5091d5 --- /dev/null +++ b/internal/cli/agent/rule_payload.go @@ -0,0 +1,295 @@ +package agent + +import ( + "fmt" + "reflect" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" +) + +type rulePayloadOptions struct { + Name string + Description string + Priority int + PrioritySet bool + EnabledSet bool + DisabledSet bool + Trigger string + MatchOperator string + Conditions []string + Actions []string +} + +func (o rulePayloadOptions) hasFlagInput() bool { + return strings.TrimSpace(o.Name) != "" || + strings.TrimSpace(o.Description) != "" || + o.PrioritySet || + o.EnabledSet || + o.DisabledSet || + strings.TrimSpace(o.Trigger) != "" || + strings.TrimSpace(o.MatchOperator) != "" || + len(o.Conditions) > 0 || + len(o.Actions) > 0 +} + +func loadRulePayload(data, dataFile string, opts rulePayloadOptions, requireBody bool) (map[string]any, error) { + payload, err := common.ReadJSONStringMap(data, dataFile) + if err != nil { + return nil, err + } + + if opts.EnabledSet && opts.DisabledSet { + return nil, common.NewUserError( + "cannot combine --enabled with --disabled", + "Use only one of the two flags", + ) + } + + usingJSON := strings.TrimSpace(data) != "" || strings.TrimSpace(dataFile) != "" + if requireBody && !usingJSON && !opts.hasFlagInput() { + return nil, common.NewUserError( + "rule create requires a rule definition", + "Use --condition/--action for the common case or --data/--data-file for full JSON", + ) + } + + if strings.TrimSpace(opts.Name) != "" { + payload["name"] = strings.TrimSpace(opts.Name) + } + if strings.TrimSpace(opts.Description) != "" { + payload["description"] = strings.TrimSpace(opts.Description) + } + if opts.PrioritySet { + payload["priority"] = opts.Priority + } + if opts.EnabledSet { + payload["enabled"] = true + } + if opts.DisabledSet { + payload["enabled"] = false + } + if strings.TrimSpace(opts.Trigger) != "" { + payload["trigger"] = strings.TrimSpace(opts.Trigger) + } + + if strings.TrimSpace(opts.MatchOperator) != "" || len(opts.Conditions) > 0 { + matchPayload, err := mergeRuleMatchPayload(payload["match"], opts) + if err != nil { + return nil, err + } + payload["match"] = matchPayload + } + + if len(opts.Actions) > 0 { + actions, err := parseRuleActions(opts.Actions) + if err != nil { + return nil, err + } + payload["actions"] = actions + } + + if requireBody && !usingJSON { + if _, ok := payload["enabled"]; !ok { + payload["enabled"] = true + } + if strings.TrimSpace(asString(payload["trigger"])) == "" { + payload["trigger"] = "inbound" + } + if err := applyDefaultMatchOperator(payload); err != nil { + return nil, err + } + if err := validateFlagBuiltRuleCreatePayload(payload); err != nil { + return nil, err + } + } + + return payload, nil +} + +func mergeRuleMatchPayload(existing any, opts rulePayloadOptions) (map[string]any, error) { + matchPayload := copyStringAnyMap(existing) + + if strings.TrimSpace(opts.MatchOperator) != "" { + matchPayload["operator"] = strings.TrimSpace(opts.MatchOperator) + } + if len(opts.Conditions) > 0 { + conditions, err := parseRuleConditions(opts.Conditions) + if err != nil { + return nil, err + } + matchPayload["conditions"] = conditions + } + + return matchPayload, nil +} + +func parseRuleConditions(rawConditions []string) ([]domain.RuleCondition, error) { + conditions := make([]domain.RuleCondition, 0, len(rawConditions)) + for _, raw := range rawConditions { + field, remainder, ok := strings.Cut(raw, ",") + if !ok { + return nil, invalidRuleConditionError(raw) + } + + operator, value, ok := strings.Cut(remainder, ",") + if !ok { + return nil, invalidRuleConditionError(raw) + } + + field = strings.TrimSpace(field) + operator = strings.TrimSpace(operator) + value = strings.TrimSpace(value) + if field == "" || operator == "" || value == "" { + return nil, invalidRuleConditionError(raw) + } + + conditions = append(conditions, domain.RuleCondition{ + Field: field, + Operator: operator, + Value: parseRuleValue(value), + }) + } + + return conditions, nil +} + +func parseRuleActions(rawActions []string) ([]domain.RuleAction, error) { + actions := make([]domain.RuleAction, 0, len(rawActions)) + for _, raw := range rawActions { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, invalidRuleActionError(raw) + } + + actionType, actionValue, hasValue := strings.Cut(raw, "=") + actionType = strings.TrimSpace(actionType) + actionValue = strings.TrimSpace(actionValue) + if actionType == "" { + return nil, invalidRuleActionError(raw) + } + + action := domain.RuleAction{Type: actionType} + if hasValue && actionValue != "" { + action.Value = parseRuleValue(actionValue) + } + + actions = append(actions, action) + } + + return actions, nil +} + +func parseRuleValue(raw string) any { + return raw +} + +func preserveRuleMatchOperator(payload map[string]any, existingRule *domain.Rule) { + if existingRule == nil || existingRule.Match == nil { + return + } + + matchPayload := copyStringAnyMap(payload["match"]) + if len(matchPayload) == 0 { + return + } + if strings.TrimSpace(asString(matchPayload["operator"])) != "" { + return + } + if sliceLen(matchPayload["conditions"]) == 0 { + return + } + + existingOperator := strings.TrimSpace(existingRule.Match.Operator) + if existingOperator == "" { + return + } + + matchPayload["operator"] = existingOperator + payload["match"] = matchPayload +} + +func applyDefaultMatchOperator(payload map[string]any) error { + matchPayload := copyStringAnyMap(payload["match"]) + if len(matchPayload) == 0 { + return nil + } + if strings.TrimSpace(asString(matchPayload["operator"])) != "" { + return nil + } + if sliceLen(matchPayload["conditions"]) == 0 { + return nil + } + matchPayload["operator"] = "all" + payload["match"] = matchPayload + return nil +} + +func validateFlagBuiltRuleCreatePayload(payload map[string]any) error { + missing := make([]string, 0, 3) + if strings.TrimSpace(asString(payload["name"])) == "" { + missing = append(missing, "--name") + } + + matchPayload := copyStringAnyMap(payload["match"]) + if sliceLen(matchPayload["conditions"]) == 0 { + missing = append(missing, "--condition") + } + if sliceLen(payload["actions"]) == 0 { + missing = append(missing, "--action") + } + + if len(missing) == 0 { + return nil + } + + return common.NewUserError( + "rule create is missing required fields", + fmt.Sprintf("Use %s, or provide a full rule body with --data/--data-file", strings.Join(missing, ", ")), + ) +} + +func copyStringAnyMap(value any) map[string]any { + existing, ok := value.(map[string]any) + if !ok { + return map[string]any{} + } + + cloned := make(map[string]any, len(existing)) + for key, entry := range existing { + cloned[key] = entry + } + return cloned +} + +func asString(value any) string { + text, _ := value.(string) + return text +} + +func sliceLen(value any) int { + if value == nil { + return 0 + } + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return 0 + } + return rv.Len() +} + +func invalidRuleConditionError(raw string) error { + return common.NewUserError( + "invalid --condition value", + fmt.Sprintf("Use field,operator,value. Got %q", raw), + ) +} + +func invalidRuleActionError(raw string) error { + return common.NewUserError( + "invalid --action value", + fmt.Sprintf("Use type or type=value. Got %q", raw), + ) +} diff --git a/internal/cli/agent/status.go b/internal/cli/agent/status.go index 038b6da..5f9f584 100644 --- a/internal/cli/agent/status.go +++ b/internal/cli/agent/status.go @@ -2,7 +2,6 @@ package agent import ( "context" - "encoding/json" "fmt" "github.com/nylas/cli/internal/cli/common" @@ -63,9 +62,7 @@ func runStatus(jsonOutput bool) error { } if jsonOutput { - data, _ := json.MarshalIndent(result, "", " ") - fmt.Println(string(data)) - return struct{}{}, nil + return struct{}{}, common.PrintJSON(result) } _, _ = common.BoldWhite.Println("Agent Status") diff --git a/internal/cli/integration/agent_policy_test.go b/internal/cli/integration/agent_policy_test.go new file mode 100644 index 0000000..c6df4c9 --- /dev/null +++ b/internal/cli/integration/agent_policy_test.go @@ -0,0 +1,471 @@ +//go:build integration +// +build integration + +package integration + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func TestCLI_AgentPolicyLifecycle_CreateGetListUpdateDelete(t *testing.T) { + skipIfMissingCreds(t) + + env := newAgentSandboxEnv(t) + name := newPolicyTestName("create") + updatedName := newPolicyTestName("updated") + var created *domain.Policy + client := getTestClient() + + t.Cleanup(func() { + if created == nil || created.ID == "" { + return + } + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeletePolicy(ctx, created.ID) + }) + + createStdout, createStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "create", "--name", name, "--json") + if err != nil { + t.Fatalf("policy create failed: %v\nstdout: %s\nstderr: %s", err, createStdout, createStderr) + } + + var policy domain.Policy + if err := json.Unmarshal([]byte(createStdout), &policy); err != nil { + t.Fatalf("failed to parse policy create JSON: %v\noutput: %s", err, createStdout) + } + if strings.TrimSpace(policy.ID) == "" { + t.Fatalf("expected created policy ID, got empty output: %s", createStdout) + } + if policy.Name != name { + t.Fatalf("created policy name = %q, want %q", policy.Name, name) + } + created = &policy + + getStdout, getStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "get", policy.ID, "--json") + if err != nil { + t.Fatalf("policy get failed: %v\nstdout: %s\nstderr: %s", err, getStdout, getStderr) + } + + var fetched domain.Policy + if err := json.Unmarshal([]byte(getStdout), &fetched); err != nil { + t.Fatalf("failed to parse policy get JSON: %v\noutput: %s", err, getStdout) + } + if fetched.ID != policy.ID { + t.Fatalf("policy get returned ID %q, want %q", fetched.ID, policy.ID) + } + + readStdout, readStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "read", policy.ID, "--json") + if err != nil { + t.Fatalf("policy read failed: %v\nstdout: %s\nstderr: %s", err, readStdout, readStderr) + } + + var readPolicy domain.Policy + if err := json.Unmarshal([]byte(readStdout), &readPolicy); err != nil { + t.Fatalf("failed to parse policy read JSON: %v\noutput: %s", err, readStdout) + } + if readPolicy.ID != policy.ID { + t.Fatalf("policy read returned ID %q, want %q", readPolicy.ID, policy.ID) + } + + readTextStdout, readTextStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "read", policy.ID) + if err != nil { + t.Fatalf("policy read text failed: %v\nstdout: %s\nstderr: %s", err, readTextStdout, readTextStderr) + } + if !strings.Contains(readTextStdout, "Limits:") { + t.Fatalf("policy read text output should include limits section\noutput: %s", readTextStdout) + } + if !strings.Contains(readTextStdout, "Options:") { + t.Fatalf("policy read text output should include options section\noutput: %s", readTextStdout) + } + if !strings.Contains(readTextStdout, "Spam detection:") { + t.Fatalf("policy read text output should include spam detection section\noutput: %s", readTextStdout) + } + + listStdout, listStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "list", "--all", "--json") + if err != nil { + t.Fatalf("policy list failed: %v\nstdout: %s\nstderr: %s", err, listStdout, listStderr) + } + + var policies []domain.Policy + if err := json.Unmarshal([]byte(listStdout), &policies); err != nil { + t.Fatalf("failed to parse policy list JSON: %v\noutput: %s", err, listStdout) + } + + for _, listed := range policies { + if listed.ID == policy.ID { + t.Fatalf("unattached policy %q should not appear in agent policy list --all\noutput: %s", policy.ID, listStdout) + } + } + + updateStdout, updateStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "update", policy.ID, "--name", updatedName, "--json") + if err != nil { + t.Fatalf("policy update failed: %v\nstdout: %s\nstderr: %s", err, updateStdout, updateStderr) + } + + var updated domain.Policy + if err := json.Unmarshal([]byte(updateStdout), &updated); err != nil { + t.Fatalf("failed to parse policy update JSON: %v\noutput: %s", err, updateStdout) + } + if updated.ID != policy.ID { + t.Fatalf("policy update returned ID %q, want %q", updated.ID, policy.ID) + } + + confirmStdout, confirmStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "get", policy.ID, "--json") + if err != nil { + t.Fatalf("policy confirm get failed: %v\nstdout: %s\nstderr: %s", err, confirmStdout, confirmStderr) + } + + var confirmed domain.Policy + if err := json.Unmarshal([]byte(confirmStdout), &confirmed); err != nil { + t.Fatalf("failed to parse policy confirm JSON: %v\noutput: %s", err, confirmStdout) + } + if confirmed.Name != updatedName { + t.Fatalf("policy name after update = %q, want %q", confirmed.Name, updatedName) + } + + deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "delete", policy.ID, "--yes") + if err != nil { + t.Fatalf("policy delete failed: %v\nstdout: %s\nstderr: %s", err, deleteStdout, deleteStderr) + } + if !strings.Contains(strings.ToLower(deleteStdout), "deleted") { + t.Fatalf("expected delete confirmation in stdout, got: %s", deleteStdout) + } + + created = nil +} + +func TestCLI_AgentPolicyCreate_RequiresNameOrData(t *testing.T) { + skipIfMissingCreds(t) + + env := newAgentSandboxEnv(t) + stdout, stderr, err := runCLIWithOverrides(30*time.Second, env, "agent", "policy", "create") + if err == nil { + t.Fatalf("expected policy create without payload to fail\nstdout: %s\nstderr: %s", stdout, stderr) + } + if !strings.Contains(strings.ToLower(stderr), "policy name is required") { + t.Fatalf("expected missing payload error, got stderr: %s", stderr) + } +} + +func TestCLI_AgentPolicyUpdate_RequiresChanges(t *testing.T) { + skipIfMissingCreds(t) + + env := newAgentSandboxEnv(t) + stdout, stderr, err := runCLIWithOverrides(30*time.Second, env, "agent", "policy", "update", "policy-id") + if err == nil { + t.Fatalf("expected policy update without changes to fail\nstdout: %s\nstderr: %s", stdout, stderr) + } + if !strings.Contains(strings.ToLower(stderr), "requires at least one field") { + t.Fatalf("expected missing update field error, got stderr: %s", stderr) + } +} + +func TestCLI_AgentPolicyList_ShowsAttachedAgentAccount(t *testing.T) { + skipIfMissingCreds(t) + skipIfMissingAgentDomain(t) + + env := newAgentSandboxEnv(t) + client := getTestClient() + email := newAgentTestEmail(t, "policy-list") + policyName := newPolicyTestName("attached") + + var createdPolicy *domain.Policy + var createdAccount *domain.AgentAccount + + t.Cleanup(func() { + if createdAccount != nil && createdAccount.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteAgentAccount(ctx, createdAccount.ID) + } + if createdPolicy != nil && createdPolicy.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeletePolicy(ctx, createdPolicy.ID) + } + }) + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + policy, err := client.CreatePolicy(ctx, map[string]any{"name": policyName}) + cancel() + if err != nil { + t.Fatalf("failed to create policy for list test: %v", err) + } + createdPolicy = policy + + createdAccount = createAgentWithPolicyForTest(t, email, createdPolicy.ID) + if exists, _ := waitForAgentByEmail(t, client, email, true); !exists { + t.Fatalf("created agent account %q did not appear in list", email) + } + + env["NYLAS_GRANT_ID"] = createdAccount.ID + + stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "list") + if err != nil { + t.Fatalf("policy list failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + if !strings.Contains(stdout, createdPolicy.Name) { + t.Fatalf("policy list output missing policy name %q\noutput: %s", createdPolicy.Name, stdout) + } + if !strings.Contains(stdout, createdAccount.Email) { + t.Fatalf("policy list output missing agent email %q\noutput: %s", createdAccount.Email, stdout) + } + if !strings.Contains(stdout, createdAccount.ID) { + t.Fatalf("policy list output missing agent grant ID %q\noutput: %s", createdAccount.ID, stdout) + } + + allStdout, allStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "list", "--all") + if err != nil { + t.Fatalf("policy list --all failed: %v\nstdout: %s\nstderr: %s", err, allStdout, allStderr) + } + if !strings.Contains(allStdout, createdPolicy.Name) { + t.Fatalf("policy list --all output missing policy name %q\noutput: %s", createdPolicy.Name, allStdout) + } + if !strings.Contains(allStdout, createdAccount.Email) { + t.Fatalf("policy list --all output missing agent email %q\noutput: %s", createdAccount.Email, allStdout) + } + if !strings.Contains(allStdout, "Agent:") { + t.Fatalf("policy list --all should include agent annotations\noutput: %s", allStdout) + } + if !strings.Contains(allStdout, fmt.Sprintf("(%s)", createdAccount.ID)) { + t.Fatalf("policy list --all output missing agent grant ID %q\noutput: %s", createdAccount.ID, allStdout) + } + if strings.Contains(allStdout, "Agent: none") { + t.Fatalf("policy list --all should not show policies without a provider=nylas account\noutput: %s", allStdout) + } +} + +func TestCLI_AgentPolicyDelete_RejectsAttachedPolicy(t *testing.T) { + skipIfMissingCreds(t) + skipIfMissingAgentDomain(t) + + env := newAgentSandboxEnv(t) + client := getTestClient() + email := newAgentTestEmail(t, "policy-delete") + policyName := newPolicyTestName("delete-guard") + + var createdPolicy *domain.Policy + var createdAccount *domain.AgentAccount + + t.Cleanup(func() { + if createdAccount != nil && createdAccount.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteAgentAccount(ctx, createdAccount.ID) + } + if createdPolicy != nil && createdPolicy.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeletePolicy(ctx, createdPolicy.ID) + } + }) + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + policy, err := client.CreatePolicy(ctx, map[string]any{"name": policyName}) + cancel() + if err != nil { + t.Fatalf("failed to create policy for delete guard test: %v", err) + } + createdPolicy = policy + + createdAccount = createAgentWithPolicyForTest(t, email, createdPolicy.ID) + if exists, _ := waitForAgentByEmail(t, client, email, true); !exists { + t.Fatalf("created agent account %q did not appear in list", email) + } + + stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "delete", createdPolicy.ID, "--yes") + if err == nil { + t.Fatalf("expected policy delete to fail while attached\nstdout: %s\nstderr: %s", stdout, stderr) + } + if !strings.Contains(strings.ToLower(stderr), "policy is attached to agent accounts") { + t.Fatalf("expected attached policy error, got stderr: %s", stderr) + } + if !strings.Contains(stderr, createdAccount.Email) { + t.Fatalf("expected attached agent email %q in stderr: %s", createdAccount.Email, stderr) + } + + acquireRateLimit(t) + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + confirmed, err := client.GetPolicy(ctx, createdPolicy.ID) + if err != nil { + t.Fatalf("expected policy to remain after rejected delete: %v", err) + } + if confirmed.ID != createdPolicy.ID { + t.Fatalf("policy after rejected delete = %q, want %q", confirmed.ID, createdPolicy.ID) + } +} + +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() + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), domain.TimeoutAPI) + defer cancel() + + client := getTestClient() + account, err := client.CreateAgentAccount(ctx, email, "", policyID) + if err != nil { + t.Fatalf("failed to create agent with policy: %v", err) + } + 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 new file mode 100644 index 0000000..d0dc154 --- /dev/null +++ b/internal/cli/integration/agent_rule_test.go @@ -0,0 +1,536 @@ +//go:build integration +// +build integration + +package integration + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { + skipIfMissingCreds(t) + skipIfMissingAgentDomain(t) + + env := newAgentSandboxEnv(t) + client := getTestClient() + email := newAgentTestEmail(t, "rule-lifecycle") + policyName := newPolicyTestName("rule-policy") + ruleName := fmt.Sprintf("it-rule-%d", time.Now().UnixNano()) + updatedRuleName := fmt.Sprintf("it-rule-updated-%d", time.Now().UnixNano()) + + var createdPolicy *domain.Policy + var createdAccount *domain.AgentAccount + var createdRule *domain.Rule + var placeholderRule *domain.Rule + + t.Cleanup(func() { + if createdPolicy != nil && createdRule != nil && createdRule.ID != "" { + removeRuleFromPolicyForTest(t, client, createdPolicy.ID, createdRule.ID) + } + if createdRule != nil && createdRule.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteRule(ctx, createdRule.ID) + } + if createdPolicy != nil && placeholderRule != nil && placeholderRule.ID != "" { + removeRuleFromPolicyForTest(t, client, createdPolicy.ID, placeholderRule.ID) + } + if placeholderRule != nil && placeholderRule.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteRule(ctx, placeholderRule.ID) + } + if createdAccount != nil && createdAccount.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteAgentAccount(ctx, createdAccount.ID) + } + if createdPolicy != nil && createdPolicy.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeletePolicy(ctx, createdPolicy.ID) + } + }) + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + policy, err := client.CreatePolicy(ctx, map[string]any{"name": policyName}) + cancel() + if err != nil { + t.Fatalf("failed to create policy for rule lifecycle: %v", err) + } + createdPolicy = policy + + createdAccount = createAgentWithPolicyForTest(t, email, createdPolicy.ID) + if exists, _ := waitForAgentByEmail(t, client, email, true); !exists { + t.Fatalf("created agent account %q did not appear in list", email) + } + env["NYLAS_GRANT_ID"] = createdAccount.ID + + placeholderRule = createRuleForTest(t, client, "it-rule-placeholder") + attachRuleToPolicyForTest(t, client, createdPolicy.ID, placeholderRule.ID) + assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, placeholderRule.ID) + + createStdout, createStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "create", + "--name", ruleName, + "--description", "Blocks example.com", + "--priority", "10", + "--disabled", + "--match-operator", "any", + "--condition", "from.domain,is,example.com", + "--condition", "from.tld,is,com", + "--action", "mark_as_spam", + "--json", + ) + if err != nil { + t.Fatalf("rule create failed: %v\nstdout: %s\nstderr: %s", err, createStdout, createStderr) + } + + var rule domain.Rule + if err := json.Unmarshal([]byte(createStdout), &rule); err != nil { + t.Fatalf("failed to parse rule create JSON: %v\noutput: %s", err, createStdout) + } + if rule.ID == "" { + t.Fatalf("expected created rule ID, got output: %s", createStdout) + } + if rule.Name != ruleName { + t.Fatalf("created rule name = %q, want %q", rule.Name, ruleName) + } + if rule.Description != "Blocks example.com" { + t.Fatalf("created rule description = %q, want %q", rule.Description, "Blocks example.com") + } + if rule.Priority == nil || *rule.Priority != 10 { + t.Fatalf("created rule priority = %v, want %d", rule.Priority, 10) + } + if rule.Enabled == nil || *rule.Enabled { + t.Fatalf("created rule enabled = %v, want false", rule.Enabled) + } + if rule.Match == nil || rule.Match.Operator != "any" { + t.Fatalf("created rule operator = %q, want %q", rule.Match.Operator, "any") + } + createdRule = &rule + + assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + + readStdout, readStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "rule", "read", createdRule.ID, "--json") + if err != nil { + t.Fatalf("rule read failed: %v\nstdout: %s\nstderr: %s", err, readStdout, readStderr) + } + + var readRule domain.Rule + if err := json.Unmarshal([]byte(readStdout), &readRule); err != nil { + t.Fatalf("failed to parse rule read JSON: %v\noutput: %s", err, readStdout) + } + if readRule.ID != createdRule.ID { + t.Fatalf("rule read returned ID %q, want %q", readRule.ID, createdRule.ID) + } + + readTextStdout, readTextStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "rule", "read", createdRule.ID) + if err != nil { + t.Fatalf("rule read text failed: %v\nstdout: %s\nstderr: %s", err, readTextStdout, readTextStderr) + } + if !strings.Contains(readTextStdout, "Match:") || !strings.Contains(readTextStdout, "Actions:") || !strings.Contains(readTextStdout, createdPolicy.Name) { + t.Fatalf("rule read text output missing expected sections\noutput: %s", readTextStdout) + } + + listStdout, listStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "rule", "list", "--json") + if err != nil { + t.Fatalf("rule list failed: %v\nstdout: %s\nstderr: %s", err, listStdout, listStderr) + } + + var listedRules []domain.Rule + if err := json.Unmarshal([]byte(listStdout), &listedRules); err != nil { + t.Fatalf("failed to parse rule list JSON: %v\noutput: %s", err, listStdout) + } + foundCreatedRule := false + for _, listedRule := range listedRules { + if listedRule.ID == createdRule.ID { + foundCreatedRule = true + break + } + } + if !foundCreatedRule { + t.Fatalf("rule list did not return the created rule\noutput: %s", listStdout) + } + + allStdout, allStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "rule", "list", "--all") + if err != nil { + t.Fatalf("rule list --all failed: %v\nstdout: %s\nstderr: %s", err, allStdout, allStderr) + } + if !strings.Contains(allStdout, createdRule.Name) || !strings.Contains(allStdout, createdPolicy.Name) || !strings.Contains(allStdout, createdAccount.Email) { + t.Fatalf("rule list --all output missing expected references\noutput: %s", allStdout) + } + + updateStdout, updateStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "update", + createdRule.ID, + "--name", updatedRuleName, + "--description", "Blocks example.org", + "--priority", "20", + "--enabled", + "--condition", "from.domain,is,example.org", + "--action", "mark_as_spam", + "--json", + ) + if err != nil { + t.Fatalf("rule update failed: %v\nstdout: %s\nstderr: %s", err, updateStdout, updateStderr) + } + + var updatedRule domain.Rule + if err := json.Unmarshal([]byte(updateStdout), &updatedRule); err != nil { + t.Fatalf("failed to parse rule update JSON: %v\noutput: %s", err, updateStdout) + } + if updatedRule.Name != updatedRuleName { + t.Fatalf("updated rule name = %q, want %q", updatedRule.Name, updatedRuleName) + } + if updatedRule.Description != "Blocks example.org" { + t.Fatalf("updated rule description = %q, want %q", updatedRule.Description, "Blocks example.org") + } + if updatedRule.Priority == nil || *updatedRule.Priority != 20 { + t.Fatalf("updated rule priority = %v, want %d", updatedRule.Priority, 20) + } + if updatedRule.Enabled == nil || !*updatedRule.Enabled { + t.Fatalf("updated rule enabled = %v, want true", updatedRule.Enabled) + } + if updatedRule.Match == nil || updatedRule.Match.Operator != "any" { + t.Fatalf("updated rule operator = %q, want %q", updatedRule.Match.Operator, "any") + } + + deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "rule", "delete", createdRule.ID, "--yes") + if err != nil { + t.Fatalf("rule delete failed: %v\nstdout: %s\nstderr: %s", err, deleteStdout, deleteStderr) + } + if !strings.Contains(strings.ToLower(deleteStdout), "deleted") { + t.Fatalf("expected delete confirmation in stdout, got: %s", deleteStdout) + } + + assertPolicyMissingRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + createdRule = nil +} + +func TestCLI_AgentRuleDelete_RejectsLastRuleOnPolicy(t *testing.T) { + skipIfMissingCreds(t) + skipIfMissingAgentDomain(t) + + env := newAgentSandboxEnv(t) + client := getTestClient() + email := newAgentTestEmail(t, "rule-delete-last") + policyName := newPolicyTestName("rule-delete-last") + ruleName := fmt.Sprintf("it-rule-last-%d", time.Now().UnixNano()) + + var createdPolicy *domain.Policy + var createdAccount *domain.AgentAccount + var createdRule *domain.Rule + + t.Cleanup(func() { + if createdPolicy != nil && createdRule != nil && createdRule.ID != "" { + removeRuleFromPolicyForTest(t, client, createdPolicy.ID, createdRule.ID) + } + if createdRule != nil && createdRule.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteRule(ctx, createdRule.ID) + } + if createdAccount != nil && createdAccount.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteAgentAccount(ctx, createdAccount.ID) + } + if createdPolicy != nil && createdPolicy.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeletePolicy(ctx, createdPolicy.ID) + } + }) + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + policy, err := client.CreatePolicy(ctx, map[string]any{"name": policyName}) + cancel() + if err != nil { + t.Fatalf("failed to create policy for delete-last test: %v", err) + } + createdPolicy = policy + + createdAccount = createAgentWithPolicyForTest(t, email, createdPolicy.ID) + if exists, _ := waitForAgentByEmail(t, client, email, true); !exists { + t.Fatalf("created agent account %q did not appear in list", email) + } + env["NYLAS_GRANT_ID"] = createdAccount.ID + + createStdout, createStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "create", + "--name", ruleName, + "--condition", "from.domain,is,example.com", + "--action", "mark_as_spam", + "--json", + ) + if err != nil { + t.Fatalf("rule create failed: %v\nstdout: %s\nstderr: %s", err, createStdout, createStderr) + } + + var rule domain.Rule + if err := json.Unmarshal([]byte(createStdout), &rule); err != nil { + t.Fatalf("failed to parse rule create JSON: %v\noutput: %s", err, createStdout) + } + createdRule = &rule + assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + + deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "delete", + createdRule.ID, + "--yes", + ) + if err == nil { + t.Fatalf("expected deleting the last rule on a policy to fail\nstdout: %s\nstderr: %s", deleteStdout, deleteStderr) + } + if !strings.Contains(strings.ToLower(deleteStderr), "cannot delete the last rule") { + t.Fatalf("expected last-rule delete error, got stderr: %s", deleteStderr) + } + + assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) +} + +func TestCLI_AgentRuleCommands_RejectMixedScopeRule(t *testing.T) { + skipIfMissingCreds(t) + skipIfMissingAgentDomain(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") + 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) + + t.Cleanup(func() { + if createdRule != nil && createdRule.ID != "" { + removeRuleFromPolicyForTest(t, client, sharedPolicyID, createdRule.ID) + } + if createdRule != nil && createdRule.ID != "" { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteRule(ctx, createdRule.ID) + } + if createdAccount != nil && createdAccount.ID != "" { + 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) + } + + updateStdout, updateStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "update", + createdRule.ID, + "--policy-id", sharedPolicyID, + "--name", fmt.Sprintf("reject-mixed-%d", time.Now().UnixNano()), + "--json", + ) + if err == nil { + t.Fatalf("expected rule update to fail for mixed-scope rule\nstdout: %s\nstderr: %s", updateStdout, updateStderr) + } + if !strings.Contains(strings.ToLower(updateStderr), "shared with a non-agent policy") { + t.Fatalf("expected mixed-scope rejection, got stderr: %s", updateStderr) + } + + deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "delete", + createdRule.ID, + "--policy-id", sharedPolicyID, + "--yes", + ) + if err == nil { + t.Fatalf("expected rule delete to fail for mixed-scope rule\nstdout: %s\nstderr: %s", deleteStdout, deleteStderr) + } + if !strings.Contains(strings.ToLower(deleteStderr), "shared with a non-agent policy") { + t.Fatalf("expected mixed-scope rejection, got stderr: %s", deleteStderr) + } +} + +func assertPolicyContainsRuleForTest(t *testing.T, client interface { + GetPolicy(context.Context, string) (*domain.Policy, error) +}, policyID, ruleID string) { + t.Helper() + + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + policy, err := client.GetPolicy(ctx, policyID) + cancel() + if err == nil && containsString(policy.Rules, ruleID) { + return + } + + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("policy %q does not include rule %q", policyID, ruleID) +} + +func assertPolicyMissingRuleForTest(t *testing.T, client interface { + GetPolicy(context.Context, string) (*domain.Policy, error) +}, policyID, ruleID string) { + t.Helper() + + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + policy, err := client.GetPolicy(ctx, policyID) + cancel() + if err == nil && !containsString(policy.Rules, ruleID) { + return + } + + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("policy %q still includes deleted rule %q", policyID, ruleID) +} + +func removeRuleFromPolicyForTest(t *testing.T, client interface { + GetPolicy(context.Context, string) (*domain.Policy, error) + UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) +}, policyID, ruleID string) { + t.Helper() + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + policy, err := client.GetPolicy(ctx, policyID) + if err != nil || !containsString(policy.Rules, ruleID) { + return + } + + updatedRules := make([]string, 0, len(policy.Rules)) + for _, existingRuleID := range policy.Rules { + if existingRuleID == ruleID { + continue + } + updatedRules = append(updatedRules, existingRuleID) + } + + _, _ = client.UpdatePolicy(ctx, policyID, map[string]any{"rules": updatedRules}) +} + +func attachRuleToPolicyForTest(t *testing.T, client interface { + GetPolicy(context.Context, string) (*domain.Policy, error) + UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) +}, policyID, ruleID string) { + t.Helper() + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + policy, err := client.GetPolicy(ctx, policyID) + if err != nil { + t.Fatalf("failed to get policy %q: %v", policyID, err) + } + if containsString(policy.Rules, ruleID) { + return + } + + updatedRules := append(append([]string(nil), policy.Rules...), ruleID) + if _, err := client.UpdatePolicy(ctx, policyID, map[string]any{"rules": updatedRules}); err != nil { + t.Fatalf("failed to attach rule %q to policy %q: %v", ruleID, policyID, err) + } +} + +func createRuleForTest(t *testing.T, client interface { + CreateRule(context.Context, map[string]any) (*domain.Rule, error) +}, name string) *domain.Rule { + t.Helper() + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rule, err := client.CreateRule(ctx, map[string]any{ + "name": name, + "enabled": true, + "trigger": "inbound", + "match": map[string]any{ + "operator": "all", + "conditions": []map[string]any{{ + "field": "from.domain", + "operator": "is", + "value": "placeholder.example", + }}, + }, + "actions": []map[string]any{{ + "type": "mark_as_spam", + }}, + }) + if err != nil { + t.Fatalf("failed to create placeholder rule %q: %v", name, err) + } + + return rule +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/cli/integration/agent_test.go b/internal/cli/integration/agent_test.go index 37f2b2a..44d61b8 100644 --- a/internal/cli/integration/agent_test.go +++ b/internal/cli/integration/agent_test.go @@ -38,7 +38,7 @@ func TestCLI_AgentLifecycle_CreateListDeleteByEmail(t *testing.T) { } }) - stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "create", email, "--app-password", appPassword, "--json") + stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "account", "create", email, "--app-password", appPassword, "--json") if err != nil { t.Fatalf("agent create failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) } @@ -62,7 +62,7 @@ func TestCLI_AgentLifecycle_CreateListDeleteByEmail(t *testing.T) { t.Fatalf("created agent account %q did not appear in list", email) } - listStdout, listStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "list", "--json") + listStdout, listStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "account", "list", "--json") if err != nil { t.Fatalf("agent list failed: %v\nstdout: %s\nstderr: %s", err, listStdout, listStderr) } @@ -85,7 +85,7 @@ func TestCLI_AgentLifecycle_CreateListDeleteByEmail(t *testing.T) { t.Fatalf("agent list did not include created account %q\noutput: %s", email, listStdout) } - deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "delete", email, "--yes") + deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "account", "delete", email, "--yes") if err != nil { t.Fatalf("agent delete by email failed: %v\nstdout: %s\nstderr: %s", err, deleteStdout, deleteStderr) } @@ -99,6 +99,67 @@ func TestCLI_AgentLifecycle_CreateListDeleteByEmail(t *testing.T) { created = nil } +func TestCLI_AgentCreate_WithPolicyID(t *testing.T) { + skipIfMissingCreds(t) + skipIfMissingAgentDomain(t) + + env := newAgentSandboxEnv(t) + email := newAgentTestEmail(t, "policy-create") + policyName := newPolicyTestName("account-create") + client := getTestClient() + + var createdPolicy *domain.Policy + var created *domain.AgentAccount + t.Cleanup(func() { + if created != nil { + if exists, account := waitForAgentByEmail(t, client, email, true); exists { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteAgentAccount(ctx, account.ID) + } + } + if createdPolicy != nil { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeletePolicy(ctx, createdPolicy.ID) + } + }) + + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + policy, err := client.CreatePolicy(ctx, map[string]any{"name": policyName}) + cancel() + if err != nil { + t.Fatalf("failed to create policy for agent account test: %v", err) + } + createdPolicy = policy + + stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "account", "create", email, "--policy-id", policy.ID, "--json") + if err != nil { + t.Fatalf("agent create with policy failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + + var account domain.AgentAccount + if err := json.Unmarshal([]byte(stdout), &account); err != nil { + t.Fatalf("failed to parse agent create with policy JSON: %v\noutput: %s", err, stdout) + } + if account.Email != email { + t.Fatalf("created email = %q, want %q", account.Email, email) + } + if account.Settings.PolicyID != policy.ID { + t.Fatalf("created policy_id = %q, want %q", account.Settings.PolicyID, policy.ID) + } + created = &account + + if exists, listed := waitForAgentByEmail(t, client, email, true); !exists { + t.Fatalf("created agent account %q did not appear in list", email) + } else if listed.Settings.PolicyID != policy.ID { + t.Fatalf("listed policy_id = %q, want %q", listed.Settings.PolicyID, policy.ID) + } +} + func TestCLI_AgentDelete_ByID(t *testing.T) { skipIfMissingCreds(t) skipIfMissingAgentDomain(t) @@ -117,7 +178,7 @@ func TestCLI_AgentDelete_ByID(t *testing.T) { } }) - stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "delete", account.ID, "--yes") + stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "account", "delete", account.ID, "--yes") if err != nil { t.Fatalf("agent delete by ID failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) } @@ -146,7 +207,7 @@ func TestCLI_AgentDelete_CancelKeepsAccount(t *testing.T) { }) acquireRateLimit(t) - stdout, stderr, err := runCLIWithInputAndOverrides(2*time.Minute, "n\n", env, "agent", "delete", account.Email) + stdout, stderr, err := runCLIWithInputAndOverrides(2*time.Minute, "n\n", env, "agent", "account", "delete", account.Email) if err != nil { t.Fatalf("agent delete cancel flow failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) } @@ -159,10 +220,55 @@ func TestCLI_AgentDelete_CancelKeepsAccount(t *testing.T) { } } +func TestCLI_AgentGet_ByEmailAndID(t *testing.T) { + skipIfMissingCreds(t) + skipIfMissingAgentDomain(t) + + env := newAgentSandboxEnv(t) + client := getTestClient() + email := newAgentTestEmail(t, "get") + account := createAgentForTest(t, client, email) + + t.Cleanup(func() { + if exists, listed := waitForAgentByEmail(t, client, email, true); exists { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = client.DeleteAgentAccount(ctx, listed.ID) + } + }) + + byEmailStdout, byEmailStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "account", "get", email, "--json") + if err != nil { + t.Fatalf("agent get by email failed: %v\nstdout: %s\nstderr: %s", err, byEmailStdout, byEmailStderr) + } + + var byEmail domain.AgentAccount + if err := json.Unmarshal([]byte(byEmailStdout), &byEmail); err != nil { + t.Fatalf("failed to parse agent get by email JSON: %v\noutput: %s", err, byEmailStdout) + } + if byEmail.ID != account.ID { + t.Fatalf("agent get by email returned ID %q, want %q", byEmail.ID, account.ID) + } + + byIDStdout, byIDStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "account", "get", account.ID, "--json") + if err != nil { + t.Fatalf("agent get by ID failed: %v\nstdout: %s\nstderr: %s", err, byIDStdout, byIDStderr) + } + + var byID domain.AgentAccount + if err := json.Unmarshal([]byte(byIDStdout), &byID); err != nil { + t.Fatalf("failed to parse agent get by ID JSON: %v\noutput: %s", err, byIDStdout) + } + if byID.Email != email { + t.Fatalf("agent get by ID returned email %q, want %q", byID.Email, email) + } +} + func TestCLI_AgentCreate_RequiresEmail(t *testing.T) { skipIfMissingCreds(t) - stdout, stderr, err := runCLI("agent", "create") + stdout, stderr, err := runCLI("agent", "account", "create") if err == nil { t.Fatalf("expected agent create without email to fail\nstdout: %s\nstderr: %s", stdout, stderr) } @@ -176,7 +282,7 @@ func TestCLI_AgentCreate_InvalidEmailWithSpaces(t *testing.T) { skipIfMissingAgentDomain(t) env := newAgentSandboxEnv(t) - stdout, stderr, err := runCLIWithOverrides(30*time.Second, env, "agent", "create", "bad email") + stdout, stderr, err := runCLIWithOverrides(30*time.Second, env, "agent", "account", "create", "bad email") if err == nil { t.Fatalf("expected invalid email with spaces to fail\nstdout: %s\nstderr: %s", stdout, stderr) } @@ -185,11 +291,24 @@ func TestCLI_AgentCreate_InvalidEmailWithSpaces(t *testing.T) { } } +func TestCLI_AgentGet_InvalidIdentifier(t *testing.T) { + skipIfMissingCreds(t) + + env := newAgentSandboxEnv(t) + stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 30*time.Second, env, "agent", "account", "get", "invalid-agent-id", "--json") + if err == nil { + t.Fatalf("expected invalid agent get to fail\nstdout: %s\nstderr: %s", stdout, stderr) + } + if !strings.Contains(strings.ToLower(stderr), "not found") && !strings.Contains(strings.ToLower(stderr), "failed to get agent account") { + t.Fatalf("expected not found error, got stderr: %s", stderr) + } +} + func TestCLI_AgentDelete_InvalidIdentifier(t *testing.T) { skipIfMissingCreds(t) env := newAgentSandboxEnv(t) - stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 30*time.Second, env, "agent", "delete", "invalid-agent-id", "--yes") + stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 30*time.Second, env, "agent", "account", "delete", "invalid-agent-id", "--yes") if err == nil { t.Fatalf("expected invalid agent delete to fail\nstdout: %s\nstderr: %s", stdout, stderr) } @@ -230,14 +349,14 @@ func newAgentTestEmail(t *testing.T, prefix string) string { } func createAgentForTest(t *testing.T, client interface { - CreateAgentAccount(context.Context, string, string) (*domain.AgentAccount, error) + CreateAgentAccount(context.Context, string, string, string) (*domain.AgentAccount, error) }, email string) *domain.AgentAccount { t.Helper() acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - account, err := client.CreateAgentAccount(ctx, email, "") + account, err := client.CreateAgentAccount(ctx, email, "", "") if err != nil { t.Fatalf("failed to create agent account %q for test setup: %v", email, err) } diff --git a/internal/cli/integration/notetaker_test.go b/internal/cli/integration/notetaker_test.go index 5334ff1..527e8f7 100644 --- a/internal/cli/integration/notetaker_test.go +++ b/internal/cli/integration/notetaker_test.go @@ -16,10 +16,11 @@ func TestNotetaker_Integration(t *testing.T) { skipIfMissingCreds(t) client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() t.Run("ListNotetakers", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + notetakers, err := client.ListNotetakers(ctx, testGrantID, nil) if err != nil { t.Fatalf("ListNotetakers() error = %v", err) @@ -29,6 +30,9 @@ func TestNotetaker_Integration(t *testing.T) { }) t.Run("CreateAndDeleteNotetaker", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // Create notetaker req := &domain.CreateNotetakerRequest{ MeetingLink: "https://zoom.us/j/123456789", @@ -81,10 +85,11 @@ func TestNotetaker_ValidationErrors(t *testing.T) { skipIfMissingCreds(t) client := getTestClient() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() t.Run("CreateNotetaker_MissingRequired", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // Missing required fields req := &domain.CreateNotetakerRequest{} @@ -95,6 +100,9 @@ func TestNotetaker_ValidationErrors(t *testing.T) { }) t.Run("GetNotetaker_InvalidID", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _, err := client.GetNotetaker(ctx, testGrantID, "invalid-notetaker-id") if err == nil { t.Error("GetNotetaker() with invalid ID should return error") @@ -102,6 +110,9 @@ func TestNotetaker_ValidationErrors(t *testing.T) { }) t.Run("DeleteNotetaker_InvalidID", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + err := client.DeleteNotetaker(ctx, testGrantID, "invalid-notetaker-id") if err == nil { t.Error("DeleteNotetaker() with invalid ID should return error") diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 9210da4..c8fccd1 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -56,6 +56,8 @@ var ( ErrTemplateNotFound = errors.New("template not found") ErrWorkflowNotFound = errors.New("workflow not found") ErrApplicationNotFound = errors.New("application not found") + ErrPolicyNotFound = errors.New("policy not found") + ErrRuleNotFound = errors.New("rule not found") ErrCallbackURINotFound = errors.New("callback URI not found") ErrConnectorNotFound = errors.New("connector not found") ErrCredentialNotFound = errors.New("credential not found") diff --git a/internal/domain/inbound.go b/internal/domain/inbound.go index 2f270f6..31b649d 100644 --- a/internal/domain/inbound.go +++ b/internal/domain/inbound.go @@ -3,8 +3,9 @@ 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) + 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"` diff --git a/internal/domain/policy.go b/internal/domain/policy.go new file mode 100644 index 0000000..6f64eba --- /dev/null +++ b/internal/domain/policy.go @@ -0,0 +1,40 @@ +package domain + +// Policy represents a policy resource that can be linked to grants and inboxes. +type Policy struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + ApplicationID string `json:"application_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + Rules []string `json:"rules,omitempty"` + Limits *PolicyLimits `json:"limits,omitempty"` + Options *PolicyOptions `json:"options,omitempty"` + SpamDetection *PolicySpamDetection `json:"spam_detection,omitempty"` + CreatedAt UnixTime `json:"created_at,omitempty"` + UpdatedAt UnixTime `json:"updated_at,omitempty"` +} + +// PolicyLimits contains limit settings for a policy. +type PolicyLimits struct { + LimitAttachmentSizeInBytes *int64 `json:"limit_attachment_size_limit,omitempty"` + LimitAttachmentCount *int `json:"limit_attachment_count_limit,omitempty"` + LimitAttachmentAllowedTypes *[]string `json:"limit_attachment_allowed_types,omitempty"` + LimitSizeTotalMimeInBytes *int64 `json:"limit_size_total_mime,omitempty"` + LimitStorageTotalInBytes *int64 `json:"limit_storage_total,omitempty"` + LimitCountDailyMessagePerGrant *int64 `json:"limit_count_daily_message_per_grant,omitempty"` + LimitInboxRetentionPeriodInDays *int `json:"limit_inbox_retention_period,omitempty"` + LimitSpamRetentionPeriodInDays *int `json:"limit_spam_retention_period,omitempty"` +} + +// PolicyOptions contains option settings for a policy. +type PolicyOptions struct { + AdditionalFolders *[]string `json:"additional_folders,omitempty"` + UseCidrAliasing *bool `json:"use_cidr_aliasing,omitempty"` +} + +// PolicySpamDetection contains spam detection settings for a policy. +type PolicySpamDetection struct { + UseListDNSBL *bool `json:"use_list_dnsbl,omitempty"` + UseHeaderAnomalyDetection *bool `json:"use_header_anomaly_detection,omitempty"` + SpamSensitivity *float64 `json:"spam_sensitivity,omitempty"` +} diff --git a/internal/domain/rule.go b/internal/domain/rule.go new file mode 100644 index 0000000..8c0b8b6 --- /dev/null +++ b/internal/domain/rule.go @@ -0,0 +1,36 @@ +package domain + +// Rule represents a policy rule resource. +type Rule struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Priority *int `json:"priority,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Trigger string `json:"trigger,omitempty"` + Match *RuleMatch `json:"match,omitempty"` + Actions []RuleAction `json:"actions,omitempty"` + ApplicationID string `json:"application_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + CreatedAt UnixTime `json:"created_at,omitempty"` + UpdatedAt UnixTime `json:"updated_at,omitempty"` +} + +// RuleMatch describes how rule conditions are evaluated. +type RuleMatch struct { + Operator string `json:"operator,omitempty"` + Conditions []RuleCondition `json:"conditions,omitempty"` +} + +// RuleCondition represents a single condition in a rule match expression. +type RuleCondition struct { + Field string `json:"field,omitempty"` + Operator string `json:"operator,omitempty"` + Value any `json:"value,omitempty"` +} + +// RuleAction represents an action executed when a rule matches. +type RuleAction struct { + Type string `json:"type,omitempty"` + Value any `json:"value,omitempty"` +} diff --git a/internal/ports/agent.go b/internal/ports/agent.go index ab0ab91..f4a8f8a 100644 --- a/internal/ports/agent.go +++ b/internal/ports/agent.go @@ -16,7 +16,8 @@ type AgentClient interface { // CreateAgentAccount creates a new agent account with the given email address. // appPassword is optional and enables IMAP/SMTP client access when set. - CreateAgentAccount(ctx context.Context, email, appPassword string) (*domain.AgentAccount, error) + // policyID is optional and attaches the created account to an existing policy. + CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) // DeleteAgentAccount deletes an agent account by revoking its grant. DeleteAgentAccount(ctx context.Context, grantID string) error diff --git a/internal/ports/nylas.go b/internal/ports/nylas.go index a095b0d..c49f26a 100644 --- a/internal/ports/nylas.go +++ b/internal/ports/nylas.go @@ -19,6 +19,8 @@ type NylasClient interface { NotetakerClient InboundClient AgentClient + PolicyClient + RuleClient SchedulerClient AdminClient TransactionalClient diff --git a/internal/ports/policy.go b/internal/ports/policy.go new file mode 100644 index 0000000..bb6c3e5 --- /dev/null +++ b/internal/ports/policy.go @@ -0,0 +1,16 @@ +package ports + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// PolicyClient defines policy management operations. +type PolicyClient interface { + ListPolicies(ctx context.Context) ([]domain.Policy, error) + GetPolicy(ctx context.Context, policyID string) (*domain.Policy, error) + CreatePolicy(ctx context.Context, payload map[string]any) (*domain.Policy, error) + UpdatePolicy(ctx context.Context, policyID string, payload map[string]any) (*domain.Policy, error) + DeletePolicy(ctx context.Context, policyID string) error +} diff --git a/internal/ports/rule.go b/internal/ports/rule.go new file mode 100644 index 0000000..ff765b1 --- /dev/null +++ b/internal/ports/rule.go @@ -0,0 +1,16 @@ +package ports + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// RuleClient defines rule management operations. +type RuleClient interface { + ListRules(ctx context.Context) ([]domain.Rule, error) + GetRule(ctx context.Context, ruleID string) (*domain.Rule, error) + CreateRule(ctx context.Context, payload map[string]any) (*domain.Rule, error) + UpdateRule(ctx context.Context, ruleID string, payload map[string]any) (*domain.Rule, error) + DeleteRule(ctx context.Context, ruleID string) error +}