diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 4354aff..b35b0f7 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -114,7 +114,7 @@ nylas auth migrate # Migrate from v2 to v3 ## Dashboard -Manage your Nylas Dashboard account, applications, and API keys directly from the CLI. +Manage your Nylas Dashboard account, applications, domains, and API keys directly from the CLI. ### Account @@ -161,6 +161,21 @@ nylas dashboard apps apikeys create --expires 30 # Expire in 30 days After creating a key, you choose: activate in CLI (recommended), copy to clipboard, or save to file. +### Domains + +```bash +nylas dashboard domains list # List inbox and Agent Account domains +nylas dashboard domains check example.com # Check org-scoped availability +nylas dashboard domains create example.com --region us # Register a domain +nylas dashboard domains show example.com # Show a registered domain +nylas dashboard domains dns example.com # Show DNS records to configure +nylas dashboard domains verify example.com --type ownership # Verify one record +nylas dashboard domains verify example.com --all # Verify all supported records +nylas dashboard domains delete example.com --yes # Delete a domain +``` + +Domain creation registers the domain in your active Dashboard organization and requires `--region us|eu`. Existing-domain commands infer the region from `domains list` when `--region` is omitted; pass `--region` to override or disambiguate. Configure the DNS records from `domains dns`, then run `domains verify`. + --- ## Demo Mode (No Account Required) diff --git a/internal/adapters/dashboard/account_client.go b/internal/adapters/dashboard/account_client.go index 6888f9b..316654e 100644 --- a/internal/adapters/dashboard/account_client.go +++ b/internal/adapters/dashboard/account_client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "github.com/nylas/cli/internal/domain" "github.com/nylas/cli/internal/ports" @@ -219,6 +220,158 @@ func (c *AccountClient) SwitchOrg(ctx context.Context, orgPublicID, userToken, o return &result, nil } +// ListDomains lists inbox/agent-account domains for the active dashboard organization. +func (c *AccountClient) ListDomains(ctx context.Context, limit int, pageToken, userToken, orgToken string) (domain.DashboardInboxDomainPage, error) { + q := url.Values{} + if limit > 0 { + q.Set("limit", fmt.Sprintf("%d", limit)) + } + if pageToken != "" { + q.Set("pageToken", pageToken) + } + path := "/orgs/inbox/domains" + if encoded := q.Encode(); encoded != "" { + path += "?" + encoded + } + + headers := bearerHeaders(userToken, orgToken) + raw, err := c.doGetRawResponse(ctx, path, headers, userToken) + if err != nil { + return domain.DashboardInboxDomainPage{}, fmt.Errorf("failed to list domains: %w", err) + } + + result, err := decodeDomainPage(raw) + if err != nil { + return domain.DashboardInboxDomainPage{}, fmt.Errorf("failed to list domains: %w", err) + } + return result, nil +} + +func decodeDomainPage(raw rawResponse) (domain.DashboardInboxDomainPage, error) { + var domains []domain.DashboardInboxDomain + if err := json.Unmarshal(raw.Data, &domains); err == nil { + return domain.DashboardInboxDomainPage{ + Domains: domains, + NextCursor: raw.NextCursor, + }, nil + } + + var payload struct { + Domains *[]domain.DashboardInboxDomain `json:"domains"` + NextCursor string `json:"nextCursor"` + NextCursorSnake string `json:"next_cursor"` + PageToken string `json:"pageToken"` + } + if err := json.Unmarshal(raw.Data, &payload); err != nil { + return domain.DashboardInboxDomainPage{}, fmt.Errorf("failed to decode response: %w", err) + } + if payload.Domains == nil { + return domain.DashboardInboxDomainPage{}, fmt.Errorf("failed to decode response: missing domains") + } + + nextCursor := raw.NextCursor + for _, cursor := range []string{payload.NextCursor, payload.NextCursorSnake, payload.PageToken} { + if nextCursor == "" && cursor != "" { + nextCursor = cursor + } + } + + return domain.DashboardInboxDomainPage{ + Domains: *payload.Domains, + NextCursor: nextCursor, + }, nil +} + +// GetDomain retrieves an inbox domain by ID or address. +func (c *AccountClient) GetDomain(ctx context.Context, domainIDOrAddress, region, userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + path := "/orgs/inbox/domains/" + url.PathEscape(domainIDOrAddress) + if region != "" { + path += "?region=" + url.QueryEscape(region) + } + + headers := bearerHeaders(userToken, orgToken) + var result domain.DashboardInboxDomain + if err := c.doGet(ctx, path, headers, userToken, &result); err != nil { + return nil, fmt.Errorf("failed to get domain: %w", err) + } + return &result, nil +} + +// CheckDomainAvailability checks org-scoped availability for a domain address. +func (c *AccountClient) CheckDomainAvailability(ctx context.Context, domainAddress, userToken, orgToken string) (*domain.DashboardInboxDomainAvailability, error) { + path := "/orgs/inbox/domains/availability?domainAddress=" + url.QueryEscape(domainAddress) + headers := bearerHeaders(userToken, orgToken) + + var result domain.DashboardInboxDomainAvailability + if err := c.doGet(ctx, path, headers, userToken, &result); err != nil { + return nil, fmt.Errorf("failed to check domain availability: %w", err) + } + return &result, nil +} + +// CreateDomain creates/registers an inbox domain. +func (c *AccountClient) CreateDomain(ctx context.Context, input domain.DashboardCreateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + headers := bearerHeaders(userToken, orgToken) + var result domain.DashboardInboxDomain + if err := c.doPost(ctx, "/orgs/inbox/domains", input, headers, userToken, &result); err != nil { + return nil, fmt.Errorf("failed to create domain: %w", err) + } + return &result, nil +} + +// UpdateDomain updates an inbox domain. +func (c *AccountClient) UpdateDomain(ctx context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + path := "/orgs/inbox/domains/" + url.PathEscape(domainID) + "?region=" + url.QueryEscape(region) + headers := bearerHeaders(userToken, orgToken) + + var result domain.DashboardInboxDomain + if err := c.doPatch(ctx, path, input, headers, userToken, &result); err != nil { + return nil, fmt.Errorf("failed to update domain: %w", err) + } + return &result, nil +} + +// DeleteDomain deletes an inbox domain. +func (c *AccountClient) DeleteDomain(ctx context.Context, domainID, region, userToken, orgToken string) (bool, error) { + path := "/orgs/inbox/domains/" + url.PathEscape(domainID) + "?region=" + url.QueryEscape(region) + headers := bearerHeaders(userToken, orgToken) + + var result struct { + Success bool `json:"success"` + } + if err := c.doDelete(ctx, path, headers, userToken, &result); err != nil { + return false, fmt.Errorf("failed to delete domain: %w", err) + } + return result.Success, nil +} + +// GetDomainInfo returns DNS-record info for a verification type. +func (c *AccountClient) GetDomainInfo(ctx context.Context, domainID, region, verificationType, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) { + q := url.Values{} + q.Set("region", region) + q.Set("type", verificationType) + path := "/orgs/inbox/domains/" + url.PathEscape(domainID) + "/info?" + q.Encode() + headers := bearerHeaders(userToken, orgToken) + + var result domain.DashboardDomainVerificationResult + if err := c.doGet(ctx, path, headers, userToken, &result); err != nil { + return nil, fmt.Errorf("failed to get domain DNS info: %w", err) + } + return &result, nil +} + +// VerifyDomain triggers verification for a DNS/authentication record type. +func (c *AccountClient) VerifyDomain(ctx context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) { + path := "/orgs/inbox/domains/" + url.PathEscape(domainID) + "/verify?region=" + url.QueryEscape(region) + headers := bearerHeaders(userToken, orgToken) + + var result domain.DashboardDomainVerificationResult + if err := c.doPost(ctx, path, input, headers, userToken, &result); err != nil { + return nil, fmt.Errorf("failed to verify domain: %w", err) + } + return &result, nil +} + // bearerHeaders creates the Authorization and X-Nylas-Org headers. func bearerHeaders(userToken, orgToken string) map[string]string { h := map[string]string{ diff --git a/internal/adapters/dashboard/account_client_test.go b/internal/adapters/dashboard/account_client_test.go index 3249cac..1e2033b 100644 --- a/internal/adapters/dashboard/account_client_test.go +++ b/internal/adapters/dashboard/account_client_test.go @@ -36,13 +36,22 @@ func newAccountClientTestServer(t *testing.T, handler func(t *testing.T, w http. func writeDashboardEnvelope(t *testing.T, w http.ResponseWriter, data any) { t.Helper() + writeDashboardEnvelopeWithCursor(t, w, data, "") +} + +func writeDashboardEnvelopeWithCursor(t *testing.T, w http.ResponseWriter, data any, nextCursor string) { + t.Helper() w.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + resp := map[string]any{ "request_id": "req-123", "success": true, "data": data, - })) + } + if nextCursor != "" { + resp["nextCursor"] = nextCursor + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) } func TestAccountClientPublicEndpoints(t *testing.T) { @@ -503,3 +512,277 @@ func TestAccountClientRefreshPropagatesUnderlyingError(t *testing.T) { assert.True(t, errors.Is(err, domain.ErrDashboardSessionExpired)) assert.Contains(t, err.Error(), "failed to refresh session") } + +func TestAccountClientDomainOperations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + handler func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, body map[string]any) + run func(t *testing.T, client *AccountClient) + }{ + { + name: "list domains", + handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, _ map[string]any) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/orgs/inbox/domains", r.URL.Path) + assert.Equal(t, "25", r.URL.Query().Get("limit")) + assert.Equal(t, "cursor-1", r.URL.Query().Get("pageToken")) + assert.Equal(t, "Bearer user-token", r.Header.Get("Authorization")) + assert.Equal(t, "org-token", r.Header.Get("X-Nylas-Org")) + assert.NotEmpty(t, r.Header.Get("DPoP")) + + writeDashboardEnvelopeWithCursor(t, w, []map[string]any{ + { + "id": "dom_1", + "name": "Example", + "domainAddress": "example.com", + "organizationId": "org_1", + "region": "us", + "branded": true, + "verifiedOwnership": true, + }, + }, "cursor-2") + }, + run: func(t *testing.T, client *AccountClient) { + page, err := client.ListDomains(context.Background(), 25, "cursor-1", "user-token", "org-token") + require.NoError(t, err) + require.Len(t, page.Domains, 1) + assert.Equal(t, "dom_1", page.Domains[0].ID) + assert.Equal(t, "example.com", page.Domains[0].DomainAddress) + assert.True(t, page.Domains[0].VerifiedOwnership) + assert.Equal(t, "cursor-2", page.NextCursor) + }, + }, + { + name: "check availability", + handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, _ map[string]any) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/orgs/inbox/domains/availability", r.URL.Path) + assert.Equal(t, "example.com", r.URL.Query().Get("domainAddress")) + + writeDashboardEnvelope(t, w, map[string]any{ + "domainAddress": "example.com", + "available": true, + "conflictsWith": nil, + }) + }, + run: func(t *testing.T, client *AccountClient) { + result, err := client.CheckDomainAvailability(context.Background(), "example.com", "user-token", "org-token") + require.NoError(t, err) + assert.True(t, result.Available) + assert.Nil(t, result.ConflictsWith) + }, + }, + { + name: "create domain", + handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, body map[string]any) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/orgs/inbox/domains", r.URL.Path) + assert.Equal(t, "Example", body["name"]) + assert.Equal(t, "example.com", body["domainAddress"]) + assert.Equal(t, "eu", body["region"]) + + writeDashboardEnvelope(t, w, map[string]any{ + "id": "dom_new", + "name": "Example", + "domainAddress": "example.com", + "region": "eu", + }) + }, + run: func(t *testing.T, client *AccountClient) { + created, err := client.CreateDomain(context.Background(), domain.DashboardCreateInboxDomainInput{ + Name: "Example", + DomainAddress: "example.com", + Region: "eu", + }, "user-token", "org-token") + require.NoError(t, err) + assert.Equal(t, "dom_new", created.ID) + }, + }, + { + name: "update domain", + handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, body map[string]any) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/orgs/inbox/domains/dom_1", r.URL.Path) + assert.Equal(t, "us", r.URL.Query().Get("region")) + assert.Equal(t, "Renamed", body["name"]) + + writeDashboardEnvelope(t, w, map[string]any{ + "id": "dom_1", + "name": "Renamed", + "domainAddress": "example.com", + "region": "us", + }) + }, + run: func(t *testing.T, client *AccountClient) { + updated, err := client.UpdateDomain(context.Background(), "dom_1", "us", domain.DashboardUpdateInboxDomainInput{Name: "Renamed"}, "user-token", "org-token") + require.NoError(t, err) + assert.Equal(t, "Renamed", updated.Name) + }, + }, + { + name: "get domain info", + handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, _ map[string]any) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/orgs/inbox/domains/dom_1/info", r.URL.Path) + assert.Equal(t, "us", r.URL.Query().Get("region")) + assert.Equal(t, "mx", r.URL.Query().Get("type")) + + writeDashboardEnvelope(t, w, map[string]any{ + "domainId": "dom_1", + "status": "pending", + "message": "Configure MX", + "attempt": map[string]any{ + "type": "mx", + "options": map[string]any{ + "host": "example.com", + "type": "MX", + "value": "10 inbound.nylas.com", + }, + }, + }) + }, + run: func(t *testing.T, client *AccountClient) { + info, err := client.GetDomainInfo(context.Background(), "dom_1", "us", "mx", "user-token", "org-token") + require.NoError(t, err) + assert.Equal(t, "pending", info.Status) + require.NotNil(t, info.Attempt) + assert.Equal(t, "MX", info.Attempt.Options.Type) + }, + }, + { + name: "verify domain", + handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, body map[string]any) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/orgs/inbox/domains/dom_1/verify", r.URL.Path) + assert.Equal(t, "eu", r.URL.Query().Get("region")) + assert.Equal(t, "ownership", body["type"]) + + writeDashboardEnvelope(t, w, map[string]any{ + "domainId": "dom_1", + "status": "done", + "message": "Verified", + }) + }, + run: func(t *testing.T, client *AccountClient) { + result, err := client.VerifyDomain(context.Background(), "dom_1", "eu", domain.DashboardVerifyInboxDomainInput{Type: "ownership"}, "user-token", "org-token") + require.NoError(t, err) + assert.Equal(t, "done", result.Status) + }, + }, + { + name: "delete domain", + handler: func(t *testing.T, w http.ResponseWriter, r *http.Request, _ []byte, _ map[string]any) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/orgs/inbox/domains/dom_1", r.URL.Path) + assert.Equal(t, "us", r.URL.Query().Get("region")) + + writeDashboardEnvelope(t, w, map[string]any{"success": true}) + }, + run: func(t *testing.T, client *AccountClient) { + deleted, err := client.DeleteDomain(context.Background(), "dom_1", "us", "user-token", "org-token") + require.NoError(t, err) + assert.True(t, deleted) + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := newAccountClientTestServer(t, tt.handler) + defer server.Close() + + client := &AccountClient{ + baseURL: server.URL, + httpClient: server.Client(), + dpop: &mockDPoP{proof: "test-proof"}, + } + + tt.run(t, client) + }) + } +} + +func TestDecodeDomainPageFallbackShapes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw rawResponse + wantIDs []string + wantCursor string + wantErr string + }{ + { + name: "object fallback with snake cursor", + raw: rawResponse{ + Data: []byte(`{"domains":[{"id":"dom_1","domainAddress":"example.com"}],"next_cursor":"cursor-2"}`), + }, + wantIDs: []string{"dom_1"}, + wantCursor: "cursor-2", + }, + { + name: "raw response cursor wins over object cursor", + raw: rawResponse{ + Data: []byte(`{"domains":[{"id":"dom_1","domainAddress":"example.com"}],"nextCursor":"payload-cursor"}`), + NextCursor: "envelope-cursor", + }, + wantIDs: []string{"dom_1"}, + wantCursor: "envelope-cursor", + }, + { + name: "empty object page with cursor", + raw: rawResponse{ + Data: []byte(`{"domains":[],"pageToken":"cursor-3"}`), + }, + wantCursor: "cursor-3", + }, + { + name: "object fallback requires domains field", + raw: rawResponse{ + Data: []byte(`{"nextCursor":"cursor-4"}`), + }, + wantErr: "missing domains", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + page, err := decodeDomainPage(tt.raw) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCursor, page.NextCursor) + require.Len(t, page.Domains, len(tt.wantIDs)) + for i, id := range tt.wantIDs { + assert.Equal(t, id, page.Domains[i].ID) + } + }) + } +} + +func TestUnwrapRawResponseExtractsNestedCursor(t *testing.T) { + t.Parallel() + + raw, err := unwrapRawResponse([]byte(`{ + "request_id":"req", + "success":true, + "data":[], + "pagination":{"next_cursor":"cursor-2"} + }`)) + + require.NoError(t, err) + assert.JSONEq(t, `[]`, string(raw.Data)) + assert.Equal(t, "cursor-2", raw.NextCursor) +} diff --git a/internal/adapters/dashboard/http.go b/internal/adapters/dashboard/http.go index b22d423..9638e9a 100644 --- a/internal/adapters/dashboard/http.go +++ b/internal/adapters/dashboard/http.go @@ -48,75 +48,100 @@ func (c *AccountClient) doPost(ctx context.Context, path string, body any, extra return nil } -// setDPoPProof generates and sets the DPoP proof header on the request. -func (c *AccountClient) setDPoPProof(req *http.Request, method, fullURL, accessToken string) error { - proof, err := c.dpop.GenerateProof(method, fullURL, accessToken) +// doPatch sends a JSON PATCH request and decodes the envelope-unwrapped response. +func (c *AccountClient) doPatch(ctx context.Context, path string, body any, extraHeaders map[string]string, accessToken string, result any) error { + raw, err := c.doPatchRaw(ctx, path, body, extraHeaders, accessToken) if err != nil { return err } - req.Header.Set("DPoP", proof) - return nil -} - -// doPostRaw sends a JSON POST request and returns the raw response body. -func (c *AccountClient) doPostRaw(ctx context.Context, path string, body any, extraHeaders map[string]string, accessToken string) ([]byte, error) { - fullURL := c.baseURL + path - var bodyReader io.Reader - if body != nil { - bodyJSON, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to encode request: %w", err) + if result != nil { + if err := json.Unmarshal(raw, result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) } - bodyReader = bytes.NewReader(bodyJSON) } + return nil +} - req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bodyReader) +// doDelete sends a DELETE request and decodes the envelope-unwrapped response. +func (c *AccountClient) doDelete(ctx context.Context, path string, extraHeaders map[string]string, accessToken string, result any) error { + raw, err := c.doDeleteRaw(ctx, path, extraHeaders, accessToken) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return err } - if body != nil { - req.Header.Set("Content-Type", "application/json") + if result != nil { + if err := json.Unmarshal(raw, result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } } - setDashboardUserAgent(req) + return nil +} - if err := c.setDPoPProof(req, http.MethodPost, fullURL, accessToken); err != nil { - return nil, err - } +type rawResponse struct { + Data []byte + NextCursor string +} - // Add extra headers (Authorization, X-Nylas-Org) - for k, v := range extraHeaders { - req.Header.Set(k, v) - } +type dashboardEnvelope struct { + Data json.RawMessage `json:"data"` + NextCursor string `json:"nextCursor"` + NextCursorSnake string `json:"next_cursor"` + PageToken string `json:"pageToken"` + Pagination *cursorEnvelope `json:"pagination"` + Meta *cursorEnvelope `json:"meta"` +} - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() +type cursorEnvelope struct { + NextCursor string `json:"nextCursor"` + NextCursorSnake string `json:"next_cursor"` + PageToken string `json:"pageToken"` +} - respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) +func (e dashboardEnvelope) cursor() string { + for _, cursor := range []string{e.NextCursor, e.NextCursorSnake, e.PageToken} { + if cursor != "" { + return cursor + } + } + for _, nested := range []*cursorEnvelope{e.Pagination, e.Meta} { + if nested == nil { + continue + } + if cursor := nested.cursor(); cursor != "" { + return cursor + } } + return "" +} - if resp.StatusCode >= 300 && resp.StatusCode < 400 { - location := resp.Header.Get("Location") - return nil, fmt.Errorf("server redirected to %s — the dashboard URL may be incorrect (set NYLAS_DASHBOARD_ACCOUNT_URL)", location) +func (e cursorEnvelope) cursor() string { + for _, cursor := range []string{e.NextCursor, e.NextCursorSnake, e.PageToken} { + if cursor != "" { + return cursor + } } + return "" +} - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, parseErrorResponse(resp.StatusCode, respBody) +// setDPoPProof generates and sets the DPoP proof header on the request. +func (c *AccountClient) setDPoPProof(req *http.Request, method, fullURL, accessToken string) error { + proof, err := c.dpop.GenerateProof(method, fullURL, accessToken) + if err != nil { + return err } + req.Header.Set("DPoP", proof) + return nil +} - // Unwrap the {request_id, success, data} envelope - data, unwrapErr := unwrapEnvelope(respBody) - if unwrapErr != nil { - return nil, unwrapErr +// doPostRaw sends a JSON POST request and returns the raw response body. +func (c *AccountClient) doPostRaw(ctx context.Context, path string, body any, extraHeaders map[string]string, accessToken string) ([]byte, error) { + resp, err := c.doRaw(ctx, http.MethodPost, path, body, extraHeaders, accessToken) + if err != nil { + return nil, err } - return data, nil + return resp.Data, nil } // unwrapEnvelope extracts the "data" field from the API response envelope. @@ -124,77 +149,134 @@ func (c *AccountClient) doPostRaw(ctx context.Context, path string, body any, ex // // {"request_id": "...", "success": true, "data": {...}} func unwrapEnvelope(body []byte) ([]byte, error) { + resp, err := unwrapRawResponse(body) + if err != nil { + return nil, err + } + return resp.Data, nil +} + +func unwrapRawResponse(body []byte) (rawResponse, error) { var envelope struct { Data json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &envelope); err != nil { - return nil, fmt.Errorf("failed to decode response envelope: %w", err) + return rawResponse{}, fmt.Errorf("failed to decode response envelope: %w", err) } if len(envelope.Data) == 0 { - return body, nil // no envelope, return as-is + return rawResponse{Data: body}, nil // no envelope, return as-is } - return envelope.Data, nil + + var full dashboardEnvelope + if err := json.Unmarshal(body, &full); err != nil { + return rawResponse{}, fmt.Errorf("failed to decode response envelope: %w", err) + } + return rawResponse{ + Data: full.Data, + NextCursor: full.cursor(), + }, nil } // doGetRaw sends a GET request and returns the raw (envelope-unwrapped) response body. func (c *AccountClient) doGetRaw(ctx context.Context, path string, extraHeaders map[string]string, accessToken string) ([]byte, error) { - fullURL := c.baseURL + path + resp, err := c.doRaw(ctx, http.MethodGet, path, nil, extraHeaders, accessToken) + if err != nil { + return nil, err + } + + return resp.Data, nil +} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) +// doGet sends a GET request and decodes the envelope-unwrapped response into result. +func (c *AccountClient) doGet(ctx context.Context, path string, extraHeaders map[string]string, accessToken string, result any) error { + raw, err := c.doGetRaw(ctx, path, extraHeaders, accessToken) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return err } - if err := c.setDPoPProof(req, http.MethodGet, fullURL, accessToken); err != nil { + if result != nil { + if err := json.Unmarshal(raw, result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + return nil +} + +// doPatchRaw sends a JSON PATCH request and returns the raw response body. +func (c *AccountClient) doPatchRaw(ctx context.Context, path string, body any, extraHeaders map[string]string, accessToken string) ([]byte, error) { + resp, err := c.doRaw(ctx, http.MethodPatch, path, body, extraHeaders, accessToken) + if err != nil { return nil, err } + + return resp.Data, nil +} + +// doDeleteRaw sends a DELETE request and returns the raw response body. +func (c *AccountClient) doDeleteRaw(ctx context.Context, path string, extraHeaders map[string]string, accessToken string) ([]byte, error) { + resp, err := c.doRaw(ctx, http.MethodDelete, path, nil, extraHeaders, accessToken) + if err != nil { + return nil, err + } + + return resp.Data, nil +} + +func (c *AccountClient) doGetRawResponse(ctx context.Context, path string, extraHeaders map[string]string, accessToken string) (rawResponse, error) { + return c.doRaw(ctx, http.MethodGet, path, nil, extraHeaders, accessToken) +} + +func (c *AccountClient) doRaw(ctx context.Context, method, path string, body any, extraHeaders map[string]string, accessToken string) (rawResponse, error) { + fullURL := c.baseURL + path + + var bodyReader io.Reader + if body != nil { + bodyJSON, err := json.Marshal(body) + if err != nil { + return rawResponse{}, fmt.Errorf("failed to encode request: %w", err) + } + bodyReader = bytes.NewReader(bodyJSON) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return rawResponse{}, fmt.Errorf("failed to create request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } setDashboardUserAgent(req) + if err := c.setDPoPProof(req, method, fullURL, accessToken); err != nil { + return rawResponse{}, err + } + for k, v := range extraHeaders { req.Header.Set(k, v) } resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return rawResponse{}, fmt.Errorf("request failed: %w", err) } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + return rawResponse{}, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode >= 300 && resp.StatusCode < 400 { location := resp.Header.Get("Location") - return nil, fmt.Errorf("server redirected to %s — the dashboard URL may be incorrect (set NYLAS_DASHBOARD_ACCOUNT_URL)", location) + return rawResponse{}, fmt.Errorf("server redirected to %s — the dashboard URL may be incorrect (set NYLAS_DASHBOARD_ACCOUNT_URL)", location) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, parseErrorResponse(resp.StatusCode, respBody) + return rawResponse{}, parseErrorResponse(resp.StatusCode, respBody) } - data, unwrapErr := unwrapEnvelope(respBody) - if unwrapErr != nil { - return nil, unwrapErr - } - - return data, nil -} - -// doGet sends a GET request and decodes the envelope-unwrapped response into result. -func (c *AccountClient) doGet(ctx context.Context, path string, extraHeaders map[string]string, accessToken string, result any) error { - raw, err := c.doGetRaw(ctx, path, extraHeaders, accessToken) - if err != nil { - return err - } - - if result != nil { - if err := json.Unmarshal(raw, result); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - } - return nil + return unwrapRawResponse(respBody) } // parseErrorResponse extracts a user-friendly error from an HTTP error response. diff --git a/internal/adapters/dashboard/mock.go b/internal/adapters/dashboard/mock.go index af24b8a..5cc9a1d 100644 --- a/internal/adapters/dashboard/mock.go +++ b/internal/adapters/dashboard/mock.go @@ -8,17 +8,25 @@ import ( // MockAccountClient is a test mock for ports.DashboardAccountClient. type MockAccountClient struct { - RegisterFn func(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) - VerifyEmailCodeFn func(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) - ResendVerificationCodeFn func(ctx context.Context, email string) error - LoginFn func(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) - LoginMFAFn func(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) - RefreshFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) - LogoutFn func(ctx context.Context, userToken, orgToken string) error - SSOStartFn func(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) - SSOPollFn func(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) - GetCurrentSessionFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) - SwitchOrgFn func(ctx context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) + RegisterFn func(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) + VerifyEmailCodeFn func(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) + ResendVerificationCodeFn func(ctx context.Context, email string) error + LoginFn func(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) + LoginMFAFn func(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) + RefreshFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) + LogoutFn func(ctx context.Context, userToken, orgToken string) error + SSOStartFn func(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) + SSOPollFn func(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) + GetCurrentSessionFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardSessionResponse, error) + SwitchOrgFn func(ctx context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) + ListDomainsFn func(ctx context.Context, limit int, pageToken, userToken, orgToken string) (domain.DashboardInboxDomainPage, error) + GetDomainFn func(ctx context.Context, domainIDOrAddress, region, userToken, orgToken string) (*domain.DashboardInboxDomain, error) + CheckDomainAvailabilityFn func(ctx context.Context, domainAddress, userToken, orgToken string) (*domain.DashboardInboxDomainAvailability, error) + CreateDomainFn func(ctx context.Context, input domain.DashboardCreateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) + UpdateDomainFn func(ctx context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) + DeleteDomainFn func(ctx context.Context, domainID, region, userToken, orgToken string) (bool, error) + GetDomainInfoFn func(ctx context.Context, domainID, region, verificationType, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) + VerifyDomainFn func(ctx context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) } func (m *MockAccountClient) Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) { @@ -60,6 +68,54 @@ func (m *MockAccountClient) SwitchOrg(ctx context.Context, orgPublicID, userToke } return &domain.DashboardSwitchOrgResponse{}, nil } +func (m *MockAccountClient) ListDomains(ctx context.Context, limit int, pageToken, userToken, orgToken string) (domain.DashboardInboxDomainPage, error) { + if m.ListDomainsFn != nil { + return m.ListDomainsFn(ctx, limit, pageToken, userToken, orgToken) + } + return domain.DashboardInboxDomainPage{}, nil +} +func (m *MockAccountClient) GetDomain(ctx context.Context, domainIDOrAddress, region, userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + if m.GetDomainFn != nil { + return m.GetDomainFn(ctx, domainIDOrAddress, region, userToken, orgToken) + } + return &domain.DashboardInboxDomain{}, nil +} +func (m *MockAccountClient) CheckDomainAvailability(ctx context.Context, domainAddress, userToken, orgToken string) (*domain.DashboardInboxDomainAvailability, error) { + if m.CheckDomainAvailabilityFn != nil { + return m.CheckDomainAvailabilityFn(ctx, domainAddress, userToken, orgToken) + } + return &domain.DashboardInboxDomainAvailability{}, nil +} +func (m *MockAccountClient) CreateDomain(ctx context.Context, input domain.DashboardCreateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + if m.CreateDomainFn != nil { + return m.CreateDomainFn(ctx, input, userToken, orgToken) + } + return &domain.DashboardInboxDomain{}, nil +} +func (m *MockAccountClient) UpdateDomain(ctx context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + if m.UpdateDomainFn != nil { + return m.UpdateDomainFn(ctx, domainID, region, input, userToken, orgToken) + } + return &domain.DashboardInboxDomain{}, nil +} +func (m *MockAccountClient) DeleteDomain(ctx context.Context, domainID, region, userToken, orgToken string) (bool, error) { + if m.DeleteDomainFn != nil { + return m.DeleteDomainFn(ctx, domainID, region, userToken, orgToken) + } + return false, nil +} +func (m *MockAccountClient) GetDomainInfo(ctx context.Context, domainID, region, verificationType, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) { + if m.GetDomainInfoFn != nil { + return m.GetDomainInfoFn(ctx, domainID, region, verificationType, userToken, orgToken) + } + return &domain.DashboardDomainVerificationResult{}, nil +} +func (m *MockAccountClient) VerifyDomain(ctx context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) { + if m.VerifyDomainFn != nil { + return m.VerifyDomainFn(ctx, domainID, region, input, userToken, orgToken) + } + return &domain.DashboardDomainVerificationResult{}, nil +} // MockGatewayClient is a test mock for ports.DashboardGatewayClient. type MockGatewayClient struct { diff --git a/internal/adapters/dpop/dpop.go b/internal/adapters/dpop/dpop.go index 9f18091..0e73353 100644 --- a/internal/adapters/dpop/dpop.go +++ b/internal/adapters/dpop/dpop.go @@ -63,7 +63,7 @@ func New(secrets ports.SecretStore) (*Service, error) { // GenerateProof creates a DPoP proof JWT for the given HTTP method and URL. // If accessToken is non-empty, the proof includes an ath claim. func (s *Service) GenerateProof(method, rawURL string, accessToken string) (string, error) { - // Normalize the URL: strip fragment and query + // Normalize the URL for the dashboard-account DPoP contract. htu, err := normalizeHTU(rawURL) if err != nil { return "", fmt.Errorf("%w: invalid URL: %w", domain.ErrDashboardDPoP, err) @@ -132,14 +132,17 @@ func (s *Service) computeThumbprint() string { return base64urlEncode(hash[:]) } -// normalizeHTU strips the fragment and query from a URL per DPoP spec. +// normalizeHTU strips the fragment from a URL. +// +// RFC 9449 excludes query strings from htu, but dashboard-account currently +// validates DPoP proofs against the full request URL including the query string +// for query-bearing endpoints added under TW-5656. func normalizeHTU(rawURL string) (string, error) { u, err := url.Parse(rawURL) if err != nil { return "", err } u.Fragment = "" - u.RawQuery = "" return u.String(), nil } diff --git a/internal/adapters/dpop/dpop_test.go b/internal/adapters/dpop/dpop_test.go index 9c07ef3..03b14fc 100644 --- a/internal/adapters/dpop/dpop_test.go +++ b/internal/adapters/dpop/dpop_test.go @@ -133,6 +133,26 @@ func TestGenerateProof_WithAccessToken(t *testing.T) { assert.Equal(t, expectedATH, claims.ATH) } +func TestGenerateProof_KeepsQueryInHTU(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + proof, err := svc.GenerateProof("GET", "https://example.com/path?domainAddress=foo.nylas.email#ignored", "token") + require.NoError(t, err) + + parts := strings.Split(proof, ".") + require.Len(t, parts, 3) + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims jwtClaims + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + assert.Equal(t, "https://example.com/path?domainAddress=foo.nylas.email", claims.HTU) +} + func TestGenerateProof_SignatureVerifies(t *testing.T) { t.Parallel() store := newMockSecretStore() @@ -210,9 +230,9 @@ func TestGenerateProof_URLNormalization(t *testing.T) { expected: "https://example.com/path", }, { - name: "strips query", + name: "keeps query", inputURL: "https://example.com/path?key=value", - expected: "https://example.com/path", + expected: "https://example.com/path?key=value", }, { name: "preserves path", diff --git a/internal/app/dashboard/domain_service.go b/internal/app/dashboard/domain_service.go new file mode 100644 index 0000000..8bdef92 --- /dev/null +++ b/internal/app/dashboard/domain_service.go @@ -0,0 +1,102 @@ +package dashboard + +import ( + "context" + "errors" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// DomainService handles inbox/agent-account domain management via dashboard-account. +type DomainService struct { + account ports.DashboardAccountClient + secrets ports.SecretStore +} + +// NewDomainService creates a new dashboard domain service. +func NewDomainService(account ports.DashboardAccountClient, secrets ports.SecretStore) *DomainService { + return &DomainService{ + account: account, + secrets: secrets, + } +} + +// ListDomains lists domains for the active dashboard organization. +func (s *DomainService) ListDomains(ctx context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + return withDomainSessionRetry(ctx, s, func(userToken, orgToken string) (domain.DashboardInboxDomainPage, error) { + return s.account.ListDomains(ctx, limit, pageToken, userToken, orgToken) + }) +} + +// GetDomain retrieves a domain by ID or domain address. +func (s *DomainService) GetDomain(ctx context.Context, domainIDOrAddress, region string) (*domain.DashboardInboxDomain, error) { + return withDomainSessionRetry(ctx, s, func(userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + return s.account.GetDomain(ctx, domainIDOrAddress, region, userToken, orgToken) + }) +} + +// CheckAvailability checks whether a domain address is already used in the active org. +func (s *DomainService) CheckAvailability(ctx context.Context, domainAddress string) (*domain.DashboardInboxDomainAvailability, error) { + return withDomainSessionRetry(ctx, s, func(userToken, orgToken string) (*domain.DashboardInboxDomainAvailability, error) { + return s.account.CheckDomainAvailability(ctx, domainAddress, userToken, orgToken) + }) +} + +// CreateDomain creates/registers a domain in dashboard-account. +func (s *DomainService) CreateDomain(ctx context.Context, input domain.DashboardCreateInboxDomainInput) (*domain.DashboardInboxDomain, error) { + return withDomainSessionRetry(ctx, s, func(userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + return s.account.CreateDomain(ctx, input, userToken, orgToken) + }) +} + +// UpdateDomain updates a domain's display name. +func (s *DomainService) UpdateDomain(ctx context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput) (*domain.DashboardInboxDomain, error) { + return withDomainSessionRetry(ctx, s, func(userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + return s.account.UpdateDomain(ctx, domainID, region, input, userToken, orgToken) + }) +} + +// DeleteDomain deletes a domain. +func (s *DomainService) DeleteDomain(ctx context.Context, domainID, region string) (bool, error) { + return withDomainSessionRetry(ctx, s, func(userToken, orgToken string) (bool, error) { + return s.account.DeleteDomain(ctx, domainID, region, userToken, orgToken) + }) +} + +// GetDomainInfo returns DNS-record info for a verification type. +func (s *DomainService) GetDomainInfo(ctx context.Context, domainID, region, verificationType string) (*domain.DashboardDomainVerificationResult, error) { + return withDomainSessionRetry(ctx, s, func(userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) { + return s.account.GetDomainInfo(ctx, domainID, region, verificationType, userToken, orgToken) + }) +} + +// VerifyDomain triggers DNS/authentication verification for a domain. +func (s *DomainService) VerifyDomain(ctx context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput) (*domain.DashboardDomainVerificationResult, error) { + return withDomainSessionRetry(ctx, s, func(userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) { + return s.account.VerifyDomain(ctx, domainID, region, input, userToken, orgToken) + }) +} + +func (s *DomainService) loadTokens() (userToken, orgToken string, err error) { + return loadDashboardTokens(s.secrets) +} + +func withDomainSessionRetry[T any](ctx context.Context, s *DomainService, call func(userToken, orgToken string) (T, error)) (T, error) { + userToken, orgToken, err := s.loadTokens() + var zero T + if err != nil { + return zero, err + } + + result, err := call(userToken, orgToken) + if !errors.Is(err, domain.ErrDashboardSessionExpired) { + return result, err + } + + userToken, orgToken, err = NewAuthService(s.account, s.secrets).refreshTokens(ctx, userToken, orgToken) + if err != nil { + return zero, err + } + return call(userToken, orgToken) +} diff --git a/internal/app/dashboard/domain_service_test.go b/internal/app/dashboard/domain_service_test.go new file mode 100644 index 0000000..20e399b --- /dev/null +++ b/internal/app/dashboard/domain_service_test.go @@ -0,0 +1,170 @@ +package dashboard + +import ( + "context" + "testing" + + dashboardadapter "github.com/nylas/cli/internal/adapters/dashboard" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDomainServiceForwardsDashboardTokens(t *testing.T) { + t.Parallel() + + store := newMemSecretStore() + seedTokens(store, "user-token", "org-token") + + var calls []string + mock := &dashboardadapter.MockAccountClient{ + ListDomainsFn: func(_ context.Context, limit int, pageToken, userToken, orgToken string) (domain.DashboardInboxDomainPage, error) { + calls = append(calls, "list") + assert.Equal(t, 25, limit) + assert.Equal(t, "cursor", pageToken) + assert.Equal(t, "user-token", userToken) + assert.Equal(t, "org-token", orgToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{{ID: "dom_1"}}, + NextCursor: "next", + }, nil + }, + GetDomainFn: func(_ context.Context, domainIDOrAddress, region, userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + calls = append(calls, "get") + assert.Equal(t, "example.com", domainIDOrAddress) + assert.Equal(t, "us", region) + assert.Equal(t, "user-token", userToken) + assert.Equal(t, "org-token", orgToken) + return &domain.DashboardInboxDomain{ID: "dom_1"}, nil + }, + CheckDomainAvailabilityFn: func(_ context.Context, domainAddress, userToken, orgToken string) (*domain.DashboardInboxDomainAvailability, error) { + calls = append(calls, "check") + assert.Equal(t, "example.com", domainAddress) + assert.Equal(t, "user-token", userToken) + assert.Equal(t, "org-token", orgToken) + return &domain.DashboardInboxDomainAvailability{Available: true}, nil + }, + CreateDomainFn: func(_ context.Context, input domain.DashboardCreateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + calls = append(calls, "create") + assert.Equal(t, "example.com", input.DomainAddress) + assert.Equal(t, "user-token", userToken) + assert.Equal(t, "org-token", orgToken) + return &domain.DashboardInboxDomain{ID: "dom_new"}, nil + }, + UpdateDomainFn: func(_ context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) { + calls = append(calls, "update") + assert.Equal(t, "dom_1", domainID) + assert.Equal(t, "us", region) + assert.Equal(t, "Renamed", input.Name) + assert.Equal(t, "user-token", userToken) + assert.Equal(t, "org-token", orgToken) + return &domain.DashboardInboxDomain{ID: "dom_1", Name: "Renamed"}, nil + }, + DeleteDomainFn: func(_ context.Context, domainID, region, userToken, orgToken string) (bool, error) { + calls = append(calls, "delete") + assert.Equal(t, "dom_1", domainID) + assert.Equal(t, "us", region) + assert.Equal(t, "user-token", userToken) + assert.Equal(t, "org-token", orgToken) + return true, nil + }, + GetDomainInfoFn: func(_ context.Context, domainID, region, verificationType, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) { + calls = append(calls, "info") + assert.Equal(t, "dom_1", domainID) + assert.Equal(t, "us", region) + assert.Equal(t, "mx", verificationType) + assert.Equal(t, "user-token", userToken) + assert.Equal(t, "org-token", orgToken) + return &domain.DashboardDomainVerificationResult{Status: "pending"}, nil + }, + VerifyDomainFn: func(_ context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) { + calls = append(calls, "verify") + assert.Equal(t, "dom_1", domainID) + assert.Equal(t, "us", region) + assert.Equal(t, "mx", input.Type) + assert.Equal(t, "user-token", userToken) + assert.Equal(t, "org-token", orgToken) + return &domain.DashboardDomainVerificationResult{Status: "done"}, nil + }, + } + + svc := NewDomainService(mock, store) + ctx := context.Background() + + page, err := svc.ListDomains(ctx, 25, "cursor") + require.NoError(t, err) + assert.Equal(t, "next", page.NextCursor) + _, err = svc.GetDomain(ctx, "example.com", "us") + require.NoError(t, err) + _, err = svc.CheckAvailability(ctx, "example.com") + require.NoError(t, err) + _, err = svc.CreateDomain(ctx, domain.DashboardCreateInboxDomainInput{DomainAddress: "example.com"}) + require.NoError(t, err) + _, err = svc.UpdateDomain(ctx, "dom_1", "us", domain.DashboardUpdateInboxDomainInput{Name: "Renamed"}) + require.NoError(t, err) + _, err = svc.DeleteDomain(ctx, "dom_1", "us") + require.NoError(t, err) + _, err = svc.GetDomainInfo(ctx, "dom_1", "us", "mx") + require.NoError(t, err) + _, err = svc.VerifyDomain(ctx, "dom_1", "us", domain.DashboardVerifyInboxDomainInput{Type: "mx"}) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"list", "get", "check", "create", "update", "delete", "info", "verify"}, calls) +} + +func TestDomainServiceReturnsNotLoggedInWhenTokensMissing(t *testing.T) { + t.Parallel() + + store := newMemSecretStore() + svc := NewDomainService(&dashboardadapter.MockAccountClient{}, store) + + _, err := svc.ListDomains(context.Background(), 25, "") + require.Error(t, err) + assert.ErrorIs(t, err, domain.ErrDashboardNotLoggedIn) +} + +func TestDomainServiceRefreshesExpiredSessionAndRetries(t *testing.T) { + t.Parallel() + + store := newMemSecretStore() + seedTokens(store, "user-token-old", "org-token-old") + + checkCalls := 0 + mock := &dashboardadapter.MockAccountClient{ + CheckDomainAvailabilityFn: func(_ context.Context, domainAddress, userToken, orgToken string) (*domain.DashboardInboxDomainAvailability, error) { + checkCalls++ + assert.Equal(t, "example.com", domainAddress) + if checkCalls == 1 { + assert.Equal(t, "user-token-old", userToken) + assert.Equal(t, "org-token-old", orgToken) + return nil, domain.NewDashboardAPIError(401, "INVALID_SESSION", "Invalid or expired session") + } + assert.Equal(t, "user-token-new", userToken) + assert.Equal(t, "org-token-new", orgToken) + return &domain.DashboardInboxDomainAvailability{DomainAddress: domainAddress, Available: true}, nil + }, + RefreshFn: func(_ context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) { + assert.Equal(t, "user-token-old", userToken) + assert.Equal(t, "org-token-old", orgToken) + return &domain.DashboardRefreshResponse{ + UserToken: "user-token-new", + OrgToken: "org-token-new", + }, nil + }, + } + + svc := NewDomainService(mock, store) + result, err := svc.CheckAvailability(context.Background(), "example.com") + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Available) + assert.Equal(t, 2, checkCalls) + + storedUserToken, err := store.Get(ports.KeyDashboardUserToken) + require.NoError(t, err) + assert.Equal(t, "user-token-new", storedUserToken) + storedOrgToken, err := store.Get(ports.KeyDashboardOrgToken) + require.NoError(t, err) + assert.Equal(t, "org-token-new", storedOrgToken) +} diff --git a/internal/cli/dashboard/dashboard.go b/internal/cli/dashboard/dashboard.go index 43c6229..2dcb587 100644 --- a/internal/cli/dashboard/dashboard.go +++ b/internal/cli/dashboard/dashboard.go @@ -21,6 +21,7 @@ Commands: status Show current dashboard authentication status refresh Refresh dashboard session tokens apps Manage Nylas applications + domains Manage inbox and Agent Account domains orgs Manage organizations (list, switch) Guide: https://developer.nylas.com/docs/dev-guide/dashboard/`, @@ -33,6 +34,7 @@ Guide: https://developer.nylas.com/docs/dev-guide/dashboard/`, cmd.AddCommand(newStatusCmd()) cmd.AddCommand(newRefreshCmd()) cmd.AddCommand(newAppsCmd()) + cmd.AddCommand(newDomainsCmd()) cmd.AddCommand(newOrgsCmd()) return cmd diff --git a/internal/cli/dashboard/dashboard_test.go b/internal/cli/dashboard/dashboard_test.go index 3beaf32..61cf133 100644 --- a/internal/cli/dashboard/dashboard_test.go +++ b/internal/cli/dashboard/dashboard_test.go @@ -1,11 +1,13 @@ package dashboard import ( + "bytes" "context" "os" "runtime" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,6 +21,100 @@ type memSecretStore struct { data map[string]string } +type fakeDomainService struct { + listFn func(ctx context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) + getFn func(ctx context.Context, domainIDOrAddress, region string) (*domain.DashboardInboxDomain, error) + checkAvailabilityFn func(ctx context.Context, domainAddress string) (*domain.DashboardInboxDomainAvailability, error) + createFn func(ctx context.Context, input domain.DashboardCreateInboxDomainInput) (*domain.DashboardInboxDomain, error) + updateFn func(ctx context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput) (*domain.DashboardInboxDomain, error) + deleteFn func(ctx context.Context, domainID, region string) (bool, error) + getDomainInfoFn func(ctx context.Context, domainID, region, verificationType string) (*domain.DashboardDomainVerificationResult, error) + verifyFn func(ctx context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput) (*domain.DashboardDomainVerificationResult, error) +} + +func (f *fakeDomainService) ListDomains(ctx context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + if f.listFn != nil { + return f.listFn(ctx, limit, pageToken) + } + return domain.DashboardInboxDomainPage{}, nil +} + +func (f *fakeDomainService) GetDomain(ctx context.Context, domainIDOrAddress, region string) (*domain.DashboardInboxDomain, error) { + if f.getFn != nil { + return f.getFn(ctx, domainIDOrAddress, region) + } + return &domain.DashboardInboxDomain{}, nil +} + +func (f *fakeDomainService) CheckAvailability(ctx context.Context, domainAddress string) (*domain.DashboardInboxDomainAvailability, error) { + if f.checkAvailabilityFn != nil { + return f.checkAvailabilityFn(ctx, domainAddress) + } + return &domain.DashboardInboxDomainAvailability{}, nil +} + +func (f *fakeDomainService) CreateDomain(ctx context.Context, input domain.DashboardCreateInboxDomainInput) (*domain.DashboardInboxDomain, error) { + if f.createFn != nil { + return f.createFn(ctx, input) + } + return &domain.DashboardInboxDomain{}, nil +} + +func (f *fakeDomainService) UpdateDomain(ctx context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput) (*domain.DashboardInboxDomain, error) { + if f.updateFn != nil { + return f.updateFn(ctx, domainID, region, input) + } + return &domain.DashboardInboxDomain{}, nil +} + +func (f *fakeDomainService) DeleteDomain(ctx context.Context, domainID, region string) (bool, error) { + if f.deleteFn != nil { + return f.deleteFn(ctx, domainID, region) + } + return false, nil +} + +func (f *fakeDomainService) GetDomainInfo(ctx context.Context, domainID, region, verificationType string) (*domain.DashboardDomainVerificationResult, error) { + if f.getDomainInfoFn != nil { + return f.getDomainInfoFn(ctx, domainID, region, verificationType) + } + return &domain.DashboardDomainVerificationResult{}, nil +} + +func (f *fakeDomainService) VerifyDomain(ctx context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput) (*domain.DashboardDomainVerificationResult, error) { + if f.verifyFn != nil { + return f.verifyFn(ctx, domainID, region, input) + } + return &domain.DashboardDomainVerificationResult{}, nil +} + +func stubDomainService(t *testing.T, svc domainService) { + t.Helper() + orig := createDomainServiceFn + createDomainServiceFn = func() (domainService, error) { + return svc, nil + } + t.Cleanup(func() { + createDomainServiceFn = orig + }) +} + +func executeTestCommand(cmd *cobra.Command, args ...string) (string, error) { + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func addTestOutputFlags(cmd *cobra.Command) { + cmd.Flags().String("format", "", "Output format") + cmd.Flags().Bool("json", false, "Output in JSON format") + cmd.Flags().Bool("quiet", false, "Quiet mode") + cmd.Flags().Bool("no-color", false, "Disable colored output") +} + func newMemSecretStore() *memSecretStore { return &memSecretStore{data: make(map[string]string)} } @@ -461,3 +557,574 @@ func TestValidateDeliveryChoices(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "invalid secret delivery method") } + +func TestDashboardDomainValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + domain string + wantErr string + }{ + {name: "valid root domain", domain: "example.com"}, + {name: "valid subdomain", domain: "mail.example.com"}, + {name: "missing dot", domain: "localhost", wantErr: "at least one dot"}, + {name: "bad character", domain: "bad slug.com", wantErr: "letters, numbers, and hyphens"}, + {name: "leading hyphen", domain: "-bad.example.com", wantErr: "cannot start or end"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateDomainAddress(tt.domain) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestResolveDomainAddressRejectsConflictingInputs(t *testing.T) { + t.Parallel() + + got, err := resolveDomainAddress([]string{"example.com"}, "") + require.NoError(t, err) + assert.Equal(t, "example.com", got) + + got, err = resolveDomainAddress([]string{"Example.COM."}, "") + require.NoError(t, err) + assert.Equal(t, "example.com", got) + + got, err = resolveDomainAddress([]string{"Example.COM."}, "example.com") + require.NoError(t, err) + assert.Equal(t, "example.com", got) + + _, err = resolveDomainAddress([]string{"example.com"}, "other.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "domain specified twice") +} + +func TestDashboardDomainRows(t *testing.T) { + t.Parallel() + + row := toDomainRow(domain.DashboardInboxDomain{ + ID: "dom_1", + Name: "Example", + DomainAddress: "example.com", + Region: "us", + Branded: true, + VerifiedOwnership: true, + VerifiedMX: true, + VerifiedSPF: false, + VerifiedDKIM: true, + }) + + assert.Equal(t, "dom_1", row.ID) + assert.Equal(t, "example.com", row.Domain) + assert.Equal(t, "yes", row.Ownership) + assert.Equal(t, "yes", row.MX) + assert.Equal(t, "no", row.SPF) + assert.Equal(t, "yes", row.DKIM) +} + +func TestNormalizeVerificationTypes(t *testing.T) { + t.Parallel() + + assert.Equal(t, domainInfoTypes, normalizeVerificationTypes(nil)) + assert.Equal(t, []string{"mx", "spf"}, normalizeVerificationTypes([]string{"MX", "spf", "mx", ""})) + + require.NoError(t, validateVerificationTypes([]string{"mx", "ownership"})) + err := validateVerificationTypes([]string{"txt"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid verification type") +} + +func TestWriteDomainDeleteResult(t *testing.T) { + t.Parallel() + + t.Run("returns an error when the API reports no deletion", func(t *testing.T) { + t.Parallel() + + cmd := newDomainsDeleteCmd() + err := writeDomainDeleteResult(cmd, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "domain was not deleted") + }) + + t.Run("writes structured success when requested", func(t *testing.T) { + t.Parallel() + + cmd := newDomainsDeleteCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.Flags().String("format", "", "Output format") + cmd.Flags().Bool("json", true, "Output in JSON format") + cmd.Flags().Bool("quiet", false, "Quiet mode") + cmd.Flags().Bool("no-color", false, "Disable colored output") + require.NoError(t, cmd.Flags().Set("json", "true")) + + err := writeDomainDeleteResult(cmd, true) + + require.NoError(t, err) + assert.JSONEq(t, `{"success":true}`, out.String()) + }) +} + +func TestDomainsListRunEClampsLimitAndPrintsNextCursor(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + assert.Equal(t, "cursor-1", pageToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + { + ID: "dom_1", + Name: "Example", + DomainAddress: "example.com", + Region: "us", + }, + }, + NextCursor: "cursor-2", + }, nil + }, + } + stubDomainService(t, svc) + + out, err := executeTestCommand(newDomainsListCmd(), "--limit", "999", "--page-token", "cursor-1") + + require.NoError(t, err) + assert.Contains(t, out, "dom_1") + assert.Contains(t, out, "Next: nylas dashboard domains list --page-token cursor-2") +} + +func TestDomainsListRunEStructuredOutputIncludesNextCursor(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 100, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + { + ID: "dom_1", + Name: "Example", + DomainAddress: "example.com", + Region: "eu", + }, + }, + NextCursor: "cursor-2", + }, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsListCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "--json") + + require.NoError(t, err) + assert.JSONEq(t, `{"domains":[{"id":"dom_1","domain":"example.com","name":"Example","region":"eu","branded":false,"ownership":"no","mx":"no","spf":"no","dkim":"no","dmarc":"no","arc":"no","feedback":"no"}],"next_cursor":"cursor-2"}`, out) +} + +func TestDomainsListRunEEmptyStructuredOutput(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 100, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + NextCursor: "cursor-2", + }, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsListCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "--json") + + require.NoError(t, err) + assert.JSONEq(t, `{"domains":[],"next_cursor":"cursor-2"}`, out) +} + +func TestDomainsListRunEEmptyQuietOutput(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 100, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + NextCursor: "cursor-2", + }, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsListCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "--quiet") + + require.NoError(t, err) + assert.Empty(t, out) +} + +func TestDomainsCreateRunERejectsIncompleteDashboardResponse(t *testing.T) { + svc := &fakeDomainService{ + createFn: func(_ context.Context, input domain.DashboardCreateInboxDomainInput) (*domain.DashboardInboxDomain, error) { + assert.Equal(t, "example.com", input.DomainAddress) + assert.Equal(t, "example.com", input.Name) + assert.Equal(t, "us", input.Region) + return &domain.DashboardInboxDomain{}, nil + }, + } + stubDomainService(t, svc) + + _, err := executeTestCommand(newDomainsCreateCmd(), "example.com", "--region", "us") + + require.Error(t, err) + assert.Contains(t, err.Error(), "domain was not created") +} + +func TestDomainsCreateRunEStillRequiresRegion(t *testing.T) { + _, err := executeTestCommand(newDomainsCreateCmd(), "example.com") + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid region") +} + +func TestDomainsShowRunEInfersRegion(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_eu", DomainAddress: "asim.nylas.email", Region: "eu"}, + }, + }, nil + }, + getFn: func(_ context.Context, domainIDOrAddress, region string) (*domain.DashboardInboxDomain, error) { + assert.Equal(t, "dom_eu", domainIDOrAddress) + assert.Equal(t, "eu", region) + return &domain.DashboardInboxDomain{ + ID: "dom_eu", + Name: "asim.nylas.email", + DomainAddress: "asim.nylas.email", + Region: "eu", + }, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsShowCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "asim.nylas.email", "--json") + + require.NoError(t, err) + assert.JSONEq(t, `{"id":"dom_eu","domain":"asim.nylas.email","name":"asim.nylas.email","region":"eu","branded":false,"ownership":"no","mx":"no","spf":"no","dkim":"no","dmarc":"no","arc":"no","feedback":"no"}`, out) +} + +func TestDomainsUpdateRunERejectsIncompleteDashboardResponse(t *testing.T) { + svc := &fakeDomainService{ + updateFn: func(_ context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput) (*domain.DashboardInboxDomain, error) { + assert.Equal(t, "dom_1", domainID) + assert.Equal(t, "eu", region) + assert.Equal(t, "Renamed", input.Name) + return &domain.DashboardInboxDomain{}, nil + }, + } + stubDomainService(t, svc) + + _, err := executeTestCommand(newDomainsUpdateCmd(), "dom_1", "--region", "eu", "--name", "Renamed") + + require.Error(t, err) + assert.Contains(t, err.Error(), "domain was not updated") +} + +func TestDomainsUpdateRunEInfersRegion(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_eu", DomainAddress: "asim.nylas.email", Region: "eu"}, + }, + }, nil + }, + updateFn: func(_ context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput) (*domain.DashboardInboxDomain, error) { + assert.Equal(t, "dom_eu", domainID) + assert.Equal(t, "eu", region) + assert.Equal(t, "Renamed", input.Name) + return &domain.DashboardInboxDomain{ + ID: "dom_eu", + Name: "Renamed", + DomainAddress: "asim.nylas.email", + Region: "eu", + }, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsUpdateCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "asim.nylas.email", "--name", "Renamed", "--json") + + require.NoError(t, err) + assert.JSONEq(t, `{"id":"dom_eu","domain":"asim.nylas.email","name":"Renamed","region":"eu","branded":false,"ownership":"no","mx":"no","spf":"no","dkim":"no","dmarc":"no","arc":"no","feedback":"no"}`, out) +} + +func TestDomainsDNSRunERequestsEachType(t *testing.T) { + var gotTypes []string + svc := &fakeDomainService{ + getDomainInfoFn: func(_ context.Context, domainID, region, verificationType string) (*domain.DashboardDomainVerificationResult, error) { + assert.Equal(t, "dom_1", domainID) + assert.Equal(t, "us", region) + gotTypes = append(gotTypes, verificationType) + return &domain.DashboardDomainVerificationResult{ + Status: "pending", + Message: "configure", + }, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsDNSCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "dom_1", "--region", "us", "--type", "mx", "--type", "spf", "--json") + + require.NoError(t, err) + assert.Equal(t, []string{"mx", "spf"}, gotTypes) + assert.JSONEq(t, `[{"type":"mx","host":"","record":"","value":"configure","status":"pending"},{"type":"spf","host":"","record":"","value":"configure","status":"pending"}]`, out) +} + +func TestDomainsDNSRunEInfersRegion(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_eu", DomainAddress: "asim.nylas.email", Region: "eu"}, + }, + }, nil + }, + getDomainInfoFn: func(_ context.Context, domainID, region, verificationType string) (*domain.DashboardDomainVerificationResult, error) { + assert.Equal(t, "dom_eu", domainID) + assert.Equal(t, "eu", region) + assert.Equal(t, "mx", verificationType) + return &domain.DashboardDomainVerificationResult{ + Status: "done", + Message: "verified", + }, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsDNSCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "asim.nylas.email", "--type", "mx", "--json") + + require.NoError(t, err) + assert.JSONEq(t, `[{"type":"mx","host":"","record":"","value":"verified","status":"done"}]`, out) +} + +func TestDomainsVerifyRunEAllRequestsEverySupportedType(t *testing.T) { + var gotTypes []string + svc := &fakeDomainService{ + verifyFn: func(_ context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput) (*domain.DashboardDomainVerificationResult, error) { + assert.Equal(t, "dom_1", domainID) + assert.Equal(t, "eu", region) + gotTypes = append(gotTypes, input.Type) + return &domain.DashboardDomainVerificationResult{ + Status: "done", + Message: "verified", + }, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsVerifyCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "dom_1", "--region", "eu", "--all", "--json") + + require.NoError(t, err) + assert.Equal(t, domainInfoTypes, gotTypes) + assert.Contains(t, out, `"type": "ownership"`) + assert.Contains(t, out, `"type": "arc"`) +} + +func TestDomainsVerifyRunEInfersRegion(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_eu", DomainAddress: "asim.nylas.email", Region: "eu"}, + }, + }, nil + }, + verifyFn: func(_ context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput) (*domain.DashboardDomainVerificationResult, error) { + assert.Equal(t, "dom_eu", domainID) + assert.Equal(t, "eu", region) + assert.Equal(t, "ownership", input.Type) + return &domain.DashboardDomainVerificationResult{ + Status: "done", + Message: "verified", + }, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsVerifyCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "asim.nylas.email", "--type", "ownership", "--json") + + require.NoError(t, err) + assert.JSONEq(t, `[{"type":"ownership","status":"done","message":"verified"}]`, out) +} + +func TestDomainsDeleteRunEInfersRegion(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_eu", DomainAddress: "asim.nylas.email", Region: "eu"}, + }, + }, nil + }, + deleteFn: func(_ context.Context, domainID, region string) (bool, error) { + assert.Equal(t, "dom_eu", domainID) + assert.Equal(t, "eu", region) + return true, nil + }, + } + stubDomainService(t, svc) + + cmd := newDomainsDeleteCmd() + addTestOutputFlags(cmd) + out, err := executeTestCommand(cmd, "asim.nylas.email", "--yes", "--json") + + require.NoError(t, err) + assert.JSONEq(t, `{"success":true}`, out) +} + +func TestResolveExistingDomainRefRequiresRegionWhenDomainIsUnknown(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{}, nil + }, + } + + _, err := resolveExistingDomainRef(context.Background(), svc, "missing.nylas.email", "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "domain not found") + assert.Contains(t, err.Error(), "--region us or --region eu") +} + +func TestResolveExistingDomainRefRejectsMultipleRegionMatches(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_us", DomainAddress: "asim.nylas.email", Region: "us"}, + {ID: "dom_eu", DomainAddress: "asim.nylas.email", Region: "eu"}, + }, + }, nil + }, + } + + _, err := resolveExistingDomainRef(context.Background(), svc, "asim.nylas.email", "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "domain matches multiple regions") + assert.Contains(t, err.Error(), "--region us or --region eu") +} + +func TestResolveExistingDomainRefMatchesByID(t *testing.T) { + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + assert.Empty(t, pageToken) + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_eu", DomainAddress: "asim.nylas.email", Region: "eu"}, + }, + }, nil + }, + } + + ref, err := resolveExistingDomainRef(context.Background(), svc, "dom_eu", "") + + require.NoError(t, err) + assert.Equal(t, "dom_eu", ref.IDOrAddress) + assert.Equal(t, "eu", ref.Region) + assert.Equal(t, "asim.nylas.email", ref.Display) +} + +func TestResolveExistingDomainRefNormalizesExplicitRegionDomainAddress(t *testing.T) { + svc := &fakeDomainService{} + + ref, err := resolveExistingDomainRef(context.Background(), svc, "ASIM.NYLAS.EMAIL.", "eu") + + require.NoError(t, err) + assert.Equal(t, "asim.nylas.email", ref.IDOrAddress) + assert.Equal(t, "eu", ref.Region) + assert.Equal(t, "asim.nylas.email", ref.Display) +} + +func TestListDomainsForResolutionAggregatesPagesAndStopsOnSeenCursor(t *testing.T) { + calls := 0 + svc := &fakeDomainService{ + listFn: func(_ context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) { + assert.Equal(t, 200, limit) + calls++ + switch pageToken { + case "": + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_1", DomainAddress: "one.nylas.email", Region: "us"}, + }, + NextCursor: "cursor-a", + }, nil + case "cursor-a": + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_2", DomainAddress: "two.nylas.email", Region: "eu"}, + }, + NextCursor: "cursor-b", + }, nil + case "cursor-b": + return domain.DashboardInboxDomainPage{ + Domains: []domain.DashboardInboxDomain{ + {ID: "dom_3", DomainAddress: "three.nylas.email", Region: "us"}, + }, + NextCursor: "cursor-a", + }, nil + default: + t.Fatalf("unexpected page token %q", pageToken) + return domain.DashboardInboxDomainPage{}, nil + } + }, + } + + domains, err := listDomainsForResolution(context.Background(), svc) + + require.NoError(t, err) + assert.Equal(t, 3, calls) + require.Len(t, domains, 3) + assert.Equal(t, "dom_1", domains[0].ID) + assert.Equal(t, "dom_2", domains[1].ID) + assert.Equal(t, "dom_3", domains[2].ID) +} diff --git a/internal/cli/dashboard/domains.go b/internal/cli/dashboard/domains.go new file mode 100644 index 0000000..d2105d8 --- /dev/null +++ b/internal/cli/dashboard/domains.go @@ -0,0 +1,770 @@ +package dashboard + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +const ( + defaultDomainListLimit = 100 + maxDomainResolutionPages = 100 +) + +var ( + domainLabelPattern = regexp.MustCompile(`^[A-Za-z0-9-]+$`) + domainInfoTypes = []string{"ownership", "mx", "spf", "feedback", "dkim", "dmarc", "arc"} +) + +func newDomainsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "domains", + Short: "Manage inbox and Agent Account domains", + Long: `Manage domains used by Nylas Inbox and Agent Accounts. + +Domains are registered in your active Dashboard organization. After creating a +custom domain, configure the DNS records printed by the dns command, then run +verification.`, + } + + cmd.AddCommand(newDomainsListCmd()) + cmd.AddCommand(newDomainsCheckCmd()) + cmd.AddCommand(newDomainsCreateCmd()) + cmd.AddCommand(newDomainsShowCmd()) + cmd.AddCommand(newDomainsDNSCmd()) + cmd.AddCommand(newDomainsVerifyCmd()) + cmd.AddCommand(newDomainsUpdateCmd()) + cmd.AddCommand(newDomainsDeleteCmd()) + + return cmd +} + +type domainRow struct { + ID string `json:"id"` + Domain string `json:"domain"` + Name string `json:"name"` + Region string `json:"region"` + Branded bool `json:"branded"` + Ownership string `json:"ownership"` + MX string `json:"mx"` + SPF string `json:"spf"` + DKIM string `json:"dkim"` + DMARC string `json:"dmarc"` + ARC string `json:"arc"` + Feedback string `json:"feedback"` +} + +type domainAvailabilityRow struct { + Domain string `json:"domain"` + Available bool `json:"available"` + ConflictsWith string `json:"conflicts_with,omitempty"` +} + +type domainListResult struct { + Domains []domainRow `json:"domains"` + NextCursor string `json:"next_cursor,omitempty"` +} + +type domainDNSRow struct { + Type string `json:"type"` + Host string `json:"host"` + Record string `json:"record"` + Value string `json:"value"` + Status string `json:"status"` +} + +type domainVerificationRow struct { + Type string `json:"type"` + Status string `json:"status"` + Message string `json:"message"` + Host string `json:"host,omitempty"` + Record string `json:"record,omitempty"` + Value string `json:"value,omitempty"` +} + +var domainColumns = []ports.Column{ + {Header: "ID", Field: "ID"}, + {Header: "DOMAIN", Field: "Domain"}, + {Header: "NAME", Field: "Name"}, + {Header: "REGION", Field: "Region"}, + {Header: "OWN", Field: "Ownership"}, + {Header: "MX", Field: "MX"}, + {Header: "SPF", Field: "SPF"}, + {Header: "DKIM", Field: "DKIM"}, +} + +var domainDNSColumns = []ports.Column{ + {Header: "TYPE", Field: "Type"}, + {Header: "HOST", Field: "Host"}, + {Header: "RECORD", Field: "Record"}, + {Header: "VALUE", Field: "Value", Width: -1}, + {Header: "STATUS", Field: "Status"}, +} + +var domainVerificationColumns = []ports.Column{ + {Header: "TYPE", Field: "Type"}, + {Header: "STATUS", Field: "Status"}, + {Header: "MESSAGE", Field: "Message", Width: -1}, +} + +func newDomainsListCmd() *cobra.Command { + var ( + limit int + pageToken string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List domains", + RunE: func(cmd *cobra.Command, args []string) error { + limit = common.NormalizePageSize(limit) + + domainSvc, err := createDomainServiceFn() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + page, err := domainSvc.ListDomains(ctx, limit, pageToken) + if err != nil { + return wrapDashboardError(err) + } + + return writeDomainListResult(cmd, page) + }, + } + + cmd.Flags().IntVar(&limit, "limit", defaultDomainListLimit, "Maximum domains to return") + cmd.Flags().StringVar(&pageToken, "page-token", "", "Page token for pagination") + + return cmd +} + +func newDomainsCheckCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "check [domain]", + Short: "Check whether a domain is already registered in your organization", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + domainAddress := normalizeDomainAddress(args[0]) + if err := validateDomainAddress(domainAddress); err != nil { + return err + } + + domainSvc, err := createDomainServiceFn() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + result, err := domainSvc.CheckAvailability(ctx, domainAddress) + if err != nil { + return wrapDashboardError(err) + } + + row := domainAvailabilityRow{ + Domain: result.DomainAddress, + Available: result.Available, + } + if result.ConflictsWith != nil { + row.ConflictsWith = *result.ConflictsWith + } + return common.GetOutputWriter(cmd).Write(row) + }, + } + + return cmd +} + +func newDomainsCreateCmd() *cobra.Command { + var ( + domainFlag string + name string + region string + ) + + cmd := &cobra.Command{ + Use: "create [domain]", + Aliases: []string{"register"}, + Short: "Register a domain", + Example: ` nylas dashboard domains create example.com --region us + nylas dashboard domains register mail.example.com --name "Mail" --region eu`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + domainAddress, err := resolveDomainAddress(args, domainFlag) + if err != nil { + return err + } + if err := validateRegion(region); err != nil { + return err + } + if name == "" { + name = domainAddress + } + + domainSvc, err := createDomainServiceFn() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + created, err := domainSvc.CreateDomain(ctx, domain.DashboardCreateInboxDomainInput{ + Name: name, + DomainAddress: domainAddress, + Region: region, + }) + if err != nil { + return wrapDashboardError(err) + } + if err := validateCreatedDomain(created); err != nil { + return err + } + + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(toDomainRow(*created)) + } + + common.PrintSuccess("Domain registered") + if err := common.GetOutputWriter(cmd).Write(toDomainRow(*created)); err != nil { + return err + } + fmt.Printf("\nNext: nylas dashboard domains dns %s --region %s\n", created.ID, created.Region) + return nil + }, + } + + cmd.Flags().StringVar(&domainFlag, "domain", "", "Domain address to register") + cmd.Flags().StringVarP(&name, "name", "n", "", "Display name (default: domain)") + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (required: us or eu)") + + return cmd +} + +func newDomainsShowCmd() *cobra.Command { + var region string + + cmd := &cobra.Command{ + Use: "show [domain-id-or-address]", + Short: "Show a domain", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + domainSvc, err := createDomainServiceFn() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + ref, err := resolveExistingDomainRef(ctx, domainSvc, args[0], region) + if err != nil { + return wrapDashboardError(err) + } + + found, err := domainSvc.GetDomain(ctx, ref.IDOrAddress, ref.Region) + if err != nil { + return wrapDashboardError(err) + } + if found == nil || found.ID == "" { + return dashboardError("domain not found", "Check the domain ID/address and region") + } + return common.GetOutputWriter(cmd).Write(toDomainRow(*found)) + }, + } + + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (us or eu; inferred for existing domains when omitted)") + + return cmd +} + +func newDomainsDNSCmd() *cobra.Command { + var ( + region string + types []string + ) + + cmd := &cobra.Command{ + Use: "dns [domain-id-or-address]", + Short: "Show DNS records required for verification", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + types = normalizeVerificationTypes(types) + if err := validateVerificationTypes(types); err != nil { + return err + } + + domainSvc, err := createDomainServiceFn() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + ref, err := resolveExistingDomainRef(ctx, domainSvc, args[0], region) + if err != nil { + return wrapDashboardError(err) + } + + rows := make([]domainDNSRow, 0, len(types)) + for _, typ := range types { + info, err := domainSvc.GetDomainInfo(ctx, ref.IDOrAddress, ref.Region, typ) + if err != nil { + return wrapDashboardError(err) + } + rows = append(rows, toDNSRow(typ, info)) + } + return common.WriteListWithColumns(cmd, rows, domainDNSColumns) + }, + } + + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (us or eu; inferred for existing domains when omitted)") + cmd.Flags().StringSliceVar(&types, "type", nil, "Verification type to show (repeatable: ownership,mx,spf,feedback,dkim,dmarc,arc)") + + return cmd +} + +func newDomainsVerifyCmd() *cobra.Command { + var ( + region string + types []string + all bool + ) + + cmd := &cobra.Command{ + Use: "verify [domain-id-or-address]", + Short: "Verify domain DNS records", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if all { + types = domainInfoTypes + } + if len(types) == 0 { + return dashboardError("verification type is required", "Pass --type ownership or --all") + } + types = normalizeVerificationTypes(types) + if err := validateVerificationTypes(types); err != nil { + return err + } + + domainSvc, err := createDomainServiceFn() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + ref, err := resolveExistingDomainRef(ctx, domainSvc, args[0], region) + if err != nil { + return wrapDashboardError(err) + } + + rows := make([]domainVerificationRow, 0, len(types)) + for _, typ := range types { + result, err := domainSvc.VerifyDomain(ctx, ref.IDOrAddress, ref.Region, domain.DashboardVerifyInboxDomainInput{Type: typ}) + if err != nil { + return wrapDashboardError(err) + } + rows = append(rows, toVerificationRow(typ, result)) + } + return common.WriteListWithColumns(cmd, rows, domainVerificationColumns) + }, + } + + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (us or eu; inferred for existing domains when omitted)") + cmd.Flags().StringSliceVar(&types, "type", nil, "Verification type to run (repeatable: ownership,mx,spf,feedback,dkim,dmarc,arc)") + cmd.Flags().BoolVar(&all, "all", false, "Verify all supported record types") + + return cmd +} + +func newDomainsUpdateCmd() *cobra.Command { + var ( + region string + name string + ) + + cmd := &cobra.Command{ + Use: "update [domain-id-or-address]", + Short: "Update a domain", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(name) == "" { + return dashboardError("domain name is required", "Pass --name") + } + + domainSvc, err := createDomainServiceFn() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + ref, err := resolveExistingDomainRef(ctx, domainSvc, args[0], region) + if err != nil { + return wrapDashboardError(err) + } + + updated, err := domainSvc.UpdateDomain(ctx, ref.IDOrAddress, ref.Region, domain.DashboardUpdateInboxDomainInput{Name: name}) + if err != nil { + return wrapDashboardError(err) + } + if err := validateUpdatedDomain(updated); err != nil { + return err + } + return common.GetOutputWriter(cmd).Write(toDomainRow(*updated)) + }, + } + + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (us or eu; inferred for existing domains when omitted)") + cmd.Flags().StringVarP(&name, "name", "n", "", "New display name") + + return cmd +} + +func newDomainsDeleteCmd() *cobra.Command { + var ( + region string + yes bool + ) + + cmd := &cobra.Command{ + Use: "delete [domain-id-or-address]", + Short: "Delete a domain", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + domainSvc, err := createDomainServiceFn() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + ref, err := resolveExistingDomainRef(ctx, domainSvc, args[0], region) + if err != nil { + return wrapDashboardError(err) + } + if !yes && !common.Confirm(fmt.Sprintf("Delete domain %s in %s?", ref.Display, ref.Region), false) { + return nil + } + + deleted, err := domainSvc.DeleteDomain(ctx, ref.IDOrAddress, ref.Region) + if err != nil { + return wrapDashboardError(err) + } + + return writeDomainDeleteResult(cmd, deleted) + }, + } + + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (us or eu; inferred for existing domains when omitted)") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation") + + return cmd +} + +type resolvedDomainRef struct { + IDOrAddress string + Region string + Display string +} + +func resolveExistingDomainRef(ctx context.Context, domainSvc domainService, domainIDOrAddress, region string) (resolvedDomainRef, error) { + domainIDOrAddress = strings.TrimSpace(domainIDOrAddress) + if region != "" { + if err := validateRegion(region); err != nil { + return resolvedDomainRef{}, err + } + domainIDOrAddress = normalizeDomainLookupValue(domainIDOrAddress) + return resolvedDomainRef{ + IDOrAddress: domainIDOrAddress, + Region: region, + Display: domainIDOrAddress, + }, nil + } + + domains, err := listDomainsForResolution(ctx, domainSvc) + if err != nil { + return resolvedDomainRef{}, err + } + + normalizedInput := normalizeDomainAddress(domainIDOrAddress) + var matches []domain.DashboardInboxDomain + for _, item := range domains { + if item.ID == domainIDOrAddress || normalizeDomainAddress(item.DomainAddress) == normalizedInput { + matches = append(matches, item) + } + } + + if len(matches) == 0 { + return resolvedDomainRef{}, dashboardError( + "domain not found", + "Pass --region us or --region eu, or run 'nylas dashboard domains list' to see registered domains", + ) + } + if len(matches) > 1 { + return resolvedDomainRef{}, dashboardError( + "domain matches multiple regions", + "Pass --region us or --region eu", + ) + } + + match := matches[0] + return resolvedDomainRef{ + IDOrAddress: match.ID, + Region: match.Region, + Display: match.DomainAddress, + }, nil +} + +func listDomainsForResolution(ctx context.Context, domainSvc domainService) ([]domain.DashboardInboxDomain, error) { + const pageSize = common.MaxAPILimit + + var all []domain.DashboardInboxDomain + pageToken := "" + seenCursors := map[string]bool{"": true} + for pageCount := 0; pageCount < maxDomainResolutionPages; pageCount++ { + page, err := domainSvc.ListDomains(ctx, pageSize, pageToken) + if err != nil { + return nil, err + } + all = append(all, page.Domains...) + if page.NextCursor == "" || len(page.Domains) == 0 { + return all, nil + } + if seenCursors[page.NextCursor] { + return all, nil + } + seenCursors[page.NextCursor] = true + pageToken = page.NextCursor + } + return nil, fmt.Errorf("too many domain pages while resolving region; pass --region us or --region eu") +} + +func writeDomainDeleteResult(cmd *cobra.Command, deleted bool) error { + if !deleted { + return dashboardError("domain was not deleted", "Check the domain ID and region") + } + if common.IsStructuredOutput(cmd) { + return common.GetOutputWriter(cmd).Write(struct { + Success bool `json:"success"` + }{Success: true}) + } + common.PrintSuccess("Domain deleted") + return nil +} + +func writeDomainListResult(cmd *cobra.Command, page domain.DashboardInboxDomainPage) error { + rows := toDomainRows(page.Domains) + format, _ := cmd.Flags().GetString("format") + if common.IsJSON(cmd) || format == "yaml" { + if rows == nil { + rows = []domainRow{} + } + return common.GetOutputWriter(cmd).Write(domainListResult{ + Domains: rows, + NextCursor: page.NextCursor, + }) + } + + if len(rows) == 0 { + quiet, _ := cmd.Flags().GetBool("quiet") + if quiet { + return nil + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No domains found.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "\nRegister one with: nylas dashboard domains create example.com --region us") + return nil + } + + if err := common.WriteListWithColumns(cmd, rows, domainColumns); err != nil { + return err + } + + quiet, _ := cmd.Flags().GetBool("quiet") + if page.NextCursor != "" && !quiet { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nNext: nylas dashboard domains list --page-token %s\n", page.NextCursor) + } + return nil +} + +func validateCreatedDomain(created *domain.DashboardInboxDomain) error { + if created == nil || created.ID == "" || created.DomainAddress == "" || created.Region == "" { + return dashboardError("domain was not created", "Dashboard returned an incomplete domain response") + } + return nil +} + +func validateUpdatedDomain(updated *domain.DashboardInboxDomain) error { + if updated == nil || updated.ID == "" { + return dashboardError("domain was not updated", "Dashboard returned an incomplete domain response") + } + return nil +} + +func resolveDomainAddress(args []string, flagValue string) (string, error) { + domainAddress := strings.TrimSpace(flagValue) + if len(args) > 0 { + argAddress := strings.TrimSpace(args[0]) + if domainAddress != "" && normalizeDomainAddress(domainAddress) != normalizeDomainAddress(argAddress) { + return "", dashboardError("domain specified twice", "Use either positional [domain] or --domain") + } + domainAddress = argAddress + } + domainAddress = normalizeDomainAddress(domainAddress) + if err := validateDomainAddress(domainAddress); err != nil { + return "", err + } + return domainAddress, nil +} + +func normalizeDomainAddress(address string) string { + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(address)), ".") +} + +func normalizeDomainLookupValue(value string) string { + value = strings.TrimSpace(value) + if strings.Contains(value, ".") { + return normalizeDomainAddress(value) + } + return value +} + +func validateDomainAddress(address string) error { + if len(address) < 3 || len(address) > 253 { + return dashboardError("invalid domain", "Domain must be between 3 and 253 characters") + } + address = strings.TrimSuffix(address, ".") + labels := strings.Split(address, ".") + if len(labels) < 2 { + return dashboardError("invalid domain", "Domain must include at least one dot, for example example.com") + } + for _, label := range labels { + if len(label) == 0 || len(label) > 63 { + return dashboardError("invalid domain", "Each domain label must be 1-63 characters") + } + if !domainLabelPattern.MatchString(label) { + return dashboardError("invalid domain", "Domain labels may contain only letters, numbers, and hyphens") + } + if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return dashboardError("invalid domain", "Domain labels cannot start or end with a hyphen") + } + } + return nil +} + +func validateRegion(region string) error { + if region != string(domain.DashboardInboxRegionUS) && region != string(domain.DashboardInboxRegionEU) { + return dashboardError("invalid region", "Use --region us or --region eu") + } + return nil +} + +func normalizeVerificationTypes(types []string) []string { + if len(types) == 0 { + return domainInfoTypes + } + out := make([]string, 0, len(types)) + seen := make(map[string]bool, len(types)) + for _, typ := range types { + typ = strings.ToLower(strings.TrimSpace(typ)) + if typ == "" || seen[typ] { + continue + } + seen[typ] = true + out = append(out, typ) + } + return out +} + +func validateVerificationTypes(types []string) error { + allowed := make(map[string]bool, len(domainInfoTypes)) + for _, typ := range domainInfoTypes { + allowed[typ] = true + } + for _, typ := range types { + if !allowed[typ] { + return dashboardError("invalid verification type", "Use one of: "+strings.Join(domainInfoTypes, ", ")) + } + } + return nil +} + +func toDomainRows(domains []domain.DashboardInboxDomain) []domainRow { + rows := make([]domainRow, len(domains)) + for i, item := range domains { + rows[i] = toDomainRow(item) + } + return rows +} + +func toDomainRow(item domain.DashboardInboxDomain) domainRow { + return domainRow{ + ID: item.ID, + Domain: item.DomainAddress, + Name: item.Name, + Region: item.Region, + Branded: item.Branded, + Ownership: yesNo(item.VerifiedOwnership), + MX: yesNo(item.VerifiedMX), + SPF: yesNo(item.VerifiedSPF), + DKIM: yesNo(item.VerifiedDKIM), + DMARC: yesNo(item.VerifiedDMARC), + ARC: yesNo(item.VerifiedARC), + Feedback: yesNo(item.VerifiedFeedback), + } +} + +func toDNSRow(typ string, result *domain.DashboardDomainVerificationResult) domainDNSRow { + row := domainDNSRow{Type: typ} + if result == nil { + return row + } + row.Status = result.Status + if result.Attempt == nil { + row.Value = result.Message + return row + } + row.Host = result.Attempt.Options.Host + row.Record = result.Attempt.Options.Type + row.Value = result.Attempt.Options.Value + return row +} + +func toVerificationRow(typ string, result *domain.DashboardDomainVerificationResult) domainVerificationRow { + row := domainVerificationRow{Type: typ} + if result == nil { + return row + } + row.Status = result.Status + row.Message = result.Message + if result.Attempt != nil { + row.Host = result.Attempt.Options.Host + row.Record = result.Attempt.Options.Type + row.Value = result.Attempt.Options.Value + } + return row +} + +func yesNo(ok bool) string { + if ok { + return "yes" + } + return "no" +} diff --git a/internal/cli/dashboard/helpers.go b/internal/cli/dashboard/helpers.go index 9f56926..1571f60 100644 --- a/internal/cli/dashboard/helpers.go +++ b/internal/cli/dashboard/helpers.go @@ -1,6 +1,7 @@ package dashboard import ( + "context" "errors" "fmt" "os" @@ -16,6 +17,19 @@ import ( "golang.org/x/term" ) +type domainService interface { + ListDomains(ctx context.Context, limit int, pageToken string) (domain.DashboardInboxDomainPage, error) + GetDomain(ctx context.Context, domainIDOrAddress, region string) (*domain.DashboardInboxDomain, error) + CheckAvailability(ctx context.Context, domainAddress string) (*domain.DashboardInboxDomainAvailability, error) + CreateDomain(ctx context.Context, input domain.DashboardCreateInboxDomainInput) (*domain.DashboardInboxDomain, error) + UpdateDomain(ctx context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput) (*domain.DashboardInboxDomain, error) + DeleteDomain(ctx context.Context, domainID, region string) (bool, error) + GetDomainInfo(ctx context.Context, domainID, region, verificationType string) (*domain.DashboardDomainVerificationResult, error) + VerifyDomain(ctx context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput) (*domain.DashboardDomainVerificationResult, error) +} + +var createDomainServiceFn = createDomainService + // createDPoPService creates a DPoP service backed by the keyring. func createDPoPService() (ports.DPoP, ports.SecretStore, error) { secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) @@ -55,6 +69,18 @@ func createAppService() (*dashboardapp.AppService, error) { return dashboardapp.NewAppService(gatewayClient, secretStore), nil } +// createDomainService creates the dashboard domain management service. +func createDomainService() (domainService, error) { + dpopSvc, secretStore, err := createDPoPService() + if err != nil { + return nil, err + } + + baseURL := getDashboardAccountBaseURL(secretStore) + accountClient := dashboard.NewAccountClient(baseURL, dpopSvc) + return dashboardapp.NewDomainService(accountClient, secretStore), nil +} + // getDashboardAccountBaseURL returns the dashboard-account base URL. // Priority: NYLAS_DASHBOARD_ACCOUNT_URL env var > config file > default. func getDashboardAccountBaseURL(secrets ports.SecretStore) string { diff --git a/internal/cli/setup/wizard.go b/internal/cli/setup/wizard.go index fa82ed4..172d01a 100644 --- a/internal/cli/setup/wizard.go +++ b/internal/cli/setup/wizard.go @@ -96,7 +96,7 @@ func runWizard(opts wizardOpts) error { stepGrantSync(&status) // Done! - printComplete() + printComplete(status) return nil } @@ -143,7 +143,7 @@ func runNonInteractive(opts wizardOpts, status SetupStatus) error { // Refresh status after activation. status = getSetupStatusFn() stepGrantSyncFn(&status) - printCompleteFn() + printCompleteFn(status) return nil } diff --git a/internal/cli/setup/wizard_helpers.go b/internal/cli/setup/wizard_helpers.go index 5a07368..00be9fb 100644 --- a/internal/cli/setup/wizard_helpers.go +++ b/internal/cli/setup/wizard_helpers.go @@ -21,7 +21,7 @@ import ( var setupCallbackProvisioner = EnsureOAuthCallbackURI // printComplete prints the final success message. -func printComplete() { +func printComplete(status SetupStatus) { fmt.Println() _, _ = common.Bold.Println(" ══════════════════════════════════════════") fmt.Println() @@ -32,10 +32,32 @@ func printComplete() { fmt.Println(" nylas calendar events Upcoming events") fmt.Println(" nylas auth status Check configuration") fmt.Println() + fmt.Println(" Register a free Agent Account email domain:") + for _, cmd := range domainRegistrationCommands(status) { + fmt.Printf(" %s\n", cmd) + } + fmt.Println() + fmt.Println(" Create an Agent Account on that domain:") + fmt.Println(" nylas agent account create user@.nylas.email") + fmt.Println() fmt.Println(" Documentation: https://cli.nylas.com/") fmt.Println() } +func domainRegistrationCommands(status SetupStatus) []string { + switch status.ActiveAppRegion { + case "us", "eu": + return []string{ + fmt.Sprintf("nylas dashboard domains create .nylas.email --region %s", status.ActiveAppRegion), + } + default: + return []string{ + "nylas dashboard domains create .nylas.email --region us", + "nylas dashboard domains create .nylas.email --region eu", + } + } +} + // printStepRecovery prints manual recovery instructions when a step fails. func printStepRecovery(step string, commands []string) { fmt.Println() diff --git a/internal/cli/setup/wizard_helpers_test.go b/internal/cli/setup/wizard_helpers_test.go index 4b0494d..45b0eb9 100644 --- a/internal/cli/setup/wizard_helpers_test.go +++ b/internal/cli/setup/wizard_helpers_test.go @@ -27,3 +27,48 @@ func TestEnsureSetupCallbackURI_RequiresClientID(t *testing.T) { t.Fatal("expected empty client ID to fail") } } + +func TestDomainRegistrationCommands(t *testing.T) { + tests := []struct { + name string + status SetupStatus + want []string + }{ + { + name: "uses US active app region", + status: SetupStatus{ActiveAppRegion: "us"}, + want: []string{ + "nylas dashboard domains create .nylas.email --region us", + }, + }, + { + name: "uses EU active app region", + status: SetupStatus{ActiveAppRegion: "eu"}, + want: []string{ + "nylas dashboard domains create .nylas.email --region eu", + }, + }, + { + name: "falls back to copy-pasteable commands when active app region is unknown", + status: SetupStatus{}, + want: []string{ + "nylas dashboard domains create .nylas.email --region us", + "nylas dashboard domains create .nylas.email --region eu", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := domainRegistrationCommands(tt.status) + if len(got) != len(tt.want) { + t.Fatalf("expected %d commands, got %d: %v", len(tt.want), len(got), got) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Fatalf("expected command %d to be %q, got %q", i, tt.want[i], got[i]) + } + } + }) + } +} diff --git a/internal/cli/setup/wizard_noninteractive_test.go b/internal/cli/setup/wizard_noninteractive_test.go index f0afe62..8593d5d 100644 --- a/internal/cli/setup/wizard_noninteractive_test.go +++ b/internal/cli/setup/wizard_noninteractive_test.go @@ -86,8 +86,11 @@ func TestRunNonInteractive_ReconfiguresExistingAPIKey(t *testing.T) { } printCompleteCalls := 0 - printCompleteFn = func() { + printCompleteFn = func(status SetupStatus) { printCompleteCalls++ + if !status.HasAPIKey { + t.Fatal("expected completion status with API key configured") + } } err := runNonInteractive(wizardOpts{ diff --git a/internal/domain/dashboard.go b/internal/domain/dashboard.go index 5aa9421..af07da0 100644 --- a/internal/domain/dashboard.go +++ b/internal/domain/dashboard.go @@ -133,6 +133,86 @@ type GatewayCreatedAPIKey struct { CreatedAt float64 `json:"createdAt"` } +// DashboardInboxRegion is a dashboard-account inbox domain region. +type DashboardInboxRegion string + +const ( + DashboardInboxRegionUS DashboardInboxRegion = "us" + DashboardInboxRegionEU DashboardInboxRegion = "eu" +) + +// DashboardInboxDomain is an inbox/agent-account domain managed through dashboard-account. +type DashboardInboxDomain struct { + ID string `json:"id"` + Name string `json:"name"` + DomainAddress string `json:"domainAddress"` + OrganizationID string `json:"organizationId"` + Region string `json:"region"` + Branded bool `json:"branded"` + VerifiedOwnership bool `json:"verifiedOwnership"` + VerifiedMX bool `json:"verifiedMx"` + VerifiedSPF bool `json:"verifiedSpf"` + VerifiedDKIM bool `json:"verifiedDkim"` + VerifiedDMARC bool `json:"verifiedDmarc"` + VerifiedARC bool `json:"verifiedArc"` + VerifiedFeedback bool `json:"verifiedFeedback"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// DashboardInboxDomainPage is a page of inbox/agent-account domains. +type DashboardInboxDomainPage struct { + Domains []DashboardInboxDomain `json:"domains"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// DashboardInboxDomainAvailability is the org-scoped preflight availability result. +type DashboardInboxDomainAvailability struct { + DomainAddress string `json:"domainAddress"` + Available bool `json:"available"` + ConflictsWith *string `json:"conflictsWith"` +} + +// DashboardCreateInboxDomainInput is the request body for creating an inbox domain. +type DashboardCreateInboxDomainInput struct { + Name string `json:"name"` + DomainAddress string `json:"domainAddress"` + Region string `json:"region"` +} + +// DashboardUpdateInboxDomainInput is the request body for renaming an inbox domain. +type DashboardUpdateInboxDomainInput struct { + Name string `json:"name"` +} + +// DashboardVerifyInboxDomainInput is the request body for triggering verification. +type DashboardVerifyInboxDomainInput struct { + Type string `json:"type"` +} + +// DashboardDomainVerificationAttempt is the DNS record that should be configured. +type DashboardDomainVerificationAttempt struct { + Type string `json:"type,omitempty"` + Options DashboardDomainVerificationAttemptOption `json:"options"` +} + +// DashboardDomainVerificationAttemptOption describes a DNS record requirement. +type DashboardDomainVerificationAttemptOption struct { + Host string `json:"host,omitempty"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` +} + +// DashboardDomainVerificationResult is returned for DNS info and verification attempts. +type DashboardDomainVerificationResult struct { + DomainID string `json:"domainId,omitempty"` + Attempt *DashboardDomainVerificationAttempt `json:"attempt,omitempty"` + Status string `json:"status"` + CreatedAt int64 `json:"createdAt,omitempty"` + ExpiresAt int64 `json:"expiresAt,omitempty"` + Message string `json:"message"` +} + // DashboardSessionRelation represents an org membership in the session response. type DashboardSessionRelation struct { OrgPublicID string `json:"orgPublicId"` diff --git a/internal/ports/dashboard.go b/internal/ports/dashboard.go index 1556978..f04fbdc 100644 --- a/internal/ports/dashboard.go +++ b/internal/ports/dashboard.go @@ -41,6 +41,30 @@ type DashboardAccountClient interface { // SwitchOrg switches the session to a different organization. SwitchOrg(ctx context.Context, orgPublicID, userToken, orgToken string) (*domain.DashboardSwitchOrgResponse, error) + + // ListDomains lists inbox/agent-account domains for the active dashboard organization. + ListDomains(ctx context.Context, limit int, pageToken, userToken, orgToken string) (domain.DashboardInboxDomainPage, error) + + // GetDomain retrieves an inbox domain by ID or address. + GetDomain(ctx context.Context, domainIDOrAddress, region, userToken, orgToken string) (*domain.DashboardInboxDomain, error) + + // CheckDomainAvailability checks org-scoped availability for a domain address. + CheckDomainAvailability(ctx context.Context, domainAddress, userToken, orgToken string) (*domain.DashboardInboxDomainAvailability, error) + + // CreateDomain creates/registers an inbox domain. + CreateDomain(ctx context.Context, input domain.DashboardCreateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) + + // UpdateDomain updates an inbox domain. + UpdateDomain(ctx context.Context, domainID, region string, input domain.DashboardUpdateInboxDomainInput, userToken, orgToken string) (*domain.DashboardInboxDomain, error) + + // DeleteDomain deletes an inbox domain. + DeleteDomain(ctx context.Context, domainID, region, userToken, orgToken string) (bool, error) + + // GetDomainInfo returns DNS-record info for a verification type. + GetDomainInfo(ctx context.Context, domainID, region, verificationType, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) + + // VerifyDomain triggers verification for a DNS/authentication record type. + VerifyDomain(ctx context.Context, domainID, region string, input domain.DashboardVerifyInboxDomainInput, userToken, orgToken string) (*domain.DashboardDomainVerificationResult, error) } // DashboardGatewayClient defines the interface for dashboard API gateway GraphQL operations.