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
28 changes: 28 additions & 0 deletions internal/adapters/nylas/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions internal/adapters/nylas/custom_grant_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
10 changes: 10 additions & 0 deletions internal/adapters/nylas/demo/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
10 changes: 10 additions & 0 deletions internal/adapters/nylas/demo_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions internal/adapters/nylas/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions internal/adapters/nylas/mock_grants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
31 changes: 31 additions & 0 deletions internal/app/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading