Skip to content
Merged
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
17 changes: 10 additions & 7 deletions docs/commands/agent-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ Notes:
## Step 4 — Policies

Policies are settings bundles (limits, options, spam detection) that
workspaces attach via `policy_id`. Your account already has a default one.
workspaces attach via `policy_id`. A starter "Default Policy" mirroring your
plan's maximums may already exist. Policies are optional: a workspace with no
policy runs at your billing plan's maximum limits, and policy limits can
never exceed the plan (blank limits default to the plan maximum; higher
values are rejected by the API).

```bash
nylas agent policy list
Expand All @@ -135,13 +139,12 @@ nylas workspace update <workspace-id> --policy-id <policy-id>
```
Policies (2)

1. Default Policy
ID: pppppppp-1111-1111-1111-111111111111
Attached: support@yourapp.nylas.email (workspace aaaaaaaa-...)
1. Default Policy pppppppp-1111-1111-1111-111111111111
Updated: 2 days ago
Agent: support@yourapp.nylas.email (gggggggg-1111-1111-1111-111111111111)

2. Strict Policy
ID: pppppppp-2222-2222-2222-222222222222
Attached: (none)
2. Strict Policy pppppppp-2222-2222-2222-222222222222
Updated: 5 hours ago
```

## Step 5 — Lists
Expand Down
20 changes: 15 additions & 5 deletions docs/commands/agent-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ nylas agent policy create --data-file policy.json
nylas agent policy create --data '{"name":"Strict Policy","rules":["rule-123"]}'
```

Every limit is optional: omitted limits default to your billing plan's
maximum, and values above the plan maximum are rejected by the API.

Example payload:

```json
Expand Down Expand Up @@ -130,19 +133,26 @@ Policies attach to workspaces via `policy_id`. To assign a policy to an agent ac
nylas workspace update <workspace-id> --policy-id <policy-id>
```

