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
2 changes: 2 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,10 @@ Create and manage Nylas-managed agent accounts backed by provider `nylas`.
```bash
nylas agent account list # List agent accounts
nylas agent account create <email> # Create agent account
nylas agent account create <email> --name "Support Bot" # Create account with a display name
nylas agent account create <email> --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 <agent-id|email> # Show one agent account
nylas agent account move <agent-id|email> --workspace <id> # Move account to another workspace
nylas agent account delete <agent-id|email> # Delete/revoke agent account
Expand Down
1 change: 1 addition & 0 deletions docs/commands/agent-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 27 additions & 2 deletions docs/commands/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions internal/adapters/nylas/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
99 changes: 91 additions & 8 deletions internal/adapters/nylas/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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{
Expand All @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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))
Expand All @@ -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")
}
Expand All @@ -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")
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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))
Expand Down
6 changes: 4 additions & 2 deletions internal/adapters/nylas/demo_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/adapters/nylas/managed_grants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions internal/adapters/nylas/mock_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading