From 5411f48515c811b5ffa01a95c5a75e4734c7e083 Mon Sep 17 00:00:00 2001 From: Qasim Date: Wed, 17 Jun 2026 13:55:52 -0400 Subject: [PATCH] TW-5464: support setting and updating agent account display name Add an optional top-level `name` (display name) to agent accounts, settable on `nylas agent account create --name` and updatable via `nylas agent account update --name`. `name` is a top-level grant field (POST /v3/connect/custom and PATCH /v3/grants/{id}), not a setting, and is omitted when empty. Because the grant update replaces the full record, updates re-send the existing name unless the caller overrides it, so an app-password rotation (or rename) never wipes the display name. Validation is 1-256 runes. Threaded through the domain model, ports.AgentClient, the HTTP/demo/ mock adapters, the CLI create/update commands (and the create app-password fallback), and the Air web handler. Docs updated. --- docs/COMMANDS.md | 2 + docs/commands/agent-getting-started.md | 1 + docs/commands/agent.md | 29 +++- internal/adapters/nylas/agent.go | 12 +- internal/adapters/nylas/agent_test.go | 99 ++++++++++++-- internal/adapters/nylas/demo_agent.go | 6 +- internal/adapters/nylas/managed_grants.go | 2 + internal/adapters/nylas/mock_agent.go | 6 +- internal/cli/agent/agent_payload_test.go | 124 ++++++++++++++++++ internal/cli/agent/create.go | 48 +++++-- internal/cli/agent/create_test.go | 48 ++++--- internal/cli/agent/helpers.go | 3 + internal/cli/agent/update.go | 30 ++++- .../cli/integration/agent_default_test.go | 2 +- internal/cli/integration/agent_policy_test.go | 2 +- internal/cli/integration/agent_test.go | 6 +- internal/domain/agent.go | 1 + internal/ports/agent.go | 7 +- internal/studio/handlers_accounts.go | 35 ++++- internal/studio/handlers_mutations_test.go | 123 +++++++++++++++++ 20 files changed, 525 insertions(+), 61 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6fd668d..4354aff 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -471,8 +471,10 @@ Create and manage Nylas-managed agent accounts backed by provider `nylas`. ```bash nylas agent account list # List agent accounts nylas agent account create # Create agent account +nylas agent account create --name "Support Bot" # Create account with a display name nylas agent account create --app-password PW # Create account with IMAP/SMTP app password nylas agent account update [agent-id|email] --app-password PW # Add or rotate IMAP/SMTP app password +nylas agent account update [agent-id|email] --name "Support Bot" # Set/rename the display name nylas agent account get # Show one agent account nylas agent account move --workspace # Move account to another workspace nylas agent account delete # Delete/revoke agent account diff --git a/docs/commands/agent-getting-started.md b/docs/commands/agent-getting-started.md index 8113cf0..5e77c8d 100644 --- a/docs/commands/agent-getting-started.md +++ b/docs/commands/agent-getting-started.md @@ -61,6 +61,7 @@ Notes: - the `nylas` connector is created automatically on first use - the API auto-creates a **default workspace and policy** for the account +- add `--name 'Support Bot'` to set a display name on the account (1–256 characters) - add `--app-password 'ValidAgentPass123ABC!'` to also enable IMAP/SMTP mail-client access (see Step 7) - `--json` prints the raw payload for scripting: diff --git a/docs/commands/agent.md b/docs/commands/agent.md index b0e051e..cd251d5 100644 --- a/docs/commands/agent.md +++ b/docs/commands/agent.md @@ -67,6 +67,7 @@ Agent Accounts (2) ```bash nylas agent account create me@yourapp.nylas.email +nylas agent account create me@yourapp.nylas.email --name 'Support Bot' nylas agent account create me@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' nylas agent account create support@yourapp.nylas.email --json ``` @@ -76,6 +77,7 @@ Behavior: - automatically creates the `nylas` connector first if it does not exist - the API auto-creates a default workspace and policy for the account - stores the created grant locally like other authenticated accounts +- optionally sets a top-level `name` (display name) on the grant - optionally sets `settings.app_password` on the grant for IMAP/SMTP mail client access To attach a custom policy after creation: @@ -94,6 +96,18 @@ Provider: nylas Status: valid ``` +### `--name` + +Use `--name` to set a top-level display name on the agent account grant. This is a single name field (not split into first/last) and is independent of `settings`. + +```bash +nylas agent account create me@yourapp.nylas.email --name 'Support Bot' +``` + +Requirements: +- 1 to 256 characters when set +- omitted from the request entirely when not provided + ### `--app-password` Use `--app-password` when you want the agent account to work with a standard mail client over IMAP/SMTP submission. @@ -127,15 +141,26 @@ You can look up an agent account by grant ID or by email address. If you omit th ```bash nylas agent account update --app-password 'ValidAgentPass123ABC!' nylas agent account update 12345678-1234-1234-1234-123456789012 --app-password 'ValidAgentPass123ABC!' +nylas agent account update me@yourapp.nylas.email --name 'Support Bot' nylas agent account update me@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' --json ``` Behavior: - updates the resolved local `provider=nylas` grant when no identifier is passed -- currently supports rotating or adding `settings.app_password` +- supports rotating or adding `settings.app_password` and/or setting the top-level `name` +- requires at least one of `--app-password` or `--name` - preserves the existing account email and policy attachment +- preserves the existing display name when `--name` is not passed (the grant update replaces the full record, so the CLI re-sends the current name) + +Use this when you want to add mail-client access after creation, rotate an existing IMAP/SMTP app password, or rename the account. + +### `--name` (update) -Use this when you want to add mail-client access after creation or rotate an existing IMAP/SMTP app password. +`--name` sets the top-level display name (1–256 characters). Omit it to leave the current name unchanged. Clearing an existing name (setting it back to empty) is not currently supported by the grant API. + +```bash +nylas agent account update me@yourapp.nylas.email --name 'Support Bot' +``` ## Move Agent Account diff --git a/internal/adapters/nylas/agent.go b/internal/adapters/nylas/agent.go index 7dbf2ea..f5c16c6 100644 --- a/internal/adapters/nylas/agent.go +++ b/internal/adapters/nylas/agent.go @@ -42,7 +42,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, workspaceID string) (*domain.AgentAccount, error) { +func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { queryURL := fmt.Sprintf("%s/v3/connect/custom", c.baseURL) settings := map[string]any{ @@ -56,6 +56,9 @@ func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword, "provider": string(domain.ProviderNylas), "settings": settings, } + if name != "" { + payload["name"] = name + } if workspaceID != "" { payload["workspace_id"] = workspaceID } @@ -78,7 +81,7 @@ func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword, } // UpdateAgentAccount updates mutable settings on an existing managed agent account grant. -func (c *HTTPClient) UpdateAgentAccount(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { +func (c *HTTPClient) UpdateAgentAccount(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { if err := validateRequired("grant ID", grantID); err != nil { return nil, err } @@ -104,6 +107,11 @@ func (c *HTTPClient) UpdateAgentAccount(ctx context.Context, grantID, email, app payload := map[string]any{ "settings": settings, } + // name is a top-level grant field. The grant update replaces the full + // record, so callers re-send the existing name to preserve it. + if name != "" { + payload["name"] = name + } resp, err := c.doJSONRequest(ctx, "PATCH", queryURL, payload) if err != nil { diff --git a/internal/adapters/nylas/agent_test.go b/internal/adapters/nylas/agent_test.go index 1ee1129..ac33d3b 100644 --- a/internal/adapters/nylas/agent_test.go +++ b/internal/adapters/nylas/agent_test.go @@ -255,11 +255,14 @@ func TestCreateAgentAccount(t *testing.T) { assert.Equal(t, "agent@example.com", settings["email"]) assert.Equal(t, "ValidAgentPass123ABC!", settings["app_password"]) assert.Equal(t, "workspace-123", payload["workspace_id"]) + assert.Equal(t, "Support Bot", payload["name"]) + assert.NotContains(t, settings, "name", "name must be a top-level grant field, not a setting") response := map[string]any{ "data": map[string]any{ "id": "agent-new", "email": "agent@example.com", + "name": "Support Bot", "provider": "nylas", "grant_status": "valid", "workspace_id": "workspace-123", @@ -276,14 +279,44 @@ func TestCreateAgentAccount(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "ValidAgentPass123ABC!", "workspace-123") + account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "Support Bot", "ValidAgentPass123ABC!", "workspace-123") require.NoError(t, err) assert.Equal(t, "agent-new", account.ID) assert.Equal(t, "agent@example.com", account.Email) + assert.Equal(t, "Support Bot", account.Name) assert.Equal(t, "workspace-123", account.WorkspaceID) assert.Empty(t, account.Settings.PolicyID) } +func TestCreateAgentAccount_OmitsNameWhenEmpty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.NotContains(t, payload, "name", "name must be omitted from the payload when empty") + + response := map[string]any{ + "data": map[string]any{ + "id": "agent-new", + "email": "agent@example.com", + "provider": "nylas", + "grant_status": "valid", + "created_at": time.Now().Unix(), + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "", "", "") + require.NoError(t, err) + assert.Empty(t, account.Name) +} + func TestUpdateAgentAccount(t *testing.T) { var getCalls int32 var patchCalls int32 @@ -320,6 +353,7 @@ func TestUpdateAgentAccount(t *testing.T) { assert.Equal(t, "agent@example.com", settings["email"]) assert.Equal(t, "ValidAgentPass123ABC!", settings["app_password"]) assert.NotContains(t, settings, "policy_id") + assert.NotContains(t, payload, "name", "name must be omitted when empty") response := map[string]any{ "data": map[string]any{ @@ -346,7 +380,7 @@ func TestUpdateAgentAccount(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - account, err := client.UpdateAgentAccount(context.Background(), "agent-123", "agent@example.com", "ValidAgentPass123ABC!") + account, err := client.UpdateAgentAccount(context.Background(), "agent-123", "agent@example.com", "", "ValidAgentPass123ABC!") require.NoError(t, err) assert.Equal(t, "agent-123", account.ID) assert.Equal(t, "agent@example.com", account.Email) @@ -355,6 +389,55 @@ func TestUpdateAgentAccount(t *testing.T) { assert.EqualValues(t, 1, atomic.LoadInt32(&patchCalls)) } +func TestUpdateAgentAccount_SendsNameTopLevel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + response := map[string]any{ + "data": map[string]any{ + "id": "agent-123", + "email": "agent@example.com", + "provider": "nylas", + "grant_status": "valid", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + case http.MethodPatch: + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + + assert.Equal(t, "Renamed Bot", payload["name"]) + settings, ok := payload["settings"].(map[string]any) + require.True(t, ok) + assert.NotContains(t, settings, "name", "name must be top-level, not a setting") + + response := map[string]any{ + "data": map[string]any{ + "id": "agent-123", + "email": "agent@example.com", + "name": "Renamed Bot", + "provider": "nylas", + "grant_status": "valid", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + account, err := client.UpdateAgentAccount(context.Background(), "agent-123", "agent@example.com", "Renamed Bot", "") + require.NoError(t, err) + assert.Equal(t, "Renamed Bot", account.Name) +} + func TestUpdateAgentAccount_PreservesEmptyPolicyID(t *testing.T) { var patchCalls int32 @@ -408,7 +491,7 @@ func TestUpdateAgentAccount_PreservesEmptyPolicyID(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - account, err := client.UpdateAgentAccount(context.Background(), "agent-123", "agent@example.com", "ValidAgentPass123ABC!") + account, err := client.UpdateAgentAccount(context.Background(), "agent-123", "agent@example.com", "", "ValidAgentPass123ABC!") require.NoError(t, err) assert.Equal(t, "agent-123", account.ID) assert.EqualValues(t, 1, atomic.LoadInt32(&patchCalls)) @@ -435,7 +518,7 @@ func TestCreateAgentAccount_RejectsNonNylasResponse(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - _, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "", "") + _, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "", "", "") require.Error(t, err) assert.Contains(t, err.Error(), "non-nylas managed grant") } @@ -461,7 +544,7 @@ func TestUpdateAgentAccount_RejectsNonNylasResponse(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - _, err := client.UpdateAgentAccount(context.Background(), "grant-001", "agent@example.com", "ValidAgentPass123ABC!") + _, err := client.UpdateAgentAccount(context.Background(), "grant-001", "agent@example.com", "", "ValidAgentPass123ABC!") require.Error(t, err) assert.Contains(t, err.Error(), "grant is not a nylas agent account") } @@ -485,7 +568,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) @@ -516,7 +599,7 @@ func TestCreateAgentAccount_OmitsWorkspaceIDWhenEmpty(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-new", account.ID) } @@ -554,7 +637,7 @@ func TestUpdateAgentAccount_RejectsNonNylasGrantBeforePatch(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - _, err := client.UpdateAgentAccount(context.Background(), "grant-001", "agent@example.com", "ValidAgentPass123ABC!") + _, err := client.UpdateAgentAccount(context.Background(), "grant-001", "agent@example.com", "", "ValidAgentPass123ABC!") require.Error(t, err) assert.Contains(t, err.Error(), "grant is not a nylas agent account") assert.EqualValues(t, 0, atomic.LoadInt32(&patchCalls)) diff --git a/internal/adapters/nylas/demo_agent.go b/internal/adapters/nylas/demo_agent.go index 9bc95e8..f58e3f7 100644 --- a/internal/adapters/nylas/demo_agent.go +++ b/internal/adapters/nylas/demo_agent.go @@ -28,21 +28,23 @@ func (d *DemoClient) GetAgentAccount(ctx context.Context, grantID string) (*doma }, nil } -func (d *DemoClient) CreateAgentAccount(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { +func (d *DemoClient) CreateAgentAccount(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { return &domain.AgentAccount{ ID: "agent-demo-new", Provider: domain.ProviderNylas, Email: email, + Name: name, GrantStatus: "valid", WorkspaceID: "workspace-demo-new", }, nil } -func (d *DemoClient) UpdateAgentAccount(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { +func (d *DemoClient) UpdateAgentAccount(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { return &domain.AgentAccount{ ID: grantID, Provider: domain.ProviderNylas, Email: email, + Name: name, GrantStatus: "valid", WorkspaceID: "workspace-demo-1", }, nil diff --git a/internal/adapters/nylas/managed_grants.go b/internal/adapters/nylas/managed_grants.go index 4cc96f0..ce511e4 100644 --- a/internal/adapters/nylas/managed_grants.go +++ b/internal/adapters/nylas/managed_grants.go @@ -11,6 +11,7 @@ import ( type managedGrantResponse struct { ID string `json:"id"` Email string `json:"email"` + Name string `json:"name,omitempty"` Provider domain.Provider `json:"provider"` GrantStatus string `json:"grant_status"` WorkspaceID string `json:"workspace_id,omitempty"` @@ -98,6 +99,7 @@ func convertManagedGrantToAgentAccount(grant managedGrantResponse) domain.AgentA ID: grant.ID, Provider: grant.Provider, Email: grant.Email, + Name: grant.Name, GrantStatus: grant.GrantStatus, WorkspaceID: grant.WorkspaceID, CreatedAt: grant.CreatedAt, diff --git a/internal/adapters/nylas/mock_agent.go b/internal/adapters/nylas/mock_agent.go index 8140eec..ada0de1 100644 --- a/internal/adapters/nylas/mock_agent.go +++ b/internal/adapters/nylas/mock_agent.go @@ -28,21 +28,23 @@ func (m *MockClient) GetAgentAccount(ctx context.Context, grantID string) (*doma }, nil } -func (m *MockClient) CreateAgentAccount(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { +func (m *MockClient) CreateAgentAccount(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { return &domain.AgentAccount{ ID: "agent-new", Provider: domain.ProviderNylas, Email: email, + Name: name, GrantStatus: "valid", WorkspaceID: "workspace-new", }, nil } -func (m *MockClient) UpdateAgentAccount(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { +func (m *MockClient) UpdateAgentAccount(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { return &domain.AgentAccount{ ID: grantID, Provider: domain.ProviderNylas, Email: email, + Name: name, GrantStatus: "valid", WorkspaceID: "workspace-1", }, nil diff --git a/internal/cli/agent/agent_payload_test.go b/internal/cli/agent/agent_payload_test.go index ca850d2..5d88ad3 100644 --- a/internal/cli/agent/agent_payload_test.go +++ b/internal/cli/agent/agent_payload_test.go @@ -1,6 +1,9 @@ package agent import ( + "context" + "net/http" + "strings" "testing" "github.com/nylas/cli/internal/cli/common" @@ -512,6 +515,127 @@ func TestValidateAgentAppPassword(t *testing.T) { assert.EqualError(t, validateAgentAppPassword("alllowercasepassword123"), "app password must include at least one uppercase letter, one lowercase letter, and one digit") } +func TestValidateAgentName(t *testing.T) { + assert.NoError(t, validateAgentName(""), "empty name is valid (field omitted)") + assert.NoError(t, validateAgentName("Support Bot")) + assert.NoError(t, validateAgentName(strings.Repeat("a", 256)), "256 chars is the upper bound") + // Length is measured in runes, not bytes: 256 multi-byte chars are valid + // even though they span far more than 256 bytes. + assert.NoError(t, validateAgentName(strings.Repeat("あ", 256)), "256 multi-byte runes must be accepted") + + assert.EqualError(t, validateAgentName(strings.Repeat("a", 257)), "name must be 256 characters or fewer") + assert.EqualError(t, validateAgentName(strings.Repeat("あ", 257)), "name must be 256 characters or fewer") +} + +func TestCreateCmd_HasNameFlag(t *testing.T) { + cmd := newCreateCmd() + assert.NotNil(t, cmd.Flags().Lookup("name"), "create command must expose a --name flag") +} + +func TestUpdateCmd_HasNameFlag(t *testing.T) { + cmd := newUpdateCmd() + assert.NotNil(t, cmd.Flags().Lookup("name"), "update command must expose a --name flag") +} + +func TestResolveEffectiveName(t *testing.T) { + // Not provided: the existing name is preserved (the grant update replaces + // the whole record, so omitting it would clear the name). + assert.Equal(t, "Existing Bot", resolveEffectiveName("Existing Bot", "", false)) + assert.Equal(t, "Existing Bot", resolveEffectiveName("Existing Bot", "Ignored", false)) + + // Provided: the caller's value wins. (An explicit empty value resolves to "" + // here, but the adapter omits an empty name from the payload, so this does + // not clear an existing name end-to-end — clearing is unsupported.) + assert.Equal(t, "Renamed Bot", resolveEffectiveName("Existing Bot", "Renamed Bot", true)) + assert.Equal(t, "", resolveEffectiveName("Existing Bot", "", true)) +} + +func TestUpdateAgentAccount_RetryPathPreservesName(t *testing.T) { + // When create is retried without the app password and then patched to set + // it, the patch must carry the name so it is not wiped. + initialErr := &domain.APIError{ + StatusCode: http.StatusBadRequest, + Message: "settings.app_password is an unknown field", + } + var createNames, updateNames []string + createCalls := 0 + + client := stubAgentClient{ + listFn: func(ctx context.Context) ([]domain.AgentAccount, error) { return nil, nil }, + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { + createCalls++ + createNames = append(createNames, name) + if appPassword != "" { + return nil, initialErr + } + return &domain.AgentAccount{ID: "agent-new", Email: email, Name: name, Provider: domain.ProviderNylas}, nil + }, + updateFn: func(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { + updateNames = append(updateNames, name) + return &domain.AgentAccount{ID: grantID, Email: email, Name: name, Provider: domain.ProviderNylas}, nil + }, + } + + account, err := createAgentAccountWithFallback(context.Background(), client, "agent@example.com", "Support Bot", "ValidAgentPass123ABC!") + require.NoError(t, err) + require.NotNil(t, account) + assert.Equal(t, []string{"Support Bot", "Support Bot"}, createNames, "both create attempts carry the name") + assert.Equal(t, []string{"Support Bot"}, updateNames, "the password-setting patch preserves the name") + assert.Equal(t, "Support Bot", account.Name) +} + +func TestUpdateAgentAccount_ExistingAccountFallbackPreservesName(t *testing.T) { + // Initial create fails on app_password, an existing account is found, and + // we set the password via update. With no --name supplied, the existing + // account's name must be preserved (not wiped by the full-record PATCH). + initialErr := &domain.APIError{ + StatusCode: http.StatusBadRequest, + Message: "settings.app_password is an unknown field", + } + var updateName string + + client := stubAgentClient{ + listFn: func(ctx context.Context) ([]domain.AgentAccount, error) { + return []domain.AgentAccount{{ + ID: "agent-existing", + Email: "agent@example.com", + Name: "Existing Bot", + Provider: domain.ProviderNylas, + }}, nil + }, + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { + return nil, initialErr + }, + updateFn: func(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { + updateName = name + return &domain.AgentAccount{ID: grantID, Email: email, Name: name, Provider: domain.ProviderNylas}, nil + }, + } + + // No name supplied by the caller (empty) — must fall back to the existing name. + account, err := createAgentAccountWithFallback(context.Background(), client, "agent@example.com", "", "ValidAgentPass123ABC!") + require.NoError(t, err) + require.NotNil(t, account) + assert.Equal(t, "Existing Bot", updateName, "existing display name must be preserved when no name is supplied") + assert.Equal(t, "Existing Bot", account.Name) +} + +func TestCreateAgentAccountWithFallback_PassesNameToCreate(t *testing.T) { + var gotName string + client := stubAgentClient{ + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { + gotName = name + return &domain.AgentAccount{ID: "agent-new", Email: email, Name: name, Provider: domain.ProviderNylas}, nil + }, + } + + account, err := createAgentAccountWithFallback(context.Background(), client, "agent@example.com", "Support Bot", "") + require.NoError(t, err) + require.NotNil(t, account) + assert.Equal(t, "Support Bot", gotName) + assert.Equal(t, "Support Bot", account.Name) +} + func TestDeleteCmd(t *testing.T) { cmd := newDeleteCmd() diff --git a/internal/cli/agent/create.go b/internal/cli/agent/create.go index 66a5cee..2c27e27 100644 --- a/internal/cli/agent/create.go +++ b/internal/cli/agent/create.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strings" + "unicode/utf8" "github.com/nylas/cli/internal/cli/common" "github.com/nylas/cli/internal/domain" @@ -15,6 +16,7 @@ import ( func newCreateCmd() *cobra.Command { var appPassword string + var name string cmd := &cobra.Command{ Use: "create ", @@ -31,19 +33,21 @@ To attach a custom policy after creation: Examples: nylas agent account create me@yourapp.nylas.email nylas agent account create support@yourapp.nylas.email --json + nylas agent account create support@yourapp.nylas.email --name 'Support Bot' nylas agent account create debug@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!'`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runCreate(args[0], appPassword, common.IsJSON(cmd)) + return runCreate(args[0], name, appPassword, common.IsJSON(cmd)) }, } + cmd.Flags().StringVar(&name, "name", "", "Optional display name for the agent account (1-256 characters)") cmd.Flags().StringVar(&appPassword, "app-password", "", "Optional IMAP/SMTP app password for mail-client access") return cmd } -func runCreate(email, appPassword string, jsonOutput bool) error { +func runCreate(email, name, appPassword string, jsonOutput bool) error { email = strings.TrimSpace(email) if email == "" { common.PrintError("Email address cannot be empty") @@ -53,6 +57,11 @@ func runCreate(email, appPassword string, jsonOutput bool) error { common.PrintError("Email address should not contain spaces") return common.NewInputError("invalid email address - should not contain spaces") } + name = strings.TrimSpace(name) + if err := validateAgentName(name); err != nil { + common.PrintError(err.Error()) + return err + } if err := validateAgentAppPassword(appPassword); err != nil { common.PrintError(err.Error()) return err @@ -63,7 +72,7 @@ func runCreate(email, appPassword string, jsonOutput bool) error { return struct{}{}, common.WrapCreateError("nylas connector", err) } - account, err := createAgentAccountWithFallback(ctx, client, email, appPassword) + account, err := createAgentAccountWithFallback(ctx, client, email, name, appPassword) if err != nil { return struct{}{}, common.WrapCreateError("agent account", err) } @@ -94,15 +103,22 @@ func runCreate(email, appPassword string, jsonOutput bool) error { return err } -func createAgentAccountWithFallback(ctx context.Context, client ports.AgentClient, email, appPassword string) (*domain.AgentAccount, error) { - account, err := client.CreateAgentAccount(ctx, email, appPassword, "") +func createAgentAccountWithFallback(ctx context.Context, client ports.AgentClient, email, name, appPassword string) (*domain.AgentAccount, error) { + account, err := client.CreateAgentAccount(ctx, email, name, appPassword, "") if err == nil || appPassword == "" || !shouldRetryAgentCreateWithoutPassword(err) { return account, err } existingAccount, lookupErr := findExistingAgentAccountByEmail(ctx, client, email) if lookupErr == nil && existingAccount != nil { - updated, updateErr := client.UpdateAgentAccount(ctx, existingAccount.ID, email, appPassword) + // Apply the requested name when given, otherwise preserve the existing + // account's name — the grant update replaces the full record, so an + // empty name would clear it. + effectiveName := name + if effectiveName == "" { + effectiveName = existingAccount.Name + } + updated, updateErr := client.UpdateAgentAccount(ctx, existingAccount.ID, email, effectiveName, appPassword) if updateErr == nil { if updated == nil { return existingAccount, nil @@ -113,12 +129,14 @@ func createAgentAccountWithFallback(ctx context.Context, client ports.AgentClien return nil, fmt.Errorf("failed to set app password on existing agent account %s: %w", email, updateErr) } - account, retryErr := client.CreateAgentAccount(ctx, email, "", "") + account, retryErr := client.CreateAgentAccount(ctx, email, name, "", "") if retryErr != nil { return nil, fmt.Errorf("failed to create agent account after retrying without app password: %w", retryErr) } - updated, updateErr := client.UpdateAgentAccount(ctx, account.ID, email, appPassword) + // Re-send name so the password-setting update preserves it (the grant + // update replaces the full record). + updated, updateErr := client.UpdateAgentAccount(ctx, account.ID, email, name, appPassword) if updateErr == nil { return updated, nil } @@ -209,6 +227,20 @@ func shouldRetryAgentCreateWithoutPassword(err error) bool { return false } +// validateAgentName enforces the grant name constraints (1-256 characters when +// set). An empty name is valid and omits the field from the create payload. +// Length is measured in Unicode characters (runes), not bytes, to match the +// documented "1-256 characters" limit for multi-byte names. +func validateAgentName(name string) error { + if name == "" { + return nil + } + if utf8.RuneCountInString(name) > 256 { + return common.NewInputError("name must be 256 characters or fewer") + } + return nil +} + func validateAgentAppPassword(appPassword string) error { if appPassword == "" { return nil diff --git a/internal/cli/agent/create_test.go b/internal/cli/agent/create_test.go index 1512eac..8d57af7 100644 --- a/internal/cli/agent/create_test.go +++ b/internal/cli/agent/create_test.go @@ -13,8 +13,8 @@ import ( type stubAgentClient struct { listFn func(ctx context.Context) ([]domain.AgentAccount, error) - createFn func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) - updateFn func(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) + createFn func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) + updateFn func(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) deleteFn func(ctx context.Context, grantID string) error } @@ -29,18 +29,18 @@ func (s stubAgentClient) GetAgentAccount(ctx context.Context, grantID string) (* return nil, nil } -func (s stubAgentClient) CreateAgentAccount(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { +func (s stubAgentClient) CreateAgentAccount(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { if s.createFn == nil { return nil, nil } - return s.createFn(ctx, email, appPassword, workspaceID) + return s.createFn(ctx, email, name, appPassword, workspaceID) } -func (s stubAgentClient) UpdateAgentAccount(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { +func (s stubAgentClient) UpdateAgentAccount(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { if s.updateFn == nil { return nil, nil } - return s.updateFn(ctx, grantID, email, appPassword) + return s.updateFn(ctx, grantID, email, name, appPassword) } func (s stubAgentClient) DeleteAgentAccount(ctx context.Context, grantID string) error { @@ -61,7 +61,7 @@ func TestCreateAgentAccountWithFallback_ReturnsRetryError(t *testing.T) { createCalls := 0 client := stubAgentClient{ - createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ switch createCalls { case 1: @@ -81,6 +81,7 @@ func TestCreateAgentAccountWithFallback_ReturnsRetryError(t *testing.T) { context.Background(), client, "agent@example.com", + "", "ValidAgentPass123ABC!", ) @@ -110,7 +111,7 @@ func TestCreateAgentAccountWithFallback_SkipsCleanupForExistingGrant(t *testing. Settings: domain.AgentAccountSettings{PolicyID: "policy-123"}, }}, nil }, - createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ if appPassword != "" { return nil, initialErr @@ -118,7 +119,7 @@ func TestCreateAgentAccountWithFallback_SkipsCleanupForExistingGrant(t *testing. t.Fatalf("unexpected create retry for existing grant") return nil, nil }, - updateFn: func(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { + updateFn: func(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { assert.Equal(t, "agent-existing", grantID) return nil, updateErr }, @@ -132,6 +133,7 @@ func TestCreateAgentAccountWithFallback_SkipsCleanupForExistingGrant(t *testing. context.Background(), client, "agent@example.com", + "", "ValidAgentPass123ABC!", ) @@ -160,12 +162,12 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutRetryCreate(t Settings: domain.AgentAccountSettings{PolicyID: "policy-123"}, }}, nil }, - createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ assert.Equal(t, "ValidAgentPass123ABC!", appPassword) return nil, initialErr }, - updateFn: func(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { + updateFn: func(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { updateCalls++ assert.Equal(t, "agent-existing", grantID) assert.Equal(t, "agent@example.com", email) @@ -182,6 +184,7 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutRetryCreate(t context.Background(), client, "agent@example.com", + "", "ValidAgentPass123ABC!", ) @@ -208,11 +211,11 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutCheckingPolic Provider: domain.ProviderNylas, }}, nil }, - createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ return nil, initialErr }, - updateFn: func(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { + updateFn: func(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { updateCalls++ return &domain.AgentAccount{ ID: grantID, @@ -226,6 +229,7 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutCheckingPolic context.Background(), client, "agent@example.com", + "", "ValidAgentPass123ABC!", ) @@ -253,11 +257,11 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantOnDifferentPolicy(t Settings: domain.AgentAccountSettings{PolicyID: "policy-other"}, }}, nil }, - createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ return nil, initialErr }, - updateFn: func(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { + updateFn: func(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { updateCalls++ return &domain.AgentAccount{ ID: grantID, @@ -272,6 +276,7 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantOnDifferentPolicy(t context.Background(), client, "agent@example.com", + "", "ValidAgentPass123ABC!", ) @@ -294,7 +299,7 @@ func TestCreateAgentAccountWithFallback_PreservesNewGrantOnUpdateFailure(t *test listFn: func(ctx context.Context) ([]domain.AgentAccount, error) { return nil, nil }, - createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { if appPassword != "" { return nil, initialErr } @@ -304,7 +309,7 @@ func TestCreateAgentAccountWithFallback_PreservesNewGrantOnUpdateFailure(t *test Provider: domain.ProviderNylas, }, nil }, - updateFn: func(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { + updateFn: func(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { return nil, updateErr }, deleteFn: func(ctx context.Context, grantID string) error { @@ -317,6 +322,7 @@ func TestCreateAgentAccountWithFallback_PreservesNewGrantOnUpdateFailure(t *test context.Background(), client, "agent@example.com", + "", "ValidAgentPass123ABC!", ) @@ -335,7 +341,7 @@ func TestCreateAgentAccountWithFallback_DoesNotInventWorkspaceID(t *testing.T) { } client := stubAgentClient{ - createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { if appPassword != "" { return nil, initialErr } @@ -345,7 +351,7 @@ func TestCreateAgentAccountWithFallback_DoesNotInventWorkspaceID(t *testing.T) { Provider: domain.ProviderNylas, }, nil }, - updateFn: func(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { + updateFn: func(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { return &domain.AgentAccount{ ID: grantID, Email: email, @@ -358,6 +364,7 @@ func TestCreateAgentAccountWithFallback_DoesNotInventWorkspaceID(t *testing.T) { context.Background(), client, "agent@example.com", + "", "ValidAgentPass123ABC!", ) @@ -374,7 +381,7 @@ func TestCreateAgentAccountWithFallback_DoesNotRetryInvalidPasswordValue(t *test } client := stubAgentClient{ - createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ return nil, initialErr }, @@ -384,6 +391,7 @@ func TestCreateAgentAccountWithFallback_DoesNotRetryInvalidPasswordValue(t *test context.Background(), client, "agent@example.com", + "", "ValidAgentPass123ABC!", ) diff --git a/internal/cli/agent/helpers.go b/internal/cli/agent/helpers.go index 47b5b05..8f118d5 100644 --- a/internal/cli/agent/helpers.go +++ b/internal/cli/agent/helpers.go @@ -97,6 +97,9 @@ func printAgentDetails(account domain.AgentAccount) { fmt.Printf("ID: %s\n", account.ID) fmt.Printf("Provider: %s\n", account.Provider.DisplayName()) fmt.Printf("Email: %s\n", account.Email) + if account.Name != "" { + fmt.Printf("Name: %s\n", account.Name) + } fmt.Printf("Status: %s\n", common.FormatGrantStatus(account.GrantStatus)) if account.CredentialID != "" { fmt.Printf("Credential: %s\n", account.CredentialID) diff --git a/internal/cli/agent/update.go b/internal/cli/agent/update.go index 15e1e0f..424caf3 100644 --- a/internal/cli/agent/update.go +++ b/internal/cli/agent/update.go @@ -12,6 +12,7 @@ import ( func newUpdateCmd() *cobra.Command { var appPassword string + var name string cmd := &cobra.Command{ Use: "update [agent-id|email]", @@ -24,6 +25,7 @@ resolves a local provider=nylas grant when one can be identified safely. Examples: nylas agent account update --app-password "MySecureP4ssword!2024" nylas agent account update 123456 --app-password "MySecureP4ssword!2024" + nylas agent account update me@yourapp.nylas.email --name "Support Bot" nylas agent account update me@yourapp.nylas.email --app-password "MySecureP4ssword!2024" --json`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -31,25 +33,33 @@ Examples: if err != nil { return err } - return runUpdate(identifier, appPassword, common.IsJSON(cmd)) + return runUpdate(identifier, name, cmd.Flags().Changed("name"), appPassword, common.IsJSON(cmd)) }, } + cmd.Flags().StringVar(&name, "name", "", "Set the agent account display name (1-256 characters)") cmd.Flags().StringVar(&appPassword, "app-password", "", "Rotate or add the IMAP/SMTP app password") return cmd } -func runUpdate(identifier, appPassword string, jsonOutput bool) error { +func runUpdate(identifier, name string, nameProvided bool, appPassword string, jsonOutput bool) error { appPassword = strings.TrimSpace(appPassword) if err := validateAgentAppPassword(appPassword); err != nil { common.PrintError(err.Error()) return err } - if appPassword == "" { + name = strings.TrimSpace(name) + if nameProvided { + if err := validateAgentName(name); err != nil { + common.PrintError(err.Error()) + return err + } + } + if appPassword == "" && !nameProvided { return common.NewUserError( "agent account update requires at least one field", - "Use --app-password", + "Use --app-password or --name", ) } @@ -64,7 +74,7 @@ func runUpdate(identifier, appPassword string, jsonOutput bool) error { return struct{}{}, common.WrapGetError("agent account", err) } - account, err := client.UpdateAgentAccount(ctx, grantID, current.Email, appPassword) + account, err := client.UpdateAgentAccount(ctx, grantID, current.Email, resolveEffectiveName(current.Name, name, nameProvided), appPassword) if err != nil { return struct{}{}, common.WrapUpdateError("agent account", err) } @@ -81,3 +91,13 @@ func runUpdate(identifier, appPassword string, jsonOutput bool) error { return err } + +// resolveEffectiveName preserves the account's current name unless the caller +// explicitly supplied a new one. The grant update replaces the full record, so +// an omitted name would otherwise clear the existing display name. +func resolveEffectiveName(current, provided string, nameProvided bool) string { + if nameProvided { + return provided + } + return current +} diff --git a/internal/cli/integration/agent_default_test.go b/internal/cli/integration/agent_default_test.go index 0fc0b80..42740f5 100644 --- a/internal/cli/integration/agent_default_test.go +++ b/internal/cli/integration/agent_default_test.go @@ -34,7 +34,7 @@ func TestCLI_AgentUpdate_UsesDefaultGrant(t *testing.T) { acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - account, err := client.CreateAgentAccount(ctx, email, "", "") + account, err := client.CreateAgentAccount(ctx, email, "", "", "") cancel() if err != nil { t.Fatalf("failed to create agent account %q for default update test: %v", email, err) diff --git a/internal/cli/integration/agent_policy_test.go b/internal/cli/integration/agent_policy_test.go index 4043e59..aaf1dd5 100644 --- a/internal/cli/integration/agent_policy_test.go +++ b/internal/cli/integration/agent_policy_test.go @@ -298,7 +298,7 @@ func createAgentWithPolicyForTest(t *testing.T, email, policyID string) *domain. acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), domain.TimeoutAPI) - account, err := client.CreateAgentAccount(ctx, email, "", "") + account, err := client.CreateAgentAccount(ctx, email, "", "", "") cancel() if err != nil { t.Fatalf("failed to create agent for policy attach: %v", err) diff --git a/internal/cli/integration/agent_test.go b/internal/cli/integration/agent_test.go index ec763c5..e4e8308 100644 --- a/internal/cli/integration/agent_test.go +++ b/internal/cli/integration/agent_test.go @@ -294,7 +294,7 @@ func TestCLI_AgentUpdate_ByEmail(t *testing.T) { acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - account, err := client.CreateAgentAccount(ctx, email, "", "") + account, err := client.CreateAgentAccount(ctx, email, "", "", "") cancel() if err != nil { t.Fatalf("failed to create agent account %q for update test: %v", email, err) @@ -608,14 +608,14 @@ func newAgentTestEmail(t *testing.T, prefix string) string { } func createAgentForTest(t *testing.T, client interface { - CreateAgentAccount(context.Context, string, string, string) (*domain.AgentAccount, error) + CreateAgentAccount(context.Context, string, 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/domain/agent.go b/internal/domain/agent.go index 4f07a70..e310024 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -5,6 +5,7 @@ type AgentAccount struct { ID string `json:"id"` Provider Provider `json:"provider"` Email string `json:"email"` + Name string `json:"name,omitempty"` GrantStatus string `json:"grant_status"` WorkspaceID string `json:"workspace_id,omitempty"` Settings AgentAccountSettings `json:"settings,omitempty"` diff --git a/internal/ports/agent.go b/internal/ports/agent.go index b83cfb1..c29941c 100644 --- a/internal/ports/agent.go +++ b/internal/ports/agent.go @@ -15,14 +15,17 @@ type AgentClient interface { GetAgentAccount(ctx context.Context, grantID string) (*domain.AgentAccount, error) // CreateAgentAccount creates a new agent account with the given email address. + // name is an optional top-level display name for the grant (1-256 chars when set). // appPassword is optional and enables IMAP/SMTP client access when set. // workspaceID assigns the account to an existing workspace when set. - CreateAgentAccount(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) + CreateAgentAccount(ctx context.Context, email, name, appPassword, workspaceID string) (*domain.AgentAccount, error) // UpdateAgentAccount updates mutable settings on an existing agent account. // email is required by the current grant update API for provider=nylas grants. + // name sets the top-level display name; callers should pass the existing name + // to preserve it, since the grant update replaces the full record. // appPassword rotates or adds IMAP/SMTP credentials when set. - UpdateAgentAccount(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) + UpdateAgentAccount(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) // DeleteAgentAccount deletes an agent account by revoking its grant. DeleteAgentAccount(ctx context.Context, grantID string) error diff --git a/internal/studio/handlers_accounts.go b/internal/studio/handlers_accounts.go index ee861cf..e4139ed 100644 --- a/internal/studio/handlers_accounts.go +++ b/internal/studio/handlers_accounts.go @@ -3,6 +3,7 @@ package studio import ( "net/http" "strings" + "unicode/utf8" "github.com/nylas/cli/internal/domain" ) @@ -27,6 +28,7 @@ func (s *Server) routeAccounts(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request) { var body struct { Email string `json:"email"` + Name string `json:"name"` AppPassword string `json:"app_password"` WorkspaceID string `json:"workspace_id"` } @@ -38,11 +40,16 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "account email is required") return } + body.Name = strings.TrimSpace(body.Name) + if utf8.RuneCountInString(body.Name) > 256 { + writeError(w, http.StatusBadRequest, "name must be 256 characters or fewer") + return + } ctx, cancel := s.withTimeout(r) defer cancel() - account, err := s.nylasClient.CreateAgentAccount(ctx, body.Email, body.AppPassword, strings.TrimSpace(body.WorkspaceID)) + account, err := s.nylasClient.CreateAgentAccount(ctx, body.Email, body.Name, body.AppPassword, strings.TrimSpace(body.WorkspaceID)) if err != nil { writeMutationError(w, "Failed to create agent account", err) return @@ -53,13 +60,24 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAccountPatch(w http.ResponseWriter, r *http.Request, id string) { var body struct { - AppPassword string `json:"app_password"` + AppPassword string `json:"app_password"` + Name *string `json:"name"` } if !decodeBody(w, r, &body) { return } - if strings.TrimSpace(body.AppPassword) == "" { - writeError(w, http.StatusBadRequest, "app_password is required") + appPassword := strings.TrimSpace(body.AppPassword) + nameProvided := body.Name != nil + var name string + if nameProvided { + name = strings.TrimSpace(*body.Name) + if utf8.RuneCountInString(name) > 256 { + writeError(w, http.StatusBadRequest, "name must be 256 characters or fewer") + return + } + } + if appPassword == "" && !nameProvided { + writeError(w, http.StatusBadRequest, "app_password or name is required") return } @@ -73,7 +91,14 @@ func (s *Server) handleAccountPatch(w http.ResponseWriter, r *http.Request, id s return } - if _, err := s.nylasClient.UpdateAgentAccount(ctx, id, account.Email, body.AppPassword); err != nil { + // Preserve the existing name unless the caller overrides it: the grant + // update replaces the full record, so an omitted name would clear it. + effectiveName := account.Name + if nameProvided { + effectiveName = name + } + + if _, err := s.nylasClient.UpdateAgentAccount(ctx, id, account.Email, effectiveName, appPassword); err != nil { writeMutationError(w, "Failed to update agent account", err) return } diff --git a/internal/studio/handlers_mutations_test.go b/internal/studio/handlers_mutations_test.go index 61cd7a6..47979ae 100644 --- a/internal/studio/handlers_mutations_test.go +++ b/internal/studio/handlers_mutations_test.go @@ -189,6 +189,37 @@ func TestHandleAccountCreate_RequiresEmail(t *testing.T) { } } +func TestHandleAccountCreate_AcceptsName(t *testing.T) { + t.Parallel() + server := newTestServer() + + w := doJSON(t, server.routeAccounts, http.MethodPost, "/api/accounts", + `{"email":"bot@app.nylas.email","name":"Support Bot"}`) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d (body: %s)", w.Code, w.Body.String()) + } + resp := decodeMutation(t, w) + if resp.ID != "agent-new" { + t.Fatalf("expected created account ID, got %q", resp.ID) + } +} + +func TestHandleAccountCreate_RejectsTooLongName(t *testing.T) { + t.Parallel() + server := newTestServer() + + // 257 multi-byte runes: well within byte limits a naive check might allow, + // but over the documented 256-character limit. + longName := strings.Repeat("あ", 257) + w := doJSON(t, server.routeAccounts, http.MethodPost, "/api/accounts", + `{"email":"bot@app.nylas.email","name":"`+longName+`"}`) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for over-long name, got %d (body: %s)", w.Code, w.Body.String()) + } +} + func TestHandleAccountPatch_RotatePassword(t *testing.T) { t.Parallel() server := newTestServer() @@ -202,6 +233,98 @@ func TestHandleAccountPatch_RotatePassword(t *testing.T) { decodeMutation(t, w) } +func TestHandleAccountPatch_NameOnly(t *testing.T) { + t.Parallel() + server := newTestServer() + + w := doJSON(t, server.routeAccounts, http.MethodPatch, "/api/accounts/agent-1", + `{"name":"Renamed Bot"}`) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for name-only update, got %d (body: %s)", w.Code, w.Body.String()) + } + decodeMutation(t, w) +} + +func TestHandleAccountPatch_RequiresAField(t *testing.T) { + t.Parallel() + server := newTestServer() + + w := doJSON(t, server.routeAccounts, http.MethodPatch, "/api/accounts/agent-1", `{}`) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when neither app_password nor name is set, got %d", w.Code) + } +} + +func TestHandleAccountPatch_RejectsTooLongName(t *testing.T) { + t.Parallel() + server := newTestServer() + + longName := strings.Repeat("あ", 257) + w := doJSON(t, server.routeAccounts, http.MethodPatch, "/api/accounts/agent-1", + `{"name":"`+longName+`"}`) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for over-long name, got %d (body: %s)", w.Code, w.Body.String()) + } +} + +// accountUpdateRecorder returns a non-empty existing name from GetAgentAccount +// and records the name forwarded to UpdateAgentAccount, so tests can assert the +// preservation branch (the grant PATCH replaces the full record). +type accountUpdateRecorder struct { + *nylasmock.MockClient + existingName string + gotName string +} + +func (c *accountUpdateRecorder) GetAgentAccount(ctx context.Context, grantID string) (*domain.AgentAccount, error) { + acct, err := c.MockClient.GetAgentAccount(ctx, grantID) + if err != nil { + return nil, err + } + acct.Name = c.existingName + return acct, nil +} + +func (c *accountUpdateRecorder) UpdateAgentAccount(ctx context.Context, grantID, email, name, appPassword string) (*domain.AgentAccount, error) { + c.gotName = name + return c.MockClient.UpdateAgentAccount(ctx, grantID, email, name, appPassword) +} + +func TestHandleAccountPatch_PreservesExistingNameOnPasswordRotation(t *testing.T) { + t.Parallel() + client := &accountUpdateRecorder{MockClient: nylasmock.NewMockClient(), existingName: "Existing Bot"} + server := NewServer("127.0.0.1:0", client) + + w := doJSON(t, server.routeAccounts, http.MethodPatch, "/api/accounts/agent-1", + `{"app_password":"NewValidAgentPass456DEF!"}`) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + if client.gotName != "Existing Bot" { + t.Fatalf("expected existing name to be preserved, got %q", client.gotName) + } +} + +func TestHandleAccountPatch_OverridesNameWhenProvided(t *testing.T) { + t.Parallel() + client := &accountUpdateRecorder{MockClient: nylasmock.NewMockClient(), existingName: "Existing Bot"} + server := NewServer("127.0.0.1:0", client) + + w := doJSON(t, server.routeAccounts, http.MethodPatch, "/api/accounts/agent-1", + `{"name":"Renamed Bot"}`) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + if client.gotName != "Renamed Bot" { + t.Fatalf("expected provided name to override, got %q", client.gotName) + } +} + func TestHandleAccountDelete(t *testing.T) { t.Parallel() server := newTestServer()