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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,4 @@ ci-results.txt

# Brainstorming visual companion sessions
.superpowers/
.understand-anything/
2 changes: 2 additions & 0 deletions docs/commands/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ Behavior:
- 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
- treats a bare account name such as `agent` as `agent@nylas.email`
- when the requested domain is not registered, points to `https://dashboard-v3.nylas.com/` to create or register the agent domain before retrying

To attach a custom policy after creation:
```bash
Expand Down
116 changes: 113 additions & 3 deletions internal/cli/agent/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/spf13/cobra"
)

const agentDomainDashboardURL = "https://dashboard-v3.nylas.com/"

func newCreateCmd() *cobra.Command {
var appPassword string
var name string
Expand Down Expand Up @@ -48,7 +50,7 @@ Examples:
}

func runCreate(email, name, appPassword string, jsonOutput bool) error {
email = strings.TrimSpace(email)
email = normalizeAgentAccountEmail(email)
if email == "" {
common.PrintError("Email address cannot be empty")
return common.NewInputError("email address cannot be empty")
Expand Down Expand Up @@ -106,7 +108,7 @@ func runCreate(email, name, appPassword string, jsonOutput bool) error {
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
return account, wrapAgentAccountCreateError(email, err)
}

existingAccount, lookupErr := findExistingAgentAccountByEmail(ctx, client, email)
Expand All @@ -131,7 +133,7 @@ func createAgentAccountWithFallback(ctx context.Context, client ports.AgentClien

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)
return nil, fmt.Errorf("failed to create agent account after retrying without app password: %w", wrapAgentAccountCreateError(email, retryErr))
}

// Re-send name so the password-setting update preserves it (the grant
Expand All @@ -158,6 +160,14 @@ func createAgentAccountWithFallback(ctx context.Context, client ports.AgentClien
)
}

func normalizeAgentAccountEmail(email string) string {
email = strings.TrimSpace(email)
if email == "" || strings.Contains(email, "@") {
return email
}
return email + "@nylas.email"
}

func findExistingAgentAccountByEmail(ctx context.Context, client ports.AgentClient, email string) (*domain.AgentAccount, error) {
accounts, err := client.ListAgentAccounts(ctx)
if err != nil {
Expand Down Expand Up @@ -227,6 +237,106 @@ func shouldRetryAgentCreateWithoutPassword(err error) bool {
return false
}

type agentAccountDomainErrorKind int

const (
agentAccountDomainErrorNone agentAccountDomainErrorKind = iota
agentAccountDomainErrorMissing
agentAccountDomainErrorLimit
)

func wrapAgentAccountCreateError(email string, err error) error {
if err == nil {
return nil
}

kind, apiErr := classifyAgentAccountDomainError(err)
if kind == agentAccountDomainErrorNone {
return err
}

domainName := agentAccountDomainFromEmail(email)
if domainName == "" {
domainName = "the requested domain"
}

suggestions := []string{
fmt.Sprintf("Create or register %q as an agent domain in the Nylas Dashboard: %s", domainName, agentDomainDashboardURL),
}

message := fmt.Sprintf("Cannot create agent account because domain %q is not registered", domainName)
if kind == agentAccountDomainErrorLimit {
message = "Maximum number of agent account domains reached"
suggestions = append(suggestions, "Remove an unused domain or use an email address on one of your existing agent domains")
} else {
suggestions = append(suggestions, "Or use an email address on an agent domain already registered in the Dashboard")
suggestions = append(suggestions, fmt.Sprintf("After registering the domain, retry: nylas agent account create %s", email))
}

requestID := ""
if apiErr != nil {
requestID = apiErr.RequestID
}

return &common.CLIError{
Err: err,
Message: message,
Suggestions: suggestions,
Code: common.ErrCodeInvalidInput,
RequestID: requestID,
}
}

func classifyAgentAccountDomainError(err error) (agentAccountDomainErrorKind, *domain.APIError) {
var apiErr *domain.APIError
if !errors.As(err, &apiErr) {
return agentAccountDomainErrorNone, nil
}
if apiErr.StatusCode != http.StatusBadRequest && apiErr.StatusCode != http.StatusUnprocessableEntity && apiErr.StatusCode != http.StatusNotFound {
return agentAccountDomainErrorNone, apiErr
}

msg := strings.ToLower(strings.TrimSpace(apiErr.Message))
if !strings.Contains(msg, "domain") {
return agentAccountDomainErrorNone, apiErr
}

limitPhrases := []string{
"maximum",
"max",
"limit",
}
for _, phrase := range limitPhrases {
if strings.Contains(msg, phrase) {
return agentAccountDomainErrorLimit, apiErr
}
}

missingPhrases := []string{
"not registered",
"not found",
"does not exist",
"doesn't exist",
"missing",
"unknown",
}
for _, phrase := range missingPhrases {
if strings.Contains(msg, phrase) {
return agentAccountDomainErrorMissing, apiErr
}
}

return agentAccountDomainErrorNone, apiErr
}

func agentAccountDomainFromEmail(email string) string {
_, domainName, ok := strings.Cut(strings.TrimSpace(email), "@")
if !ok {
return ""
}
return strings.TrimSpace(domainName)
}

// 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
Expand Down
98 changes: 97 additions & 1 deletion internal/cli/agent/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"testing"

"github.com/nylas/cli/internal/cli/common"
"github.com/nylas/cli/internal/domain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -57,7 +58,11 @@ func TestCreateAgentAccountWithFallback_ReturnsRetryError(t *testing.T) {
StatusCode: http.StatusBadRequest,
Message: "settings.app_password is an unknown field",
}
retryErr := errors.New("domain is not registered")
retryErr := &domain.APIError{
StatusCode: http.StatusBadRequest,
Message: "domain is not registered",
RequestID: "req-123",
}
createCalls := 0

client := stubAgentClient{
Expand Down Expand Up @@ -89,8 +94,17 @@ func TestCreateAgentAccountWithFallback_ReturnsRetryError(t *testing.T) {
assert.Nil(t, account)
assert.ErrorIs(t, err, retryErr)
assert.ErrorContains(t, err, "retrying without app password")
assert.ErrorContains(t, err, "Cannot create agent account because domain")
assert.NotErrorIs(t, err, initialErr)
assert.Equal(t, 2, createCalls)

var cliErr *common.CLIError
require.ErrorAs(t, err, &cliErr)
assert.Equal(t, "req-123", cliErr.RequestID)
assert.Equal(t, `Cannot create agent account because domain "example.com" is not registered`, cliErr.Message)
assert.Contains(t, cliErr.Suggestions, `Create or register "example.com" as an agent domain in the Nylas Dashboard: `+agentDomainDashboardURL)
assert.Contains(t, cliErr.Suggestions, "Or use an email address on an agent domain already registered in the Dashboard")
assert.Contains(t, cliErr.Suggestions, "After registering the domain, retry: nylas agent account create agent@example.com")
}

func TestCreateAgentAccountWithFallback_SkipsCleanupForExistingGrant(t *testing.T) {
Expand Down Expand Up @@ -400,3 +414,85 @@ func TestCreateAgentAccountWithFallback_DoesNotRetryInvalidPasswordValue(t *test
assert.ErrorIs(t, err, initialErr)
assert.Equal(t, 1, createCalls)
}

func TestNormalizeAgentAccountEmail(t *testing.T) {
assert.Equal(t, "agent@nylas.email", normalizeAgentAccountEmail(" agent "))
assert.Equal(t, "agent@example.com", normalizeAgentAccountEmail(" agent@example.com "))
assert.Equal(t, "", normalizeAgentAccountEmail(" "))
}

func TestWrapAgentAccountCreateError_DomainFailures(t *testing.T) {
tests := []struct {
name string
err error
email string
wantMessage string
wantSuggestion string
wantRetry string
wantOriginalError bool
}{
{
name: "missing domain",
err: &domain.APIError{
StatusCode: http.StatusBadRequest,
Message: "Domain doesn't exist",
RequestID: "req-domain",
},
email: "support@example.com",
wantMessage: `Cannot create agent account because domain "example.com" is not registered`,
wantSuggestion: `Create or register "example.com" as an agent domain in the Nylas Dashboard: ` + agentDomainDashboardURL,
wantRetry: "After registering the domain, retry: nylas agent account create support@example.com",
},
{
name: "live api missing domain wording",
err: &domain.APIError{
StatusCode: http.StatusNotFound,
Type: "api.not_found_error",
Message: "Provisioning the inbox failed: Domain not found",
RequestID: "req-domain",
},
email: "agent@missing.nylas.email",
wantMessage: `Cannot create agent account because domain "missing.nylas.email" is not registered`,
wantSuggestion: `Create or register "missing.nylas.email" as an agent domain in the Nylas Dashboard: ` + agentDomainDashboardURL,
wantRetry: "After registering the domain, retry: nylas agent account create agent@missing.nylas.email",
},
{
name: "domain limit",
err: &domain.APIError{
StatusCode: http.StatusUnprocessableEntity,
Message: "maximum number of domains reached",
},
email: "support@example.com",
wantMessage: "Maximum number of agent account domains reached",
wantSuggestion: `Create or register "example.com" as an agent domain in the Nylas Dashboard: ` + agentDomainDashboardURL,
wantRetry: "Remove an unused domain or use an email address on one of your existing agent domains",
},
{
name: "unrelated api error",
err: &domain.APIError{
StatusCode: http.StatusBadRequest,
Message: "invalid email address",
},
email: "support@example.com",
wantOriginalError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := wrapAgentAccountCreateError(tt.email, tt.err)
require.Error(t, err)
if tt.wantOriginalError {
assert.Same(t, tt.err, err)
return
}

var cliErr *common.CLIError
require.ErrorAs(t, err, &cliErr)
assert.Equal(t, tt.wantMessage, cliErr.Message)
assert.Contains(t, cliErr.Suggestions, tt.wantSuggestion)
assert.Contains(t, cliErr.Suggestions, tt.wantRetry)
assert.ErrorIs(t, err, tt.err)
})
}
}
Loading