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
25 changes: 25 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ nylas email delete <message-id> # Delete email
nylas email mark read <message-id> # Mark as read
nylas email mark unread <message-id> # Mark as unread
nylas email mark starred <message-id> # Star a message
nylas email move <message-id> --folder <folder-id> # Move a message to a folder
nylas email move <message-id> --archive # Archive a message (clear folders/labels)
nylas email clean <message-id> # Strip quoted replies & signatures (clean conversation)
nylas email clean <id-1> <id-2> --keep-links # Clean multiple messages, keep links (--json for raw HTML)
nylas email attachments list <message-id> # List attachments
nylas email attachments download <message-id> <attachment-id> # Download attachment
nylas email metadata show <message-id> # Show message metadata
Expand Down Expand Up @@ -361,7 +365,9 @@ nylas calendar events create --title T --start TIME --end TIME # Create event
nylas calendar events update <event-id> --title "New Title" # Update event
nylas calendar events delete <event-id> # Delete event
nylas calendar events rsvp <event-id> --status yes # RSVP to event
nylas calendar events import --calendar primary --start 2026-01-01 --end 2026-12-31 --json # Bulk export/migrate
nylas calendar availability check # Check availability
nylas calendar resources # List bookable rooms/equipment (alias: rooms)
nylas calendar recurring list # List recurring events
nylas calendar virtual list # List virtual meetings
nylas calendar focus-time list # List focus time blocks
Expand Down Expand Up @@ -809,11 +815,22 @@ nylas notetaker show <notetaker-id>
# Get recording and transcript URLs
nylas notetaker media <notetaker-id>

# Update a scheduled notetaker (before it joins)
nylas notetaker update <notetaker-id> --join-time "tomorrow 2pm"
nylas notetaker update <notetaker-id> --bot-name "Recorder" --transcription=false

# Make an active notetaker leave the meeting (keeps the recording)
nylas notetaker leave <notetaker-id>

# Delete/cancel a notetaker
nylas notetaker delete <notetaker-id> # With confirmation
nylas notetaker delete <notetaker-id> --yes # Skip confirmation
```

> **`leave` vs `delete`:** `leave` removes an *active* bot from its meeting and triggers
> recording/transcript generation, keeping the notetaker record. `delete` cancels a
> scheduled bot or removes the notetaker and any unsaved media entirely.

**Aliases:** `nylas nt`, `nylas bot`

**Supported Providers:** Zoom, Google Meet, Microsoft Teams
Expand Down Expand Up @@ -888,6 +905,14 @@ nylas scheduler configurations delete <config-id> # Delete configuration
# Sessions
nylas scheduler sessions create # Create booking session
nylas scheduler sessions show <session-id> # Show session details

# Group events (shared/group booking under a configuration; alias: ge)
nylas scheduler group-events list <config-id> --calendar primary # List group events (in a time window)
nylas scheduler group-events create <config-id> --calendar primary \
--title "Workshop" --capacity 50 --start "2026-07-01 18:00" --end "2026-07-01 19:00"
nylas scheduler group-events update <config-id> <event-id> --capacity 80
nylas scheduler group-events delete <config-id> <event-id> # Delete a group event
nylas scheduler group-events import <config-id> --file events.json # Import provider events
```

**Details:** `docs/commands/scheduler.md`
Expand Down
50 changes: 50 additions & 0 deletions internal/adapters/nylas/calendars_events_import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package nylas

import (
"context"
"fmt"
"net/url"

"github.com/nylas/cli/internal/domain"
)

// importEventsDefaultLimit mirrors the API default page size for the import
// endpoint (max allowed is 500).
const importEventsDefaultLimit = 50

// ImportEvents bulk-reads events from a calendar over a time window
// (GET /v3/grants/{id}/events/import), including expanded recurring instances.
// Unlike GetEvents this endpoint is intended for migration/export and requires
// a calendar ID. When start/end are omitted the API defaults to now .. +1 month.
func (c *HTTPClient) ImportEvents(ctx context.Context, grantID string, params *domain.EventQueryParams) ([]domain.Event, error) {
if err := validateRequired("grant ID", grantID); err != nil {
return nil, err
}
if params == nil || params.CalendarID == "" {
return nil, fmt.Errorf("%w: calendar ID is required for import", domain.ErrInvalidInput)
}
// Don't mutate the caller's params: a shared/reused EventQueryParams must
// not be silently altered by this call.
limit := params.Limit
if limit <= 0 {
limit = importEventsDefaultLimit
}

baseURL := fmt.Sprintf("%s/v3/grants/%s/events/import", c.baseURL, url.PathEscape(grantID))
queryURL := NewQueryBuilder().
Add("calendar_id", params.CalendarID).
AddInt("limit", limit).
Add("page_token", params.PageToken).
AddInt64("start", params.Start).
AddInt64("end", params.End).
BuildURL(baseURL)

var result struct {
Data []eventResponse `json:"data"`
}
if err := c.doGet(ctx, queryURL, &result); err != nil {
return nil, err
}

return convertEvents(result.Data), nil
}
72 changes: 72 additions & 0 deletions internal/adapters/nylas/calendars_events_import_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//go:build !integration
// +build !integration

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_ImportEvents(t *testing.T) {
var gotQuery string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Import hits the dedicated /events/import path, not /events.
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/v3/grants/grant-1/events/import", r.URL.Path)
gotQuery = r.URL.RawQuery

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{
{"id": "evt-1", "calendar_id": "primary", "title": "Kickoff"},
},
})
}))
defer server.Close()

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

