diff --git a/.gitignore b/.gitignore index b8907e5..46d4538 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,8 @@ secrets.yml *secrets* *secret* !internal/ports/secrets.go +!internal/cli/webhook/rotate_secret.go +!internal/cli/webhook/verify_rotate_test.go # OAuth tokens oauth_token* diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6b35907..3ab2038 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -398,9 +398,20 @@ nylas webhook show # Show webhook details nylas webhook create --url URL --triggers "event.created,event.updated" nylas webhook update --url NEW_URL # Update webhook nylas webhook delete # Delete webhook +nylas webhook rotate-secret --yes # Rotate webhook signing secret +nylas webhook verify --payload-file body.json --signature SIG --secret SECRET nylas webhook triggers # List available triggers ``` +**Pub/Sub channels:** +```bash +nylas webhook pubsub list +nylas webhook pubsub show +nylas webhook pubsub create --topic projects/PROJ/topics/TOPIC --triggers "message.created" +nylas webhook pubsub update --status inactive +nylas webhook pubsub delete --yes +``` + **Testing & development:** ```bash nylas webhook test send # Send test payload diff --git a/docs/commands/webhooks.md b/docs/commands/webhooks.md index f8af35a..1a91f56 100644 --- a/docs/commands/webhooks.md +++ b/docs/commands/webhooks.md @@ -1,6 +1,21 @@ ## Webhook Management -Create and manage webhooks for real-time event notifications. +Create and manage Nylas notification destinations for real-time event delivery. + +### Quick Reference + +```bash +nylas webhook list +nylas webhook create --url https://example.com/webhook --triggers message.created +nylas webhook rotate-secret --yes +nylas webhook verify --payload-file body.json --signature --secret + +nylas webhook pubsub list +nylas webhook pubsub create --topic projects/PROJ/topics/TOPIC --triggers message.created +nylas webhook pubsub show +nylas webhook pubsub update --status inactive +nylas webhook pubsub delete --yes +``` ### Built-in Webhook Server @@ -128,6 +143,46 @@ Important: Save your webhook secret - it won't be shown again: Use this secret to verify webhook signatures. ``` +### Rotate Webhook Secret + +```bash +nylas webhook rotate-secret --yes +``` + +Rotate the signing secret for a webhook and print the new value. Update your +receiving service to use the new secret before processing future deliveries. + +### Verify Webhook Signature + +```bash +nylas webhook verify \ + --payload-file ./body.json \ + --signature \ + --secret +``` + +This verifies the exact raw body against the `x-nylas-signature` or +`X-Nylas-Signature` header value using HMAC-SHA256. + +### Pub/Sub Notification Channels + +Manage Google Cloud Pub/Sub notification channels under the existing webhook +namespace: + +```bash +nylas webhook pubsub list +nylas webhook pubsub show +nylas webhook pubsub create \ + --topic projects/PROJ/topics/TOPIC \ + --triggers message.created,event.created +nylas webhook pubsub update --status inactive +nylas webhook pubsub delete --yes +``` + +Use Pub/Sub channels when you want queue-based delivery for higher-volume +notification processing or when webhook delivery latency and retries are a +concern. + ### Update Webhook ```bash @@ -470,4 +525,3 @@ nylas webhook test ``` --- - diff --git a/internal/adapters/nylas/demo_pubsub.go b/internal/adapters/nylas/demo_pubsub.go new file mode 100644 index 0000000..ea50961 --- /dev/null +++ b/internal/adapters/nylas/demo_pubsub.go @@ -0,0 +1,77 @@ +package nylas + +import ( + "context" + "fmt" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func (d *DemoClient) ListPubSubChannels(ctx context.Context) (*domain.PubSubChannelListResponse, error) { + return &domain.PubSubChannelListResponse{ + Data: []domain.PubSubChannel{ + { + ID: "pubsub-001", + Description: "High-volume message events", + TriggerTypes: []string{domain.TriggerMessageCreated, domain.TriggerMessageUpdated}, + Topic: "projects/demo/topics/messages", + Status: "active", + CreatedAt: time.Now().Add(-14 * 24 * time.Hour), + }, + { + ID: "pubsub-002", + Description: "Calendar events", + TriggerTypes: []string{domain.TriggerEventCreated, domain.TriggerEventUpdated}, + Topic: "projects/demo/topics/calendar", + Status: "active", + CreatedAt: time.Now().Add(-7 * 24 * time.Hour), + }, + }, + }, nil +} + +func (d *DemoClient) GetPubSubChannel(ctx context.Context, channelID string) (*domain.PubSubChannel, error) { + channels, _ := d.ListPubSubChannels(ctx) + for _, channel := range channels.Data { + if channel.ID == channelID { + return &channel, nil + } + } + return nil, fmt.Errorf("%w: %s", domain.ErrPubSubChannelNotFound, channelID) +} + +func (d *DemoClient) CreatePubSubChannel( + ctx context.Context, + req *domain.CreatePubSubChannelRequest, +) (*domain.PubSubChannel, error) { + return &domain.PubSubChannel{ + ID: "pubsub-new", + Description: req.Description, + TriggerTypes: req.TriggerTypes, + Topic: req.Topic, + EncryptionKey: req.EncryptionKey, + NotificationEmailAddresses: req.NotificationEmailAddresses, + Status: "active", + }, nil +} + +func (d *DemoClient) UpdatePubSubChannel( + ctx context.Context, + channelID string, + req *domain.UpdatePubSubChannelRequest, +) (*domain.PubSubChannel, error) { + return &domain.PubSubChannel{ + ID: channelID, + Description: req.Description, + TriggerTypes: req.TriggerTypes, + Topic: req.Topic, + EncryptionKey: req.EncryptionKey, + NotificationEmailAddresses: req.NotificationEmailAddresses, + Status: req.Status, + }, nil +} + +func (d *DemoClient) DeletePubSubChannel(ctx context.Context, channelID string) error { + return nil +} diff --git a/internal/adapters/nylas/demo_webhooks.go b/internal/adapters/nylas/demo_webhooks.go index 24c93c2..3040fcc 100644 --- a/internal/adapters/nylas/demo_webhooks.go +++ b/internal/adapters/nylas/demo_webhooks.go @@ -69,6 +69,14 @@ func (d *DemoClient) DeleteWebhook(ctx context.Context, webhookID string) error return nil } +// RotateWebhookSecret simulates rotating a webhook secret. +func (d *DemoClient) RotateWebhookSecret(ctx context.Context, webhookID string) (*domain.RotateWebhookSecretResponse, error) { + return &domain.RotateWebhookSecretResponse{ + ID: webhookID, + WebhookSecret: "whsec_demo_rotated_987654321", + }, nil +} + // SendWebhookTestEvent simulates sending a test event. func (d *DemoClient) SendWebhookTestEvent(ctx context.Context, webhookURL string) error { return nil diff --git a/internal/adapters/nylas/mock_client.go b/internal/adapters/nylas/mock_client.go index 6cfd01f..d64af25 100644 --- a/internal/adapters/nylas/mock_client.go +++ b/internal/adapters/nylas/mock_client.go @@ -44,6 +44,19 @@ type MockClient struct { ListAttachmentsCalled bool GetAttachmentCalled bool DownloadAttachmentCalled bool + ListWebhooksCalled bool + GetWebhookCalled bool + CreateWebhookCalled bool + UpdateWebhookCalled bool + DeleteWebhookCalled bool + ListPubSubChannelsCalled bool + GetPubSubChannelCalled bool + CreatePubSubChannelCalled bool + UpdatePubSubChannelCalled bool + DeletePubSubChannelCalled bool + RotateWebhookSecretCalled bool + SendWebhookTestEventCalled bool + GetWebhookMockPayloadCalled bool ListRemoteTemplatesCalled bool GetRemoteTemplateCalled bool CreateRemoteTemplateCalled bool @@ -66,6 +79,8 @@ type MockClient struct { LastDraftID string LastFolderID string LastAttachmentID string + LastWebhookID string + LastPubSubChannelID string LastTemplateID string LastWorkflowID string @@ -100,6 +115,19 @@ type MockClient struct { ListAttachmentsFunc func(ctx context.Context, grantID, messageID string) ([]domain.Attachment, error) GetAttachmentFunc func(ctx context.Context, grantID, messageID, attachmentID string) (*domain.Attachment, error) DownloadAttachmentFunc func(ctx context.Context, grantID, messageID, attachmentID string) (io.ReadCloser, error) + ListWebhooksFunc func(ctx context.Context) ([]domain.Webhook, error) + GetWebhookFunc func(ctx context.Context, webhookID string) (*domain.Webhook, error) + CreateWebhookFunc func(ctx context.Context, req *domain.CreateWebhookRequest) (*domain.Webhook, error) + UpdateWebhookFunc func(ctx context.Context, webhookID string, req *domain.UpdateWebhookRequest) (*domain.Webhook, error) + DeleteWebhookFunc func(ctx context.Context, webhookID string) error + RotateWebhookSecretFunc func(ctx context.Context, webhookID string) (*domain.RotateWebhookSecretResponse, error) + SendWebhookTestEventFunc func(ctx context.Context, webhookURL string) error + GetWebhookMockPayloadFunc func(ctx context.Context, triggerType string) (map[string]any, error) + ListPubSubChannelsFunc func(ctx context.Context) (*domain.PubSubChannelListResponse, error) + GetPubSubChannelFunc func(ctx context.Context, channelID string) (*domain.PubSubChannel, error) + CreatePubSubChannelFunc func(ctx context.Context, req *domain.CreatePubSubChannelRequest) (*domain.PubSubChannel, error) + UpdatePubSubChannelFunc func(ctx context.Context, channelID string, req *domain.UpdatePubSubChannelRequest) (*domain.PubSubChannel, error) + DeletePubSubChannelFunc func(ctx context.Context, channelID string) error ListRemoteTemplatesFunc func(ctx context.Context, scope domain.RemoteScope, grantID string, params *domain.CursorListParams) (*domain.RemoteTemplateListResponse, error) GetRemoteTemplateFunc func(ctx context.Context, scope domain.RemoteScope, grantID, templateID string) (*domain.RemoteTemplate, error) CreateRemoteTemplateFunc func(ctx context.Context, scope domain.RemoteScope, grantID string, req *domain.CreateRemoteTemplateRequest) (*domain.RemoteTemplate, error) diff --git a/internal/adapters/nylas/mock_pubsub.go b/internal/adapters/nylas/mock_pubsub.go new file mode 100644 index 0000000..497c79d --- /dev/null +++ b/internal/adapters/nylas/mock_pubsub.go @@ -0,0 +1,107 @@ +package nylas + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +func (m *MockClient) ListPubSubChannels(ctx context.Context) (*domain.PubSubChannelListResponse, error) { + m.ListPubSubChannelsCalled = true + if m.ListPubSubChannelsFunc != nil { + return m.ListPubSubChannelsFunc(ctx) + } + + return &domain.PubSubChannelListResponse{ + Data: []domain.PubSubChannel{ + { + ID: "pubsub-1", + Description: "Message notifications", + TriggerTypes: []string{domain.TriggerMessageCreated}, + Topic: "projects/demo/topics/notifications", + Status: "active", + }, + }, + }, nil +} + +func (m *MockClient) GetPubSubChannel(ctx context.Context, channelID string) (*domain.PubSubChannel, error) { + m.GetPubSubChannelCalled = true + m.LastPubSubChannelID = channelID + if m.GetPubSubChannelFunc != nil { + return m.GetPubSubChannelFunc(ctx, channelID) + } + + return &domain.PubSubChannel{ + ID: channelID, + Description: "Message notifications", + TriggerTypes: []string{domain.TriggerMessageCreated}, + Topic: "projects/demo/topics/notifications", + Status: "active", + }, nil +} + +func (m *MockClient) CreatePubSubChannel( + ctx context.Context, + req *domain.CreatePubSubChannelRequest, +) (*domain.PubSubChannel, error) { + m.CreatePubSubChannelCalled = true + if m.CreatePubSubChannelFunc != nil { + return m.CreatePubSubChannelFunc(ctx, req) + } + + return &domain.PubSubChannel{ + ID: "pubsub-new", + Description: req.Description, + TriggerTypes: req.TriggerTypes, + Topic: req.Topic, + EncryptionKey: req.EncryptionKey, + NotificationEmailAddresses: req.NotificationEmailAddresses, + Status: "active", + }, nil +} + +func (m *MockClient) UpdatePubSubChannel( + ctx context.Context, + channelID string, + req *domain.UpdatePubSubChannelRequest, +) (*domain.PubSubChannel, error) { + m.UpdatePubSubChannelCalled = true + m.LastPubSubChannelID = channelID + if m.UpdatePubSubChannelFunc != nil { + return m.UpdatePubSubChannelFunc(ctx, channelID, req) + } + + channel := &domain.PubSubChannel{ + ID: channelID, + Status: "active", + } + if req.Description != "" { + channel.Description = req.Description + } + if len(req.TriggerTypes) > 0 { + channel.TriggerTypes = req.TriggerTypes + } + if req.Topic != "" { + channel.Topic = req.Topic + } + if req.EncryptionKey != "" { + channel.EncryptionKey = req.EncryptionKey + } + if len(req.NotificationEmailAddresses) > 0 { + channel.NotificationEmailAddresses = req.NotificationEmailAddresses + } + if req.Status != "" { + channel.Status = req.Status + } + return channel, nil +} + +func (m *MockClient) DeletePubSubChannel(ctx context.Context, channelID string) error { + m.DeletePubSubChannelCalled = true + m.LastPubSubChannelID = channelID + if m.DeletePubSubChannelFunc != nil { + return m.DeletePubSubChannelFunc(ctx, channelID) + } + return nil +} diff --git a/internal/adapters/nylas/mock_webhooks.go b/internal/adapters/nylas/mock_webhooks.go index fd5e787..a3d0435 100644 --- a/internal/adapters/nylas/mock_webhooks.go +++ b/internal/adapters/nylas/mock_webhooks.go @@ -7,6 +7,10 @@ import ( ) func (m *MockClient) ListWebhooks(ctx context.Context) ([]domain.Webhook, error) { + m.ListWebhooksCalled = true + if m.ListWebhooksFunc != nil { + return m.ListWebhooksFunc(ctx) + } return []domain.Webhook{ { ID: "webhook-1", @@ -20,6 +24,11 @@ func (m *MockClient) ListWebhooks(ctx context.Context) ([]domain.Webhook, error) // GetWebhook retrieves a single webhook. func (m *MockClient) GetWebhook(ctx context.Context, webhookID string) (*domain.Webhook, error) { + m.GetWebhookCalled = true + m.LastWebhookID = webhookID + if m.GetWebhookFunc != nil { + return m.GetWebhookFunc(ctx, webhookID) + } return &domain.Webhook{ ID: webhookID, Description: "Test Webhook", @@ -31,6 +40,10 @@ func (m *MockClient) GetWebhook(ctx context.Context, webhookID string) (*domain. // CreateWebhook creates a new webhook. func (m *MockClient) CreateWebhook(ctx context.Context, req *domain.CreateWebhookRequest) (*domain.Webhook, error) { + m.CreateWebhookCalled = true + if m.CreateWebhookFunc != nil { + return m.CreateWebhookFunc(ctx, req) + } return &domain.Webhook{ ID: "new-webhook-id", Description: req.Description, @@ -43,6 +56,11 @@ func (m *MockClient) CreateWebhook(ctx context.Context, req *domain.CreateWebhoo // UpdateWebhook updates an existing webhook. func (m *MockClient) UpdateWebhook(ctx context.Context, webhookID string, req *domain.UpdateWebhookRequest) (*domain.Webhook, error) { + m.UpdateWebhookCalled = true + m.LastWebhookID = webhookID + if m.UpdateWebhookFunc != nil { + return m.UpdateWebhookFunc(ctx, webhookID, req) + } webhook := &domain.Webhook{ ID: webhookID, Status: "active", @@ -64,16 +82,42 @@ func (m *MockClient) UpdateWebhook(ctx context.Context, webhookID string, req *d // DeleteWebhook deletes a webhook. func (m *MockClient) DeleteWebhook(ctx context.Context, webhookID string) error { + m.DeleteWebhookCalled = true + m.LastWebhookID = webhookID + if m.DeleteWebhookFunc != nil { + return m.DeleteWebhookFunc(ctx, webhookID) + } return nil } +// RotateWebhookSecret rotates a webhook secret. +func (m *MockClient) RotateWebhookSecret(ctx context.Context, webhookID string) (*domain.RotateWebhookSecretResponse, error) { + m.RotateWebhookSecretCalled = true + m.LastWebhookID = webhookID + if m.RotateWebhookSecretFunc != nil { + return m.RotateWebhookSecretFunc(ctx, webhookID) + } + return &domain.RotateWebhookSecretResponse{ + ID: webhookID, + WebhookSecret: "rotated-mock-secret-67890", + }, nil +} + // SendWebhookTestEvent sends a test event to a webhook URL. func (m *MockClient) SendWebhookTestEvent(ctx context.Context, webhookURL string) error { + m.SendWebhookTestEventCalled = true + if m.SendWebhookTestEventFunc != nil { + return m.SendWebhookTestEventFunc(ctx, webhookURL) + } return nil } // GetWebhookMockPayload returns a mock payload for a trigger type. func (m *MockClient) GetWebhookMockPayload(ctx context.Context, triggerType string) (map[string]any, error) { + m.GetWebhookMockPayloadCalled = true + if m.GetWebhookMockPayloadFunc != nil { + return m.GetWebhookMockPayloadFunc(ctx, triggerType) + } return map[string]any{ "specversion": "1.0", "type": triggerType, diff --git a/internal/adapters/nylas/pubsub.go b/internal/adapters/nylas/pubsub.go new file mode 100644 index 0000000..d1d0991 --- /dev/null +++ b/internal/adapters/nylas/pubsub.go @@ -0,0 +1,137 @@ +package nylas + +import ( + "context" + "fmt" + "net/url" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/util" +) + +type pubSubChannelResponse struct { + ID string `json:"id"` + Description string `json:"description"` + TriggerTypes []string `json:"trigger_types"` + Topic string `json:"topic"` + EncryptionKey string `json:"encryption_key"` + Status string `json:"status"` + NotificationEmailAddresses []string `json:"notification_email_addresses"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Object string `json:"object"` +} + +func (c *HTTPClient) ListPubSubChannels(ctx context.Context) (*domain.PubSubChannelListResponse, error) { + queryURL := fmt.Sprintf("%s/v3/channels/pubsub", c.baseURL) + + var result struct { + Data []pubSubChannelResponse `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` + RequestID string `json:"request_id,omitempty"` + } + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + + return &domain.PubSubChannelListResponse{ + Data: util.Map(result.Data, convertPubSubChannel), + NextCursor: result.NextCursor, + RequestID: result.RequestID, + }, nil +} + +func (c *HTTPClient) GetPubSubChannel(ctx context.Context, channelID string) (*domain.PubSubChannel, error) { + if err := validateRequired("channel ID", channelID); err != nil { + return nil, err + } + + queryURL := fmt.Sprintf("%s/v3/channels/pubsub/%s", c.baseURL, url.PathEscape(channelID)) + + var result struct { + Data pubSubChannelResponse `json:"data"` + } + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrPubSubChannelNotFound); err != nil { + return nil, err + } + + channel := convertPubSubChannel(result.Data) + return &channel, nil +} + +func (c *HTTPClient) CreatePubSubChannel(ctx context.Context, req *domain.CreatePubSubChannelRequest) (*domain.PubSubChannel, error) { + if req == nil { + return nil, fmt.Errorf("create pub/sub channel request is required") + } + + queryURL := fmt.Sprintf("%s/v3/channels/pubsub", c.baseURL) + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data pubSubChannelResponse `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + channel := convertPubSubChannel(result.Data) + return &channel, nil +} + +func (c *HTTPClient) UpdatePubSubChannel( + ctx context.Context, + channelID string, + req *domain.UpdatePubSubChannelRequest, +) (*domain.PubSubChannel, error) { + if err := validateRequired("channel ID", channelID); err != nil { + return nil, err + } + if req == nil { + return nil, fmt.Errorf("update pub/sub channel request is required") + } + + queryURL := fmt.Sprintf("%s/v3/channels/pubsub/%s", c.baseURL, url.PathEscape(channelID)) + + resp, err := c.doJSONRequest(ctx, "PUT", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data pubSubChannelResponse `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + channel := convertPubSubChannel(result.Data) + return &channel, nil +} + +func (c *HTTPClient) DeletePubSubChannel(ctx context.Context, channelID string) error { + if err := validateRequired("channel ID", channelID); err != nil { + return err + } + + queryURL := fmt.Sprintf("%s/v3/channels/pubsub/%s", c.baseURL, url.PathEscape(channelID)) + return c.doDelete(ctx, queryURL) +} + +func convertPubSubChannel(channel pubSubChannelResponse) domain.PubSubChannel { + return domain.PubSubChannel{ + ID: channel.ID, + Description: channel.Description, + TriggerTypes: channel.TriggerTypes, + Topic: channel.Topic, + EncryptionKey: channel.EncryptionKey, + Status: channel.Status, + NotificationEmailAddresses: channel.NotificationEmailAddresses, + CreatedAt: unixToTime(channel.CreatedAt), + UpdatedAt: unixToTime(channel.UpdatedAt), + Object: channel.Object, + } +} diff --git a/internal/adapters/nylas/pubsub_test.go b/internal/adapters/nylas/pubsub_test.go new file mode 100644 index 0000000..14a9bdb --- /dev/null +++ b/internal/adapters/nylas/pubsub_test.go @@ -0,0 +1,261 @@ +package nylas_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPClient_ListPubSubChannels(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/channels/pubsub", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + response := map[string]any{ + "data": []map[string]any{ + { + "id": "pubsub-1", + "description": "Message notifications", + "trigger_types": []string{"message.created"}, + "topic": "projects/demo/topics/messages", + "status": "active", + }, + }, + "next_cursor": "cursor-123", + "request_id": "req-123", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + resp, err := client.ListPubSubChannels(context.Background()) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Data, 1) + assert.Equal(t, "pubsub-1", resp.Data[0].ID) + assert.Equal(t, "projects/demo/topics/messages", resp.Data[0].Topic) + assert.Equal(t, "cursor-123", resp.NextCursor) + assert.Equal(t, "req-123", resp.RequestID) +} + +func TestHTTPClient_GetPubSubChannel(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/channels/pubsub/pubsub-1", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + response := map[string]any{ + "data": map[string]any{ + "id": "pubsub-1", + "description": "Message notifications", + "trigger_types": []string{"message.created", "message.updated"}, + "topic": "projects/demo/topics/messages", + "notification_email_addresses": []string{"admin@example.com"}, + "status": "active", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + channel, err := client.GetPubSubChannel(context.Background(), "pubsub-1") + + require.NoError(t, err) + require.NotNil(t, channel) + assert.Equal(t, "pubsub-1", channel.ID) + assert.Equal(t, "active", channel.Status) + assert.Len(t, channel.NotificationEmailAddresses, 1) +} + +func TestHTTPClient_GetPubSubChannel_EmptyID(t *testing.T) { + t.Parallel() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + channel, err := client.GetPubSubChannel(context.Background(), "") + + require.Error(t, err) + assert.Nil(t, channel) + assert.Contains(t, err.Error(), "channel ID") +} + +func TestHTTPClient_CreatePubSubChannel(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/channels/pubsub", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "projects/demo/topics/messages", body["topic"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "pubsub-new", + "description": body["description"], + "trigger_types": body["trigger_types"], + "topic": body["topic"], + "status": "active", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + channel, err := client.CreatePubSubChannel(context.Background(), &domain.CreatePubSubChannelRequest{ + Description: "Message notifications", + TriggerTypes: []string{"message.created"}, + Topic: "projects/demo/topics/messages", + }) + + require.NoError(t, err) + require.NotNil(t, channel) + assert.Equal(t, "pubsub-new", channel.ID) + assert.Equal(t, "projects/demo/topics/messages", channel.Topic) +} + +func TestHTTPClient_UpdatePubSubChannel(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/channels/pubsub/pubsub-1", r.URL.Path) + assert.Equal(t, http.MethodPut, r.Method) + + response := map[string]any{ + "data": map[string]any{ + "id": "pubsub-1", + "description": "Updated channel", + "trigger_types": []string{"event.created"}, + "topic": "projects/demo/topics/calendar", + "status": "inactive", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + channel, err := client.UpdatePubSubChannel(context.Background(), "pubsub-1", &domain.UpdatePubSubChannelRequest{ + Description: "Updated channel", + Status: "inactive", + }) + + require.NoError(t, err) + require.NotNil(t, channel) + assert.Equal(t, "inactive", channel.Status) + assert.Equal(t, "Updated channel", channel.Description) +} + +func TestHTTPClient_UpdatePubSubChannel_EmptyID(t *testing.T) { + t.Parallel() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + channel, err := client.UpdatePubSubChannel(context.Background(), "", &domain.UpdatePubSubChannelRequest{ + Description: "Updated channel", + }) + + require.Error(t, err) + assert.Nil(t, channel) + assert.Contains(t, err.Error(), "channel ID") +} + +func TestHTTPClient_DeletePubSubChannel(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/channels/pubsub/pubsub-1", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + err := client.DeletePubSubChannel(context.Background(), "pubsub-1") + require.NoError(t, err) +} + +func TestHTTPClient_DeletePubSubChannel_EmptyID(t *testing.T) { + t.Parallel() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + err := client.DeletePubSubChannel(context.Background(), "") + require.Error(t, err) + assert.Contains(t, err.Error(), "channel ID") +} + +func TestHTTPClient_CreatePubSubChannel_NilRequest(t *testing.T) { + t.Parallel() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + channel, err := client.CreatePubSubChannel(context.Background(), nil) + + require.Error(t, err) + assert.Nil(t, channel) + assert.Contains(t, err.Error(), "request is required") +} + +func TestHTTPClient_UpdatePubSubChannel_NilRequest(t *testing.T) { + t.Parallel() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + channel, err := client.UpdatePubSubChannel(context.Background(), "pubsub-1", nil) + + require.Error(t, err) + assert.Nil(t, channel) + assert.Contains(t, err.Error(), "request is required") +} + +func TestDemoClient_GetPubSubChannel_NotFound(t *testing.T) { + t.Parallel() + + client := nylas.NewDemoClient() + + channel, err := client.GetPubSubChannel(context.Background(), "missing-channel") + + require.Error(t, err) + assert.Nil(t, channel) + assert.ErrorIs(t, err, domain.ErrPubSubChannelNotFound) +} diff --git a/internal/adapters/nylas/webhooks.go b/internal/adapters/nylas/webhooks.go index d9686de..67d2d72 100644 --- a/internal/adapters/nylas/webhooks.go +++ b/internal/adapters/nylas/webhooks.go @@ -110,6 +110,50 @@ func (c *HTTPClient) DeleteWebhook(ctx context.Context, webhookID string) error return c.doDelete(ctx, queryURL) } +// RotateWebhookSecret rotates the secret for a webhook. +func (c *HTTPClient) RotateWebhookSecret( + ctx context.Context, + webhookID string, +) (*domain.RotateWebhookSecretResponse, error) { + if err := validateRequired("webhook ID", webhookID); err != nil { + return nil, err + } + + queryURL := fmt.Sprintf("%s/v3/webhooks/rotate-secret/%s", c.baseURL, webhookID) + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, nil) + if err != nil { + return nil, err + } + + var result struct { + Data struct { + ID string `json:"id"` + WebhookSecret string `json:"webhook_secret"` + } `json:"data"` + WebhookSecret string `json:"webhook_secret"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + + rotated := &domain.RotateWebhookSecretResponse{ + ID: webhookID, + WebhookSecret: result.Data.WebhookSecret, + } + if result.Data.ID != "" { + rotated.ID = result.Data.ID + } + if rotated.WebhookSecret == "" { + rotated.WebhookSecret = result.WebhookSecret + } + if rotated.WebhookSecret == "" { + return nil, fmt.Errorf("%w: rotate webhook secret response missing webhook_secret", domain.ErrAPIError) + } + + return rotated, nil +} + // SendWebhookTestEvent sends a test event to a webhook URL. func (c *HTTPClient) SendWebhookTestEvent(ctx context.Context, webhookURL string) error { if err := validateRequired("webhook URL", webhookURL); err != nil { diff --git a/internal/adapters/nylas/webhooks_test.go b/internal/adapters/nylas/webhooks_test.go index e653c69..2fc2e06 100644 --- a/internal/adapters/nylas/webhooks_test.go +++ b/internal/adapters/nylas/webhooks_test.go @@ -249,6 +249,100 @@ func TestHTTPClient_DeleteWebhook_EmptyID(t *testing.T) { assert.Contains(t, err.Error(), "webhook ID") } +func TestHTTPClient_RotateWebhookSecret(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/webhooks/rotate-secret/webhook-789", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + response := map[string]any{ + "data": map[string]any{ + "id": "webhook-789", + "webhook_secret": "rotated-secret-123", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + rotated, err := client.RotateWebhookSecret(ctx, "webhook-789") + + require.NoError(t, err) + require.NotNil(t, rotated) + assert.Equal(t, "webhook-789", rotated.ID) + assert.Equal(t, "rotated-secret-123", rotated.WebhookSecret) +} + +func TestHTTPClient_RotateWebhookSecret_RootLevelSecretFallback(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/webhooks/rotate-secret/webhook-789", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + response := map[string]any{ + "webhook_secret": "root-secret-123", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + rotated, err := client.RotateWebhookSecret(ctx, "webhook-789") + + require.NoError(t, err) + require.NotNil(t, rotated) + assert.Equal(t, "webhook-789", rotated.ID) + assert.Equal(t, "root-secret-123", rotated.WebhookSecret) +} + +func TestHTTPClient_RotateWebhookSecret_EmptyID(t *testing.T) { + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + ctx := context.Background() + rotated, err := client.RotateWebhookSecret(ctx, "") + + require.Error(t, err) + assert.Nil(t, rotated) + assert.Contains(t, err.Error(), "webhook ID") +} + +func TestHTTPClient_RotateWebhookSecret_MissingSecret(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/webhooks/rotate-secret/webhook-789", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + response := map[string]any{ + "data": map[string]any{ + "id": "webhook-789", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + rotated, err := client.RotateWebhookSecret(ctx, "webhook-789") + + require.Error(t, err) + assert.Nil(t, rotated) + assert.Contains(t, err.Error(), "missing webhook_secret") +} + func TestHTTPClient_SendWebhookTestEvent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/v3/webhooks/send-test-event", r.URL.Path) diff --git a/internal/adapters/webhookserver/server.go b/internal/adapters/webhookserver/server.go index 62924fa..459eeb7 100644 --- a/internal/adapters/webhookserver/server.go +++ b/internal/adapters/webhookserver/server.go @@ -3,9 +3,6 @@ package webhookserver import ( "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" @@ -356,8 +353,5 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { // verifySignature verifies the webhook signature using HMAC-SHA256. func (s *Server) verifySignature(payload []byte, signature string) bool { - mac := hmac.New(sha256.New, []byte(s.config.WebhookSecret)) - mac.Write(payload) - expected := hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(signature), []byte(expected)) + return VerifySignature(payload, signature, s.config.WebhookSecret) } diff --git a/internal/adapters/webhookserver/signature.go b/internal/adapters/webhookserver/signature.go new file mode 100644 index 0000000..f487e5f --- /dev/null +++ b/internal/adapters/webhookserver/signature.go @@ -0,0 +1,31 @@ +package webhookserver + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strings" +) + +// NormalizeSignature trims spaces and accepts optional sha256= prefixes. +func NormalizeSignature(signature string) string { + signature = strings.ToLower(strings.TrimSpace(signature)) + return strings.TrimPrefix(signature, "sha256=") +} + +// ComputeSignature computes a hex-encoded HMAC-SHA256 signature for a payload. +func ComputeSignature(payload []byte, webhookSecret string) string { + mac := hmac.New(sha256.New, []byte(webhookSecret)) + _, _ = mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} + +// VerifySignature checks that the payload matches the provided signature. +func VerifySignature(payload []byte, signature, webhookSecret string) bool { + if webhookSecret == "" { + return false + } + expected := ComputeSignature(payload, webhookSecret) + normalizedSignature := NormalizeSignature(signature) + return hmac.Equal([]byte(normalizedSignature), []byte(expected)) +} diff --git a/internal/adapters/webhookserver/signature_test.go b/internal/adapters/webhookserver/signature_test.go new file mode 100644 index 0000000..6be38ff --- /dev/null +++ b/internal/adapters/webhookserver/signature_test.go @@ -0,0 +1,62 @@ +package webhookserver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVerifySignature(t *testing.T) { + t.Parallel() + + payload := []byte(`{"type":"message.created"}`) + binaryPayload := []byte{0x00, 0x01, 0x02, 0xff} + secret := "test-secret" + signature := ComputeSignature(payload, secret) + binarySignature := ComputeSignature(binaryPayload, secret) + + tests := []struct { + name string + payload []byte + signature string + secret string + want bool + }{ + {name: "plain hex signature", payload: payload, signature: signature, secret: secret, want: true}, + {name: "sha256 prefix", payload: payload, signature: "sha256=" + signature, secret: secret, want: true}, + {name: "uppercase prefix and whitespace", payload: payload, signature: " SHA256=" + signature + " ", secret: secret, want: true}, + {name: "invalid signature", payload: payload, signature: "invalid", secret: secret, want: false}, + {name: "wrong secret", payload: payload, signature: signature, secret: "wrong-secret", want: false}, + {name: "empty secret rejected", payload: payload, signature: ComputeSignature(payload, ""), secret: "", want: false}, + {name: "empty payload", payload: []byte{}, signature: ComputeSignature([]byte{}, secret), secret: secret, want: true}, + {name: "binary payload", payload: binaryPayload, signature: binarySignature, secret: secret, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, VerifySignature(tt.payload, tt.signature, tt.secret)) + }) + } +} + +func TestNormalizeSignature(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "trim lowercase prefix", input: " sha256=ABCDEF ", want: "abcdef"}, + {name: "trim uppercase prefix", input: "SHA256=ABCDEF", want: "abcdef"}, + {name: "plain hex", input: "ABCDEF", want: "abcdef"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, NormalizeSignature(tt.input)) + }) + } +} diff --git a/internal/cli/integration/webhooks_notifications_test.go b/internal/cli/integration/webhooks_notifications_test.go new file mode 100644 index 0000000..2e56f86 --- /dev/null +++ b/internal/cli/integration/webhooks_notifications_test.go @@ -0,0 +1,94 @@ +//go:build integration + +package integration + +import ( + "strings" + "testing" + + "github.com/nylas/cli/internal/adapters/webhookserver" +) + +func TestCLI_WebhookRotateSecretHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + stdout, stderr, err := runCLI("webhook", "rotate-secret", "--help") + + if err != nil { + t.Fatalf("webhook rotate-secret --help failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "--yes") { + t.Errorf("Expected --yes flag in help, got: %s", stdout) + } + + t.Logf("webhook rotate-secret --help output:\n%s", stdout) +} + +func TestCLI_WebhookVerifyHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + stdout, stderr, err := runCLI("webhook", "verify", "--help") + + if err != nil { + t.Fatalf("webhook verify --help failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "--payload") || !strings.Contains(stdout, "--payload-file") { + t.Errorf("Expected payload flags in help, got: %s", stdout) + } + if !strings.Contains(stdout, "--signature") || !strings.Contains(stdout, "--secret") { + t.Errorf("Expected signature and secret flags in help, got: %s", stdout) + } + + t.Logf("webhook verify --help output:\n%s", stdout) +} + +func TestCLI_WebhookVerifyLocal(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + payload := `{"type":"message.created"}` + secret := "integration-secret" + signature := webhookserver.ComputeSignature([]byte(payload), secret) + + stdout, stderr, err := runCLI( + "webhook", "verify", + "--payload", payload, + "--signature", "sha256="+signature, + "--secret", secret, + ) + + if err != nil { + t.Fatalf("webhook verify failed: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "Signature is valid") { + t.Errorf("Expected signature validation output, got: %s", stdout) + } +} + +func TestCLI_WebhookPubSubHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + stdout, stderr, err := runCLI("webhook", "pubsub", "--help") + + if err != nil { + t.Fatalf("webhook pubsub --help failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "list") || !strings.Contains(stdout, "create") { + t.Errorf("Expected list and create subcommands in help, got: %s", stdout) + } + if !strings.Contains(stdout, "show") || !strings.Contains(stdout, "update") || !strings.Contains(stdout, "delete") { + t.Errorf("Expected show, update, and delete subcommands in help, got: %s", stdout) + } + + t.Logf("webhook pubsub --help output:\n%s", stdout) +} diff --git a/internal/cli/integration/webhooks_test.go b/internal/cli/integration/webhooks_test.go index c379f35..29c08a0 100644 --- a/internal/cli/integration/webhooks_test.go +++ b/internal/cli/integration/webhooks_test.go @@ -38,6 +38,9 @@ func TestCLI_WebhookHelp(t *testing.T) { if !strings.Contains(stdout, "triggers") || !strings.Contains(stdout, "test") { t.Errorf("Expected triggers and test subcommands in help, got: %s", stdout) } + if !strings.Contains(stdout, "rotate-secret") || !strings.Contains(stdout, "verify") || !strings.Contains(stdout, "pubsub") { + t.Errorf("Expected rotate-secret, verify, and pubsub subcommands in help, got: %s", stdout) + } t.Logf("webhook --help output:\n%s", stdout) } diff --git a/internal/cli/webhook/pubsub.go b/internal/cli/webhook/pubsub.go new file mode 100644 index 0000000..31b52c8 --- /dev/null +++ b/internal/cli/webhook/pubsub.go @@ -0,0 +1,27 @@ +package webhook + +import ( + "github.com/nylas/cli/internal/cli/common" + "github.com/spf13/cobra" +) + +// NewPubSubCmd creates the Pub/Sub notification command group. +func newPubSubCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pubsub", + Short: "Manage Pub/Sub notification channels", + Long: `Manage Nylas Pub/Sub notification channels for queue-based event delivery. + +Pub/Sub channels deliver notifications to Google Cloud Pub/Sub topics and are +useful for higher-volume or latency-sensitive event processing.`, + } + + common.AddOutputFlags(cmd) + cmd.AddCommand(newPubSubListCmd()) + cmd.AddCommand(newPubSubShowCmd()) + cmd.AddCommand(newPubSubCreateCmd()) + cmd.AddCommand(newPubSubUpdateCmd()) + cmd.AddCommand(newPubSubDeleteCmd()) + + return cmd +} diff --git a/internal/cli/webhook/pubsub_create_update_delete.go b/internal/cli/webhook/pubsub_create_update_delete.go new file mode 100644 index 0000000..90b5b2a --- /dev/null +++ b/internal/cli/webhook/pubsub_create_update_delete.go @@ -0,0 +1,176 @@ +package webhook + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newPubSubCreateCmd() *cobra.Command { + var ( + description string + topic string + encryptionKey string + triggers []string + notifyEmails []string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a Pub/Sub notification channel", + RunE: func(cmd *cobra.Command, _ []string) error { + if err := validatePubSubTopic(topic); err != nil { + return err + } + allTriggers, err := parseAndValidateTriggers(triggers) + if err != nil { + return err + } + + req := &domain.CreatePubSubChannelRequest{ + Description: description, + TriggerTypes: allTriggers, + Topic: topic, + EncryptionKey: encryptionKey, + NotificationEmailAddresses: notifyEmails, + } + + _, err = common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + channel, err := client.CreatePubSubChannel(ctx, req) + if err != nil { + return struct{}{}, common.WrapCreateError("pub/sub channel", err) + } + if common.IsStructuredOutput(cmd) { + return struct{}{}, common.GetOutputWriter(cmd).Write(channel) + } + printPubSubChannel(channel) + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVar(&description, "description", "", "Channel description") + cmd.Flags().StringVar(&topic, "topic", "", "Google Cloud Pub/Sub topic path (required)") + cmd.Flags().StringVar(&encryptionKey, "encryption-key", "", "Optional encryption key identifier") + cmd.Flags().StringSliceVarP(&triggers, "triggers", "t", nil, "Trigger types (required, comma-separated or repeated)") + cmd.Flags().StringSliceVarP(¬ifyEmails, "notify", "n", nil, "Notification email addresses") + _ = cmd.MarkFlagRequired("topic") + _ = cmd.MarkFlagRequired("triggers") + + return cmd +} + +func newPubSubUpdateCmd() *cobra.Command { + var ( + description string + topic string + encryptionKey string + triggers []string + notifyEmails []string + status string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a Pub/Sub notification channel", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + req := &domain.UpdatePubSubChannelRequest{} + + if topic != "" { + if err := validatePubSubTopic(topic); err != nil { + return err + } + req.Topic = topic + } + if len(triggers) > 0 { + allTriggers, err := parseAndValidateTriggers(triggers) + if err != nil { + return err + } + req.TriggerTypes = allTriggers + } + if status != "" { + if err := common.ValidateOneOf("status", status, []string{"active", "inactive"}); err != nil { + return common.NewUserError("invalid status", "Use --status active or --status inactive") + } + req.Status = status + } + if description != "" { + req.Description = description + } + if encryptionKey != "" { + req.EncryptionKey = encryptionKey + } + if len(notifyEmails) > 0 { + req.NotificationEmailAddresses = notifyEmails + } + + if err := common.ValidateAtLeastOne( + "pub/sub channel field", + description, topic, encryptionKey, status, + ); err != nil && len(triggers) == 0 && len(notifyEmails) == 0 { + return err + } + + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + channel, err := client.UpdatePubSubChannel(ctx, args[0], req) + if err != nil { + return struct{}{}, common.WrapUpdateError("pub/sub channel", err) + } + if common.IsStructuredOutput(cmd) { + return struct{}{}, common.GetOutputWriter(cmd).Write(channel) + } + printPubSubChannel(channel) + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVar(&description, "description", "", "Updated channel description") + cmd.Flags().StringVar(&topic, "topic", "", "Updated Google Cloud Pub/Sub topic path") + cmd.Flags().StringVar(&encryptionKey, "encryption-key", "", "Updated encryption key identifier") + cmd.Flags().StringSliceVarP(&triggers, "triggers", "t", nil, "Updated trigger types (comma-separated or repeated)") + cmd.Flags().StringSliceVarP(¬ifyEmails, "notify", "n", nil, "Updated notification email addresses") + cmd.Flags().StringVar(&status, "status", "", "Updated status (active or inactive)") + + return cmd +} + +func newPubSubDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a Pub/Sub notification channel", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !yes { + return common.NewUserError( + "deletion requires confirmation", + "Re-run with --yes to delete the Pub/Sub channel", + ) + } + + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + if err := client.DeletePubSubChannel(ctx, args[0]); err != nil { + return struct{}{}, common.WrapDeleteError("pub/sub channel", err) + } + if !common.IsStructuredOutput(cmd) { + common.PrintSuccess("Pub/Sub channel deleted") + } + return struct{}{}, nil + }) + return err + }, + } + + common.AddYesFlag(cmd, &yes) + + return cmd +} diff --git a/internal/cli/webhook/pubsub_helpers.go b/internal/cli/webhook/pubsub_helpers.go new file mode 100644 index 0000000..3925d7f --- /dev/null +++ b/internal/cli/webhook/pubsub_helpers.go @@ -0,0 +1,94 @@ +package webhook + +import ( + "fmt" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +var pubSubColumns = []ports.Column{ + {Header: "ID", Field: "ID", Width: -1}, + {Header: "Description", Field: "Description", Width: 28}, + {Header: "Topic", Field: "Topic", Width: 40}, + {Header: "Status", Field: "Status", Width: 10}, +} + +func parseAndValidateTriggers(values []string) ([]string, error) { + var triggers []string + for _, value := range values { + for _, part := range strings.Split(value, ",") { + trigger := strings.TrimSpace(part) + if trigger != "" { + triggers = append(triggers, trigger) + } + } + } + + if len(triggers) == 0 { + return nil, common.NewUserError( + "at least one trigger type is required", + "Use --triggers and run 'nylas webhook triggers' to see available types", + ) + } + + validTriggers := domain.AllTriggerTypes() + for _, trigger := range triggers { + if err := common.ValidateOneOf("trigger type", trigger, validTriggers); err != nil { + return nil, common.NewUserError( + fmt.Sprintf("invalid trigger type: %s", trigger), + "Run 'nylas webhook triggers' to see available trigger types", + ) + } + } + + return triggers, nil +} + +func validatePubSubTopic(topic string) error { + if err := common.ValidateRequiredFlag("--topic", topic); err != nil { + return err + } + if !strings.HasPrefix(topic, "projects/") || !strings.Contains(topic, "/topics/") { + return common.NewUserError( + "invalid Pub/Sub topic", + "Use the full topic path: projects//topics/", + ) + } + return nil +} + +func printPubSubChannel(channel *domain.PubSubChannel) { + fmt.Printf("ID: %s\n", channel.ID) + fmt.Printf("Description: %s\n", channel.Description) + fmt.Printf("Topic: %s\n", channel.Topic) + if channel.Status != "" { + fmt.Printf("Status: %s\n", channel.Status) + } + if channel.EncryptionKey != "" { + fmt.Printf("Encryption Key: %s\n", channel.EncryptionKey) + } + if len(channel.TriggerTypes) > 0 { + fmt.Println("\nTrigger Types:") + for _, trigger := range channel.TriggerTypes { + fmt.Printf(" • %s\n", trigger) + } + } + if len(channel.NotificationEmailAddresses) > 0 { + fmt.Println("\nNotification Emails:") + for _, email := range channel.NotificationEmailAddresses { + fmt.Printf(" • %s\n", email) + } + } + if !channel.CreatedAt.IsZero() || !channel.UpdatedAt.IsZero() { + fmt.Println("\nTimestamps:") + if !channel.CreatedAt.IsZero() { + fmt.Printf(" Created: %s\n", channel.CreatedAt.Format("2006-01-02 15:04:05")) + } + if !channel.UpdatedAt.IsZero() { + fmt.Printf(" Updated: %s\n", channel.UpdatedAt.Format("2006-01-02 15:04:05")) + } + } +} diff --git a/internal/cli/webhook/pubsub_list_show.go b/internal/cli/webhook/pubsub_list_show.go new file mode 100644 index 0000000..952dae1 --- /dev/null +++ b/internal/cli/webhook/pubsub_list_show.go @@ -0,0 +1,66 @@ +package webhook + +import ( + "context" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newPubSubListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List Pub/Sub notification channels", + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + resp, err := client.ListPubSubChannels(ctx) + if err != nil { + return struct{}{}, common.WrapListError("pub/sub channels", err) + } + if len(resp.Data) == 0 { + common.PrintEmptyStateWithHint( + "pub/sub channels", + "Create one with: nylas webhook pubsub create --topic --triggers ", + ) + return struct{}{}, nil + } + + out := common.GetOutputWriter(cmd) + if common.IsStructuredOutput(cmd) { + return struct{}{}, out.Write(resp) + } + return struct{}{}, out.WriteList(resp.Data, pubSubColumns) + }) + return err + }, + } + + return cmd +} + +func newPubSubShowCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a Pub/Sub notification channel", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + channel, err := client.GetPubSubChannel(ctx, args[0]) + if err != nil { + return struct{}{}, common.WrapGetError("pub/sub channel", err) + } + + if common.IsStructuredOutput(cmd) { + return struct{}{}, common.GetOutputWriter(cmd).Write(channel) + } + + printPubSubChannel(channel) + return struct{}{}, nil + }) + return err + }, + } + + return cmd +} diff --git a/internal/cli/webhook/pubsub_test.go b/internal/cli/webhook/pubsub_test.go new file mode 100644 index 0000000..7ea27be --- /dev/null +++ b/internal/cli/webhook/pubsub_test.go @@ -0,0 +1,97 @@ +package webhook + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPubSubCommand(t *testing.T) { + cmd := newPubSubCmd() + + t.Run("command_name", func(t *testing.T) { + assert.Equal(t, "pubsub", cmd.Use) + }) + + t.Run("has_required_subcommands", func(t *testing.T) { + expected := []string{"list", "show", "create", "update", "delete"} + cmdMap := make(map[string]bool) + for _, sub := range cmd.Commands() { + cmdMap[sub.Name()] = true + } + for _, name := range expected { + assert.True(t, cmdMap[name], "Missing expected subcommand: %s", name) + } + }) +} + +func TestPubSubCreateCommand(t *testing.T) { + cmd := newPubSubCreateCmd() + + assert.Equal(t, "create", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("topic")) + assert.NotNil(t, cmd.Flags().Lookup("triggers")) + assert.NotNil(t, cmd.Flags().Lookup("notify")) + assert.NotNil(t, cmd.Flags().Lookup("encryption-key")) + assert.Contains(t, cmd.Flags().Lookup("topic").Annotations, cobra.BashCompOneRequiredFlag) + assert.Contains(t, cmd.Flags().Lookup("triggers").Annotations, cobra.BashCompOneRequiredFlag) +} + +func TestPubSubUpdateCommand(t *testing.T) { + cmd := newPubSubUpdateCmd() + + assert.Equal(t, "update ", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("topic")) + assert.NotNil(t, cmd.Flags().Lookup("triggers")) + assert.NotNil(t, cmd.Flags().Lookup("status")) +} + +func TestValidatePubSubTopic(t *testing.T) { + assert.NoError(t, validatePubSubTopic("projects/demo/topics/events")) + assert.Error(t, validatePubSubTopic("demo/topics/events")) + assert.Error(t, validatePubSubTopic("")) + assert.Error(t, validatePubSubTopic("projects/demo/subscriptions/events")) +} + +func TestParseAndValidateTriggers(t *testing.T) { + triggers, err := parseAndValidateTriggers([]string{"message.created, message.updated", "thread.replied"}) + require.NoError(t, err) + assert.Equal(t, []string{"message.created", "message.updated", "thread.replied"}, triggers) + + _, err = parseAndValidateTriggers([]string{" ", ","}) + require.Error(t, err) + + _, err = parseAndValidateTriggers([]string{"message.created", "totally.invalid"}) + require.Error(t, err) +} + +func TestPubSubCreateCommand_Validation(t *testing.T) { + _, _, err := executeCommand(newPubSubCreateCmd(), "--topic", "projects/demo/topics/events") + require.Error(t, err) + + _, _, err = executeCommand(newPubSubCreateCmd(), "--triggers", "message.created") + require.Error(t, err) +} + +func TestPubSubUpdateCommand_Validation(t *testing.T) { + _, _, err := executeCommand(newPubSubUpdateCmd(), "pubsub-1") + require.Error(t, err) + + _, _, err = executeCommand(newPubSubUpdateCmd(), "pubsub-1", "--status", "paused") + require.Error(t, err) +} + +func TestPubSubDeleteCommand_RequiresYes(t *testing.T) { + _, _, err := executeCommand(newPubSubDeleteCmd(), "pubsub-1") + require.Error(t, err) +} + +func TestPubSubCreateCommand_RejectsMalformedTopic(t *testing.T) { + _, _, err := executeCommand(newPubSubCreateCmd(), + "--topic", "projects/demo/subscriptions/events", + "--triggers", "message.created", + ) + require.Error(t, err) +} diff --git a/internal/cli/webhook/rotate_secret.go b/internal/cli/webhook/rotate_secret.go new file mode 100644 index 0000000..7f7ac81 --- /dev/null +++ b/internal/cli/webhook/rotate_secret.go @@ -0,0 +1,51 @@ +package webhook + +import ( + "context" + "fmt" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newRotateSecretCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "rotate-secret ", + Short: "Rotate a webhook secret", + Long: `Rotate a webhook secret and print the new value. + +Rotating a secret changes the signing key used for future webhook deliveries, so +you should update your receiving service before reactivating traffic.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !yes { + return common.NewUserError( + "secret rotation requires confirmation", + "Re-run with --yes to rotate the webhook secret", + ) + } + + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + rotated, err := client.RotateWebhookSecret(ctx, args[0]) + if err != nil { + return struct{}{}, common.WrapUpdateError("webhook secret", err) + } + + fmt.Printf("%s Webhook secret rotated successfully.\n", common.Green.Sprint("✓")) + fmt.Println() + fmt.Printf(" Webhook ID: %s\n", rotated.ID) + fmt.Printf(" Secret: %s\n", rotated.WebhookSecret) + fmt.Println() + fmt.Println("Update your webhook receiver to use this new secret before processing future events.") + return struct{}{}, nil + }) + return err + }, + } + + common.AddYesFlag(cmd, &yes) + return cmd +} diff --git a/internal/cli/webhook/verify.go b/internal/cli/webhook/verify.go new file mode 100644 index 0000000..b88189d --- /dev/null +++ b/internal/cli/webhook/verify.go @@ -0,0 +1,55 @@ +package webhook + +import ( + "fmt" + + "github.com/nylas/cli/internal/adapters/webhookserver" + "github.com/nylas/cli/internal/cli/common" + "github.com/spf13/cobra" +) + +func newVerifyCmd() *cobra.Command { + var payload string + var payloadFile string + var signature string + var secret string + + cmd := &cobra.Command{ + Use: "verify", + Short: "Verify a webhook signature locally", + Long: `Verify a webhook payload against the x-nylas-signature header value. + +The payload must be the exact raw body that Nylas sent. Do not reformat or +re-encode the JSON before verifying it.`, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := common.ValidateRequiredFlag("--signature", signature); err != nil { + return err + } + if err := common.ValidateRequiredFlag("--secret", secret); err != nil { + return err + } + + payload, err := common.ReadStringOrFile("payload", payload, payloadFile, true) + if err != nil { + return err + } + + if webhookserver.VerifySignature([]byte(payload), signature, secret) { + fmt.Printf("%s Signature is valid.\n", common.Green.Sprint("✓")) + return nil + } + + return common.NewUserError( + "signature verification failed", + "Ensure you are using the exact raw request body, the x-nylas-signature header value, and the current webhook secret", + ) + }, + } + + cmd.Flags().StringVar(&payload, "payload", "", "Inline webhook payload body") + cmd.Flags().StringVar(&payloadFile, "payload-file", "", "Path to a file containing the raw webhook payload body") + cmd.Flags().StringVar(&signature, "signature", "", "Webhook signature from the x-nylas-signature header") + cmd.Flags().StringVar(&secret, "secret", "", "Webhook secret used to verify the signature") + + return cmd +} diff --git a/internal/cli/webhook/verify_rotate_test.go b/internal/cli/webhook/verify_rotate_test.go new file mode 100644 index 0000000..a534db1 --- /dev/null +++ b/internal/cli/webhook/verify_rotate_test.go @@ -0,0 +1,75 @@ +package webhook + +import ( + "os" + "testing" + + "github.com/nylas/cli/internal/adapters/webhookserver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRotateSecretCommand(t *testing.T) { + cmd := newRotateSecretCmd() + + assert.Equal(t, "rotate-secret ", cmd.Use) + flag := cmd.Flags().Lookup("yes") + assert.NotNil(t, flag) +} + +func TestVerifyCommand(t *testing.T) { + cmd := newVerifyCmd() + + assert.Equal(t, "verify", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("payload")) + assert.NotNil(t, cmd.Flags().Lookup("payload-file")) + assert.NotNil(t, cmd.Flags().Lookup("signature")) + assert.NotNil(t, cmd.Flags().Lookup("secret")) +} + +func TestRotateSecretCommand_RequiresYes(t *testing.T) { + _, _, err := executeCommand(newRotateSecretCmd(), "webhook-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "secret rotation requires confirmation") +} + +func TestVerifyCommand_Validation(t *testing.T) { + payload := `{"type":"message.created"}` + secret := "test-secret" + signature := webhookserver.ComputeSignature([]byte(payload), secret) + + _, _, err := executeCommand(newVerifyCmd(), "--payload", payload, "--signature", signature, "--secret", secret) + require.NoError(t, err) + + _, _, err = executeCommand(newVerifyCmd(), "--payload", payload, "--signature", "invalid", "--secret", secret) + require.Error(t, err) + assert.Contains(t, err.Error(), "signature verification failed") + + _, _, err = executeCommand(newVerifyCmd(), "--payload", payload, "--secret", secret) + require.Error(t, err) + assert.Contains(t, err.Error(), "--signature") +} + +func TestVerifyCommand_PayloadFileValidation(t *testing.T) { + tmpFile, err := os.CreateTemp("", "verify-payload-*.json") + require.NoError(t, err) + defer func() { + require.NoError(t, os.Remove(tmpFile.Name())) + }() + + _, err = tmpFile.WriteString(`{"type":"message.created"}`) + require.NoError(t, err) + require.NoError(t, tmpFile.Close()) + + secret := "test-secret" + signature := webhookserver.ComputeSignature([]byte(`{"type":"message.created"}`), secret) + + _, _, err = executeCommand(newVerifyCmd(), + "--payload", `{"type":"message.created"}`, + "--payload-file", tmpFile.Name(), + "--signature", signature, + "--secret", secret, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "only one of --payload or --payload-file") +} diff --git a/internal/cli/webhook/webhook.go b/internal/cli/webhook/webhook.go index 7cf4e19..e128c09 100644 --- a/internal/cli/webhook/webhook.go +++ b/internal/cli/webhook/webhook.go @@ -10,13 +10,13 @@ func NewWebhookCmd() *cobra.Command { cmd := &cobra.Command{ Use: "webhook", Aliases: []string{"webhooks", "wh"}, - Short: "Manage webhooks", - Long: `Manage Nylas webhooks for event notifications. + Short: "Manage notification destinations", + Long: `Manage Nylas notification destinations, including webhooks and Pub/Sub channels. -Webhooks allow you to receive real-time notifications when events occur, -such as new messages, calendar events, or contact changes. +Use webhooks for direct HTTPS push delivery, or Pub/Sub channels for +high-volume queue-based notification delivery. -Note: Webhook management requires an API key (admin-level access).`, +Note: Notification destination management requires an API key (admin-level access).`, } cmd.AddCommand(newListCmd()) @@ -24,6 +24,9 @@ Note: Webhook management requires an API key (admin-level access).`, cmd.AddCommand(newCreateCmd()) cmd.AddCommand(newUpdateCmd()) cmd.AddCommand(newDeleteCmd()) + cmd.AddCommand(newRotateSecretCmd()) + cmd.AddCommand(newVerifyCmd()) + cmd.AddCommand(newPubSubCmd()) cmd.AddCommand(newTestCmd()) cmd.AddCommand(newTriggersCmd()) cmd.AddCommand(newServerCmd()) diff --git a/internal/cli/webhook/webhook_advanced_test.go b/internal/cli/webhook/webhook_advanced_test.go index 4d50e8c..3f6ac5a 100644 --- a/internal/cli/webhook/webhook_advanced_test.go +++ b/internal/cli/webhook/webhook_advanced_test.go @@ -107,6 +107,9 @@ func TestWebhookCommandHelp(t *testing.T) { "create", "update", "delete", + "rotate-secret", + "verify", + "pubsub", "test", "triggers", } diff --git a/internal/cli/webhook/webhook_basic_test.go b/internal/cli/webhook/webhook_basic_test.go index 7db46d0..af87fb2 100644 --- a/internal/cli/webhook/webhook_basic_test.go +++ b/internal/cli/webhook/webhook_basic_test.go @@ -36,12 +36,13 @@ func TestNewWebhookCmd(t *testing.T) { t.Run("has_short_description", func(t *testing.T) { assert.NotEmpty(t, cmd.Short) - assert.Contains(t, cmd.Short, "webhook") + assert.Contains(t, cmd.Short, "notification") }) t.Run("has_long_description", func(t *testing.T) { assert.NotEmpty(t, cmd.Long) - assert.Contains(t, cmd.Long, "Nylas webhooks") + assert.Contains(t, cmd.Long, "webhooks") + assert.Contains(t, cmd.Long, "Pub/Sub") }) t.Run("has_subcommands", func(t *testing.T) { @@ -50,7 +51,10 @@ func TestNewWebhookCmd(t *testing.T) { }) t.Run("has_required_subcommands", func(t *testing.T) { - expectedCmds := []string{"list", "show", "create", "update", "delete", "test", "triggers", "server"} + expectedCmds := []string{ + "list", "show", "create", "update", "delete", + "rotate-secret", "verify", "pubsub", "test", "triggers", "server", + } cmdMap := make(map[string]bool) for _, sub := range cmd.Commands() { diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 5df7733..5b859a1 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -41,22 +41,23 @@ var ( // Resource not found errors - use these instead of creating ad-hoc errors. // Wrap with additional context: fmt.Errorf("%w: %s", domain.ErrContactNotFound, id) - ErrContactNotFound = errors.New("contact not found") - ErrEventNotFound = errors.New("event not found") - ErrCalendarNotFound = errors.New("calendar not found") - ErrMessageNotFound = errors.New("message not found") - ErrFolderNotFound = errors.New("folder not found") - ErrDraftNotFound = errors.New("draft not found") - ErrThreadNotFound = errors.New("thread not found") - ErrAttachmentNotFound = errors.New("attachment not found") - ErrWebhookNotFound = errors.New("webhook not found") - ErrNotetakerNotFound = errors.New("notetaker not found") - ErrTemplateNotFound = errors.New("template not found") - ErrWorkflowNotFound = errors.New("workflow not found") - ErrApplicationNotFound = errors.New("application not found") - ErrCallbackURINotFound = errors.New("callback URI not found") - ErrConnectorNotFound = errors.New("connector not found") - ErrCredentialNotFound = errors.New("credential not found") + ErrContactNotFound = errors.New("contact not found") + ErrEventNotFound = errors.New("event not found") + ErrCalendarNotFound = errors.New("calendar not found") + ErrMessageNotFound = errors.New("message not found") + ErrFolderNotFound = errors.New("folder not found") + ErrDraftNotFound = errors.New("draft not found") + ErrThreadNotFound = errors.New("thread not found") + ErrAttachmentNotFound = errors.New("attachment not found") + ErrWebhookNotFound = errors.New("webhook not found") + ErrPubSubChannelNotFound = errors.New("pub/sub channel not found") + ErrNotetakerNotFound = errors.New("notetaker not found") + ErrTemplateNotFound = errors.New("template not found") + ErrWorkflowNotFound = errors.New("workflow not found") + ErrApplicationNotFound = errors.New("application not found") + ErrCallbackURINotFound = errors.New("callback URI not found") + ErrConnectorNotFound = errors.New("connector not found") + ErrCredentialNotFound = errors.New("credential not found") // Dashboard auth errors ErrDashboardNotLoggedIn = errors.New("not logged in to Nylas Dashboard") diff --git a/internal/domain/pubsub.go b/internal/domain/pubsub.go new file mode 100644 index 0000000..ea40d53 --- /dev/null +++ b/internal/domain/pubsub.go @@ -0,0 +1,48 @@ +package domain + +import "time" + +// PubSubChannel represents a Nylas Pub/Sub notification channel. +type PubSubChannel struct { + ID string `json:"id"` + Description string `json:"description,omitempty"` + TriggerTypes []string `json:"trigger_types"` + Topic string `json:"topic"` + EncryptionKey string `json:"encryption_key,omitempty"` + Status string `json:"status,omitempty"` + NotificationEmailAddresses []string `json:"notification_email_addresses,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Object string `json:"object,omitempty"` +} + +// QuietField returns the field used by quiet output mode. +func (c PubSubChannel) QuietField() string { + return c.ID +} + +// PubSubChannelListResponse contains Pub/Sub channels for an application. +type PubSubChannelListResponse struct { + Data []PubSubChannel `json:"data"` + NextCursor string `json:"next_cursor,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +// CreatePubSubChannelRequest creates a Pub/Sub notification channel. +type CreatePubSubChannelRequest struct { + TriggerTypes []string `json:"trigger_types"` + Topic string `json:"topic"` + Description string `json:"description,omitempty"` + EncryptionKey string `json:"encryption_key,omitempty"` + NotificationEmailAddresses []string `json:"notification_email_addresses,omitempty"` +} + +// UpdatePubSubChannelRequest updates a Pub/Sub notification channel. +type UpdatePubSubChannelRequest struct { + TriggerTypes []string `json:"trigger_types,omitempty"` + Topic string `json:"topic,omitempty"` + Description string `json:"description,omitempty"` + EncryptionKey string `json:"encryption_key,omitempty"` + NotificationEmailAddresses []string `json:"notification_email_addresses,omitempty"` + Status string `json:"status,omitempty"` +} diff --git a/internal/domain/webhook.go b/internal/domain/webhook.go index 7396886..af217f9 100644 --- a/internal/domain/webhook.go +++ b/internal/domain/webhook.go @@ -49,6 +49,12 @@ type WebhookListResponse struct { Pagination Pagination `json:"pagination,omitempty"` } +// RotateWebhookSecretResponse contains the result of a webhook secret rotation. +type RotateWebhookSecretResponse struct { + ID string `json:"id,omitempty"` + WebhookSecret string `json:"webhook_secret"` +} + // Common webhook trigger types. const ( // Grant triggers diff --git a/internal/ports/nylas.go b/internal/ports/nylas.go index 75c8fa3..d584432 100644 --- a/internal/ports/nylas.go +++ b/internal/ports/nylas.go @@ -15,6 +15,7 @@ type NylasClient interface { CalendarClient ContactClient WebhookClient + PubSubClient NotetakerClient InboundClient SchedulerClient diff --git a/internal/ports/pubsub.go b/internal/ports/pubsub.go new file mode 100644 index 0000000..d163a08 --- /dev/null +++ b/internal/ports/pubsub.go @@ -0,0 +1,16 @@ +package ports + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// PubSubClient defines the interface for Pub/Sub notification channels. +type PubSubClient interface { + ListPubSubChannels(ctx context.Context) (*domain.PubSubChannelListResponse, error) + GetPubSubChannel(ctx context.Context, channelID string) (*domain.PubSubChannel, error) + CreatePubSubChannel(ctx context.Context, req *domain.CreatePubSubChannelRequest) (*domain.PubSubChannel, error) + UpdatePubSubChannel(ctx context.Context, channelID string, req *domain.UpdatePubSubChannelRequest) (*domain.PubSubChannel, error) + DeletePubSubChannel(ctx context.Context, channelID string) error +} diff --git a/internal/ports/webhooks.go b/internal/ports/webhooks.go index 59dedbb..cbfbc12 100644 --- a/internal/ports/webhooks.go +++ b/internal/ports/webhooks.go @@ -23,6 +23,9 @@ type WebhookClient interface { // DeleteWebhook deletes a webhook. DeleteWebhook(ctx context.Context, webhookID string) error + // RotateWebhookSecret rotates and returns the secret for a webhook. + RotateWebhookSecret(ctx context.Context, webhookID string) (*domain.RotateWebhookSecretResponse, error) + // SendWebhookTestEvent sends a test event to a webhook URL. SendWebhookTestEvent(ctx context.Context, webhookURL string) error