From aa17ab68e29094d35fc029ad816865b66163af98 Mon Sep 17 00:00:00 2001 From: Qasim Date: Mon, 11 May 2026 20:30:22 -0400 Subject: [PATCH] TW-4924: credential-based auth flow for IMAP, iCloud, Yahoo, and EWS providers Extends nylas auth login from 2 providers (Google, Microsoft) to 6 by adding credential-based authentication via POST /v3/connect/custom for IMAP, iCloud, and Yahoo, plus OAuth support for Exchange on-premises (EWS). Layers changed: - domain: add ProviderEWS, ProviderICloud, ProviderYahoo constants - ports: add CreateCustomGrant to AuthClient interface - adapter: implement CreateCustomGrant on HTTPClient, MockClient, demo clients - service: add LoginWithCredentials (saves grant + persists default) - CLI: extend nylas auth login with per-provider credential prompts - init wizard: offer all 6 providers during nylas init setup - integration test: update error message assertion for new provider list --- internal/adapters/nylas/auth.go | 28 ++ internal/adapters/nylas/custom_grant_test.go | 179 +++++++++++++ internal/adapters/nylas/demo/base.go | 10 + internal/adapters/nylas/demo_client.go | 10 + internal/adapters/nylas/mock_client.go | 1 + internal/adapters/nylas/mock_grants.go | 13 + internal/app/auth/service.go | 31 +++ internal/app/auth/service_test.go | 90 +++++++ internal/cli/auth/auth_test.go | 49 +++- internal/cli/auth/login.go | 247 +++++++++++++++--- .../cli/integration/inbound_removed_test.go | 2 +- internal/cli/setup/wizard_helpers.go | 138 +++++++++- internal/domain/provider.go | 11 +- internal/ports/auth.go | 4 + 14 files changed, 767 insertions(+), 46 deletions(-) create mode 100644 internal/adapters/nylas/custom_grant_test.go diff --git a/internal/adapters/nylas/auth.go b/internal/adapters/nylas/auth.go index 9ccbb5a..7c1d52b 100644 --- a/internal/adapters/nylas/auth.go +++ b/internal/adapters/nylas/auth.go @@ -72,6 +72,34 @@ func (c *HTTPClient) ExchangeCode(ctx context.Context, code, redirectURI, codeVe }, nil } +// CreateCustomGrant creates a grant via the custom auth endpoint. +// Used for credential-based providers (IMAP, iCloud, Yahoo). +func (c *HTTPClient) CreateCustomGrant(ctx context.Context, provider string, settings map[string]any) (*domain.Grant, error) { + queryURL := fmt.Sprintf("%s/v3/connect/custom", c.baseURL) + + payload := map[string]any{ + "provider": provider, + "settings": settings, + } + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, payload) + if err != nil { + return nil, err + } + + grant, err := decodeManagedGrantResponse(resp) + if err != nil { + return nil, err + } + + return &domain.Grant{ + ID: grant.ID, + Email: grant.Email, + Provider: grant.Provider, + GrantStatus: grant.GrantStatus, + }, nil +} + // ListGrants lists all grants for the application, transparently // following next_cursor pagination so callers always see the complete // result set. The Nylas v3 default page size (10) would otherwise diff --git a/internal/adapters/nylas/custom_grant_test.go b/internal/adapters/nylas/custom_grant_test.go new file mode 100644 index 0000000..b716ae2 --- /dev/null +++ b/internal/adapters/nylas/custom_grant_test.go @@ -0,0 +1,179 @@ +//go:build !integration +// +build !integration + +package nylas + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateCustomGrant_ICloud(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/connect/custom", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "icloud", payload["provider"]) + + settings, ok := payload["settings"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "user@icloud.com", settings["username"]) + assert.Equal(t, "abcd-efgh-ijkl-mnop", settings["password"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "icloud-grant-001", + "email": "user@icloud.com", + "provider": "icloud", + "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") + + grant, err := client.CreateCustomGrant(context.Background(), "icloud", map[string]any{ + "username": "user@icloud.com", + "password": "abcd-efgh-ijkl-mnop", + }) + require.NoError(t, err) + assert.Equal(t, "icloud-grant-001", grant.ID) + assert.Equal(t, "user@icloud.com", grant.Email) + assert.Equal(t, "icloud", string(grant.Provider)) +} + +func TestCreateCustomGrant_Yahoo(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/connect/custom", r.URL.Path) + + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "imap", payload["provider"]) + + settings, ok := payload["settings"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "user@yahoo.com", settings["imap_username"]) + assert.Equal(t, "imap.mail.yahoo.com", settings["imap_host"]) + assert.Equal(t, float64(993), settings["imap_port"]) + assert.Equal(t, "yahoo", settings["type"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "yahoo-grant-001", + "email": "user@yahoo.com", + "provider": "imap", + "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") + + grant, err := client.CreateCustomGrant(context.Background(), "imap", map[string]any{ + "imap_username": "user@yahoo.com", + "imap_password": "app-password-123", + "imap_host": "imap.mail.yahoo.com", + "imap_port": 993, + "type": "yahoo", + }) + require.NoError(t, err) + assert.Equal(t, "yahoo-grant-001", grant.ID) + assert.Equal(t, "user@yahoo.com", grant.Email) +} + +func TestCreateCustomGrant_IMAP(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.Equal(t, "imap", payload["provider"]) + + settings, ok := payload["settings"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "user@company.com", settings["imap_username"]) + assert.Equal(t, "mail.company.com", settings["imap_host"]) + assert.Equal(t, float64(993), settings["imap_port"]) + assert.Equal(t, "smtp.company.com", settings["smtp_host"]) + assert.Equal(t, float64(465), settings["smtp_port"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "imap-grant-001", + "email": "user@company.com", + "provider": "imap", + "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") + + grant, err := client.CreateCustomGrant(context.Background(), "imap", map[string]any{ + "imap_username": "user@company.com", + "imap_password": "secret", + "imap_host": "mail.company.com", + "imap_port": 993, + "smtp_host": "smtp.company.com", + "smtp_port": 465, + }) + require.NoError(t, err) + assert.Equal(t, "imap-grant-001", grant.ID) + assert.Equal(t, "user@company.com", grant.Email) + assert.Equal(t, "imap", string(grant.Provider)) +} + +func TestCreateCustomGrant_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "type": "invalid_request_error", + "message": "Invalid IMAP credentials", + }, + }) + })) + defer server.Close() + + client := NewHTTPClient() + client.baseURL = server.URL + client.SetCredentials("", "", "test-api-key") + + _, err := client.CreateCustomGrant(context.Background(), "imap", map[string]any{ + "imap_username": "bad@example.com", + "imap_password": "wrong", + "imap_host": "mail.example.com", + "imap_port": 993, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "Invalid IMAP credentials") +} diff --git a/internal/adapters/nylas/demo/base.go b/internal/adapters/nylas/demo/base.go index 9d90878..0c2c208 100644 --- a/internal/adapters/nylas/demo/base.go +++ b/internal/adapters/nylas/demo/base.go @@ -35,3 +35,13 @@ func (d *Client) ExchangeCode(ctx context.Context, code, redirectURI, codeVerifi GrantStatus: "valid", }, nil } + +// CreateCustomGrant returns a mock grant for demo mode. +func (d *Client) CreateCustomGrant(_ context.Context, provider string, _ map[string]any) (*domain.Grant, error) { + return &domain.Grant{ + ID: "demo-custom-grant-id", + Email: "demo@example.com", + Provider: domain.Provider(provider), + GrantStatus: "valid", + }, nil +} diff --git a/internal/adapters/nylas/demo_client.go b/internal/adapters/nylas/demo_client.go index b8b9873..f208003 100644 --- a/internal/adapters/nylas/demo_client.go +++ b/internal/adapters/nylas/demo_client.go @@ -35,3 +35,13 @@ func (d *DemoClient) ExchangeCode(ctx context.Context, code, redirectURI, codeVe GrantStatus: "valid", }, nil } + +// CreateCustomGrant returns a mock grant for demo mode. +func (d *DemoClient) CreateCustomGrant(_ context.Context, provider string, _ map[string]any) (*domain.Grant, error) { + return &domain.Grant{ + ID: "demo-custom-grant-id", + Email: "demo@example.com", + Provider: domain.Provider(provider), + GrantStatus: "valid", + }, nil +} diff --git a/internal/adapters/nylas/mock_client.go b/internal/adapters/nylas/mock_client.go index 6020975..94839e8 100644 --- a/internal/adapters/nylas/mock_client.go +++ b/internal/adapters/nylas/mock_client.go @@ -93,6 +93,7 @@ type MockClient struct { // Custom functions BuildAuthURLFunc func(provider domain.Provider, redirectURI, state, codeChallenge string) string ExchangeCodeFunc func(ctx context.Context, code, redirectURI, codeVerifier string) (*domain.Grant, error) + CreateCustomGrantFunc func(ctx context.Context, provider string, settings map[string]any) (*domain.Grant, error) ListGrantsFunc func(ctx context.Context) ([]domain.Grant, error) GetGrantFunc func(ctx context.Context, grantID string) (*domain.Grant, error) RevokeGrantFunc func(ctx context.Context, grantID string) error diff --git a/internal/adapters/nylas/mock_grants.go b/internal/adapters/nylas/mock_grants.go index 325ac4e..0db454f 100644 --- a/internal/adapters/nylas/mock_grants.go +++ b/internal/adapters/nylas/mock_grants.go @@ -55,4 +55,17 @@ func (m *MockClient) RevokeGrant(ctx context.Context, grantID string) error { return nil } +// CreateCustomGrant creates a grant via the custom auth endpoint. +func (m *MockClient) CreateCustomGrant(ctx context.Context, provider string, settings map[string]any) (*domain.Grant, error) { + if m.CreateCustomGrantFunc != nil { + return m.CreateCustomGrantFunc(ctx, provider, settings) + } + return &domain.Grant{ + ID: "mock-custom-grant-id", + Email: "test@example.com", + Provider: domain.Provider(provider), + GrantStatus: "valid", + }, nil +} + // GetMessages retrieves recent messages. diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index 79f8aef..6042e1a 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -110,6 +110,37 @@ func (s *Service) Login(ctx context.Context, provider domain.Provider) (*domain. return grant, nil } +// LoginWithCredentials creates a grant via the custom auth endpoint for +// credential-based providers (IMAP, iCloud, Yahoo). Unlike Login, this +// does not open a browser or start an OAuth server. +func (s *Service) LoginWithCredentials(ctx context.Context, provider string, settings map[string]any) (*domain.Grant, error) { + grant, err := s.client.CreateCustomGrant(ctx, provider, settings) + if err != nil { + return nil, err + } + + grantInfo := domain.GrantInfo{ + ID: grant.ID, + Email: grant.Email, + Provider: grant.Provider, + } + if err := s.grantStore.SaveGrant(grantInfo); err != nil { + return nil, err + } + + defaultGrant, err := s.grantStore.GetDefaultGrant() + if err == domain.ErrNoDefaultGrant { + defaultGrant = grant.ID + } else if err != nil { + return nil, fmt.Errorf("failed to read default grant: %w", err) + } + if err := PersistDefaultGrant(s.config, s.grantStore, defaultGrant); err != nil { + return nil, fmt.Errorf("failed to persist default grant: %w", err) + } + + return grant, nil +} + // Logout revokes the current grant. func (s *Service) Logout(ctx context.Context) error { grantID, err := s.grantStore.GetDefaultGrant() diff --git a/internal/app/auth/service_test.go b/internal/app/auth/service_test.go index 4d36216..ebdcc55 100644 --- a/internal/app/auth/service_test.go +++ b/internal/app/auth/service_test.go @@ -745,6 +745,96 @@ func TestService_RemoveLocalGrant(t *testing.T) { assert.Empty(t, configStore.config.Grants) } +func TestService_LoginWithCredentials(t *testing.T) { + t.Run("successful icloud login saves grant and sets default", func(t *testing.T) { + client := nylas.NewMockClient() + client.CreateCustomGrantFunc = func(ctx context.Context, provider string, settings map[string]any) (*domain.Grant, error) { + assert.Equal(t, "icloud", provider) + assert.Equal(t, "user@icloud.com", settings["username"]) + return &domain.Grant{ + ID: "icloud-grant-001", + Email: "user@icloud.com", + Provider: domain.ProviderICloud, + GrantStatus: "valid", + }, nil + } + + grantStore := newMockGrantStore() + configStore := newMockConfigStore() + + svc := NewService(client, grantStore, configStore, &mockOAuthServer{}, &mockBrowser{}) + + grant, err := svc.LoginWithCredentials(context.Background(), "icloud", map[string]any{ + "username": "user@icloud.com", + "password": "app-specific-password", + }) + + require.NoError(t, err) + assert.Equal(t, "icloud-grant-001", grant.ID) + assert.Equal(t, "user@icloud.com", grant.Email) + + savedGrant, err := grantStore.GetGrant("icloud-grant-001") + require.NoError(t, err) + assert.Equal(t, domain.ProviderICloud, savedGrant.Provider) + + defaultID, err := grantStore.GetDefaultGrant() + require.NoError(t, err) + assert.Equal(t, "icloud-grant-001", defaultID) + }) + + t.Run("does not override existing default", func(t *testing.T) { + client := nylas.NewMockClient() + client.CreateCustomGrantFunc = func(ctx context.Context, provider string, settings map[string]any) (*domain.Grant, error) { + return &domain.Grant{ + ID: "yahoo-grant-001", + Email: "user@yahoo.com", + Provider: domain.ProviderIMAP, + }, nil + } + + grantStore := newMockGrantStore() + require.NoError(t, grantStore.SaveGrant(domain.GrantInfo{ID: "existing-grant", Email: "first@example.com"})) + require.NoError(t, grantStore.SetDefaultGrant("existing-grant")) + configStore := newMockConfigStore() + + svc := NewService(client, grantStore, configStore, &mockOAuthServer{}, &mockBrowser{}) + + grant, err := svc.LoginWithCredentials(context.Background(), "imap", map[string]any{ + "imap_username": "user@yahoo.com", + "imap_password": "secret", + "imap_host": "imap.mail.yahoo.com", + "imap_port": 993, + "type": "yahoo", + }) + + require.NoError(t, err) + assert.Equal(t, "yahoo-grant-001", grant.ID) + + defaultID, err := grantStore.GetDefaultGrant() + require.NoError(t, err) + assert.Equal(t, "existing-grant", defaultID) + }) + + t.Run("api error propagates", func(t *testing.T) { + client := nylas.NewMockClient() + client.CreateCustomGrantFunc = func(ctx context.Context, provider string, settings map[string]any) (*domain.Grant, error) { + return nil, errors.New("Invalid IMAP credentials") + } + + svc := NewService(client, newMockGrantStore(), newMockConfigStore(), &mockOAuthServer{}, &mockBrowser{}) + + _, err := svc.LoginWithCredentials(context.Background(), "imap", map[string]any{ + "imap_username": "bad@example.com", + "imap_password": "wrong", + "imap_host": "mail.example.com", + "imap_port": 993, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Invalid IMAP credentials") + }) +} + func pkceChallenge(verifier string) string { hash := sha256.Sum256([]byte(verifier)) hexHash := fmt.Sprintf("%x", hash) diff --git a/internal/cli/auth/auth_test.go b/internal/cli/auth/auth_test.go index 8e7d226..563e3f1 100644 --- a/internal/cli/auth/auth_test.go +++ b/internal/cli/auth/auth_test.go @@ -162,7 +162,12 @@ func TestParseLoginProvider(t *testing.T) { }{ {name: "google", input: "google", want: domain.ProviderGoogle}, {name: "microsoft", input: "microsoft", want: domain.ProviderMicrosoft}, + {name: "icloud", input: "icloud", want: domain.ProviderICloud}, + {name: "yahoo", input: "yahoo", want: domain.ProviderYahoo}, + {name: "imap", input: "imap", want: domain.ProviderIMAP}, + {name: "ews", input: "ews", want: domain.ProviderEWS}, {name: "mixed case", input: "Google", want: domain.ProviderGoogle}, + {name: "mixed case icloud", input: "ICloud", want: domain.ProviderICloud}, {name: "reject nylas", input: "nylas", wantErr: true}, {name: "reject inbox", input: "inbox", wantErr: true}, } @@ -174,9 +179,6 @@ func TestParseLoginProvider(t *testing.T) { if err == nil { t.Fatalf("parseLoginProvider(%q) expected error", tt.input) } - if tt.input == "inbox" && !strings.Contains(err.Error(), "use 'google' or 'microsoft'") { - t.Fatalf("parseLoginProvider(%q) error = %q, want provider guidance", tt.input, err.Error()) - } return } if err != nil { @@ -189,6 +191,47 @@ func TestParseLoginProvider(t *testing.T) { } } +func TestCredentialAPIProvider(t *testing.T) { + tests := []struct { + provider domain.Provider + want string + }{ + {domain.ProviderYahoo, "imap"}, + {domain.ProviderICloud, "icloud"}, + {domain.ProviderIMAP, "imap"}, + } + + for _, tt := range tests { + t.Run(string(tt.provider), func(t *testing.T) { + got := credentialAPIProvider(tt.provider) + if got != tt.want { + t.Fatalf("credentialAPIProvider(%q) = %q, want %q", tt.provider, got, tt.want) + } + }) + } +} + +func TestOAuthProviders(t *testing.T) { + if !oauthProviders[domain.ProviderGoogle] { + t.Error("Google should be an OAuth provider") + } + if !oauthProviders[domain.ProviderMicrosoft] { + t.Error("Microsoft should be an OAuth provider") + } + if !oauthProviders[domain.ProviderEWS] { + t.Error("EWS should be an OAuth provider") + } + if oauthProviders[domain.ProviderICloud] { + t.Error("iCloud should not be an OAuth provider") + } + if oauthProviders[domain.ProviderYahoo] { + t.Error("Yahoo should not be an OAuth provider") + } + if oauthProviders[domain.ProviderIMAP] { + t.Error("IMAP should not be an OAuth provider") + } +} + // TestLogoutCommand tests the logout subcommand. func TestLogoutCommand(t *testing.T) { cmd := newLogoutCmd() diff --git a/internal/cli/auth/login.go b/internal/cli/auth/login.go index 821d403..f6b32fc 100644 --- a/internal/cli/auth/login.go +++ b/internal/cli/auth/login.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "strconv" "strings" "github.com/spf13/cobra" @@ -10,32 +11,52 @@ import ( "github.com/nylas/cli/internal/domain" ) +var oauthProviders = map[domain.Provider]bool{ + domain.ProviderGoogle: true, + domain.ProviderMicrosoft: true, + domain.ProviderEWS: true, +} + func newLoginCmd() *cobra.Command { var provider string cmd := &cobra.Command{ Use: "login", Short: "Authenticate with an email provider", - Long: `Authenticate with an email provider via OAuth. + Long: `Authenticate with an email provider. -Supported providers: +OAuth providers (opens browser): google Google/Gmail - microsoft Microsoft/Outlook`, + microsoft Microsoft/Outlook + ews Exchange on-premises (EWS) + +Credential providers (prompts for credentials): + icloud iCloud (requires app-specific password) + yahoo Yahoo (requires app password) + imap Generic IMAP server`, Example: ` # Login with Google (default) nylas auth login - # Login with Google explicitly - nylas auth login --provider google - # Login with Microsoft/Outlook - nylas auth login --provider microsoft`, + nylas auth login --provider microsoft + + # Login with Exchange on-premises + nylas auth login --provider ews + + # Login with iCloud + nylas auth login --provider icloud + + # Login with Yahoo + nylas auth login --provider yahoo + + # Login with a generic IMAP server + nylas auth login --provider imap`, RunE: func(cmd *cobra.Command, args []string) error { p, err := parseLoginProvider(provider) if err != nil { return err } - // Check if configured configSvc, _, _, err := createConfigService() if err != nil { return err @@ -45,35 +66,193 @@ Supported providers: return fmt.Errorf("nylas not configured - run 'nylas auth config' first") } - // Create auth service - authSvc, _, err := createAuthService() - if err != nil { - return err + if oauthProviders[p] { + return loginOAuth(p) } + return loginCredentials(p) + }, + } - fmt.Println("Opening browser for authentication...") - fmt.Println("Complete the sign-in process in your browser.") + cmd.Flags().StringVarP(&provider, "provider", "p", "google", "Email provider (google, microsoft, ews, icloud, yahoo, imap)") - ctx, cancel := common.CreateLongContext() - defer cancel() + return cmd +} - grant, err := authSvc.Login(ctx, p) - if err != nil { - return err - } +func loginOAuth(provider domain.Provider) error { + authSvc, _, err := createAuthService() + if err != nil { + return err + } - _, _ = common.Green.Printf("\n✓ Successfully authenticated!\n") - fmt.Printf(" Email: %s\n", grant.Email) - fmt.Printf(" Provider: %s\n", grant.Provider.DisplayName()) - fmt.Printf(" Grant ID: %s\n", grant.ID) + fmt.Println("Opening browser for authentication...") + fmt.Println("Complete the sign-in process in your browser.") - return nil - }, + ctx, cancel := common.CreateLongContext() + defer cancel() + + grant, err := authSvc.Login(ctx, provider) + if err != nil { + return err } - cmd.Flags().StringVarP(&provider, "provider", "p", "google", "Email provider (google, microsoft)") + printLoginSuccess(grant) + return nil +} - return cmd +func loginCredentials(provider domain.Provider) error { + settings, err := promptCredentials(provider) + if err != nil { + return err + } + + authSvc, _, err := createAuthService() + if err != nil { + return err + } + + apiProvider := credentialAPIProvider(provider) + + ctx, cancel := common.CreateLongContext() + defer cancel() + + var grant *domain.Grant + err = common.RunWithSpinner("Authenticating...", func() error { + grant, err = authSvc.LoginWithCredentials(ctx, apiProvider, settings) + return err + }) + if err != nil { + return err + } + + printLoginSuccess(grant) + return nil +} + +func promptCredentials(provider domain.Provider) (map[string]any, error) { + switch provider { + case domain.ProviderICloud: + return promptICloudCredentials() + case domain.ProviderYahoo: + return promptYahooCredentials() + case domain.ProviderIMAP: + return promptIMAPCredentials() + default: + return nil, fmt.Errorf("unsupported credential provider: %s", provider) + } +} + +func promptICloudCredentials() (map[string]any, error) { + fmt.Println() + _, _ = common.Dim.Println(" iCloud requires an app-specific password.") + _, _ = common.Dim.Println(" Generate one at: https://appleid.apple.com/account/manage") + fmt.Println() + + username, err := common.InputPrompt("iCloud email", "") + if err != nil { + return nil, err + } + + password, err := common.PasswordPrompt("App-specific password") + if err != nil { + return nil, err + } + + return map[string]any{ + "username": strings.TrimSpace(username), + "password": password, + }, nil +} + +func promptYahooCredentials() (map[string]any, error) { + fmt.Println() + _, _ = common.Dim.Println(" Yahoo requires an app password.") + _, _ = common.Dim.Println(" Generate one at: https://login.yahoo.com/account/security/app-passwords") + fmt.Println() + + email, err := common.InputPrompt("Yahoo email", "") + if err != nil { + return nil, err + } + + password, err := common.PasswordPrompt("App password") + if err != nil { + return nil, err + } + + return map[string]any{ + "imap_username": strings.TrimSpace(email), + "imap_password": password, + "imap_host": "imap.mail.yahoo.com", + "imap_port": 993, + "type": "yahoo", + }, nil +} + +func promptIMAPCredentials() (map[string]any, error) { + fmt.Println() + + username, err := common.InputPrompt("IMAP username (email)", "") + if err != nil { + return nil, err + } + + password, err := common.PasswordPrompt("IMAP password") + if err != nil { + return nil, err + } + + imapHost, err := common.InputPrompt("IMAP host", "") + if err != nil { + return nil, err + } + + imapPort := promptPort("IMAP port", 993) + + settings := map[string]any{ + "imap_username": strings.TrimSpace(username), + "imap_password": password, + "imap_host": strings.TrimSpace(imapHost), + "imap_port": imapPort, + } + + addSMTP, err := common.ConfirmPrompt("Add SMTP settings for sending email?", true) + if err == nil && addSMTP { + smtpHost, smtpErr := common.InputPrompt("SMTP host", strings.TrimSpace(imapHost)) + if smtpErr == nil && smtpHost != "" { + settings["smtp_host"] = strings.TrimSpace(smtpHost) + settings["smtp_port"] = promptPort("SMTP port", 465) + } + } + + return settings, nil +} + +// credentialAPIProvider maps domain providers to the API provider string. +// Yahoo uses "imap" as the API provider with a "type": "yahoo" setting. +func credentialAPIProvider(provider domain.Provider) string { + if provider == domain.ProviderYahoo { + return "imap" + } + return string(provider) +} + +func promptPort(title string, defaultPort int) int { + raw, err := common.InputPrompt(title, strconv.Itoa(defaultPort)) + if err != nil || strings.TrimSpace(raw) == "" { + return defaultPort + } + port, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || port <= 0 || port > 65535 { + return defaultPort + } + return port +} + +func printLoginSuccess(grant *domain.Grant) { + _, _ = common.Green.Printf("\n✓ Successfully authenticated!\n") + fmt.Printf(" Email: %s\n", grant.Email) + fmt.Printf(" Provider: %s\n", grant.Provider.DisplayName()) + fmt.Printf(" Grant ID: %s\n", grant.ID) } func parseLoginProvider(provider string) (domain.Provider, error) { @@ -82,10 +261,18 @@ func parseLoginProvider(provider string) (domain.Provider, error) { return domain.ProviderGoogle, nil case string(domain.ProviderMicrosoft): return domain.ProviderMicrosoft, nil + case string(domain.ProviderEWS): + return domain.ProviderEWS, nil + case string(domain.ProviderICloud): + return domain.ProviderICloud, nil + case string(domain.ProviderYahoo): + return domain.ProviderYahoo, nil + case string(domain.ProviderIMAP): + return domain.ProviderIMAP, nil default: return "", common.NewUserError( - fmt.Sprintf("invalid provider: %s (use 'google' or 'microsoft')", provider), - "use 'google' or 'microsoft'", + fmt.Sprintf("invalid provider: %s", provider), + "use 'google', 'microsoft', 'ews', 'icloud', 'yahoo', or 'imap'", ) } } diff --git a/internal/cli/integration/inbound_removed_test.go b/internal/cli/integration/inbound_removed_test.go index d8db40b..3ff6bc8 100644 --- a/internal/cli/integration/inbound_removed_test.go +++ b/internal/cli/integration/inbound_removed_test.go @@ -68,7 +68,7 @@ func TestCLI_AuthLoginRejectsInboxProvider(t *testing.T) { if !strings.Contains(output, "invalid provider: inbox") { t.Fatalf("expected invalid provider error, got stdout=%q stderr=%q", stdout, stderr) } - if !strings.Contains(output, "use 'google' or 'microsoft'") { + if !strings.Contains(output, "use 'google', 'microsoft', 'ews', 'icloud', 'yahoo', or 'imap'") { t.Fatalf("expected provider guidance, got stdout=%q stderr=%q", stdout, stderr) } } diff --git a/internal/cli/setup/wizard_helpers.go b/internal/cli/setup/wizard_helpers.go index fd20b61..26d39a1 100644 --- a/internal/cli/setup/wizard_helpers.go +++ b/internal/cli/setup/wizard_helpers.go @@ -2,6 +2,7 @@ package setup import ( "fmt" + "strconv" "strings" "github.com/nylas/cli/internal/adapters/browser" @@ -212,10 +213,10 @@ func sanitizeAPIKey(key string) string { return strings.TrimSpace(result.String()) } -// promptAuthLogin asks the user to connect an email account via OAuth. +// promptAuthLogin asks the user to connect an email account. // Returns nil grant if the user declines. func promptAuthLogin(configStore ports.ConfigStore, grantStore ports.GrantStore) (*domain.Grant, error) { - yes, err := common.ConfirmPrompt("Connect a Google or Microsoft email account now?", true) + yes, err := common.ConfirmPrompt("Connect an email account now?", true) if err != nil || !yes { return nil, nil } @@ -223,11 +224,123 @@ func promptAuthLogin(configStore ports.ConfigStore, grantStore ports.GrantStore) provider, err := common.Select("Which provider?", []common.SelectOption[domain.Provider]{ {Label: "Google (Gmail)", Value: domain.ProviderGoogle}, {Label: "Microsoft (Outlook)", Value: domain.ProviderMicrosoft}, + {Label: "Exchange on-premises (EWS)", Value: domain.ProviderEWS}, + {Label: "iCloud", Value: domain.ProviderICloud}, + {Label: "Yahoo", Value: domain.ProviderYahoo}, + {Label: "IMAP (other)", Value: domain.ProviderIMAP}, }) if err != nil { return nil, err } + authSvc, err := buildAuthService(configStore, grantStore) + if err != nil { + return nil, err + } + + switch provider { + case domain.ProviderGoogle, domain.ProviderMicrosoft, domain.ProviderEWS: + fmt.Println() + fmt.Println(" Opening browser for authentication...") + fmt.Println(" Complete the sign-in process in your browser.") + + ctx, cancel := common.CreateLongContext() + defer cancel() + + return authSvc.Login(ctx, provider) + default: + return initCredentialLogin(authSvc, provider) + } +} + +func initCredentialLogin(authSvc *authapp.Service, provider domain.Provider) (*domain.Grant, error) { + var settings map[string]any + var apiProvider string + var err error + + switch provider { + case domain.ProviderICloud: + fmt.Println() + _, _ = common.Dim.Println(" iCloud requires an app-specific password.") + _, _ = common.Dim.Println(" Generate one at: https://appleid.apple.com/account/manage") + fmt.Println() + + username, promptErr := common.InputPrompt("iCloud email", "") + if promptErr != nil { + return nil, promptErr + } + password, promptErr := common.PasswordPrompt("App-specific password") + if promptErr != nil { + return nil, promptErr + } + settings = map[string]any{"username": username, "password": password} + apiProvider = "icloud" + + case domain.ProviderYahoo: + fmt.Println() + _, _ = common.Dim.Println(" Yahoo requires an app password.") + _, _ = common.Dim.Println(" Generate one at: https://login.yahoo.com/account/security/app-passwords") + fmt.Println() + + email, promptErr := common.InputPrompt("Yahoo email", "") + if promptErr != nil { + return nil, promptErr + } + password, promptErr := common.PasswordPrompt("App password") + if promptErr != nil { + return nil, promptErr + } + settings = map[string]any{ + "imap_username": email, + "imap_password": password, + "imap_host": "imap.mail.yahoo.com", + "imap_port": 993, + "type": "yahoo", + } + apiProvider = "imap" + + case domain.ProviderIMAP: + fmt.Println() + + username, promptErr := common.InputPrompt("IMAP username (email)", "") + if promptErr != nil { + return nil, promptErr + } + password, promptErr := common.PasswordPrompt("IMAP password") + if promptErr != nil { + return nil, promptErr + } + host, promptErr := common.InputPrompt("IMAP host", "") + if promptErr != nil { + return nil, promptErr + } + settings = map[string]any{ + "imap_username": username, + "imap_password": password, + "imap_host": host, + "imap_port": promptPortDefault("IMAP port", 993), + } + apiProvider = "imap" + + default: + return nil, fmt.Errorf("unsupported provider: %s", provider) + } + + ctx, cancel := common.CreateLongContext() + defer cancel() + + var grant *domain.Grant + err = common.RunWithSpinner("Authenticating...", func() error { + grant, err = authSvc.LoginWithCredentials(ctx, apiProvider, settings) + return err + }) + if err != nil { + return nil, err + } + return grant, nil +} + +func buildAuthService(configStore ports.ConfigStore, grantStore ports.GrantStore) (*authapp.Service, error) { cfg, _ := configStore.Load() if cfg == nil { cfg = domain.DefaultConfig() @@ -251,14 +364,17 @@ func promptAuthLogin(configStore ports.ConfigStore, grantStore ports.GrantStore) oauthServer := oauth.NewCallbackServer(cfg.CallbackPort) b := browser.NewDefaultBrowser() - authSvc := authapp.NewService(client, grantStore, configStore, oauthServer, b) - - fmt.Println() - fmt.Println(" Opening browser for authentication...") - fmt.Println(" Complete the sign-in process in your browser.") - - ctx, cancel := common.CreateLongContext() - defer cancel() + return authapp.NewService(client, grantStore, configStore, oauthServer, b), nil +} - return authSvc.Login(ctx, provider) +func promptPortDefault(title string, defaultPort int) int { + raw, err := common.InputPrompt(title, strconv.Itoa(defaultPort)) + if err != nil || strings.TrimSpace(raw) == "" { + return defaultPort + } + port, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || port <= 0 || port > 65535 { + return defaultPort + } + return port } diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 5d90ab3..db4bfd7 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -8,7 +8,10 @@ type Provider string const ( ProviderGoogle Provider = "google" ProviderMicrosoft Provider = "microsoft" + ProviderEWS Provider = "ews" ProviderIMAP Provider = "imap" + ProviderICloud Provider = "icloud" + ProviderYahoo Provider = "yahoo" ProviderVirtual Provider = "virtual" ProviderNylas Provider = "nylas" ) @@ -23,8 +26,14 @@ func (p Provider) DisplayName() string { return "Google" case ProviderMicrosoft: return "Microsoft" + case ProviderEWS: + return "Exchange (EWS)" case ProviderIMAP: return "IMAP" + case ProviderICloud: + return "iCloud" + case ProviderYahoo: + return "Yahoo" case ProviderVirtual: return "Virtual" case ProviderNylas: @@ -37,7 +46,7 @@ func (p Provider) DisplayName() string { // IsValid checks if the provider is a known type. func (p Provider) IsValid() bool { switch p { - case ProviderGoogle, ProviderMicrosoft, ProviderIMAP, ProviderVirtual, ProviderNylas: + case ProviderGoogle, ProviderMicrosoft, ProviderEWS, ProviderIMAP, ProviderICloud, ProviderYahoo, ProviderVirtual, ProviderNylas: return true default: return false diff --git a/internal/ports/auth.go b/internal/ports/auth.go index 98dae4d..ae9daf5 100644 --- a/internal/ports/auth.go +++ b/internal/ports/auth.go @@ -22,4 +22,8 @@ type AuthClient interface { // RevokeGrant revokes a specific grant. RevokeGrant(ctx context.Context, grantID string) error + + // CreateCustomGrant creates a grant via POST /v3/connect/custom for + // credential-based providers (IMAP, iCloud, Yahoo). + CreateCustomGrant(ctx context.Context, provider string, settings map[string]any) (*domain.Grant, error) }