Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
11 changes: 11 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,20 @@ nylas webhook show <webhook-id> # Show webhook details
nylas webhook create --url URL --triggers "event.created,event.updated"
nylas webhook update <webhook-id> --url NEW_URL # Update webhook
nylas webhook delete <webhook-id> # Delete webhook
nylas webhook rotate-secret <webhook-id> --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 <channel-id>
nylas webhook pubsub create --topic projects/PROJ/topics/TOPIC --triggers "message.created"
nylas webhook pubsub update <channel-id> --status inactive
nylas webhook pubsub delete <channel-id> --yes
```

**Testing & development:**
```bash
nylas webhook test send <webhook-url> # Send test payload
Expand Down
58 changes: 56 additions & 2 deletions docs/commands/webhooks.md
Original file line number Diff line number Diff line change
@@ -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 <webhook-id> --yes
nylas webhook verify --payload-file body.json --signature <sig> --secret <secret>

nylas webhook pubsub list
nylas webhook pubsub create --topic projects/PROJ/topics/TOPIC --triggers message.created
nylas webhook pubsub show <channel-id>
nylas webhook pubsub update <channel-id> --status inactive
nylas webhook pubsub delete <channel-id> --yes
```

### Built-in Webhook Server

Expand Down Expand Up @@ -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 <webhook-id> --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 <x-nylas-signature> \
--secret <webhook-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 <channel-id>
nylas webhook pubsub create \
--topic projects/PROJ/topics/TOPIC \
--triggers message.created,event.created
nylas webhook pubsub update <channel-id> --status inactive
nylas webhook pubsub delete <channel-id> --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
Expand Down Expand Up @@ -470,4 +525,3 @@ nylas webhook test <webhook-id>
```

---

77 changes: 77 additions & 0 deletions internal/adapters/nylas/demo_pubsub.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions internal/adapters/nylas/demo_webhooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions internal/adapters/nylas/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -66,6 +79,8 @@ type MockClient struct {
LastDraftID string
LastFolderID string
LastAttachmentID string
LastWebhookID string
LastPubSubChannelID string
LastTemplateID string
LastWorkflowID string

Expand Down Expand Up @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions internal/adapters/nylas/mock_pubsub.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading