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
7 changes: 7 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ All demo commands mirror real CLI structure: `nylas demo <feature> <command>`

```bash
nylas email list [grant-id] # List emails
nylas email subscriptions list # Discover subscriptions for the active account
nylas email subscriptions list --since 180d --all-folders # Scan older and archived mail
nylas email subscriptions unsubscribe news@example.com --dry-run # Preview unsubscribe
nylas email subscriptions unsubscribe news@example.com # Open unsubscribe action; finish externally
nylas email subscriptions cleanup news@example.com --all-folders # Move matching messages to Trash
nylas email subscriptions cleanup news@example.com --permanent # Permanently remove matching messages from Trash
nylas email subscriptions cleanup news@example.com --permanent --all-folders # Permanently remove matches everywhere
nylas email read <message-id> # Read email
nylas email read <message-id> --raw # Show raw body without HTML
nylas email read <message-id> --mime # Show raw RFC822/MIME format
Expand Down
59 changes: 59 additions & 0 deletions docs/commands/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,65 @@ Recent Emails
Found 5 emails
```

### List Email Subscriptions

Discover mailing-list subscriptions from standard email headers. These
commands use the active account, omit postable discussion lists, and never
print unsubscribe links. Discovery currently supports Google and Microsoft
grants. Nylas does not expose the required original headers for EWS or IMAP
messages; see [Headers and MIME data](https://developer.nylas.com/docs/v3/email/headers-mime-data/).

```bash
nylas email subscriptions list # Scan 90 days of inbox mail
nylas email subscriptions list --since 180d # Scan a longer period
nylas email subscriptions list --all-folders # Include archived and filed mail
nylas email subscriptions list --limit 2000 --json # Machine-readable output
```

### Unsubscribe

Select subscriptions by the email or List-ID shown by `subscriptions list`.
The command shows the sanitized destination in its review and asks for
confirmation. It never deletes existing mail.

```bash
nylas email subscriptions unsubscribe noreply@medium.com --dry-run
nylas email subscriptions unsubscribe noreply@medium.com
nylas email subscriptions unsubscribe news@example.com updates.example.com
nylas email subscriptions unsubscribe news@example.com --yes --json
```

Use `--since`, `--limit`, and `--all-folders` to control subscription matching.
HTTPS actions open the unsubscribe page for review; the CLI does not submit
one-click POST requests from untrusted message headers. Email actions open the
header's unsubscribe message in the OS mail composer for review; the CLI never
sends header-supplied content itself. Conflicting destinations are rejected.
`--yes` skips only the CLI confirmation; users must still finish each opened
action. `--dry-run` performs no unsubscribe, browser, or mail-composer action.

### Clean Up Subscription Email

Cleanup is a separate action, so it works after an earlier unsubscribe and
never repeats an unsubscribe request or opens a browser. By default it
moves matching inbox mail to Trash. `--permanent` scans Trash by default and
deletes matches irreversibly; add `--all-folders` only to delete matching mail
everywhere.

```bash
nylas email subscriptions cleanup news@example.com --dry-run
nylas email subscriptions cleanup news@example.com --all-folders
nylas email subscriptions cleanup news@example.com --permanent --dry-run
nylas email subscriptions cleanup news@example.com --permanent
nylas email subscriptions cleanup news@example.com --permanent --all-folders
```

Permanent deletion requires **Enable hard delete** under **Customizations >
API** in the Nylas Dashboard. Gmail grants also require
`https://mail.google.com/`; Microsoft grants require `Mail.ReadWrite`.
Re-authenticate a grant after adding scopes. Cleanup stops on the first API
error and reports the completed count. Retrying the same selectors skips ones
that are no longer present, and already-removed messages count as complete.

### Read Email

```bash
Expand Down
10 changes: 7 additions & 3 deletions internal/adapters/browser/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,20 @@ func (b *DefaultBrowser) Open(url string) error {

// createCommand creates the appropriate command to open a URL based on the OS.
func createCommand(url string) *exec.Cmd {
switch runtime.GOOS {
return createCommandForOS(runtime.GOOS, url)
}

func createCommandForOS(goos, url string) *exec.Cmd {
switch goos {
case "linux":
// Use xdg-open on Linux
return exec.Command("xdg-open", url)
case "darwin":
// Use open on macOS
return exec.Command("open", url)
case "windows":
// Use start on Windows
return exec.Command("cmd", "/c", "start", url)
// Invoke the URL handler directly so shell metacharacters remain inert.
return exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", url)
default:
// Fallback to xdg-open
return exec.Command("xdg-open", url)
Expand Down
16 changes: 16 additions & 0 deletions internal/adapters/browser/browser_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package browser

import (
"path/filepath"
"slices"
"strings"
"testing"
)

Expand Down Expand Up @@ -66,3 +68,17 @@ func TestCreateCommand_ReturnsNonNil(t *testing.T) {
t.Fatal("createCommand() should never return nil")
}
}

func TestCreateCommandForOS_WindowsDoesNotUseShell(t *testing.T) {
url := "https://example.com/u?x=1&calc.exe|whoami^%PATH%"
cmd := createCommandForOS("windows", url)

name := strings.ToLower(filepath.Base(cmd.Path))
if name == "cmd" || name == "cmd.exe" {
t.Fatalf("Windows browser command uses a shell: %v", cmd.Args)
}
want := []string{"rundll32.exe", "url.dll,FileProtocolHandler", url}
if !slices.Equal(cmd.Args, want) {
t.Fatalf("createCommandForOS() args = %v, want %v", cmd.Args, want)
}
}
6 changes: 6 additions & 0 deletions internal/adapters/nylas/client_mock_methods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ func TestMockClient_Messages(t *testing.T) {
require.NoError(t, err)
assert.True(t, mock.DeleteMessageCalled)
})

t.Run("DeleteMessagePermanently", func(t *testing.T) {
err := mock.DeleteMessagePermanently(ctx, "grant-123", "msg-789")
require.NoError(t, err)
assert.True(t, mock.DeleteMessagePermanentlyCalled)
})
}

func TestMockClient_Threads(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions internal/adapters/nylas/demo_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ func (d *DemoClient) DeleteMessage(ctx context.Context, grantID, messageID strin
return nil
}

// DeleteMessagePermanently simulates permanently deleting a message.
func (d *DemoClient) DeleteMessagePermanently(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))
Expand Down
37 changes: 29 additions & 8 deletions internal/adapters/nylas/folders.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,43 @@ type folderResponse struct {
Attributes []string `json:"attributes"`
}

type folderListResponse struct {
Data []folderResponse `json:"data"`
NextCursor string `json:"next_cursor,omitempty"`
}

// GetFolders retrieves all folders for a grant.
func (c *HTTPClient) GetFolders(ctx context.Context, grantID string) ([]domain.Folder, error) {
if err := validateRequired("grant ID", grantID); err != nil {
return nil, err
}

queryURL := fmt.Sprintf("%s/v3/grants/%s/folders", c.baseURL, url.PathEscape(grantID))
baseURL := fmt.Sprintf("%s/v3/grants/%s/folders", c.baseURL, url.PathEscape(grantID))
pageToken := ""
folders := make([]domain.Folder, 0)

var result struct {
Data []folderResponse `json:"data"`
}
if err := c.doGet(ctx, queryURL, &result); err != nil {
return nil, err
}
for {
queryBuilder := NewQueryBuilder()
if pageToken != "" {
queryBuilder.Add("page_token", pageToken)
}
queryURL := queryBuilder.BuildURL(baseURL)

var result folderListResponse
if err := c.doGet(ctx, queryURL, &result); err != nil {
return nil, err
}

folders = append(folders, convertFolders(result.Data)...)

return convertFolders(result.Data), nil
if result.NextCursor == "" {
return folders, nil
}
if result.NextCursor == pageToken {
return nil, fmt.Errorf("failed to paginate folders: repeated cursor %q", result.NextCursor)
}
pageToken = result.NextCursor
}
}

// GetFolder retrieves a single folder by ID.
Expand Down
60 changes: 60 additions & 0 deletions internal/adapters/nylas/folders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,66 @@ func TestHTTPClient_GetFolders(t *testing.T) {
assert.Equal(t, "Projects", folders[2].Name)
}

func TestHTTPClient_GetFolders_Pagination(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
assert.Equal(t, "/v3/grants/grant-123/folders", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "application/json")

if calls == 1 {
assert.Empty(t, r.URL.Query().Get("page_token"))
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{{"id": "custom-trash", "name": "Trash"}},
"next_cursor": "cursor-2",
})
return
}

assert.Equal(t, "cursor-2", r.URL.Query().Get("page_token"))
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{{"id": "system-trash", "name": "Deleted Items", "attributes": []string{"\\Trash"}}},
})
}))
defer server.Close()

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

folders, err := client.GetFolders(context.Background(), "grant-123")

require.NoError(t, err)
require.Len(t, folders, 2)
assert.Equal(t, "custom-trash", folders[0].ID)
assert.Equal(t, "system-trash", folders[1].ID)
assert.Equal(t, 2, calls)
}

func TestHTTPClient_GetFolders_RepeatedCursorFails(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{{"id": "folder-1"}},
"next_cursor": "stuck-cursor",
})
}))
defer server.Close()

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

_, err := client.GetFolders(context.Background(), "grant-123")

require.Error(t, err)
assert.Contains(t, err.Error(), "repeated cursor")
assert.Equal(t, 2, calls)
}

func TestHTTPClient_GetFolder(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v3/grants/grant-123/folders/folder-456", r.URL.Path)
Expand Down
12 changes: 12 additions & 0 deletions internal/adapters/nylas/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,19 @@ func (c *HTTPClient) UpdateMessage(ctx context.Context, grantID, messageID strin

// DeleteMessage deletes a message (moves to trash).
func (c *HTTPClient) DeleteMessage(ctx context.Context, grantID, messageID string) error {
return c.deleteMessage(ctx, grantID, messageID, false)
}

// DeleteMessagePermanently irreversibly deletes a message.
func (c *HTTPClient) DeleteMessagePermanently(ctx context.Context, grantID, messageID string) error {
return c.deleteMessage(ctx, grantID, messageID, true)
}

func (c *HTTPClient) deleteMessage(ctx context.Context, grantID, messageID string, hardDelete bool) error {
queryURL := fmt.Sprintf("%s/v3/grants/%s/messages/%s", c.baseURL, url.PathEscape(grantID), url.PathEscape(messageID))
if hardDelete {
queryURL += "?hard_delete=true"
}
return c.doDelete(ctx, queryURL)
}

Expand Down
21 changes: 20 additions & 1 deletion internal/adapters/nylas/messages_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ func TestHTTPClient_DeleteMessage(t *testing.T) {
name string
grantID string
messageID string
permanent bool
statusCode int
wantErr bool
}{
Expand All @@ -292,6 +293,14 @@ func TestHTTPClient_DeleteMessage(t *testing.T) {
statusCode: http.StatusNoContent,
wantErr: false,
},
{
name: "permanently deletes with hard_delete",
grantID: "grant-123",
messageID: "msg-hard-delete",
permanent: true,
statusCode: http.StatusOK,
wantErr: false,
},
{
name: "returns error for not found",
grantID: "grant-123",
Expand All @@ -307,6 +316,11 @@ func TestHTTPClient_DeleteMessage(t *testing.T) {
assert.Equal(t, "DELETE", r.Method)
expectedPath := "/v3/grants/" + tt.grantID + "/messages/" + tt.messageID
assert.Equal(t, expectedPath, r.URL.Path)
if tt.permanent {
assert.Equal(t, "true", r.URL.Query().Get("hard_delete"))
} else {
assert.Empty(t, r.URL.Query().Get("hard_delete"))
}

w.WriteHeader(tt.statusCode)
if tt.statusCode >= 400 {
Expand All @@ -322,7 +336,12 @@ func TestHTTPClient_DeleteMessage(t *testing.T) {
client.SetBaseURL(server.URL)

ctx := context.Background()
err := client.DeleteMessage(ctx, tt.grantID, tt.messageID)
var err error
if tt.permanent {
err = client.DeleteMessagePermanently(ctx, tt.grantID, tt.messageID)
} else {
err = client.DeleteMessage(ctx, tt.grantID, tt.messageID)
}

if tt.wantErr {
assert.Error(t, err)
Expand Down
6 changes: 6 additions & 0 deletions internal/adapters/nylas/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ type MockClient struct {
DeleteSignatureCalled bool
UpdateMessageCalled bool
DeleteMessageCalled bool

DeleteMessagePermanentlyCalled bool

GetThreadsCalled bool
GetThreadCalled bool
UpdateThreadCalled bool
Expand Down Expand Up @@ -110,6 +113,9 @@ type MockClient struct {
DeleteSignatureFunc func(ctx context.Context, grantID, signatureID string) error
UpdateMessageFunc func(ctx context.Context, grantID, messageID string, req *domain.UpdateMessageRequest) (*domain.Message, error)
DeleteMessageFunc func(ctx context.Context, grantID, messageID string) error

DeleteMessagePermanentlyFunc 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)
Expand Down
11 changes: 11 additions & 0 deletions internal/adapters/nylas/mock_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,17 @@ func (m *MockClient) DeleteMessage(ctx context.Context, grantID, messageID strin
return nil
}

// DeleteMessagePermanently irreversibly deletes a message.
func (m *MockClient) DeleteMessagePermanently(ctx context.Context, grantID, messageID string) error {
m.DeleteMessagePermanentlyCalled = true
m.LastGrantID = grantID
m.LastMessageID = messageID
if m.DeleteMessagePermanentlyFunc != nil {
return m.DeleteMessagePermanentlyFunc(ctx, grantID, messageID)
}
return nil
}

// ListScheduledMessages retrieves scheduled messages.
func (m *MockClient) ListScheduledMessages(ctx context.Context, grantID string) ([]domain.ScheduledMessage, error) {
m.LastGrantID = grantID
Expand Down
Loading
Loading