The API auto-creates a default workspace and policy when an agent account is created.
The API auto-creates a default workspace when an agent account is created
(and may create a starter "Default Policy" mirroring your plan's maximums).
A workspace with no `policy_id` attached runs at your billing plan's maximum
limits — having zero policies is a valid state.

## Troubleshooting

If `nylas agent policy list` returns nothing:

- no policies have been explicitly created via `/v3/policies`
- the API auto-creates a default policy on the workspace, but it does not appear in `/v3/policies`
- no policies currently exist for the application — accounts run at your
billing plan's maximum limits
- create one with `nylas agent policy create`; blank limits default to the
plan maximums

If `nylas agent policy delete` fails:

- the policy is still attached to one or more agent workspaces
- run `nylas agent policy list` to see the attached workspace mappings
- the policy is still attached to one or more agent workspaces (the CLI
blocks the delete to avoid leaving dangling `policy_id` references)
- run `nylas agent policy list` to see the attached workspace mappings, then
detach with `nylas workspace update <workspace-id> --policy-id <other-id>`

## See Also

Expand Down
21 changes: 11 additions & 10 deletions docs/commands/agent-studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ a chip.

## Accounts view

One row per account: status dot, email, workspace, governing policy (🔒 when
it is the plan ceiling), rule count, and a shared-workspace badge. Substring
search filters by email, workspace, or policy. Inline quick actions:
One row per account: status dot, email, workspace, governing policy ("plan
maximums" when none is attached), rule count, and a shared-workspace badge.
Substring search filters by email, workspace, or policy. Inline quick actions:

- **✈ Test** — send a self-addressed test email
- **⟳ Rotate** — rotate the app password (the new password is shown once)
Expand All @@ -54,18 +54,19 @@ Account moves use the workspace `manual-assign` API; the workspace can also
be chosen up-front when creating the account, or moved from the terminal
with `nylas agent account move <email> --workspace <id>`.

## Plan ceiling
## Plan limits

The policy attached to the default workspace is your **plan ceiling**: it
renders locked (🔒) and cannot be edited, deleted, or swapped. Custom policy
limits are validated against the ceiling both in the editor and server-side
(`above_plan_ceiling` on violation).
Your **billing plan** caps every policy limit, enforced by the Nylas API:
omitted limits default to the plan maximum, and values above it are rejected.
A workspace with no policy attached simply runs at plan maximums, so any
policy — including the default workspace's — can be edited, deleted, or
swapped freely.

## Creating resources

The **+ New** menu creates agent accounts (with an app-password generator and
optional workspace pick), workspaces, policies (ceiling-bounded limit fields),
rules, and lists — plus one-click rule recipes.
optional workspace pick), workspaces, policies (blank limits default to plan
maximums), rules, and lists — plus one-click rule recipes.

The **rule builder** is sentence-shaped and constrained by the live API
matrix: inbound rules only offer `from.*` fields; outbound adds `recipient.*`
Expand Down
3 changes: 3 additions & 0 deletions docs/commands/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ support@yourapp.nylas.email valid
└── Archive newsletters (inbound) [disabled]
```

Workspaces with no policy (or a dangling `policy_id`) note that plan
maximums apply — accounts without a policy run at the billing plan's limits.

The overview also flags problems the API does not prevent:
- ⚠ dangling references — workspace `policy_id`/`rule_ids` or rule `in_list`
conditions pointing at deleted resources
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/agent/overview.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ func printOverviewWorkspace(acct agentgraph.Account) {
fmt.Printf("└── Workspace: %s%s\n", name, suffix)

if acct.Policy == nil {
fmt.Println(" ├── (no policy attached)")
fmt.Println(" ├── (no policy attached — plan maximums apply)")
} else if acct.Policy.Missing {
fmt.Printf(" ├── ⚠ Policy %s no longer exists\n", acct.Policy.ID)
fmt.Printf(" ├── ⚠ Policy %s no longer exists — plan maximums apply\n", acct.Policy.ID)
} else {
fmt.Printf(" ├── Policy: %s\n", acct.Policy.Name)
}
Expand Down
25 changes: 24 additions & 1 deletion internal/cli/agent/overview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ package agent
import (
"testing"

"github.com/nylas/cli/internal/agentgraph"
"github.com/stretchr/testify/assert"
)

// Graph-assembly behavior is tested in internal/agentgraph; this file covers
// only the command wiring.
// the command wiring and the policy-line rendering.

func TestAgentOverviewCmd(t *testing.T) {
cmd := newOverviewCmd()
Expand All @@ -16,3 +17,25 @@ func TestAgentOverviewCmd(t *testing.T) {
assert.Contains(t, cmd.Aliases, "tree")
assert.Contains(t, cmd.Short, "overview")
}

// A workspace without a (live) policy is a normal state, not an error: the
// account runs at the billing plan's maximum limits, and the output must say
// so instead of implying a policy is required.
func TestPrintOverviewWorkspace_PolicyFallbackMessaging(t *testing.T) {
base := agentgraph.Account{WorkspaceID: "ws-1", WorkspaceName: "Support"}

noPolicy := base
out := captureStdout(t, func() { printOverviewWorkspace(noPolicy) })
assert.Contains(t, out, "no policy attached — plan maximums apply")

missing := base
missing.Policy = &agentgraph.Policy{ID: "policy-gone", Missing: true}
out = captureStdout(t, func() { printOverviewWorkspace(missing) })
assert.Contains(t, out, "Policy policy-gone no longer exists — plan maximums apply")

attached := base
attached.Policy = &agentgraph.Policy{ID: "policy-1", Name: "Strict"}
out = captureStdout(t, func() { printOverviewWorkspace(attached) })
assert.Contains(t, out, "Policy: Strict")
assert.NotContains(t, out, "plan maximums")
}
21 changes: 13 additions & 8 deletions internal/studio/handlers_mutations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,25 +97,30 @@ func TestHandleWorkspacePatch_SetPolicy(t *testing.T) {
}
}

func TestHandleWorkspacePatch_DefaultWorkspacePolicyLocked(t *testing.T) {
func TestHandleWorkspacePatch_DefaultWorkspacePolicyAllowed(t *testing.T) {
t.Parallel()
server := newTestServer()
rec := &workspaceUpdateRecorder{MockClient: nylasmock.NewMockClient()}
server := NewServer("127.0.0.1:0", rec)

// workspace-1 is the default workspace: its policy defines the plan
// ceiling, so swapping it would bypass the ceiling-policy protection.
// workspace-1 is the default workspace. Its policy is not a plan ceiling
// (the ceiling is the billing plan, enforced by the API), so swapping it
// must work — otherwise a stale policy reference on the default workspace
// becomes unrepairable from Studio.
w := doJSON(t, server.routeWorkspaces, http.MethodPatch, "/api/workspaces/workspace-1", `{"policy_id":"policy-9"}`)

if w.Code != http.StatusForbidden {
t.Fatalf("default workspace policy swap must be rejected: expected 403, got %d (body: %s)", w.Code, w.Body.String())
if w.Code != http.StatusOK {
t.Fatalf("default workspace policy swap must be allowed: expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
decodeMutation(t, w)
if rec.gotPolicyID == nil || *rec.gotPolicyID != "policy-9" {
t.Fatalf("expected policy_id forwarded to UpdateWorkspace, got %v", rec.gotPolicyID)
}
}

func TestHandleWorkspacePatch_DefaultWorkspaceRuleAttachAllowed(t *testing.T) {
t.Parallel()
server := newTestServer()

// Only the policy slot is locked on the default workspace; rule
// attachments remain legitimate.
w := doJSON(t, server.routeWorkspaces, http.MethodPatch, "/api/workspaces/workspace-1", `{"add_rule_ids":["rule-2"]}`)

if w.Code != http.StatusOK {
Expand Down
120 changes: 7 additions & 113 deletions internal/studio/handlers_policies.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
package studio

import (
"context"
"fmt"
"net/http"
"strings"

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

func (s *Server) routePolicies(w http.ResponseWriter, r *http.Request) {
Expand All @@ -26,7 +22,7 @@ func (s *Server) routePolicies(w http.ResponseWriter, r *http.Request) {
}

// handlePolicyGet returns full policy detail (limits, spam detection) for the
// ceiling-bounded editor.
// policy editor.
func (s *Server) handlePolicyGet(w http.ResponseWriter, r *http.Request, id string) {
ctx, cancel := s.withTimeout(r)
defer cancel()
Expand All @@ -39,6 +35,12 @@ func (s *Server) handlePolicyGet(w http.ResponseWriter, r *http.Request, id stri
writeJSON(w, http.StatusOK, policy)
}

// Policy mutations forward to the API unchecked: the plan ceiling is the
// billing plan itself, enforced server-side by Nylas — limits above the plan
// maximum are rejected upstream, and omitted limits default to the plan
// maximum. No policy is special; a workspace with no policy simply runs at
// plan maximums.

func (s *Server) handlePolicyCreate(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if !decodeBody(w, r, &payload) {
Expand All @@ -52,10 +54,6 @@ func (s *Server) handlePolicyCreate(w http.ResponseWriter, r *http.Request) {
ctx, cancel := s.withTimeout(r)
defer cancel()

if !s.validateAgainstCeiling(ctx, w, payload) {
return
}

policy, err := s.nylasClient.CreatePolicy(ctx, payload)
if err != nil {
writeMutationError(w, "Failed to create policy", err)
Expand All @@ -74,13 +72,6 @@ func (s *Server) handlePolicyPatch(w http.ResponseWriter, r *http.Request, id st
ctx, cancel := s.withTimeout(r)
defer cancel()

if !s.requireMutablePolicy(ctx, w, id) {
return
}
if !s.validateAgainstCeiling(ctx, w, payload) {
return
}

if _, err := s.nylasClient.UpdatePolicy(ctx, id, payload); err != nil {
writeMutationError(w, "Failed to update policy", err)
return
Expand All @@ -93,107 +84,10 @@ func (s *Server) handlePolicyDelete(w http.ResponseWriter, r *http.Request, id s
ctx, cancel := s.withTimeout(r)
defer cancel()

if !s.requireMutablePolicy(ctx, w, id) {
return
}

if err := s.nylasClient.DeletePolicy(ctx, id); err != nil {
writeMutationError(w, "Failed to delete policy", err)
return
}

s.respondMutation(ctx, w, http.StatusOK, id)
}

// planCeilingPolicyID identifies the plan-ceiling policy: the one attached to
// the default workspace. It is immutable and its limits bound custom policies.
func (s *Server) planCeilingPolicyID(ctx context.Context) (string, error) {
workspaces, err := s.nylasClient.ListWorkspaces(ctx)
if err != nil {
return "", err
}
for _, ws := range workspaces {
if ws.Default {
return strings.TrimSpace(ws.PolicyID), nil
}
}
return "", nil
}

// requireMutablePolicy rejects writes against the plan-ceiling policy.
func (s *Server) requireMutablePolicy(ctx context.Context, w http.ResponseWriter, id string) bool {
ceilingID, err := s.planCeilingPolicyID(ctx)
if err != nil {
writeMutationError(w, "Failed to resolve plan ceiling policy", err)
return false
}
if ceilingID != "" && id == ceilingID {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "default_policy_immutable",
"message": "The default policy is your plan ceiling and cannot be modified",
})
return false
}
return true
}

// validateAgainstCeiling rejects numeric limits exceeding the plan ceiling.
func (s *Server) validateAgainstCeiling(ctx context.Context, w http.ResponseWriter, payload map[string]any) bool {
limits, _ := payload["limits"].(map[string]any)
if len(limits) == 0 {
return true
}

ceilingID, err := s.planCeilingPolicyID(ctx)
if err != nil || ceilingID == "" {
// No resolvable ceiling: let the API enforce its own bounds.
return true
}
ceiling, err := s.nylasClient.GetPolicy(ctx, ceilingID)
if err != nil || ceiling == nil || ceiling.Limits == nil {
return true
}

for field, max := range ceilingLimitValues(ceiling.Limits) {
requested, ok := limits[field].(float64)
if !ok {
continue
}
if requested > max {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "above_plan_ceiling",
"message": fmt.Sprintf("%s exceeds the plan ceiling (%v > %v)", field, requested, max),
})
return false
}
}
return true
}

// ceilingLimitValues flattens the ceiling policy's numeric limits into the
// wire field names used by policy payloads.
func ceilingLimitValues(limits *domain.PolicyLimits) map[string]float64 {
out := make(map[string]float64, 7)
if limits.LimitAttachmentSizeInBytes != nil {
out["limit_attachment_size_limit"] = float64(*limits.LimitAttachmentSizeInBytes)
}
if limits.LimitAttachmentCount != nil {
out["limit_attachment_count_limit"] = float64(*limits.LimitAttachmentCount)
}
if limits.LimitSizeTotalMimeInBytes != nil {
out["limit_size_total_mime"] = float64(*limits.LimitSizeTotalMimeInBytes)
}
if limits.LimitStorageTotalInBytes != nil {
out["limit_storage_total"] = float64(*limits.LimitStorageTotalInBytes)
}
if limits.LimitCountDailyMessagePerGrant != nil {
out["limit_count_daily_message_per_grant"] = float64(*limits.LimitCountDailyMessagePerGrant)
}
if limits.LimitInboxRetentionPeriodInDays != nil {
out["limit_inbox_retention_period"] = float64(*limits.LimitInboxRetentionPeriodInDays)
}
if limits.LimitSpamRetentionPeriodInDays != nil {
out["limit_spam_retention_period"] = float64(*limits.LimitSpamRetentionPeriodInDays)
}
return out
}
Loading
Loading