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
17 changes: 16 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test-unit test-race test-integration test-integration-fast test-cli-regressions test-integration-agent test-cleanup test-coverage test-air test-air-integration test-e2e test-e2e-air test-e2e-ui test-playwright test-playwright-air test-playwright-ui test-playwright-studio test-playwright-interactive test-playwright-headed clean clean-cache install fmt vet lint vuln deps security check-context ci ci-full help
.PHONY: build test-unit test-race test-integration test-integration-fast test-cli-regressions test-integration-agent test-integration-rpc test-cleanup test-coverage test-air test-air-integration test-e2e test-e2e-air test-e2e-ui test-playwright test-playwright-air test-playwright-ui test-playwright-studio test-playwright-interactive test-playwright-headed clean clean-cache install fmt vet lint vuln deps security check-context ci ci-full help

# Disable parallel Make execution - prevents Go build cache corruption on btrfs (CachyOS)
.NOTPARALLEL:
Expand Down Expand Up @@ -186,6 +186,21 @@ test-integration-agent: build
-run 'TestCLI_Agent.*$$'
@echo "✓ Agent integration checks passed"

# RPC WebSocket server integration checks: boots `nylas rpc serve`, verifies token auth
# (wrong token rejected, correct token connects) and a live email.list over JSON-RPC.
test-integration-rpc: build
@echo "=== Running RPC Server Integration Checks ==="
@: "$${NYLAS_API_KEY:?NYLAS_API_KEY is required for rpc integration tests}"
@: "$${NYLAS_GRANT_ID:?NYLAS_GRANT_ID is required for rpc integration tests}"
@go clean -testcache
NYLAS_DISABLE_KEYRING=true \
NYLAS_TEST_RATE_LIMIT_RPS=$(NYLAS_TEST_RATE_LIMIT_RPS) \
NYLAS_TEST_RATE_LIMIT_BURST=$(NYLAS_TEST_RATE_LIMIT_BURST) \
NYLAS_TEST_BINARY=$(CURDIR)/bin/nylas \
go test ./internal/cli/integration/... -tags=integration -v -timeout 10m -p 1 \
-run 'TestCLI_RPC.*$$'
@echo "✓ RPC server integration checks passed"

