Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,10 @@ nylas agent rule list --all # List all rules attached to agen
nylas agent rule read <rule-id> # Read one rule
nylas agent rule get <rule-id> # 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 --name NAME --trigger outbound --condition outbound.type,is,reply --action block # Create an outbound rule from common flags
nylas agent rule create --data-file rule.json # Create a rule from full JSON
nylas agent rule update <rule-id> --name NAME --description TEXT # Update a rule
nylas agent rule update <rule-id> --trigger outbound --condition recipient.domain,is,example.org --action archive # Update an outbound rule
nylas agent rule delete <rule-id> --yes # Delete a rule
nylas agent status # Check connector + account status
```
Expand Down
49 changes: 45 additions & 4 deletions docs/commands/agent-rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ nylas agent rule list --all
nylas agent rule get <rule-id>
nylas agent rule read <rule-id>
nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action mark_as_spam
nylas agent rule create --name "Block Replies" --trigger outbound --condition outbound.type,is,reply --action block
nylas agent rule create --data-file rule.json
nylas agent rule update <rule-id> --name "Updated Rule"
nylas agent rule update <rule-id> --trigger outbound --condition recipient.domain,is,example.org --action archive
nylas agent rule delete <rule-id> --yes
```

Expand All @@ -29,6 +31,12 @@ The CLI resolves rules through agent policy attachment:

This prevents the agent command surface from mutating rules that are only in non-agent policy usage.

Runtime note:

- inbound rule evaluation is policy-linked
- outbound rule evaluation is application-scoped in the API
- the CLI still attaches created outbound rules to the selected policy so they remain visible in the agent-scoped surface

## Listing Rules

### Rules for the Default Agent Policy
Expand Down Expand Up @@ -104,6 +112,14 @@ nylas agent rule create \
--action mark_as_starred
```

```bash
nylas agent rule create \
--name "Block Replies" \
--trigger outbound \
--condition outbound.type,is,reply \
--action block
```

Available common flags:

- `--name`
Expand Down Expand Up @@ -136,14 +152,19 @@ Examples:
```bash
--condition from.domain,is,example.com
--condition from.address,is,ceo@example.com
--condition subject.contains,is,invoice
--condition recipient.domain,is,example.com
--condition outbound.type,is,reply
```

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
- inbound rules support `from.address`, `from.domain`, and `from.tld`
- outbound rules also support `recipient.address`, `recipient.domain`, `recipient.tld`, and `outbound.type`
- `outbound.type` only supports `is` and `is_not`, with values `compose` or `reply`
- `in_list` requires a JSON array value, so use `--data` or `--data-file` instead of the scalar `--condition` flag syntax

### `--action`

Expand All @@ -157,14 +178,21 @@ Formats:
Examples:

```bash
--action block
--action mark_as_spam
--action mark_as_read
--action move_to_folder=vip
--action tag=security
--action assign_to_folder=vip
--action archive
```

Action values are also treated as strings by default.

Important:

- supported actions are `block`, `mark_as_spam`, `assign_to_folder`, `mark_as_read`, `mark_as_starred`, `archive`, and `trash`
- `assign_to_folder` requires a value
- `block` cannot be combined with other actions

### Full JSON Create

```bash
Expand Down Expand Up @@ -194,6 +222,13 @@ nylas agent rule update <rule-id> \
--action mark_as_spam
```

```bash
nylas agent rule update <rule-id> \
--trigger outbound \
--condition recipient.domain,is,example.org \
--action archive
```

Behavior:

- `--condition` replaces the rule's condition set
Expand Down Expand Up @@ -222,13 +257,19 @@ nylas agent rule delete <rule-id> --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
- delete is rejected if removing an inbound rule would leave an attached agent policy with zero attached rules
- outbound rules can still be deleted when they are the only rule attached to a policy, because outbound evaluation is not policy-selected at send time

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.
Inbound evaluation follows that chain directly. Outbound evaluation does not:

- inbound rules run only when attached to the selected policy
- outbound rules are evaluated by the application at send time
- the CLI keeps outbound rules attached to the selected policy so they stay discoverable through the agent-scoped command surface

Practical flow:

Expand Down
5 changes: 4 additions & 1 deletion docs/commands/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,17 +160,20 @@ nylas agent rule list --all
nylas agent rule read <rule-id>
nylas agent rule get <rule-id>
nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action mark_as_spam
nylas agent rule create --name "Block Replies" --trigger outbound --condition outbound.type,is,reply --action block
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 <rule-id> --name "Updated Rule" --description "Block example.org"
nylas agent rule update <rule-id> --trigger outbound --condition recipient.domain,is,example.org --action archive
nylas agent rule update <rule-id> --condition from.domain,is,example.org --action mark_as_spam
nylas agent rule delete <rule-id> --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`
- `create` supports common-case flags like `--name`, repeatable `--condition`, and repeatable `--action`, with `trigger=inbound` as the default
- outbound rules are supported with `--trigger outbound`, including `recipient.*` and `outbound.type` conditions
- `get` and `read` are aliases
- `update` and `delete` refuse to operate on rules that are outside the current `provider=nylas` agent scope