events, err := client.ImportEvents(context.Background(), "grant-1", &domain.EventQueryParams{
CalendarID: "primary",
Start: 1735689600,
End: 1767225600,
})

require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, "evt-1", events[0].ID)
// calendar_id is required by the API and must be forwarded as a query param.
assert.Contains(t, gotQuery, "calendar_id=primary")
assert.Contains(t, gotQuery, "start=1735689600")
}

func TestHTTPClient_ImportEvents_RequiresCalendar(t *testing.T) {
client := nylas.NewHTTPClient()
client.SetCredentials("client-id", "secret", "api-key")

t.Run("missing calendar id", func(t *testing.T) {
_, err := client.ImportEvents(context.Background(), "grant-1", &domain.EventQueryParams{})
assert.Error(t, err)
})

t.Run("nil params", func(t *testing.T) {
_, err := client.ImportEvents(context.Background(), "grant-1", nil)
assert.Error(t, err)
})

t.Run("missing grant", func(t *testing.T) {
_, err := client.ImportEvents(context.Background(), "", &domain.EventQueryParams{CalendarID: "primary"})
assert.Error(t, err)
})
}
33 changes: 33 additions & 0 deletions internal/adapters/nylas/demo_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,39 @@ func (d *DemoClient) CancelBooking(ctx context.Context, bookingID string, reason
return nil
}

// Group Event Demo Implementations

func (d *DemoClient) ListGroupEvents(ctx context.Context, grantID, configID, calendarID string, startTime, endTime int64) ([]domain.GroupEvent, error) {
return []domain.GroupEvent{
{ID: "ge-001", Title: "Annual Philosophy Club Meeting", CalendarID: "primary", Capacity: 50, Location: "Library, Cave Room"},
{ID: "ge-002", Title: "Intro to Stoicism Workshop", CalendarID: "primary", Capacity: 20},
}, nil
}

func (d *DemoClient) CreateGroupEvent(ctx context.Context, grantID, configID string, req *domain.CreateGroupEventRequest) ([]domain.GroupEvent, error) {
return []domain.GroupEvent{
{ID: "ge-new", Title: req.Title, CalendarID: req.CalendarID, Capacity: req.Capacity, Participants: req.Participants, When: req.When},
}, nil
}

func (d *DemoClient) UpdateGroupEvent(ctx context.Context, grantID, configID, eventID string, req *domain.UpdateGroupEventRequest) ([]domain.GroupEvent, error) {
return []domain.GroupEvent{
{ID: eventID, Title: req.Title, CalendarID: req.CalendarID, Capacity: req.Capacity},
}, nil
}

func (d *DemoClient) DeleteGroupEvent(ctx context.Context, grantID, configID, eventID string) error {
return nil
}

func (d *DemoClient) ImportGroupEvents(ctx context.Context, configID string, items []domain.ImportGroupEventItem) ([]domain.GroupEvent, error) {
events := make([]domain.GroupEvent, 0, len(items))
for _, it := range items {
events = append(events, domain.GroupEvent{ID: "ge-import", CalendarID: it.CalendarID, Capacity: it.Capacity})
}
return events, nil
}

// Admin Demo Implementations