# Clean up test resources (virtual calendars, test grants, test events, test emails, etc.)
test-cleanup:
@echo "=== Cleaning up test resources ==="
Expand Down
2 changes: 2 additions & 0 deletions cmd/nylas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/nylas/cli/internal/cli/mcp"
"github.com/nylas/cli/internal/cli/notetaker"
"github.com/nylas/cli/internal/cli/otp"
"github.com/nylas/cli/internal/cli/rpc"
"github.com/nylas/cli/internal/cli/scheduler"
"github.com/nylas/cli/internal/cli/setup"
"github.com/nylas/cli/internal/cli/slack"
Expand Down Expand Up @@ -58,6 +59,7 @@ func main() {
rootCmd.AddCommand(notetaker.NewNotetakerCmd())
rootCmd.AddCommand(timezone.NewTimezoneCmd())
rootCmd.AddCommand(mcp.NewMCPCmd())
rootCmd.AddCommand(rpc.NewRPCCmd())
rootCmd.AddCommand(slack.NewSlackCmd())
rootCmd.AddCommand(templatecmd.NewTemplateCmd())
rootCmd.AddCommand(demo.NewDemoCmd())
Expand Down
427 changes: 427 additions & 0 deletions docs/RPC.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions docs/commands/agent-rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ nylas agent rule create \
--action archive
```

```bash
nylas agent rule create \
--name "File receipts" \
--condition from.domain,is,billing.example.com \
--action assign_to_folder=Receipts
```

The `assign_to_folder` value is a folder **name** — a custom folder's name (use its full path for a nested folder, e.g. `Clients/Acme`) or a system folder name (`Inbox`, `Sent`, `Drafts`, `Trash`, `Junk`, `Archive`). The name is resolved when the rule runs, so a reference to a folder that doesn't exist is skipped.

Available common flags:

- `--name`
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/fatih/color v1.18.0
github.com/gdamore/tcell/v2 v2.13.4
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/ncruces/go-sqlite3 v0.30.4
github.com/rivo/tview v0.42.0
github.com/slack-go/slack v0.23.1
Expand Down Expand Up @@ -47,7 +48,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.2.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
Expand Down
2 changes: 2 additions & 0 deletions internal/adapters/nylas/calendars_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ func (c *HTTPClient) GetEventsWithCursor(ctx context.Context, grantID, calendarI
Add("page_token", params.PageToken).
AddInt64("start", params.Start).
AddInt64("end", params.End).
AddInt64("updated_after", params.UpdatedAfter).
AddInt64("updated_before", params.UpdatedBefore).
Add("title", params.Title).
Add("location", params.Location).
AddBool("show_cancelled", params.ShowCancelled).
Expand Down
8 changes: 8 additions & 0 deletions internal/adapters/nylas/calendars_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ func TestHTTPClient_GetEventsWithCursor(t *testing.T) {
},
wantQueryKeys: []string{"page_token"},
},
{
name: "includes updated_after filter",
params: &domain.EventQueryParams{
Limit: 10,
UpdatedAfter: 1710000000,
},
wantQueryKeys: []string{"updated_after"},
},
{
// ical_uid is the bridge between an emailed invite and a Nylas
// event ID; the RSVP handler relies on the upstream filter so
Expand Down
2 changes: 2 additions & 0 deletions internal/adapters/nylas/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type contactResponse struct {
Notes string `json:"notes"`
PictureURL string `json:"picture_url"`
Picture string `json:"picture"`
UpdatedAt int64 `json:"updated_at"`
Emails []domain.ContactEmail `json:"emails"`
PhoneNumbers []domain.ContactPhone `json:"phone_numbers"`
WebPages []domain.ContactWebPage `json:"web_pages"`
Expand Down Expand Up @@ -248,6 +249,7 @@ func convertContact(c contactResponse) domain.Contact {
Notes: c.Notes,
PictureURL: c.PictureURL,
Picture: c.Picture,
UpdatedAt: c.UpdatedAt,
Emails: c.Emails,
PhoneNumbers: c.PhoneNumbers,
WebPages: c.WebPages,
Expand Down
17 changes: 17 additions & 0 deletions internal/adapters/nylas/contacts_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build !integration
// +build !integration

package nylas

import "testing"

func TestConvertContactIncludesUpdatedAt(t *testing.T) {
contact := convertContact(contactResponse{
ID: "contact-1",
UpdatedAt: 1700000000,
})

if contact.UpdatedAt != 1700000000 {
t.Fatalf("UpdatedAt = %d, want 1700000000", contact.UpdatedAt)
}
}
7 changes: 7 additions & 0 deletions internal/adapters/nylas/demo_threads.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ func (d *DemoClient) GetThreads(ctx context.Context, grantID string, params *dom
return d.getDemoThreads(), nil
}

// GetThreadsWithCursor returns demo threads with pagination.
func (d *DemoClient) GetThreadsWithCursor(ctx context.Context, grantID string, params *domain.ThreadQueryParams) (*domain.ThreadListResponse, error) {
return &domain.ThreadListResponse{
Data: d.getDemoThreads(),
}, nil
}

func (d *DemoClient) getDemoThreads() []domain.Thread {
now := time.Now()
return []domain.Thread{
Expand Down
1 change: 1 addition & 0 deletions internal/adapters/nylas/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ type MockClient struct {
UpdateMessageFunc func(ctx context.Context, grantID, messageID string, req *domain.UpdateMessageRequest) (*domain.Message, error)
DeleteMessageFunc func(ctx context.Context, grantID, messageID string) error
GetThreadsFunc func(ctx context.Context, grantID string, params *domain.ThreadQueryParams) ([]domain.Thread, error)
GetThreadsWithCursorFunc func(ctx context.Context, grantID string, params *domain.ThreadQueryParams) (*domain.ThreadListResponse, error)
GetThreadFunc func(ctx context.Context, grantID, threadID string) (*domain.Thread, error)
UpdateThreadFunc func(ctx context.Context, grantID, threadID string, req *domain.UpdateMessageRequest) (*domain.Thread, error)
DeleteThreadFunc func(ctx context.Context, grantID, threadID string) error
Expand Down
14 changes: 14 additions & 0 deletions internal/adapters/nylas/mock_threads.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ func (m *MockClient) GetThreads(ctx context.Context, grantID string, params *dom
return []domain.Thread{}, nil
}

// GetThreadsWithCursor retrieves threads with pagination cursor support.
func (m *MockClient) GetThreadsWithCursor(ctx context.Context, grantID string, params *domain.ThreadQueryParams) (*domain.ThreadListResponse, error) {
m.GetThreadsCalled = true
m.LastGrantID = grantID
if m.GetThreadsWithCursorFunc != nil {
return m.GetThreadsWithCursorFunc(ctx, grantID, params)
}
if m.GetThreadsFunc != nil {
threads, err := m.GetThreadsFunc(ctx, grantID, params)
return &domain.ThreadListResponse{Data: threads}, err
}
return &domain.ThreadListResponse{Data: []domain.Thread{}}, nil
}

// GetThread retrieves a single thread.
func (m *MockClient) GetThread(ctx context.Context, grantID, threadID string) (*domain.Thread, error) {
m.GetThreadCalled = true
Expand Down
27 changes: 25 additions & 2 deletions internal/adapters/nylas/threads.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ type threadResponse struct {

// GetThreads retrieves threads with query parameters.
func (c *HTTPClient) GetThreads(ctx context.Context, grantID string, params *domain.ThreadQueryParams) ([]domain.Thread, error) {
resp, err := c.GetThreadsWithCursor(ctx, grantID, params)
if err != nil {
return nil, err
}
return resp.Data, nil
}

// GetThreadsWithCursor retrieves threads with pagination cursor support.
func (c *HTTPClient) GetThreadsWithCursor(ctx context.Context, grantID string, params *domain.ThreadQueryParams) (*domain.ThreadListResponse, error) {
if err := validateRequired("grant ID", grantID); err != nil {
return nil, err
}
if params == nil {
params = &domain.ThreadQueryParams{Limit: 10}
}
Expand All @@ -45,23 +57,34 @@ func (c *HTTPClient) GetThreads(ctx context.Context, grantID string, params *dom
queryURL := NewQueryBuilder().
AddInt("limit", params.Limit).
AddInt("offset", params.Offset).
Add("page_token", params.PageToken).
Add("subject", params.Subject).
Add("from", params.From).
Add("to", params.To).
AddBoolPtr("unread", params.Unread).
AddBoolPtr("starred", params.Starred).
AddInt64("latest_message_before", params.LatestMsgBefore).
AddInt64("latest_message_after", params.LatestMsgAfter).
Add("q", params.SearchQuery).
AddSlice("in", params.In).
BuildURL(baseURL)

var result struct {
Data []threadResponse `json:"data"`
Data []threadResponse `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 convertThreads(result.Data), nil
return &domain.ThreadListResponse{
Data: convertThreads(result.Data),
Pagination: domain.Pagination{
NextCursor: result.NextCursor,
HasMore: result.NextCursor != "",
},
}, nil
}