Expand Down
12 changes: 10 additions & 2 deletions internal/adapters/nylas/demo_rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,33 @@ func (d *DemoClient) GetRule(ctx context.Context, ruleID string) (*domain.Rule,

func (d *DemoClient) CreateRule(ctx context.Context, payload map[string]any) (*domain.Rule, error) {
name, _ := payload["name"].(string)
trigger, _ := payload["trigger"].(string)
if trigger == "" {
trigger = "inbound"
}
enabled := true
return &domain.Rule{
ID: "rule-demo-new",
Name: name,
Enabled: &enabled,
Trigger: "inbound",
Trigger: trigger,
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)
trigger, _ := payload["trigger"].(string)
if trigger == "" {
trigger = "inbound"
}
enabled := true
return &domain.Rule{
ID: ruleID,
Name: name,
Enabled: &enabled,
Trigger: "inbound",
Trigger: trigger,
ApplicationID: "app-demo",
OrganizationID: "org-demo",
}, nil
Expand Down
12 changes: 10 additions & 2 deletions internal/adapters/nylas/mock_rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,33 @@ func (m *MockClient) GetRule(ctx context.Context, ruleID string) (*domain.Rule,

func (m *MockClient) CreateRule(ctx context.Context, payload map[string]any) (*domain.Rule, error) {
name, _ := payload["name"].(string)
trigger, _ := payload["trigger"].(string)
if trigger == "" {
trigger = "inbound"
}
enabled := true
return &domain.Rule{
ID: "rule-new",
Name: name,
Enabled: &enabled,
Trigger: "inbound",
Trigger: trigger,
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)
trigger, _ := payload["trigger"].(string)
if trigger == "" {
trigger = "inbound"
}
enabled := true
return &domain.Rule{
ID: ruleID,
Name: name,
Enabled: &enabled,
Trigger: "inbound",
Trigger: trigger,
ApplicationID: "app-123",
OrganizationID: "org-123",
}, nil
Expand Down
8 changes: 4 additions & 4 deletions internal/cli/agent/agent_payload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func TestLoadRulePayload(t *testing.T) {
Name: "Flag Name",
MatchOperator: "any",
Conditions: []string{"from.address,is,ceo@example.com"},
Actions: []string{"move_to_folder=vip"},
Actions: []string{"assign_to_folder=vip"},
}, true)
if assert.NoError(t, err) {
assert.Equal(t, "Flag Name", payload["name"])
Expand All @@ -53,15 +53,15 @@ func TestLoadRulePayload(t *testing.T) {
}
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, "assign_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"},
Conditions: []string{"from.address,is,true", "from.tld,is,123"},
Actions: []string{"assign_to_folder=123", "assign_to_folder=true"},
}, true)
if assert.NoError(t, err) {
matchPayload, ok := payload["match"].(map[string]any)
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ func TestPrintRuleDetails(t *testing.T) {
Description: "Blocks example.com",
Priority: &priority,
Enabled: &enabled,
Trigger: "inbound",
Trigger: "outbound",
ApplicationID: "app-123",
OrganizationID: "org-123",
Match: &domain.RuleMatch{
Expand Down Expand Up @@ -275,6 +275,7 @@ func TestPrintRuleDetails(t *testing.T) {
assert.Contains(t, output, "Policies:")
assert.Contains(t, output, "Default Policy")
assert.Contains(t, output, "agent@example.com")
assert.Contains(t, output, "Trigger: outbound")
assert.Contains(t, output, "Match:")
assert.Contains(t, output, "from.domain is example.com")
assert.Contains(t, output, "Actions:")
Expand Down
10 changes: 7 additions & 3 deletions internal/cli/agent/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ func newRuleCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "rule",
Short: "Manage agent rules",
Long: `Manage rules used by policies attached to agent accounts.
Long: `Manage rules used by agent accounts.

Rules are backed by the /v3/rules API. The agent namespace scopes them through
policies that are attached to provider=nylas accounts.
Rules are backed by the /v3/rules API. Inbound rules are scoped through
policies attached to provider=nylas accounts, and outbound rules can be
managed through the same command surface.

Examples:
nylas agent rule list
Expand Down Expand Up @@ -390,6 +391,9 @@ func rollbackPolicyRuleUpdates(ctx context.Context, client ports.NylasClient, or

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.Trigger != "" {
_, _ = common.Dim.Printf(" Trigger: %s\n", rule.Trigger)
}
if !rule.UpdatedAt.IsZero() {
_, _ = common.Dim.Printf(" Updated: %s\n", common.FormatTimeAgo(rule.UpdatedAt.Time))
}
Expand Down
42 changes: 42 additions & 0 deletions internal/cli/agent/rule_backend_support.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package agent

import (
"strings"

"github.com/nylas/cli/internal/cli/common"
"github.com/nylas/cli/internal/domain"
)

func wrapRuleMutationError(operation string, payload map[string]any, existingRule *domain.Rule, err error) error {
if outboundRuleUnsupportedError(payload, existingRule, err) {
return &common.CLIError{
Err: err,
Message: "outbound rules are not enabled on this API environment",
Suggestion: "Retry on an environment with outbound rule support, or remove --trigger outbound",
}
}

switch operation {
case "create":
return common.WrapCreateError("rule", err)
case "update":
return common.WrapUpdateError("rule", err)
default:
return err
}
}

func outboundRuleUnsupportedError(payload map[string]any, existingRule *domain.Rule, err error) bool {
if err == nil {
return false
}

trigger, _, resolveErr := resolveRuleTrigger(payload, existingRule)
if resolveErr != nil || trigger != ruleTriggerOutbound {
return false
}

message := strings.ToLower(err.Error())
return strings.Contains(message, "invalid rule trigger") &&
strings.Contains(message, "must be 'inbound'")
}
49 changes: 49 additions & 0 deletions internal/cli/agent/rule_backend_support_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package agent

import (
"errors"
"testing"

"github.com/nylas/cli/internal/cli/common"
"github.com/nylas/cli/internal/domain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWrapRuleMutationError_CreateOutboundUnsupported(t *testing.T) {
err := wrapRuleMutationError(
"create",
map[string]any{"trigger": "outbound"},
nil,
errors.New("nylas API error: Invalid rule trigger. Must be 'inbound'"),
)

var cliErr *common.CLIError
require.ErrorAs(t, err, &cliErr)
assert.Equal(t, "outbound rules are not enabled on this API environment", cliErr.Message)
assert.Equal(t, "Retry on an environment with outbound rule support, or remove --trigger outbound", cliErr.Suggestion)
}

func TestWrapRuleMutationError_UpdateUsesExistingOutboundTrigger(t *testing.T) {
err := wrapRuleMutationError(
"update",
map[string]any{"name": "updated"},
&domain.Rule{Trigger: "outbound"},
errors.New("nylas API error: Invalid rule trigger. Must be 'inbound'"),
)

var cliErr *common.CLIError
require.ErrorAs(t, err, &cliErr)
assert.Equal(t, "outbound rules are not enabled on this API environment", cliErr.Message)
}

func TestWrapRuleMutationError_LeavesInboundErrorsWrapped(t *testing.T) {
err := wrapRuleMutationError(
"create",
map[string]any{"trigger": "inbound"},
nil,
errors.New("boom"),
)

assert.EqualError(t, err, "failed to create rule: boom")
}
Loading
Loading