func (d *DemoClient) ListApplications(ctx context.Context) ([]domain.Application, error) {
Expand Down
8 changes: 8 additions & 0 deletions internal/adapters/nylas/demo_calendars.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ func (d *DemoClient) GetCalendar(ctx context.Context, grantID, calendarID string
return &domain.Calendar{ID: calendarID, Name: "Demo Calendar", IsPrimary: true}, nil
}

// ListRoomResources returns demo room resources.
func (d *DemoClient) ListRoomResources(ctx context.Context, grantID string) ([]domain.RoomResource, error) {
return []domain.RoomResource{
{Email: "boardroom@demo.nylas.com", Name: "Boardroom", Capacity: 20, Building: "Main", FloorName: "5", FloorNumber: 5, Object: "room_resource"},
{Email: "huddle-1@demo.nylas.com", Name: "Huddle Room 1", Capacity: 4, Building: "Main", FloorName: "2", FloorNumber: 2, Object: "room_resource"},
}, nil
}

// CreateCalendar simulates creating a calendar.
func (d *DemoClient) CreateCalendar(ctx context.Context, grantID string, req *domain.CreateCalendarRequest) (*domain.Calendar, error) {
return &domain.Calendar{
Expand Down
5 changes: 5 additions & 0 deletions internal/adapters/nylas/demo_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ func (d *DemoClient) GetEventsWithCursor(ctx context.Context, grantID, calendarI
return &domain.EventListResponse{Data: d.getDemoEvents()}, nil
}

// ImportEvents returns demo events for bulk import/export.
func (d *DemoClient) ImportEvents(ctx context.Context, grantID string, params *domain.EventQueryParams) ([]domain.Event, error) {
return d.getDemoEvents(), nil
}

func (d *DemoClient) getDemoEvents() []domain.Event {
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
Expand Down
15 changes: 15 additions & 0 deletions internal/adapters/nylas/demo_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,18 @@ func (d *DemoClient) UpdateMessage(ctx context.Context, grantID, messageID strin
func (d *DemoClient) DeleteMessage(ctx context.Context, grantID, messageID string) error {
return nil
}

// CleanMessages simulates cleaning messages into display-ready text.
func (d *DemoClient) CleanMessages(ctx context.Context, grantID string, req *domain.CleanMessagesRequest) ([]domain.CleanedMessage, error) {
result := make([]domain.CleanedMessage, 0, len(req.MessageIDs))
for _, id := range req.MessageIDs {
result = append(result, domain.CleanedMessage{
ID: id,
GrantID: grantID,
Object: "message",
Subject: "Quarterly Review",
Conversation: "Thanks for the update — the numbers look great. Let's sync on Thursday.",
})
}
return result, nil
}
22 changes: 22 additions & 0 deletions internal/adapters/nylas/demo_productivity.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,28 @@ func (d *DemoClient) DeleteNotetaker(ctx context.Context, grantID, notetakerID s
return nil
}

// LeaveNotetaker simulates an active notetaker leaving its meeting.
func (d *DemoClient) LeaveNotetaker(ctx context.Context, grantID, notetakerID string) error {
return nil
}

// UpdateNotetaker simulates updating a scheduled notetaker.
func (d *DemoClient) UpdateNotetaker(ctx context.Context, grantID, notetakerID string, req *domain.UpdateNotetakerRequest) (*domain.Notetaker, error) {
now := time.Now()
nt := &domain.Notetaker{
ID: notetakerID,
State: domain.NotetakerStateScheduled,
UpdatedAt: now,
}
if req.JoinTime > 0 {
nt.JoinTime = time.Unix(req.JoinTime, 0)
}
if req.Name != "" {
nt.BotConfig = &domain.BotConfig{Name: req.Name}
}
return nt, nil
}

// GetNotetakerMedia returns demo notetaker media.
func (d *DemoClient) GetNotetakerMedia(ctx context.Context, grantID, notetakerID string) (*domain.MediaData, error) {
return &domain.MediaData{
Expand Down
40 changes: 40 additions & 0 deletions internal/adapters/nylas/messages_clean.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package nylas

import (
"context"
"fmt"
"net/url"

"github.com/nylas/cli/internal/domain"
)

// CleanMessages parses one or more messages into clean, display-ready text,
// stripping quoted reply/forward chains, signatures, and conclusion phrases
// (PUT /v3/grants/{id}/messages/clean).
func (c *HTTPClient) CleanMessages(ctx context.Context, grantID string, req *domain.CleanMessagesRequest) ([]domain.CleanedMessage, error) {
if err := validateRequired("grant ID", grantID); err != nil {
return nil, err
}
if req == nil || len(req.MessageIDs) == 0 {
return nil, fmt.Errorf("%w: at least one message ID is required", domain.ErrInvalidInput)
}
if len(req.MessageIDs) > domain.CleanMessagesMaxIDs {
return nil, fmt.Errorf("%w: clean accepts at most %d message IDs (got %d)", domain.ErrInvalidInput, domain.CleanMessagesMaxIDs, len(req.MessageIDs))
}

queryURL := fmt.Sprintf("%s/v3/grants/%s/messages/clean", c.baseURL, url.PathEscape(grantID))

resp, err := c.doJSONRequest(ctx, "PUT", queryURL, req)
if err != nil {
return nil, err
}

var result struct {
Data []domain.CleanedMessage `json:"data"`
}
if err := c.decodeJSONResponse(resp, &result); err != nil {
return nil, err
}

return result.Data, nil
}
Loading
Loading