From a02b3bcebfb9d613fcc258384048f8b3e720dc72 Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 16 Apr 2026 18:56:02 -0400 Subject: [PATCH] feat(agent): add outbound rule support to agent rules --- docs/COMMANDS.md | 2 + docs/commands/agent-rule.md | 49 +- docs/commands/agent.md | 5 +- internal/adapters/nylas/demo_rule.go | 12 +- internal/adapters/nylas/mock_rule.go | 12 +- internal/cli/agent/agent_payload_test.go | 8 +- internal/cli/agent/agent_test.go | 3 +- internal/cli/agent/rule.go | 10 +- internal/cli/agent/rule_backend_support.go | 42 ++ .../cli/agent/rule_backend_support_test.go | 49 ++ .../cli/agent/rule_create_update_delete.go | 31 +- internal/cli/agent/rule_payload.go | 6 + internal/cli/agent/rule_validation.go | 571 ++++++++++++++++++ internal/cli/agent/rule_validation_test.go | 118 ++++ .../integration/agent_rule_outbound_test.go | 175 ++++++ 15 files changed, 1064 insertions(+), 29 deletions(-) create mode 100644 internal/cli/agent/rule_backend_support.go create mode 100644 internal/cli/agent/rule_backend_support_test.go create mode 100644 internal/cli/agent/rule_validation.go create mode 100644 internal/cli/agent/rule_validation_test.go create mode 100644 internal/cli/integration/agent_rule_outbound_test.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 46eafe3..fb3453c 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -471,8 +471,10 @@ nylas agent rule list --all # List all rules attached to agen 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 --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 --name NAME --description TEXT # Update a rule +nylas agent rule update --trigger outbound --condition recipient.domain,is,example.org --action archive # Update an outbound rule nylas agent rule delete --yes # Delete a rule nylas agent status # Check connector + account status ``` diff --git a/docs/commands/agent-rule.md b/docs/commands/agent-rule.md index bc2cd2f..028fb8e 100644 --- a/docs/commands/agent-rule.md +++ b/docs/commands/agent-rule.md @@ -13,8 +13,10 @@ 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 --name "Block Replies" --trigger outbound --condition outbound.type,is,reply --action block nylas agent rule create --data-file rule.json nylas agent rule update --name "Updated Rule" +nylas agent rule update --trigger outbound --condition recipient.domain,is,example.org --action archive nylas agent rule delete --yes ``` @@ -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 @@ -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` @@ -136,7 +152,8 @@ 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: @@ -144,6 +161,10 @@ 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` @@ -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 @@ -194,6 +222,13 @@ nylas agent rule update \ --action mark_as_spam ``` +```bash +nylas agent rule update \ + --trigger outbound \ + --condition recipient.domain,is,example.org \ + --action archive +``` + Behavior: - `--condition` replaces the rule's condition set @@ -222,13 +257,19 @@ 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 +- 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: diff --git a/docs/commands/agent.md b/docs/commands/agent.md index 8689938..c520df6 100644 --- a/docs/commands/agent.md +++ b/docs/commands/agent.md @@ -160,9 +160,11 @@ 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 "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 --name "Updated Rule" --description "Block example.org" +nylas agent rule update --trigger outbound --condition recipient.domain,is,example.org --action archive nylas agent rule update --condition from.domain,is,example.org --action mark_as_spam nylas agent rule delete --yes ``` @@ -170,7 +172,8 @@ 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` +- `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 diff --git a/internal/adapters/nylas/demo_rule.go b/internal/adapters/nylas/demo_rule.go index c8ea5a0..11c8452 100644 --- a/internal/adapters/nylas/demo_rule.go +++ b/internal/adapters/nylas/demo_rule.go @@ -56,12 +56,16 @@ 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 @@ -69,12 +73,16 @@ func (d *DemoClient) CreateRule(ctx context.Context, payload map[string]any) (*d 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 diff --git a/internal/adapters/nylas/mock_rule.go b/internal/adapters/nylas/mock_rule.go index 35959c3..3070d7a 100644 --- a/internal/adapters/nylas/mock_rule.go +++ b/internal/adapters/nylas/mock_rule.go @@ -56,12 +56,16 @@ 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 @@ -69,12 +73,16 @@ func (m *MockClient) CreateRule(ctx context.Context, payload map[string]any) (*d 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 diff --git a/internal/cli/agent/agent_payload_test.go b/internal/cli/agent/agent_payload_test.go index 929ebd2..850c4f3 100644 --- a/internal/cli/agent/agent_payload_test.go +++ b/internal/cli/agent/agent_payload_test.go @@ -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"]) @@ -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) diff --git a/internal/cli/agent/agent_test.go b/internal/cli/agent/agent_test.go index daceb78..720e14a 100644 --- a/internal/cli/agent/agent_test.go +++ b/internal/cli/agent/agent_test.go @@ -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{ @@ -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:") diff --git a/internal/cli/agent/rule.go b/internal/cli/agent/rule.go index bb6412e..61f4be7 100644 --- a/internal/cli/agent/rule.go +++ b/internal/cli/agent/rule.go @@ -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 @@ -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)) } diff --git a/internal/cli/agent/rule_backend_support.go b/internal/cli/agent/rule_backend_support.go new file mode 100644 index 0000000..e5c1757 --- /dev/null +++ b/internal/cli/agent/rule_backend_support.go @@ -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'") +} diff --git a/internal/cli/agent/rule_backend_support_test.go b/internal/cli/agent/rule_backend_support_test.go new file mode 100644 index 0000000..f7a2137 --- /dev/null +++ b/internal/cli/agent/rule_backend_support_test.go @@ -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") +} diff --git a/internal/cli/agent/rule_create_update_delete.go b/internal/cli/agent/rule_create_update_delete.go index b479018..39c7b38 100644 --- a/internal/cli/agent/rule_create_update_delete.go +++ b/internal/cli/agent/rule_create_update_delete.go @@ -33,6 +33,7 @@ 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 --name "Block Replies" --trigger outbound --condition outbound.type,is,reply --action block 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 { @@ -54,7 +55,7 @@ Examples: 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.Trigger, "trigger", "", "Rule trigger: inbound or outbound (defaults to inbound when omitted)") 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)") @@ -75,7 +76,7 @@ func runRuleCreate(payload map[string]any, policyID string, jsonOutput bool) err rule, err := client.CreateRule(ctx, payload) if err != nil { - return struct{}{}, common.WrapCreateError("rule", err) + return struct{}{}, wrapRuleMutationError("create", payload, nil, err) } if err := attachRuleToPolicy(ctx, client, *policy, rule.ID); err != nil { @@ -128,6 +129,7 @@ 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 --trigger outbound --condition recipient.domain,is,example.org --action archive nylas agent rule update --data-file update.json nylas agent rule update --all --json`, Args: cobra.ExactArgs(1), @@ -160,7 +162,7 @@ Examples: 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.Trigger, "trigger", "", "Updated rule trigger: inbound or outbound") 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)") @@ -187,10 +189,13 @@ func runRuleUpdate(ruleID string, payload map[string]any, policyID string, allRu } preserveRuleMatchOperator(payload, scope.Rule) + if err := validateRulePayload(payload, scope.Rule, false); err != nil { + return struct{}{}, err + } rule, err := client.UpdateRule(ctx, ruleID, payload) if err != nil { - return struct{}{}, common.WrapUpdateError("rule", err) + return struct{}{}, wrapRuleMutationError("update", payload, scope.Rule, err) } if jsonOutput { @@ -252,15 +257,17 @@ func runRuleDelete(ruleID, policyID string, allRules bool) error { "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) + if scope.Rule.Trigger != ruleTriggerOutbound { + 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), + ) } - 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) diff --git a/internal/cli/agent/rule_payload.go b/internal/cli/agent/rule_payload.go index f5091d5..d8ad8c0 100644 --- a/internal/cli/agent/rule_payload.go +++ b/internal/cli/agent/rule_payload.go @@ -105,6 +105,12 @@ func loadRulePayload(data, dataFile string, opts rulePayloadOptions, requireBody } } + if requireBody { + if err := validateRulePayload(payload, nil, true); err != nil { + return nil, err + } + } + return payload, nil } diff --git a/internal/cli/agent/rule_validation.go b/internal/cli/agent/rule_validation.go new file mode 100644 index 0000000..40d6833 --- /dev/null +++ b/internal/cli/agent/rule_validation.go @@ -0,0 +1,571 @@ +package agent + +import ( + "fmt" + "reflect" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" +) + +const ( + ruleTriggerInbound = "inbound" + ruleTriggerOutbound = "outbound" + + ruleMatchOperatorAll = "all" + ruleMatchOperatorAny = "any" + + ruleConditionOperatorIs = "is" + ruleConditionOperatorIsNot = "is_not" + ruleConditionOperatorContains = "contains" + ruleConditionOperatorInList = "in_list" + + ruleConditionFieldFromAddress = "from.address" + ruleConditionFieldFromDomain = "from.domain" + ruleConditionFieldFromTLD = "from.tld" + ruleConditionFieldRecipientAddress = "recipient.address" + ruleConditionFieldRecipientDomain = "recipient.domain" + ruleConditionFieldRecipientTLD = "recipient.tld" + ruleConditionFieldOutboundType = "outbound.type" + + ruleActionBlock = "block" + ruleActionMarkAsSpam = "mark_as_spam" + ruleActionAssignToFolder = "assign_to_folder" + ruleActionMarkAsRead = "mark_as_read" + ruleActionMarkAsStarred = "mark_as_starred" + ruleActionArchive = "archive" + ruleActionTrash = "trash" + + ruleOutboundTypeCompose = "compose" + ruleOutboundTypeReply = "reply" + + maxRuleConditions = 50 + maxRuleActions = 20 + maxRuleListsPerMatch = 10 + maxRuleValueLength = 500 +) + +type normalizedRuleMatch struct { + Operator string + Conditions []normalizedRuleCondition +} + +type normalizedRuleCondition struct { + Field string + Operator string + Value any +} + +type normalizedRuleAction struct { + Type string + Value any +} + +func validateRulePayload(payload map[string]any, existingRule *domain.Rule, requireCreateFields bool) error { + trigger, payloadHasTrigger, err := resolveRuleTrigger(payload, existingRule) + if err != nil { + return err + } + + if requireCreateFields && strings.TrimSpace(asString(payload["name"])) == "" { + return common.NewUserError("rule name is required", "Use --name or include a non-empty name in --data/--data-file") + } + + if _, ok := payload["priority"]; ok { + priority, err := parseRulePriority(payload["priority"]) + if err != nil { + return err + } + if priority < 0 || priority > 1000 { + return common.NewUserError("rule priority must be between 0 and 1000", "Use --priority with a value from 0 to 1000") + } + } + + shouldValidateMatch := requireCreateFields + if _, ok := payload["match"]; ok || payloadHasTrigger { + shouldValidateMatch = true + } + if shouldValidateMatch { + match, err := resolveRuleMatch(payload, existingRule) + if err != nil { + return err + } + if err := validateRuleMatch(match, trigger); err != nil { + return err + } + } + + shouldValidateActions := requireCreateFields + if _, ok := payload["actions"]; ok { + shouldValidateActions = true + } + if shouldValidateActions { + actions, err := resolveRuleActions(payload, existingRule) + if err != nil { + return err + } + if err := validateRuleActions(actions); err != nil { + return err + } + } + + return nil +} + +func resolveRuleTrigger(payload map[string]any, existingRule *domain.Rule) (string, bool, error) { + if rawTrigger, ok := payload["trigger"]; ok { + trigger := strings.TrimSpace(asString(rawTrigger)) + switch trigger { + case ruleTriggerInbound, ruleTriggerOutbound: + return trigger, true, nil + default: + return "", true, common.NewUserError("invalid rule trigger", "Use --trigger inbound or --trigger outbound") + } + } + + if existingRule != nil { + trigger := strings.TrimSpace(existingRule.Trigger) + if trigger != "" { + return trigger, false, nil + } + } + + return ruleTriggerInbound, false, nil +} + +func parseRulePriority(value any) (int, error) { + switch v := value.(type) { + case int: + return v, nil + case int8: + return int(v), nil + case int16: + return int(v), nil + case int32: + return int(v), nil + case int64: + return int(v), nil + case float32: + if float32(int(v)) != v { + return 0, common.NewUserError("invalid rule priority", "Use an integer between 0 and 1000") + } + return int(v), nil + case float64: + if float64(int(v)) != v { + return 0, common.NewUserError("invalid rule priority", "Use an integer between 0 and 1000") + } + return int(v), nil + default: + return 0, common.NewUserError("invalid rule priority", "Use an integer between 0 and 1000") + } +} + +func resolveRuleMatch(payload map[string]any, existingRule *domain.Rule) (*normalizedRuleMatch, error) { + if rawMatch, ok := payload["match"]; ok && rawMatch != nil { + return normalizeRuleMatch(rawMatch) + } + + if existingRule != nil && existingRule.Match != nil { + return normalizeRuleMatch(existingRule.Match) + } + + return nil, nil +} + +func normalizeRuleMatch(value any) (*normalizedRuleMatch, error) { + switch v := value.(type) { + case map[string]any: + conditions, err := normalizeRuleConditions(v["conditions"]) + if err != nil { + return nil, err + } + return &normalizedRuleMatch{ + Operator: strings.TrimSpace(asString(v["operator"])), + Conditions: conditions, + }, nil + case *domain.RuleMatch: + if v == nil { + return nil, nil + } + return &normalizedRuleMatch{ + Operator: strings.TrimSpace(v.Operator), + Conditions: normalizeDomainRuleConditions(v.Conditions), + }, nil + case domain.RuleMatch: + return &normalizedRuleMatch{ + Operator: strings.TrimSpace(v.Operator), + Conditions: normalizeDomainRuleConditions(v.Conditions), + }, nil + default: + return nil, common.NewUserError("invalid rule match payload", "Provide match as an object with operator and conditions") + } +} + +func normalizeDomainRuleConditions(conditions []domain.RuleCondition) []normalizedRuleCondition { + normalized := make([]normalizedRuleCondition, 0, len(conditions)) + for _, condition := range conditions { + normalized = append(normalized, normalizedRuleCondition{ + Field: strings.TrimSpace(condition.Field), + Operator: strings.TrimSpace(condition.Operator), + Value: condition.Value, + }) + } + return normalized +} + +func normalizeRuleConditions(value any) ([]normalizedRuleCondition, error) { + if value == nil { + return nil, nil + } + + switch conditions := value.(type) { + case []domain.RuleCondition: + return normalizeDomainRuleConditions(conditions), nil + case []any: + return normalizeRuleConditionsFromAny(conditions) + default: + rv := reflect.ValueOf(value) + if !rv.IsValid() || rv.Kind() != reflect.Slice { + return nil, common.NewUserError("invalid rule conditions payload", "Provide conditions as an array") + } + + items := make([]any, 0, rv.Len()) + for i := range rv.Len() { + items = append(items, rv.Index(i).Interface()) + } + return normalizeRuleConditionsFromAny(items) + } +} + +func normalizeRuleConditionsFromAny(items []any) ([]normalizedRuleCondition, error) { + conditions := make([]normalizedRuleCondition, 0, len(items)) + for _, item := range items { + switch condition := item.(type) { + case domain.RuleCondition: + conditions = append(conditions, normalizedRuleCondition{ + Field: strings.TrimSpace(condition.Field), + Operator: strings.TrimSpace(condition.Operator), + Value: condition.Value, + }) + case map[string]any: + conditions = append(conditions, normalizedRuleCondition{ + Field: strings.TrimSpace(asString(condition["field"])), + Operator: strings.TrimSpace(asString(condition["operator"])), + Value: condition["value"], + }) + default: + return nil, common.NewUserError("invalid rule condition payload", "Provide each condition as an object with field, operator, and value") + } + } + return conditions, nil +} + +func resolveRuleActions(payload map[string]any, existingRule *domain.Rule) ([]normalizedRuleAction, error) { + if rawActions, ok := payload["actions"]; ok && rawActions != nil { + return normalizeRuleActions(rawActions) + } + + if existingRule != nil { + return normalizeRuleActions(existingRule.Actions) + } + + return nil, nil +} + +func normalizeRuleActions(value any) ([]normalizedRuleAction, error) { + if value == nil { + return nil, nil + } + + switch actions := value.(type) { + case []domain.RuleAction: + normalized := make([]normalizedRuleAction, 0, len(actions)) + for _, action := range actions { + normalized = append(normalized, normalizedRuleAction{ + Type: strings.TrimSpace(action.Type), + Value: action.Value, + }) + } + return normalized, nil + case []any: + return normalizeRuleActionsFromAny(actions) + default: + rv := reflect.ValueOf(value) + if !rv.IsValid() || rv.Kind() != reflect.Slice { + return nil, common.NewUserError("invalid rule actions payload", "Provide actions as an array") + } + + items := make([]any, 0, rv.Len()) + for i := range rv.Len() { + items = append(items, rv.Index(i).Interface()) + } + return normalizeRuleActionsFromAny(items) + } +} + +func normalizeRuleActionsFromAny(items []any) ([]normalizedRuleAction, error) { + actions := make([]normalizedRuleAction, 0, len(items)) + for _, item := range items { + switch action := item.(type) { + case domain.RuleAction: + actions = append(actions, normalizedRuleAction{ + Type: strings.TrimSpace(action.Type), + Value: action.Value, + }) + case map[string]any: + actions = append(actions, normalizedRuleAction{ + Type: strings.TrimSpace(asString(action["type"])), + Value: action["value"], + }) + default: + return nil, common.NewUserError("invalid rule action payload", "Provide each action as an object with type and optional value") + } + } + return actions, nil +} + +func validateRuleMatch(match *normalizedRuleMatch, trigger string) error { + if match == nil { + return common.NewUserError("rule match is required", "Add at least one condition or provide a full rule body with match conditions") + } + + if match.Operator != "" { + switch match.Operator { + case ruleMatchOperatorAll, ruleMatchOperatorAny: + default: + return common.NewUserError("invalid rule match operator", "Use --match-operator all or --match-operator any") + } + } + + if len(match.Conditions) == 0 { + return common.NewUserError("at least one rule condition is required", "Add one or more --condition entries, or provide conditions in --data/--data-file") + } + if len(match.Conditions) > maxRuleConditions { + return common.NewUserError( + fmt.Sprintf("rule cannot have more than %d conditions", maxRuleConditions), + "Reduce the number of conditions in the rule body", + ) + } + + for i, condition := range match.Conditions { + if err := validateRuleCondition(condition, trigger, i+1); err != nil { + return err + } + } + + return nil +} + +func validateRuleCondition(condition normalizedRuleCondition, trigger string, index int) error { + if condition.Field == "" { + return common.NewUserError( + fmt.Sprintf("condition %d is missing a field", index), + "Provide field,operator,value or use JSON with field/operator/value keys", + ) + } + if !isValidRuleConditionField(condition.Field) { + return common.NewUserError( + fmt.Sprintf("condition %d uses an unsupported field", index), + "Use from.address, from.domain, from.tld, recipient.address, recipient.domain, recipient.tld, or outbound.type", + ) + } + + if trigger != ruleTriggerOutbound { + switch condition.Field { + case ruleConditionFieldRecipientAddress, ruleConditionFieldRecipientDomain, ruleConditionFieldRecipientTLD: + return common.NewUserError( + fmt.Sprintf("condition %d uses recipient.* on an inbound rule", index), + "Set --trigger outbound or use only from.* fields", + ) + case ruleConditionFieldOutboundType: + return common.NewUserError( + fmt.Sprintf("condition %d uses outbound.type on an inbound rule", index), + "Set --trigger outbound when matching outbound.type", + ) + } + } + + if condition.Operator == "" { + return common.NewUserError( + fmt.Sprintf("condition %d is missing an operator", index), + "Use is, is_not, contains, or in_list", + ) + } + if !isValidRuleConditionOperator(condition.Operator) { + return common.NewUserError( + fmt.Sprintf("condition %d uses an unsupported operator", index), + "Use is, is_not, contains, or in_list", + ) + } + + if condition.Field == ruleConditionFieldOutboundType && + condition.Operator != ruleConditionOperatorIs && + condition.Operator != ruleConditionOperatorIsNot { + return common.NewUserError( + "outbound.type only supports is and is_not operators", + "Use --condition outbound.type,is,compose or --condition outbound.type,is_not,reply", + ) + } + + if condition.Operator == ruleConditionOperatorInList { + listCount, ok := listValueLen(condition.Value) + if !ok { + return common.NewUserError( + fmt.Sprintf("condition %d requires an array value for in_list", index), + "Use --data/--data-file to pass a JSON array of list IDs for in_list conditions", + ) + } + if listCount == 0 { + return common.NewUserError( + fmt.Sprintf("condition %d requires at least one list ID", index), + "Provide one or more list IDs in the in_list array", + ) + } + if listCount > maxRuleListsPerMatch { + return common.NewUserError( + fmt.Sprintf("condition %d cannot reference more than %d lists", index, maxRuleListsPerMatch), + "Reduce the number of list IDs in the in_list array", + ) + } + return nil + } + + value, ok := condition.Value.(string) + if !ok { + return common.NewUserError( + fmt.Sprintf("condition %d value must be a string", index), + "Use a string value, or use --data/--data-file for structured values", + ) + } + if strings.TrimSpace(value) == "" { + return common.NewUserError( + fmt.Sprintf("condition %d value cannot be empty", index), + "Provide a non-empty condition value", + ) + } + if len(value) > maxRuleValueLength { + return common.NewUserError( + fmt.Sprintf("condition %d value is too long", index), + fmt.Sprintf("Keep condition values at %d characters or fewer", maxRuleValueLength), + ) + } + + if condition.Field == ruleConditionFieldOutboundType { + switch strings.ToLower(strings.TrimSpace(value)) { + case ruleOutboundTypeCompose, ruleOutboundTypeReply: + default: + return common.NewUserError("outbound.type must be compose or reply", "Use outbound.type with value compose or reply") + } + } + + return nil +} + +func validateRuleActions(actions []normalizedRuleAction) error { + if len(actions) == 0 { + return common.NewUserError("rule actions are required", "Add one or more --action entries, or provide actions in --data/--data-file") + } + if len(actions) > maxRuleActions { + return common.NewUserError( + fmt.Sprintf("rule cannot have more than %d actions", maxRuleActions), + "Reduce the number of actions in the rule body", + ) + } + + hasBlock := false + for i, action := range actions { + if action.Type == "" { + return common.NewUserError( + fmt.Sprintf("action %d is missing a type", i+1), + "Provide an action type such as block, mark_as_spam, or assign_to_folder", + ) + } + if !isValidRuleActionType(action.Type) { + return common.NewUserError( + fmt.Sprintf("action %d uses an unsupported type", i+1), + "Use block, mark_as_spam, assign_to_folder, mark_as_read, mark_as_starred, archive, or trash", + ) + } + if action.Type == ruleActionAssignToFolder && isEmptyRuleValue(action.Value) { + return common.NewUserError("assign_to_folder requires a value", "Use --action assign_to_folder=") + } + if action.Type == ruleActionBlock { + hasBlock = true + } + } + + if hasBlock && len(actions) > 1 { + return common.NewUserError("block cannot be combined with other actions", "Use block by itself or remove the other actions") + } + + return nil +} + +func isValidRuleConditionField(field string) bool { + switch field { + case ruleConditionFieldFromAddress, + ruleConditionFieldFromDomain, + ruleConditionFieldFromTLD, + ruleConditionFieldRecipientAddress, + ruleConditionFieldRecipientDomain, + ruleConditionFieldRecipientTLD, + ruleConditionFieldOutboundType: + return true + default: + return false + } +} + +func isValidRuleConditionOperator(operator string) bool { + switch operator { + case ruleConditionOperatorIs, + ruleConditionOperatorIsNot, + ruleConditionOperatorContains, + ruleConditionOperatorInList: + return true + default: + return false + } +} + +func isValidRuleActionType(actionType string) bool { + switch actionType { + case ruleActionBlock, + ruleActionMarkAsSpam, + ruleActionAssignToFolder, + ruleActionMarkAsRead, + ruleActionMarkAsStarred, + ruleActionArchive, + ruleActionTrash: + return true + default: + return false + } +} + +func listValueLen(value any) (int, bool) { + switch v := value.(type) { + case []string: + return len(v), true + case []any: + return len(v), true + default: + rv := reflect.ValueOf(value) + if !rv.IsValid() || rv.Kind() != reflect.Slice { + return 0, false + } + return rv.Len(), true + } +} + +func isEmptyRuleValue(value any) bool { + switch v := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(v) == "" + default: + return false + } +} diff --git a/internal/cli/agent/rule_validation_test.go b/internal/cli/agent/rule_validation_test.go new file mode 100644 index 0000000..4286233 --- /dev/null +++ b/internal/cli/agent/rule_validation_test.go @@ -0,0 +1,118 @@ +package agent + +import ( + "testing" + + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadRulePayload_PreservesOutboundTrigger(t *testing.T) { + payload, err := loadRulePayload("", "", rulePayloadOptions{ + Name: "Block Replies", + Trigger: ruleTriggerOutbound, + Conditions: []string{"outbound.type,is,reply"}, + Actions: []string{"block"}, + }, true) + require.NoError(t, err) + + assert.Equal(t, ruleTriggerOutbound, payload["trigger"]) + + matchPayload, ok := payload["match"].(map[string]any) + require.True(t, ok) + + conditions, ok := matchPayload["conditions"].([]domain.RuleCondition) + require.True(t, ok) + require.Len(t, conditions, 1) + assert.Equal(t, ruleConditionFieldOutboundType, conditions[0].Field) + assert.Equal(t, ruleConditionOperatorIs, conditions[0].Operator) + assert.Equal(t, ruleOutboundTypeReply, conditions[0].Value) +} + +func TestValidateRulePayload_RejectsRecipientConditionsOnInboundRules(t *testing.T) { + err := validateRulePayload(map[string]any{ + "name": "Inbound Recipient Rule", + "trigger": ruleTriggerInbound, + "match": map[string]any{ + "conditions": []map[string]any{{ + "field": ruleConditionFieldRecipientDomain, + "operator": ruleConditionOperatorIs, + "value": "example.com", + }}, + }, + "actions": []map[string]any{{ + "type": ruleActionArchive, + }}, + }, nil, true) + + require.Error(t, err) + assert.EqualError(t, err, "condition 1 uses recipient.* on an inbound rule") +} + +func TestValidateRulePayload_RejectsInvalidOutboundTypeOperator(t *testing.T) { + err := validateRulePayload(map[string]any{ + "name": "Outbound Type Rule", + "trigger": ruleTriggerOutbound, + "match": map[string]any{ + "conditions": []map[string]any{{ + "field": ruleConditionFieldOutboundType, + "operator": ruleConditionOperatorContains, + "value": ruleOutboundTypeReply, + }}, + }, + "actions": []map[string]any{{ + "type": ruleActionMarkAsStarred, + }}, + }, nil, true) + + require.Error(t, err) + assert.EqualError(t, err, "outbound.type only supports is and is_not operators") +} + +func TestValidateRulePayload_RejectsBlockWithOtherActions(t *testing.T) { + err := validateRulePayload(map[string]any{ + "name": "Mixed Actions Rule", + "match": map[string]any{ + "conditions": []map[string]any{{ + "field": ruleConditionFieldFromDomain, + "operator": ruleConditionOperatorIs, + "value": "example.com", + }}, + }, + "actions": []map[string]any{ + {"type": ruleActionBlock}, + {"type": ruleActionArchive}, + }, + }, nil, true) + + require.Error(t, err) + assert.EqualError(t, err, "block cannot be combined with other actions") +} + +func TestValidateRulePayload_UpdateUsesExistingTrigger(t *testing.T) { + err := validateRulePayload(map[string]any{ + "match": map[string]any{ + "conditions": []map[string]any{{ + "field": ruleConditionFieldRecipientAddress, + "operator": ruleConditionOperatorContains, + "value": "vip", + }}, + }, + }, &domain.Rule{ + Trigger: ruleTriggerOutbound, + Match: &domain.RuleMatch{ + Operator: ruleMatchOperatorAny, + Conditions: []domain.RuleCondition{{ + Field: ruleConditionFieldOutboundType, + Operator: ruleConditionOperatorIs, + Value: ruleOutboundTypeCompose, + }}, + }, + Actions: []domain.RuleAction{{ + Type: ruleActionArchive, + }}, + }, false) + + require.NoError(t, err) +} diff --git a/internal/cli/integration/agent_rule_outbound_test.go b/internal/cli/integration/agent_rule_outbound_test.go new file mode 100644 index 0000000..e991d9e --- /dev/null +++ b/internal/cli/integration/agent_rule_outbound_test.go @@ -0,0 +1,175 @@ +//go:build integration +// +build integration + +package integration + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func TestCLI_AgentRuleLifecycle_OutboundCreateUpdateDelete(t *testing.T) { + skipIfMissingCreds(t) + skipIfMissingAgentDomain(t) + + env := newAgentSandboxEnv(t) + client := getTestClient() + email := newAgentTestEmail(t, "rule-outbound") + policyName := newPolicyTestName("rule-outbound") + + 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 outbound 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 + + createStdout, createStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "create", + "--name", "Block Replies", + "--trigger", "outbound", + "--condition", "outbound.type,is,reply", + "--action", "block", + "--json", + ) + if err != nil { + if outboundTriggerUnsupported(createStderr) { + t.Skip("outbound rule trigger not supported by this API environment yet") + } + t.Fatalf("outbound rule create failed: %v\nstdout: %s\nstderr: %s", err, createStdout, createStderr) + } + + var created domain.Rule + if err := json.Unmarshal([]byte(createStdout), &created); err != nil { + t.Fatalf("failed to parse outbound rule create JSON: %v\noutput: %s", err, createStdout) + } + if created.ID == "" { + t.Fatalf("expected created outbound rule ID, got output: %s", createStdout) + } + if created.Trigger != "outbound" { + t.Fatalf("created outbound rule trigger = %q, want %q", created.Trigger, "outbound") + } + createdRule = &created + + assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + + readStdout, readStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "read", + createdRule.ID, + ) + if err != nil { + t.Fatalf("outbound rule read failed: %v\nstdout: %s\nstderr: %s", err, readStdout, readStderr) + } + if !strings.Contains(readStdout, "Trigger: outbound") { + t.Fatalf("expected outbound trigger in read output\noutput: %s", readStdout) + } + + updateStdout, updateStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "update", + createdRule.ID, + "--condition", "recipient.domain,is,example.org", + "--action", "archive", + "--json", + ) + if err != nil { + t.Fatalf("outbound rule update failed: %v\nstdout: %s\nstderr: %s", err, updateStdout, updateStderr) + } + + var updated domain.Rule + if err := json.Unmarshal([]byte(updateStdout), &updated); err != nil { + t.Fatalf("failed to parse outbound rule update JSON: %v\noutput: %s", err, updateStdout) + } + if updated.Trigger != "outbound" { + t.Fatalf("updated outbound rule trigger = %q, want %q", updated.Trigger, "outbound") + } + if updated.Match == nil || len(updated.Match.Conditions) != 1 || updated.Match.Conditions[0].Field != "recipient.domain" { + t.Fatalf("updated outbound rule conditions = %#v, want recipient.domain", updated.Match) + } + if len(updated.Actions) != 1 || updated.Actions[0].Type != "archive" { + t.Fatalf("updated outbound rule actions = %#v, want archive", updated.Actions) + } + + deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit( + t, + 2*time.Minute, + env, + "agent", + "rule", + "delete", + createdRule.ID, + "--yes", + ) + if err != nil { + t.Fatalf("outbound 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 outboundTriggerUnsupported(stderr string) bool { + stderr = strings.ToLower(stderr) + if strings.Contains(stderr, "outbound rules are not enabled on this api environment") { + return true + } + return strings.Contains(stderr, "invalid rule trigger") && strings.Contains(stderr, "must be 'inbound'") +}