// GetThread retrieves a single thread by ID.
Expand Down
60 changes: 52 additions & 8 deletions internal/adapters/nylas/threads_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,13 @@ func TestHTTPClient_GetThreads_WithFilters(t *testing.T) {
// Check query params
assert.Equal(t, "20", r.URL.Query().Get("limit"))
assert.Equal(t, "5", r.URL.Query().Get("offset"))
assert.Equal(t, "cursor-xyz", r.URL.Query().Get("page_token"))
assert.Equal(t, "Important", r.URL.Query().Get("subject"))
assert.Equal(t, "alice@example.com", r.URL.Query().Get("from"))
assert.Equal(t, "bob@example.com", r.URL.Query().Get("to"))
assert.Equal(t, "true", r.URL.Query().Get("unread"))
assert.Equal(t, "false", r.URL.Query().Get("starred"))
assert.Equal(t, "1700000000", r.URL.Query().Get("latest_message_after"))
assert.Equal(t, "project X", r.URL.Query().Get("q"))

response := map[string]any{
Expand All @@ -237,21 +239,63 @@ func TestHTTPClient_GetThreads_WithFilters(t *testing.T) {
unread := true
starred := false
params := &domain.ThreadQueryParams{
Limit: 20,
Offset: 5,
Subject: "Important",
From: "alice@example.com",
To: "bob@example.com",
Unread: &unread,
Starred: &starred,
SearchQuery: "project X",
Limit: 20,
Offset: 5,
PageToken: "cursor-xyz",
Subject: "Important",
From: "alice@example.com",
To: "bob@example.com",
Unread: &unread,
Starred: &starred,
LatestMsgAfter: 1700000000,
SearchQuery: "project X",
}
threads, err := client.GetThreads(ctx, "grant-filter", params)

require.NoError(t, err)
assert.Len(t, threads, 0)
}

func TestHTTPClient_GetThreadsWithCursor(t *testing.T) {
now := time.Now().Unix()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v3/grants/grant-123/threads", r.URL.Path)
assert.Equal(t, "cursor-1", r.URL.Query().Get("page_token"))

response := map[string]any{
"data": []map[string]any{
{
"id": "thread-1",
"grant_id": "grant-123",
"subject": "First",
"earliest_message_date": now,
"latest_message_received_date": now,
"latest_message_sent_date": now,
},
},
"next_cursor": "cursor-2",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer server.Close()

client := NewHTTPClient()
client.SetCredentials("client-id", "secret", "api-key")
client.SetBaseURL(server.URL)

result, err := client.GetThreadsWithCursor(context.Background(), "grant-123", &domain.ThreadQueryParams{
Limit: 1,
PageToken: "cursor-1",
})

require.NoError(t, err)
require.Len(t, result.Data, 1)
assert.Equal(t, "thread-1", result.Data[0].ID)
assert.Equal(t, "cursor-2", result.Pagination.NextCursor)
assert.True(t, result.Pagination.HasMore)
}

func TestHTTPClient_GetThread(t *testing.T) {
now := time.Now().Unix()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading