diff --git a/.gitignore b/.gitignore index 9b9e3eb..a293063 100644 --- a/.gitignore +++ b/.gitignore @@ -197,3 +197,4 @@ ci-results.txt # Brainstorming visual companion sessions .superpowers/ +.understand-anything/ diff --git a/docs/commands/agent.md b/docs/commands/agent.md index cd251d5..fc72c74 100644 --- a/docs/commands/agent.md +++ b/docs/commands/agent.md @@ -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 diff --git a/internal/cli/agent/create.go b/internal/cli/agent/create.go index 2c27e27..adbd436 100644 --- a/internal/cli/agent/create.go +++ b/internal/cli/agent/create.go @@ -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 @@ -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") @@ -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) @@ -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 @@ -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 { @@ -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 diff --git a/internal/cli/agent/create_test.go b/internal/cli/agent/create_test.go index 8d57af7..955ea8e 100644 --- a/internal/cli/agent/create_test.go +++ b/internal/cli/agent/create_test.go @@ -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" @@ -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{ @@ -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) { @@ -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) + }) + } +}