From 3690fed077e6653112d9852b7979aa556deaed36 Mon Sep 17 00:00:00 2001 From: Qasim Date: Mon, 17 Aug 2026 16:11:40 -0400 Subject: [PATCH 1/3] feat(email): add subscription cleanup commands (TW-6481) --- docs/COMMANDS.md | 7 + docs/commands/email.md | 59 ++ internal/adapters/browser/browser.go | 10 +- internal/adapters/browser/browser_test.go | 16 + .../nylas/client_mock_methods_test.go | 6 + internal/adapters/nylas/demo_messages.go | 5 + internal/adapters/nylas/messages.go | 12 + .../adapters/nylas/messages_update_test.go | 21 +- internal/adapters/nylas/mock_client.go | 6 + internal/adapters/nylas/mock_messages.go | 11 + internal/cli/email/email.go | 1 + internal/cli/email/email_basic_test.go | 2 +- internal/cli/email/list.go | 18 +- internal/cli/email/subscriptions.go | 308 ++++++++++ internal/cli/email/subscriptions_cleanup.go | 206 +++++++ internal/cli/email/subscriptions_test.go | 558 ++++++++++++++++++ .../cli/email/subscriptions_unsubscribe.go | 539 +++++++++++++++++ .../cli/integration/email_list_read_test.go | 163 +++++ internal/cli/notetaker/list.go | 8 +- internal/ports/messages.go | 3 + 20 files changed, 1949 insertions(+), 10 deletions(-) create mode 100644 internal/cli/email/subscriptions.go create mode 100644 internal/cli/email/subscriptions_cleanup.go create mode 100644 internal/cli/email/subscriptions_test.go create mode 100644 internal/cli/email/subscriptions_unsubscribe.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 8597449..4874536 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -199,6 +199,13 @@ All demo commands mirror real CLI structure: `nylas demo ` ```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 # Read email nylas email read --raw # Show raw body without HTML nylas email read --mime # Show raw RFC822/MIME format diff --git a/docs/commands/email.md b/docs/commands/email.md index d37518b..b8ef88a 100644 --- a/docs/commands/email.md +++ b/docs/commands/email.md @@ -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 diff --git a/internal/adapters/browser/browser.go b/internal/adapters/browser/browser.go index 7a70264..e81e301 100644 --- a/internal/adapters/browser/browser.go +++ b/internal/adapters/browser/browser.go @@ -23,7 +23,11 @@ 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) @@ -31,8 +35,8 @@ func createCommand(url string) *exec.Cmd { // 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) diff --git a/internal/adapters/browser/browser_test.go b/internal/adapters/browser/browser_test.go index 64f296f..3745841 100644 --- a/internal/adapters/browser/browser_test.go +++ b/internal/adapters/browser/browser_test.go @@ -1,7 +1,9 @@ package browser import ( + "path/filepath" "slices" + "strings" "testing" ) @@ -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) + } +} diff --git a/internal/adapters/nylas/client_mock_methods_test.go b/internal/adapters/nylas/client_mock_methods_test.go index 9891515..3bd36a5 100644 --- a/internal/adapters/nylas/client_mock_methods_test.go +++ b/internal/adapters/nylas/client_mock_methods_test.go @@ -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) { diff --git a/internal/adapters/nylas/demo_messages.go b/internal/adapters/nylas/demo_messages.go index fe8e086..0f63209 100644 --- a/internal/adapters/nylas/demo_messages.go +++ b/internal/adapters/nylas/demo_messages.go @@ -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)) diff --git a/internal/adapters/nylas/messages.go b/internal/adapters/nylas/messages.go index e005b5e..3c3e560 100644 --- a/internal/adapters/nylas/messages.go +++ b/internal/adapters/nylas/messages.go @@ -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) } diff --git a/internal/adapters/nylas/messages_update_test.go b/internal/adapters/nylas/messages_update_test.go index 0f7b20f..35cf173 100644 --- a/internal/adapters/nylas/messages_update_test.go +++ b/internal/adapters/nylas/messages_update_test.go @@ -275,6 +275,7 @@ func TestHTTPClient_DeleteMessage(t *testing.T) { name string grantID string messageID string + permanent bool statusCode int wantErr bool }{ @@ -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", @@ -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 { @@ -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) diff --git a/internal/adapters/nylas/mock_client.go b/internal/adapters/nylas/mock_client.go index 2003dfa..51c5bfb 100644 --- a/internal/adapters/nylas/mock_client.go +++ b/internal/adapters/nylas/mock_client.go @@ -31,6 +31,9 @@ type MockClient struct { DeleteSignatureCalled bool UpdateMessageCalled bool DeleteMessageCalled bool + + DeleteMessagePermanentlyCalled bool + GetThreadsCalled bool GetThreadCalled bool UpdateThreadCalled bool @@ -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) diff --git a/internal/adapters/nylas/mock_messages.go b/internal/adapters/nylas/mock_messages.go index 643fd90..07a16f5 100644 --- a/internal/adapters/nylas/mock_messages.go +++ b/internal/adapters/nylas/mock_messages.go @@ -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 diff --git a/internal/cli/email/email.go b/internal/cli/email/email.go index 0afdd79..43f98cb 100644 --- a/internal/cli/email/email.go +++ b/internal/cli/email/email.go @@ -16,6 +16,7 @@ API reference: https://developer.nylas.com/docs/v3/email/`, } cmd.AddCommand(newListCmd()) + cmd.AddCommand(newSubscriptionsCmd()) cmd.AddCommand(newReadCmd()) cmd.AddCommand(newSendCmd()) cmd.AddCommand(newReplyCmd()) diff --git a/internal/cli/email/email_basic_test.go b/internal/cli/email/email_basic_test.go index f0a3da6..d26b160 100644 --- a/internal/cli/email/email_basic_test.go +++ b/internal/cli/email/email_basic_test.go @@ -30,7 +30,7 @@ func TestNewEmailCmd(t *testing.T) { }) t.Run("has_required_subcommands", func(t *testing.T) { - expectedCmds := []string{"list", "read", "send", "search", "mark", "delete", "folders", "threads", "drafts", "signatures"} + expectedCmds := []string{"list", "subscriptions", "read", "send", "search", "mark", "delete", "folders", "threads", "drafts", "signatures"} cmdMap := make(map[string]bool) for _, sub := range cmd.Commands() { diff --git a/internal/cli/email/list.go b/internal/cli/email/list.go index 8d6ef4e..b4ae200 100644 --- a/internal/cli/email/list.go +++ b/internal/cli/email/list.go @@ -184,8 +184,10 @@ func resolveFolderName(ctx context.Context, client ports.NylasClient, grantID, f // Find matching aliases for the search name var searchAliases []string + systemName := searchName for key, aliases := range nameAliases { if key == searchName || slices.Contains(aliases, searchName) { + systemName = key searchAliases = aliases break } @@ -194,7 +196,21 @@ func resolveFolderName(ctx context.Context, client ports.NylasClient, grantID, f searchAliases = []string{searchName} } - // Search for matching folder + // Prefer provider metadata so localized folders win over custom folders + // whose display name happens to be an English system-folder alias. + for _, f := range folders { + if strings.EqualFold(strings.TrimSpace(f.SystemFolder), systemName) { + return f.ID, nil + } + for _, attribute := range f.Attributes { + attribute = strings.ToLower(strings.TrimLeft(strings.TrimSpace(attribute), "\\")) + if attribute == systemName || slices.Contains(searchAliases, attribute) { + return f.ID, nil + } + } + } + + // Fall back to display names for providers that omit system metadata. for _, f := range folders { folderNameLower := strings.ToLower(f.Name) for _, alias := range searchAliases { diff --git a/internal/cli/email/subscriptions.go b/internal/cli/email/subscriptions.go new file mode 100644 index 0000000..057034e --- /dev/null +++ b/internal/cli/email/subscriptions.go @@ -0,0 +1,308 @@ +package email + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + "unicode" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +const ( + defaultSubscriptionScanLimit = 1000 + maxSubscriptionScanLimit = 10000 + maxSubscriptionScanAge = 365 * 24 * time.Hour + maxSubscriptionHeaderBytes = 8192 +) + +type subscriptionListOptions struct { + limit int + since time.Duration + allFolders bool + folder string +} + +type emailSubscription struct { + Sender string `json:"-"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + ListID string `json:"list_id,omitempty"` + List string `json:"-"` + Messages int `json:"messages"` + LastSeen time.Time `json:"last_seen"` + LastSeenAgo string `json:"-"` + Method string `json:"method"` + actionTarget string + actionKey string + actionSeen time.Time + actionUnsafe bool + messageIDs []string +} + +func (s emailSubscription) QuietField() string { + if s.Email != "" { + return s.Email + } + return s.ListID +} + +func newSubscriptionsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "subscriptions", + Aliases: []string{"subs"}, + Short: "Discover email subscriptions", + Long: `Discover mailing-list subscriptions from the active account. + +Subscriptions are detected from standard List-Unsubscribe headers. Discovery is +read-only, omits postable discussion lists, and never follows or prints +unsubscribe links.`, + Args: cobra.NoArgs, + } + + cmd.AddCommand(newSubscriptionsListCmd(), newSubscriptionsUnsubscribeCmd(), newSubscriptionsCleanupCmd()) + return cmd +} + +func newSubscriptionsListCmd() *cobra.Command { + var limit int + var since string + var allFolders bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List detected email subscriptions", + Long: `List subscriptions detected in recent messages for the active account. + +By default, the last 90 days of inbox messages are scanned. Use --all-folders +to include archived and automatically filed messages.`, + Example: ` # List subscriptions for the active account + nylas email subscriptions list + + # Scan a longer period and include archived mail + nylas email subscriptions list --since 180d --all-folders + + # Produce machine-readable output + nylas email subscriptions list --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + opts, err := parseSubscriptionListOptions(limit, since, allFolders) + if err != nil { + return err + } + + _, err = common.WithClient(nil, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + subscriptions, fetchErr := fetchEmailSubscriptions(ctx, cmd, client, grantID, opts, time.Now()) + if fetchErr != nil { + return struct{}{}, common.WrapFetchError("subscriptions", fetchErr) + } + + if len(subscriptions) == 0 && !common.IsStructuredOutput(cmd) { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No subscriptions found.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Try a longer --since period or --all-folders.") + return struct{}{}, nil + } + + return struct{}{}, common.WriteListWithColumns(cmd, subscriptions, subscriptionColumns()) + }) + return err + }, + } + + cmd.Flags().IntVarP(&limit, "limit", "n", defaultSubscriptionScanLimit, "Maximum messages to scan") + cmd.Flags().StringVar(&since, "since", "90d", "Only scan messages received within this duration (for example, 30d or 12w)") + cmd.Flags().BoolVar(&allFolders, "all-folders", false, "Scan all folders instead of only the inbox") + + return cmd +} + +func parseSubscriptionListOptions(limit int, since string, allFolders bool) (subscriptionListOptions, error) { + if limit < 1 || limit > maxSubscriptionScanLimit { + return subscriptionListOptions{}, common.NewInputError("--limit must be between 1 and 10000") + } + if len(since) > 16 { + return subscriptionListOptions{}, common.NewInputError("invalid --since duration") + } + + duration, err := common.ParseDuration(since) + if err != nil || duration < time.Second || duration > maxSubscriptionScanAge { + return subscriptionListOptions{}, common.NewInputError("--since must be a duration between 1 second and 365 days") + } + + return subscriptionListOptions{limit: limit, since: duration, allFolders: allFolders}, nil +} + +func fetchEmailSubscriptions(ctx context.Context, cmd *cobra.Command, client ports.NylasClient, grantID string, opts subscriptionListOptions, now time.Time) ([]emailSubscription, error) { + grant, err := client.GetGrant(ctx, grantID) + if err != nil { + return nil, err + } + if grant == nil || (grant.Provider != domain.ProviderGoogle && grant.Provider != domain.ProviderMicrosoft) { + return nil, common.NewUserError( + "Email subscription discovery is unavailable for this provider", + "Use a Google or Microsoft grant; other providers do not expose List-Unsubscribe headers through Nylas", + ) + } + + params := &domain.MessageQueryParams{ + Limit: opts.limit, + ReceivedAfter: now.Add(-opts.since).Unix(), + Fields: "include_headers", + } + applyListFolderFilter(ctx, cmd.ErrOrStderr(), client, grantID, params, opts.folder, opts.allFolders) + + messages, err := fetchMessages(ctx, client, grantID, params, opts.limit) + if err != nil { + return nil, err + } + return summarizeEmailSubscriptions(messages), nil +} + +func summarizeEmailSubscriptions(messages []domain.Message) []emailSubscription { + byKey := make(map[string]emailSubscription) + for _, message := range messages { + if isPostableDiscussionList(message.Headers) { + continue + } + unsubscribe := messageHeader(message.Headers, "List-Unsubscribe") + if unsubscribe == "" { + continue + } + + listID := parseListID(messageHeader(message.Headers, "List-ID")) + name, email := "", "" + if len(message.From) > 0 { + name = safeSubscriptionText(message.From[0].Name, 100) + email = safeSubscriptionText(message.From[0].Email, 254) + } + + key := "list:" + strings.ToLower(listID) + if listID == "" { + key = "sender:" + strings.ToLower(email) + } + if key == "sender:" { + continue + } + + method, target := subscriptionAction(message.Headers) + actionKey := subscriptionActionKey(method, target) + current, exists := byKey[key] + current.Messages++ + if message.ID != "" { + current.messageIDs = append(current.messageIDs, message.ID) + } + if actionKey != "" { + switch { + case current.actionKey == "": + current.actionKey = actionKey + case current.actionKey != actionKey: + current.actionUnsafe = true + } + if !current.actionUnsafe && (current.actionSeen.IsZero() || message.Date.After(current.actionSeen)) { + current.Method = method + current.actionTarget = target + current.actionSeen = message.Date + } + if current.actionKey == actionKey && (current.Method == "Web" || method == "Web") { + current.Method = "Web" + } + } else if !exists { + current.Method = "Unsupported" + } + if !exists || message.Date.After(current.LastSeen) { + current.Name = name + current.Email = email + current.ListID = listID + current.LastSeen = message.Date + } + if current.actionUnsafe { + current.Method = "Unsupported" + current.actionTarget = "" + } + byKey[key] = current + } + + subscriptions := make([]emailSubscription, 0, len(byKey)) + for _, subscription := range byKey { + subscription.Sender = subscription.Email + if subscription.Name != "" && subscription.Email != "" { + subscription.Sender = fmt.Sprintf("%s <%s>", subscription.Name, subscription.Email) + } else if subscription.Name != "" { + subscription.Sender = subscription.Name + } + subscription.List = subscription.ListID + if subscription.List == "" { + subscription.List = "—" + } + subscription.LastSeenAgo = common.FormatTimeAgo(subscription.LastSeen) + subscriptions = append(subscriptions, subscription) + } + + sort.Slice(subscriptions, func(i, j int) bool { + if subscriptions[i].Messages != subscriptions[j].Messages { + return subscriptions[i].Messages > subscriptions[j].Messages + } + if !subscriptions[i].LastSeen.Equal(subscriptions[j].LastSeen) { + return subscriptions[i].LastSeen.After(subscriptions[j].LastSeen) + } + return subscriptions[i].Sender < subscriptions[j].Sender + }) + return subscriptions +} + +func isPostableDiscussionList(headers []domain.Header) bool { + listPost := messageHeader(headers, "List-Post") + return listPost != "" && !strings.EqualFold(strings.TrimSpace(listPost), "NO") +} + +func subscriptionColumns() []ports.Column { + return []ports.Column{ + {Header: "SENDER", Field: "Sender", Width: 40}, + {Header: "LIST", Field: "List", Width: 32}, + {Header: "MESSAGES", Field: "Messages", Width: 0}, + {Header: "LAST SEEN", Field: "LastSeenAgo", Width: 16}, + {Header: "METHOD", Field: "Method", Width: 12}, + } +} + +func messageHeader(headers []domain.Header, name string) string { + for _, header := range headers { + if strings.EqualFold(strings.TrimSpace(header.Name), name) { + value := strings.TrimSpace(header.Value) + if len(value) > maxSubscriptionHeaderBytes { + value = value[:maxSubscriptionHeaderBytes] + } + return value + } + } + return "" +} + +func parseListID(value string) string { + if open := strings.LastIndex(value, "<"); open >= 0 { + if close := strings.Index(value[open+1:], ">"); close >= 0 { + value = value[open+1 : open+1+close] + } + } + return safeSubscriptionText(strings.Trim(value, "<> \t"), 160) +} + +func safeSubscriptionText(value string, maxRunes int) string { + value = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return -1 + } + return r + }, strings.TrimSpace(value)) + + runes := []rune(value) + if len(runes) > maxRunes { + runes = runes[:maxRunes] + } + return strings.TrimSpace(string(runes)) +} diff --git a/internal/cli/email/subscriptions_cleanup.go b/internal/cli/email/subscriptions_cleanup.go new file mode 100644 index 0000000..f35d777 --- /dev/null +++ b/internal/cli/email/subscriptions_cleanup.go @@ -0,0 +1,206 @@ +package email + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newSubscriptionsCleanupCmd() *cobra.Command { + var limit int + var since string + var allFolders bool + var permanent bool + var dryRun bool + var yes bool + + cmd := &cobra.Command{ + Use: "cleanup ...", + Short: "Delete messages from selected subscriptions", + Long: `Delete messages from selected subscriptions in the active account. + +By default, matching inbox messages move to Trash. --permanent irreversibly +deletes matching messages from Trash; combine it with --all-folders only when +you intend to delete matching mail everywhere. This command never unsubscribes.`, + Example: ` # Preview moving matching inbox messages to Trash + nylas email subscriptions cleanup news@example.com --dry-run + + # Move matching messages from all folders to Trash + nylas email subscriptions cleanup news@example.com --all-folders + + # Permanently remove matching messages already in Trash + nylas email subscriptions cleanup news@example.com --permanent + + # Permanently remove matching messages from every folder + nylas email subscriptions cleanup news@example.com --permanent --all-folders`, + Args: func(_ *cobra.Command, args []string) error { + _, err := parseSubscriptionSelectors(args) + return err + }, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseSubscriptionListOptions(limit, since, allFolders) + if err != nil { + return err + } + if permanent && !allFolders { + opts.folder = "TRASH" + } + selectors, err := parseSubscriptionSelectors(args) + if err != nil { + return err + } + if !dryRun && common.IsStructuredOutput(cmd) && !yes { + return common.NewInputError("structured cleanup output requires --yes or --dry-run") + } + + _, err = common.WithClient(nil, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + subscriptions, fetchErr := fetchEmailSubscriptions(ctx, cmd, client, grantID, opts, time.Now()) + if fetchErr != nil { + return struct{}{}, common.WrapFetchError("subscriptions", fetchErr) + } + selected, missing, selectErr := selectEmailSubscriptions(subscriptions, selectors, false) + if selectErr != nil { + return struct{}{}, selectErr + } + scope := "Inbox" + advice := "Increase --since or --limit, or use --all-folders, if needed." + if permanent { + scope = "Trash" + } + if allFolders { + scope = "all folders" + advice = "Increase --since or --limit if needed." + } + for _, selector := range missing { + _, _ = fmt.Fprintf( + cmd.ErrOrStderr(), + "Warning: subscription %q was not found in %s with --since %s and --limit %d; it may already be clean. %s\n", + selector, scope, since, limit, advice, + ) + } + if len(selected) == 0 { + if common.IsStructuredOutput(cmd) { + return struct{}{}, common.WriteListWithColumns(cmd, []subscriptionActionResult{}, subscriptionActionColumns()) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No matching messages found. Cleanup may already be complete.") + return struct{}{}, nil + } + + if dryRun { + return struct{}{}, common.WriteListWithColumns(cmd, plannedCleanupResults(selected, permanent), subscriptionActionColumns()) + } + + if !yes { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Selected subscriptions:") + if writeErr := common.WriteListWithColumns(cmd, selected, subscriptionColumns()); writeErr != nil { + return struct{}{}, writeErr + } + prompt := fmt.Sprintf("Move %d matching message(s) to Trash?", selectedMessageCount(selected)) + if permanent { + scope := "from Trash" + if allFolders { + scope = "from all folders" + } + prompt = fmt.Sprintf("Permanently delete %d matching message(s) %s? This cannot be undone.", selectedMessageCount(selected), scope) + } + if !common.Confirm(prompt, false) { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Cancelled.") + return struct{}{}, nil + } + } + + counter := common.NewCounter("Cleaning messages") + results, cleanupErr := executeSubscriptionCleanup(context.WithoutCancel(ctx), client, grantID, selected, permanent, counter.Increment) + counter.Finish() + if writeErr := common.WriteListWithColumns(cmd, results, subscriptionActionColumns()); writeErr != nil { + return struct{}{}, writeErr + } + if cleanupErr != nil { + return struct{}{}, subscriptionCleanupError(cleanupErr, permanent) + } + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().IntVarP(&limit, "limit", "n", defaultSubscriptionScanLimit, "Maximum messages to scan") + cmd.Flags().StringVar(&since, "since", "90d", "Only scan messages received within this duration (for example, 30d or 12w)") + cmd.Flags().BoolVar(&allFolders, "all-folders", false, "Scan all folders instead of only the inbox (or Trash with --permanent)") + cmd.Flags().BoolVar(&permanent, "permanent", false, "Permanently delete matches; scans Trash unless --all-folders is set") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview cleanup actions without making changes") + common.AddYesFlag(cmd, &yes) + + return cmd +} + +func plannedCleanupResults(subscriptions []emailSubscription, permanent bool) []subscriptionActionResult { + results := make([]subscriptionActionResult, 0, len(subscriptions)) + for _, subscription := range subscriptions { + status := fmt.Sprintf("Would move %d to Trash", len(subscription.messageIDs)) + if permanent { + status = fmt.Sprintf("Would permanently delete %d", len(subscription.messageIDs)) + } + results = append(results, newSubscriptionActionResult(subscription, status)) + } + return results +} + +func executeSubscriptionCleanup( + ctx context.Context, + client ports.NylasClient, + grantID string, + subscriptions []emailSubscription, + permanent bool, + progress func(), +) ([]subscriptionActionResult, error) { + deleteMessage := client.DeleteMessage + statusFormat := "Moved %d/%d to Trash" + if permanent { + deleteMessage = client.DeleteMessagePermanently + statusFormat = "Permanently deleted %d/%d" + } + + results := make([]subscriptionActionResult, 0, len(subscriptions)) + for _, subscription := range subscriptions { + deleted := 0 + for _, messageID := range subscription.messageIDs { + if messageID == "" || len(messageID) > 1024 { + results = append(results, newSubscriptionActionResult(subscription, fmt.Sprintf(statusFormat+"; stopped after an error", deleted, len(subscription.messageIDs)))) + return results, fmt.Errorf("invalid message identifier") + } + err := deleteMessage(ctx, grantID, messageID) + if err != nil && !apiErrorHasStatus(err, 404) { + results = append(results, newSubscriptionActionResult(subscription, fmt.Sprintf(statusFormat+"; stopped after an error", deleted, len(subscription.messageIDs)))) + return results, err + } + deleted++ + if progress != nil { + progress() + } + } + results = append(results, newSubscriptionActionResult(subscription, fmt.Sprintf(statusFormat, deleted, len(subscription.messageIDs)))) + } + return results, nil +} + +func subscriptionCleanupError(err error, permanent bool) error { + if permanent && (apiErrorHasStatus(err, 400) || apiErrorHasStatus(err, 403)) { + return common.NewUserError( + "Permanent email cleanup is not enabled for this grant", + "Enable hard delete in Nylas Dashboard > Customizations > API, verify the provider write scope, then re-authenticate the grant", + ) + } + return common.WrapDeleteError("subscription messages", err) +} + +func apiErrorHasStatus(err error, status int) bool { + var apiErr *domain.APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == status +} diff --git a/internal/cli/email/subscriptions_test.go b/internal/cli/email/subscriptions_test.go new file mode 100644 index 0000000..a4c8b7b --- /dev/null +++ b/internal/cli/email/subscriptions_test.go @@ -0,0 +1,558 @@ +package email + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" + "time" + "unicode" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSummarizeEmailSubscriptions(t *testing.T) { + newer := time.Now().Add(-time.Hour) + older := newer.Add(-24 * time.Hour) + messages := []domain.Message{ + { + ID: "new-message", + Date: newer, + From: []domain.EmailParticipant{{Name: "News\n\x1b[31m", Email: "latest@example.com"}}, + Headers: []domain.Header{ + {Name: "list-id", Value: "Example News "}, + {Name: "LIST-UNSUBSCRIBE", Value: ", "}, + {Name: "List-Unsubscribe-Post", Value: "List-Unsubscribe=One-Click"}, + {Name: "Authentication-Results", Value: "mx.example; dkim=pass header.i=@example.com header.s=news"}, + {Name: "DKIM-Signature", Value: "v=1; d=example.com; s=news; h=from:list-unsubscribe:list-unsubscribe-post:to"}, + }, + }, + { + ID: "old-message", + Date: older, + From: []domain.EmailParticipant{{Name: "Old sender", Email: "old@example.com"}}, + Headers: []domain.Header{ + {Name: "List-ID", Value: "news.example.com"}, + {Name: "List-Unsubscribe", Value: ""}, + {Name: "List-Unsubscribe-Post", Value: "List-Unsubscribe=One-Click"}, + {Name: "Authentication-Results", Value: "mx.example; dkim=pass header.d=example.com header.s=news"}, + {Name: "DKIM-Signature", Value: "v=1; d=example.com; s=news; h=from:list-unsubscribe:list-unsubscribe-post:to"}, + }, + }, + { + Date: older, + From: []domain.EmailParticipant{{Name: "Deals", Email: "deals@example.com"}}, + Headers: []domain.Header{ + {Name: "List-Unsubscribe", Value: ""}, + }, + }, + { + Date: newer, + From: []domain.EmailParticipant{{Email: "ordinary@example.com"}}, + Headers: []domain.Header{{Name: "List-ID", Value: "ordinary.example.com"}}, + }, + { + Date: newer, + From: []domain.EmailParticipant{{Name: "'Support' via Limitless", Email: "limitless@nylas.com"}}, + Headers: []domain.Header{ + {Name: "List-ID", Value: ""}, + {Name: "List-Post", Value: ""}, + {Name: "List-Unsubscribe", Value: ""}, + }, + }, + } + + got := summarizeEmailSubscriptions(messages) + require.Len(t, got, 2) + + assert.Equal(t, 2, got[0].Messages) + assert.Equal(t, "news.example.com", got[0].ListID) + assert.Equal(t, "latest@example.com", got[0].Email) + assert.Equal(t, "Web", got[0].Method) + assert.Equal(t, newer, got[0].LastSeen) + assert.ElementsMatch(t, []string{"new-message", "old-message"}, got[0].messageIDs) + assert.Equal(t, "Email", got[1].Method) + assert.Equal(t, "deals@example.com", got[1].QuietField()) + + for _, r := range got[0].Sender { + assert.False(t, unicode.IsControl(r), "sender contains terminal control character") + } + encoded, err := json.Marshal(got) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "token=secret") + assert.NotContains(t, string(encoded), "unsubscribe@example.com") +} + +func TestIsPostableDiscussionList(t *testing.T) { + assert.True(t, isPostableDiscussionList([]domain.Header{{Name: "List-Post", Value: ""}})) + assert.False(t, isPostableDiscussionList([]domain.Header{{Name: "List-Post", Value: " no "}})) + assert.False(t, isPostableDiscussionList(nil)) +} + +func TestSummarizeEmailSubscriptionsKeepsAnnouncementLists(t *testing.T) { + got := summarizeEmailSubscriptions([]domain.Message{{ + Date: time.Now(), + From: []domain.EmailParticipant{{Email: "announcements@example.com"}}, + Headers: []domain.Header{ + {Name: "List-Post", Value: "NO"}, + {Name: "List-Unsubscribe", Value: ""}, + }, + }}) + + require.Len(t, got, 1) + assert.Equal(t, "announcements@example.com", got[0].Email) +} + +func TestSummarizeEmailSubscriptionsRejectsConflictingDestinations(t *testing.T) { + got := summarizeEmailSubscriptions([]domain.Message{ + { + ID: "legitimate", Date: time.Now().Add(-time.Hour), + From: []domain.EmailParticipant{{Email: "news@example.com"}}, + Headers: []domain.Header{{Name: "List-Unsubscribe", Value: ""}}, + }, + { + ID: "spoofed-newer", Date: time.Now(), + From: []domain.EmailParticipant{{Email: "news@example.com"}}, + Headers: []domain.Header{{Name: "List-Unsubscribe", Value: ""}}, + }, + }) + + require.Len(t, got, 1) + assert.Equal(t, "Unsupported", got[0].Method) + assert.Empty(t, got[0].actionTarget) + assert.ElementsMatch(t, []string{"legitimate", "spoofed-newer"}, got[0].messageIDs) +} + +func TestSubscriptionAction(t *testing.T) { + tests := []struct { + name string + unsubscribe string + want string + }{ + {name: "HTTPS opens for review", unsubscribe: "", want: "Web"}, + {name: "web preferred when web and mailto are present", unsubscribe: ", ", want: "Web"}, + {name: "email", unsubscribe: "", want: "Email"}, + {name: "plain HTTP is unsupported", unsubscribe: "", want: "Unsupported"}, + {name: "unsupported", unsubscribe: "unsubscribe.example.com", want: "Unsupported"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := []domain.Header{{Name: "List-Unsubscribe", Value: tt.unsubscribe}} + method, target := subscriptionAction(headers) + assert.Equal(t, tt.want, method) + if tt.want == "Unsupported" { + assert.Empty(t, target) + } else { + assert.NotEmpty(t, target) + } + }) + } +} + +func TestParseAndSelectSubscriptions(t *testing.T) { + selectors, err := parseSubscriptionSelectors([]string{"NEWS@example.com", "digest.example.com"}) + require.NoError(t, err) + assert.Equal(t, "news@example.com", selectors[0].email) + assert.Equal(t, "digest.example.com", selectors[1].list) + unusual, err := parseSubscriptionSelectors([]string{"digest+weekly/list.example.com"}) + require.NoError(t, err) + assert.Equal(t, "digest+weekly/list.example.com", unusual[0].list) + + subscriptions := []emailSubscription{ + {Email: "news@example.com", ListID: "daily.example.com"}, + {Email: "news@example.com", ListID: "weekly.example.com"}, + {Email: "digest@example.com", ListID: "digest.example.com"}, + } + selected, missing, err := selectEmailSubscriptions(subscriptions, selectors, true) + require.NoError(t, err) + assert.Len(t, selected, 3) + assert.Empty(t, missing) + + for _, args := range [][]string{ + nil, + {"bad selector"}, + {"bad@example"}, + {"list:id"}, + {".bad"}, + {"bad."}, + {"bad..id"}, + {strings.Repeat("x", 255)}, + make([]string, maxSubscriptionSelectors+1), + } { + _, err := parseSubscriptionSelectors(args) + assert.Error(t, err, "selectors: %q", args) + } + + _, _, err = selectEmailSubscriptions(subscriptions, []subscriptionSelector{{email: "missing@example.com"}}, true) + assert.Error(t, err) + + selected, missing, err = selectEmailSubscriptions(subscriptions, []subscriptionSelector{{email: "missing@example.com"}, {list: "digest.example.com"}}, false) + require.NoError(t, err) + assert.Equal(t, []emailSubscription{subscriptions[2]}, selected) + assert.Equal(t, []string{"missing@example.com"}, missing) +} + +func TestParseMailtoUnsubscribe(t *testing.T) { + req, err := parseMailtoUnsubscribe("mailto:leave@example.com?subject=unsubscribe&body=Please%20remove%20me") + require.NoError(t, err) + require.Len(t, req.To, 1) + assert.Equal(t, "leave@example.com", req.To[0].Email) + assert.Equal(t, "unsubscribe", req.Subject) + assert.Equal(t, "Please remove me", req.Body) + + for _, target := range []string{ + "mailto:one@example.com,two@example.com", + "mailto:leave@example.com?cc=other@example.com", + "mailto:leave@example.com?subject=ok%0d%0aBcc:evil@example.com", + "javascript:alert(1)", + } { + _, err := parseMailtoUnsubscribe(target) + assert.Error(t, err, "target: %q", target) + } +} + +func TestValidateHTTPSUnsubscribeTarget(t *testing.T) { + _, err := validateHTTPSUnsubscribeTarget("https://example.com/unsubscribe?token=secret") + require.NoError(t, err) + + for _, target := range []string{ + "http://example.com/unsubscribe", + "https://user:pass@example.com/unsubscribe", + "https://example.com:8443/unsubscribe", + "https://localhost/unsubscribe", + "https://127.0.0.1/unsubscribe", + "https://127.1/unsubscribe", + "https://0x7f.1/unsubscribe", + "https://0177.0.0.1/unsubscribe", + "https://10.0.0.1/unsubscribe", + "https://[::1]/unsubscribe", + "https://example.com./unsubscribe", + } { + _, err := validateHTTPSUnsubscribeTarget(target) + assert.Error(t, err, "target: %q", target) + } + + for _, address := range []string{"1.1.1.1", "2606:4700:4700::1111"} { + assert.True(t, isPublicUnsubscribeIP(netip.MustParseAddr(address)), address) + } + for _, address := range []string{"10.0.0.1", "100.64.0.1", "192.0.2.1", "192.88.99.1", "127.0.0.1", "fc00::1", "fe80::1", "2001::1", "2001:db8::1", "2002::1", "3fff::1"} { + assert.False(t, isPublicUnsubscribeIP(netip.MustParseAddr(address)), address) + } + +} + +func TestExecuteSubscriptionActions(t *testing.T) { + client := nylas.NewMockClient() + + subscriptions := []emailSubscription{ + {Sender: "Web", Email: "web@example.com", Method: "Web", Messages: 1, actionTarget: "https://example.com/web", messageIDs: []string{"m2"}}, + {Sender: "Email", Email: "email@example.com", Method: "Email", Messages: 1, actionTarget: "mailto:leave@example.com?subject=unsubscribe", messageIDs: []string{"m3"}}, + } + var opened []string + results, failures := executeSubscriptionActions(subscriptions, unsubscribeActions{ + openURL: func(target string) error { + opened = append(opened, target) + return nil + }, + }) + + assert.Zero(t, failures) + assert.Len(t, results, 2) + assert.Equal(t, []string{"https://example.com/web", "mailto:leave@example.com?subject=unsubscribe"}, opened) + assert.False(t, client.SendMessageCalled) + assert.False(t, client.DeleteMessageCalled) + assert.False(t, client.DeleteMessagePermanentlyCalled) +} + +func TestExecuteSubscriptionCleanupPermanentlyDeletes(t *testing.T) { + client := nylas.NewMockClient() + var deleted []string + client.DeleteMessagePermanentlyFunc = func(_ context.Context, _, messageID string) error { + deleted = append(deleted, messageID) + return nil + } + results, err := executeSubscriptionCleanup(context.Background(), client, "grant-123", []emailSubscription{{ + Email: "one@example.com", Method: "Web", actionTarget: "https://example.com/one", messageIDs: []string{"m1", "m2"}, + }}, true, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, []string{"m1", "m2"}, deleted) + assert.Contains(t, results[0].Status, "Permanently deleted 2/2") + assert.False(t, client.DeleteMessageCalled) +} + +func TestExecuteSubscriptionActionsFailureDoesNotDelete(t *testing.T) { + client := nylas.NewMockClient() + results, failures := executeSubscriptionActions([]emailSubscription{{ + Email: "one@example.com", Method: "Web", actionTarget: "https://example.com/one", messageIDs: []string{"m1"}, + }}, unsubscribeActions{openURL: func(string) error { return fmt.Errorf("failed") }}) + + assert.Equal(t, 1, failures) + require.Len(t, results, 1) + assert.Equal(t, "opening unsubscribe page failed", results[0].Status) + assert.False(t, client.SendMessageCalled) + assert.False(t, client.DeleteMessageCalled) +} + +func TestExecuteSubscriptionCleanupStopsAndReportsFirstError(t *testing.T) { + client := nylas.NewMockClient() + var attempted []string + client.DeleteMessageFunc = func(_ context.Context, _, messageID string) error { + attempted = append(attempted, messageID) + if messageID == "m2" { + return fmt.Errorf("delete failed") + } + return nil + } + progress := 0 + results, err := executeSubscriptionCleanup(context.Background(), client, "grant-123", []emailSubscription{{ + Email: "one@example.com", Method: "Web", actionTarget: "https://example.com/one", messageIDs: []string{"m1", "m2", "m3"}, + }}, false, func() { progress++ }) + + require.Error(t, err) + require.Len(t, results, 1) + assert.Contains(t, results[0].Status, "Moved 1/3 to Trash; stopped after an error") + assert.Equal(t, []string{"m1", "m2"}, attempted) + assert.Equal(t, 1, progress) +} + +func TestExecuteSubscriptionCleanupTreatsMissingMessagesAsComplete(t *testing.T) { + client := nylas.NewMockClient() + client.DeleteMessagePermanentlyFunc = func(_ context.Context, _, _ string) error { + return &domain.APIError{StatusCode: http.StatusNotFound} + } + + results, err := executeSubscriptionCleanup(context.Background(), client, "grant-123", []emailSubscription{{ + Email: "one@example.com", messageIDs: []string{"already-gone"}, + }}, true, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "Permanently deleted 1/1", results[0].Status) +} + +func TestSubscriptionCleanupErrorGuidesHardDeleteSetup(t *testing.T) { + err := subscriptionCleanupError(&domain.APIError{StatusCode: http.StatusForbidden}, true) + assert.Contains(t, err.Error(), "Permanent email cleanup is not enabled") + + err = subscriptionCleanupError(fmt.Errorf("network failed"), true) + assert.Contains(t, err.Error(), "failed to delete subscription messages") + + err = subscriptionCleanupError(&domain.APIError{StatusCode: http.StatusUnauthorized}, true) + assert.NotContains(t, err.Error(), "Permanent email cleanup is not enabled") +} + +func TestPlannedSubscriptionResultsDoNotExposeTargetsOrAct(t *testing.T) { + subscription := emailSubscription{ + Email: "news@example.com", + Method: "Web", + Messages: 2, + actionTarget: "https://example.com/unsubscribe?token=secret", + messageIDs: []string{"m1", "m2"}, + } + results := plannedSubscriptionResults([]emailSubscription{subscription}) + require.Len(t, results, 1) + assert.Equal(t, "Would open unsubscribe page", results[0].Status) + assert.Equal(t, "example.com", results[0].Destination) + + encoded, err := json.Marshal(results) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "token=secret") + assert.NotContains(t, string(encoded), "messageIDs") + + permanentResults := plannedCleanupResults([]emailSubscription{subscription}, true) + assert.Contains(t, permanentResults[0].Status, "Would permanently delete 2") +} + +func TestMessageHeaderCapsExternalValues(t *testing.T) { + value := messageHeader([]domain.Header{{Name: "List-Unsubscribe", Value: strings.Repeat("x", maxSubscriptionHeaderBytes+1)}}, "list-unsubscribe") + assert.Len(t, value, maxSubscriptionHeaderBytes) +} + +func TestParseSubscriptionListOptions(t *testing.T) { + opts, err := parseSubscriptionListOptions(500, "12w", true) + require.NoError(t, err) + assert.Equal(t, 500, opts.limit) + assert.Equal(t, 12*7*24*time.Hour, opts.since) + assert.True(t, opts.allFolders) + + for _, input := range []struct { + limit int + since string + }{ + {limit: 0, since: "90d"}, + {limit: 10001, since: "90d"}, + {limit: 10, since: "0d"}, + {limit: 10, since: "1ns"}, + {limit: 10, since: "366d"}, + {limit: 10, since: strings.Repeat("1", 17)}, + } { + _, err := parseSubscriptionListOptions(input.limit, input.since, false) + assert.Error(t, err) + } +} + +func TestFetchEmailSubscriptions(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + client := &testListClient{MockClient: nylas.NewMockClient()} + client.GetGrantFunc = func(context.Context, string) (*domain.Grant, error) { + return &domain.Grant{Provider: domain.ProviderGoogle}, nil + } + client.GetFoldersFunc = func(ctx context.Context, grantID string) ([]domain.Folder, error) { + assert.Equal(t, "grant-123", grantID) + return []domain.Folder{{ID: "inbox-id", Name: "Inbox"}}, nil + } + client.getMessagesWithCursorFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) (*domain.MessageListResponse, error) { + assert.Equal(t, "grant-123", grantID) + assert.Equal(t, "include_headers", params.Fields) + assert.Equal(t, now.Add(-90*24*time.Hour).Unix(), params.ReceivedAfter) + assert.Equal(t, []string{"inbox-id"}, params.In) + assert.Equal(t, 200, params.Limit) + return &domain.MessageListResponse{Data: []domain.Message{{ + Date: now, + From: []domain.EmailParticipant{{Email: "news@example.com"}}, + Headers: []domain.Header{{Name: "List-Unsubscribe", Value: ""}}, + }}}, nil + } + + got, err := fetchEmailSubscriptions(context.Background(), newSubscriptionsListCmd(), client, "grant-123", subscriptionListOptions{ + limit: 500, + since: 90 * 24 * time.Hour, + }, now) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "news@example.com", got[0].Email) +} + +func TestFetchEmailSubscriptionsAllFolders(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + client := &testListClient{MockClient: nylas.NewMockClient()} + client.GetGrantFunc = func(context.Context, string) (*domain.Grant, error) { + return &domain.Grant{Provider: domain.ProviderMicrosoft}, nil + } + client.getMessagesWithCursorFunc = func(ctx context.Context, grantID string, params *domain.MessageQueryParams) (*domain.MessageListResponse, error) { + assert.Nil(t, params.In) + assert.Equal(t, 25, params.Limit) + return &domain.MessageListResponse{}, nil + } + + got, err := fetchEmailSubscriptions(context.Background(), newSubscriptionsListCmd(), client, "grant-123", subscriptionListOptions{ + limit: 25, + since: 30 * 24 * time.Hour, + allFolders: true, + }, now) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestFetchEmailSubscriptionsRejectsUnsupportedProvider(t *testing.T) { + client := &testListClient{MockClient: nylas.NewMockClient()} + client.GetGrantFunc = func(context.Context, string) (*domain.Grant, error) { + return &domain.Grant{Provider: domain.ProviderIMAP}, nil + } + + _, err := fetchEmailSubscriptions(context.Background(), newSubscriptionsListCmd(), client, "grant-123", subscriptionListOptions{ + limit: 25, + since: 30 * 24 * time.Hour, + }, time.Now()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unavailable for this provider") + assert.False(t, client.GetMessagesCalled) +} + +func TestSubscriptionsCommand(t *testing.T) { + cmd := newSubscriptionsCmd() + assert.Equal(t, "subscriptions", cmd.Use) + assert.Contains(t, cmd.Aliases, "subs") + + list, _, err := cmd.Find([]string{"list"}) + require.NoError(t, err) + assert.Equal(t, "list", list.Use) + assert.NotNil(t, list.Flags().Lookup("limit")) + assert.Equal(t, "1000", list.Flags().Lookup("limit").DefValue) + assert.NotNil(t, list.Flags().Lookup("since")) + assert.NotContains(t, list.Use, "grant") + assert.Error(t, list.Args(list, []string{"grant-123"})) + + unsubscribe, _, err := cmd.Find([]string{"unsubscribe"}) + require.NoError(t, err) + assert.Equal(t, "unsubscribe ...", unsubscribe.Use) + assert.NotContains(t, unsubscribe.Use, "grant") + for _, flag := range []string{"limit", "since", "all-folders", "dry-run", "yes"} { + assert.NotNil(t, unsubscribe.Flags().Lookup(flag), flag) + } + assert.Nil(t, unsubscribe.Flags().Lookup("delete-emails")) + assert.Nil(t, unsubscribe.Flags().Lookup("permanent")) + assert.Error(t, unsubscribe.Args(unsubscribe, nil)) + assert.NoError(t, unsubscribe.Args(unsubscribe, []string{"news@example.com"})) + + cleanup, _, err := cmd.Find([]string{"cleanup"}) + require.NoError(t, err) + assert.Equal(t, "cleanup ...", cleanup.Use) + for _, flag := range []string{"limit", "since", "all-folders", "permanent", "dry-run", "yes"} { + assert.NotNil(t, cleanup.Flags().Lookup(flag), flag) + } +} + +func TestSubscriptionsCleanupCommandPermanentlyDeletesWithoutUnsubscribing(t *testing.T) { + var messageGets, deletes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test": + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": "grant-test", "provider": "google"}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/folders": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{ + {"id": "custom-trash", "name": "Trash"}, + {"id": "trash-id", "name": "Papierkorb", "attributes": []string{"\\Trash"}}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/messages": + messageGets++ + assert.Equal(t, "trash-id", r.URL.Query().Get("in")) + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{ + "id": "m1", "date": time.Now().Unix(), + "from": []map[string]string{{"email": "news@example.com"}}, + "headers": []map[string]string{{"name": "List-Unsubscribe", "value": ""}}, + }}}) + case r.Method == http.MethodDelete && r.URL.Path == "/v3/grants/grant-test/messages/m1": + deletes++ + assert.Equal(t, "true", r.URL.Query().Get("hard_delete")) + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected %s request to %s", r.Method, r.URL.String()) + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer server.Close() + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("NYLAS_API_KEY", "test-api-key") + t.Setenv("NYLAS_GRANT_ID", "grant-test") + t.Setenv("NYLAS_API_BASE_URL", server.URL) + + root := &cobra.Command{Use: "test", SilenceErrors: true, SilenceUsage: true} + common.AddOutputFlags(root) + root.AddCommand(newSubscriptionsCmd()) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"subscriptions", "cleanup", "news@example.com", "missing@example.com", "--permanent", "--yes", "--json"}) + + require.NoError(t, root.Execute(), stderr.String()) + assert.Equal(t, 1, messageGets) + assert.Equal(t, 1, deletes) + assert.Contains(t, stdout.String(), "Permanently deleted 1/1") + assert.Contains(t, stderr.String(), `subscription "missing@example.com" was not found in Trash with --since 90d and --limit 1000`) +} diff --git a/internal/cli/email/subscriptions_unsubscribe.go b/internal/cli/email/subscriptions_unsubscribe.go new file mode 100644 index 0000000..89595c6 --- /dev/null +++ b/internal/cli/email/subscriptions_unsubscribe.go @@ -0,0 +1,539 @@ +package email + +import ( + "context" + "fmt" + "net/mail" + "net/netip" + "net/url" + "strings" + "time" + "unicode" + + "github.com/nylas/cli/internal/adapters/browser" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +const ( + maxSubscriptionSelectors = 50 +) + +var blockedUnsubscribePrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("64:ff9b::/96"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("2001::/23"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("3fff::/20"), +} + +type subscriptionSelector struct { + email string + list string +} + +type subscriptionActionResult struct { + Sender string `json:"-"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + ListID string `json:"list_id,omitempty"` + Method string `json:"method"` + Destination string `json:"destination,omitempty"` + Messages int `json:"messages"` + Status string `json:"status"` +} + +func (r subscriptionActionResult) QuietField() string { + if r.Email != "" { + return r.Email + } + return r.ListID +} + +type unsubscribeActions struct { + openURL func(string) error +} + +func newSubscriptionsUnsubscribeCmd() *cobra.Command { + var limit int + var since string + var allFolders bool + var dryRun bool + var yes bool + + cmd := &cobra.Command{ + Use: "unsubscribe ...", + Short: "Open unsubscribe actions for selected mailing lists", + Long: `Open unsubscribe actions for selected senders or List-IDs in the active account. + +The command reviews recent messages to find a consistent unsubscribe action. +Finish each action in the browser or mail composer it opens. The command never +deletes existing mail; use 'nylas email subscriptions cleanup' afterward.`, + Example: ` # Preview one subscription without making changes + nylas email subscriptions unsubscribe noreply@medium.com --dry-run + + # Unsubscribe from several senders + nylas email subscriptions unsubscribe news@example.com updates.example.com + + # Skip the CLI confirmation, then finish the opened action + nylas email subscriptions unsubscribe news@example.com --yes --json`, + Args: func(_ *cobra.Command, args []string) error { + _, err := parseSubscriptionSelectors(args) + return err + }, + RunE: func(cmd *cobra.Command, args []string) error { + opts, err := parseSubscriptionListOptions(limit, since, allFolders) + if err != nil { + return err + } + selectors, err := parseSubscriptionSelectors(args) + if err != nil { + return err + } + if !dryRun && common.IsStructuredOutput(cmd) && !yes { + return common.NewInputError("structured unsubscribe output requires --yes or --dry-run") + } + + _, err = common.WithClient(nil, func(ctx context.Context, client ports.NylasClient, grantID string) (struct{}, error) { + subscriptions, fetchErr := fetchEmailSubscriptions(ctx, cmd, client, grantID, opts, time.Now()) + if fetchErr != nil { + return struct{}{}, common.WrapFetchError("subscriptions", fetchErr) + } + selected, _, selectErr := selectEmailSubscriptions(subscriptions, selectors, true) + if selectErr != nil { + return struct{}{}, selectErr + } + + if dryRun { + return struct{}{}, common.WriteListWithColumns(cmd, plannedSubscriptionResults(selected), subscriptionActionColumns()) + } + + if !yes { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Selected subscriptions:") + if writeErr := common.WriteListWithColumns(cmd, plannedSubscriptionResults(selected), subscriptionActionColumns()); writeErr != nil { + return struct{}{}, writeErr + } + if !common.Confirm(fmt.Sprintf("Open unsubscribe actions for %d subscription(s)?", len(selected)), false) { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Cancelled.") + return struct{}{}, nil + } + } + + browserClient := browser.NewDefaultBrowser() + results, failures := executeSubscriptionActions(selected, unsubscribeActions{ + openURL: browserClient.Open, + }) + if writeErr := common.WriteListWithColumns(cmd, results, subscriptionActionColumns()); writeErr != nil { + return struct{}{}, writeErr + } + if failures > 0 { + return struct{}{}, common.NewUserError( + fmt.Sprintf("%d subscription operation(s) did not complete", failures), + "Review the result table and retry failed subscriptions", + ) + } + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().IntVarP(&limit, "limit", "n", defaultSubscriptionScanLimit, "Maximum messages to scan") + cmd.Flags().StringVar(&since, "since", "90d", "Only scan messages received within this duration (for example, 30d or 12w)") + cmd.Flags().BoolVar(&allFolders, "all-folders", false, "Scan all folders instead of only the inbox") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview unsubscribe actions without making changes") + common.AddYesFlag(cmd, &yes) + + return cmd +} + +func parseSubscriptionSelectors(args []string) ([]subscriptionSelector, error) { + if len(args) < 1 || len(args) > maxSubscriptionSelectors { + return nil, common.NewInputError("provide between 1 and 50 subscription emails or List-IDs") + } + + selectors := make([]subscriptionSelector, 0, len(args)) + for _, arg := range args { + value := strings.TrimSpace(arg) + if value != arg || value == "" || len(value) > 254 { + return nil, common.NewInputError("invalid subscription selector") + } + if strings.Contains(value, "@") { + address, err := parseSubscriptionEmail(value) + if err != nil { + return nil, common.NewInputError("subscription email selector is invalid") + } + selectors = append(selectors, subscriptionSelector{email: strings.ToLower(address.Address)}) + continue + } + if len(value) > 160 || !validListIDSelector(value) { + return nil, common.NewInputError("subscription List-ID selector is invalid") + } + selectors = append(selectors, subscriptionSelector{list: strings.ToLower(value)}) + } + return selectors, nil +} + +func validListIDSelector(value string) bool { + runes := []rune(value) + if len(runes) == 0 || runes[0] == '.' || runes[len(runes)-1] == '.' || strings.Contains(value, "..") { + return false + } + for _, r := range runes { + if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("!#$%&'*+-/=?^_`{|}~.", r) { + continue + } + return false + } + return true +} + +func selectEmailSubscriptions(subscriptions []emailSubscription, selectors []subscriptionSelector, requireAll bool) ([]emailSubscription, []string, error) { + selected := make([]emailSubscription, 0, len(selectors)) + missing := make([]string, 0) + seen := make(map[string]bool) + for _, selector := range selectors { + matched := false + for _, subscription := range subscriptions { + match := selector.email != "" && strings.EqualFold(selector.email, subscription.Email) + match = match || selector.list != "" && strings.EqualFold(selector.list, subscription.ListID) + if !match { + continue + } + matched = true + key := strings.ToLower(subscription.ListID) + if key == "" { + key = "sender:" + strings.ToLower(subscription.Email) + } + if !seen[key] { + selected = append(selected, subscription) + seen[key] = true + } + } + if !matched && requireAll { + value := selector.email + if value == "" { + value = selector.list + } + return nil, nil, common.NewUserError( + fmt.Sprintf("subscription not found: %s", value), + "Run 'nylas email subscriptions list' or expand --since/--all-folders", + ) + } + if !matched { + value := selector.email + if value == "" { + value = selector.list + } + missing = append(missing, value) + } + } + return selected, missing, nil +} + +func plannedSubscriptionResults(subscriptions []emailSubscription) []subscriptionActionResult { + results := make([]subscriptionActionResult, 0, len(subscriptions)) + for _, subscription := range subscriptions { + status := map[string]string{ + "Web": "Would open unsubscribe page", + "Email": "Would open unsubscribe email draft", + "Unsupported": "No supported unsubscribe action", + }[subscription.Method] + results = append(results, newSubscriptionActionResult(subscription, status)) + } + return results +} + +func executeSubscriptionActions( + subscriptions []emailSubscription, + actions unsubscribeActions, +) ([]subscriptionActionResult, int) { + results := make([]subscriptionActionResult, 0, len(subscriptions)) + failures := 0 + for _, subscription := range subscriptions { + status, err := executeSubscriptionAction(subscription, actions) + if err != nil { + failures++ + results = append(results, newSubscriptionActionResult(subscription, err.Error())) + continue + } + results = append(results, newSubscriptionActionResult(subscription, status)) + } + return results, failures +} + +func executeSubscriptionAction( + subscription emailSubscription, + actions unsubscribeActions, +) (string, error) { + switch subscription.Method { + case "Web": + if actions.openURL == nil || actions.openURL(subscription.actionTarget) != nil { + return "", fmt.Errorf("opening unsubscribe page failed") + } + return "Opened unsubscribe page", nil + case "Email": + if _, err := parseMailtoUnsubscribe(subscription.actionTarget); err != nil { + return "", fmt.Errorf("invalid email unsubscribe action") + } + if actions.openURL == nil || actions.openURL(subscription.actionTarget) != nil { + return "", fmt.Errorf("opening unsubscribe email draft failed") + } + return "Opened unsubscribe email draft", nil + default: + return "", fmt.Errorf("unsupported unsubscribe action") + } +} + +func newSubscriptionActionResult(subscription emailSubscription, status string) subscriptionActionResult { + return subscriptionActionResult{ + Sender: subscription.Sender, + Name: subscription.Name, + Email: subscription.Email, + ListID: subscription.ListID, + Method: subscription.Method, + Destination: subscriptionDestination(subscription.Method, subscription.actionTarget), + Messages: subscription.Messages, + Status: status, + } +} + +func subscriptionActionColumns() []ports.Column { + return []ports.Column{ + {Header: "SENDER", Field: "Sender", Width: 38}, + {Header: "METHOD", Field: "Method", Width: 12}, + {Header: "DESTINATION", Field: "Destination", Width: 28}, + {Header: "MESSAGES", Field: "Messages", Width: 0}, + {Header: "STATUS", Field: "Status", Width: 48}, + } +} + +func selectedMessageCount(subscriptions []emailSubscription) int { + total := 0 + for _, subscription := range subscriptions { + total += len(subscription.messageIDs) + } + return total +} + +func subscriptionAction(headers []domain.Header) (string, string) { + targets := parseUnsubscribeTargets(messageHeader(headers, "List-Unsubscribe")) + var webTarget, emailTarget string + for _, target := range targets { + if webTarget == "" { + if _, err := validateHTTPSUnsubscribeTarget(target); err == nil { + webTarget = target + } + } + if emailTarget == "" && strings.HasPrefix(strings.ToLower(target), "mailto:") { + if _, err := parseMailtoUnsubscribe(target); err == nil { + emailTarget = target + } + } + } + + if webTarget != "" { + return "Web", webTarget + } + if emailTarget != "" { + return "Email", emailTarget + } + return "Unsupported", "" +} + +func subscriptionActionKey(method, target string) string { + destination := strings.ToLower(subscriptionDestination(method, target)) + if destination == "" { + return "" + } + if method == "Email" { + return "email:" + destination + } + return "web:" + destination +} + +func subscriptionDestination(method, target string) string { + switch method { + case "Web": + u, err := validateHTTPSUnsubscribeTarget(target) + if err == nil { + return strings.ToLower(u.Hostname()) + } + case "Email": + req, err := parseMailtoUnsubscribe(target) + if err == nil && len(req.To) == 1 { + return strings.ToLower(req.To[0].Email) + } + } + return "" +} + +func parseUnsubscribeTargets(value string) []string { + targets := make([]string, 0, 2) + for len(targets) < 10 { + open := strings.IndexByte(value, '<') + if open < 0 { + break + } + value = value[open+1:] + close := strings.IndexByte(value, '>') + if close < 0 { + break + } + target := strings.TrimSpace(value[:close]) + if target != "" { + targets = append(targets, target) + } + value = value[close+1:] + } + return targets +} + +func parseMailtoUnsubscribe(target string) (*domain.SendMessageRequest, error) { + if len(target) > maxSubscriptionHeaderBytes { + return nil, fmt.Errorf("mailto target is too long") + } + u, err := url.Parse(target) + if err != nil || !strings.EqualFold(u.Scheme, "mailto") || u.Host != "" || u.Fragment != "" { + return nil, fmt.Errorf("invalid mailto target") + } + addressValue := u.Opaque + if addressValue == "" { + addressValue = strings.TrimPrefix(u.Path, "/") + } + addressValue, err = url.PathUnescape(addressValue) + if err != nil || len(addressValue) > 254 { + return nil, fmt.Errorf("invalid mailto address") + } + address, err := parseSubscriptionEmail(addressValue) + if err != nil { + return nil, fmt.Errorf("invalid mailto address") + } + + query, err := url.ParseQuery(u.RawQuery) + if err != nil || len(query) > 2 { + return nil, fmt.Errorf("invalid mailto query") + } + for key, values := range query { + if (key != "subject" && key != "body") || len(values) != 1 { + return nil, fmt.Errorf("unsupported mailto query") + } + } + subject, body := query.Get("subject"), query.Get("body") + if len(subject) > 998 || strings.ContainsAny(subject, "\r\n") || containsUnsafeMailtoControl(subject) || len(body) > 16*1024 || containsUnsafeMailtoControl(body) { + return nil, fmt.Errorf("unsafe mailto content") + } + + return &domain.SendMessageRequest{ + To: []domain.EmailParticipant{{Email: address.Address}}, + Subject: subject, + Body: body, + }, nil +} + +func parseSubscriptionEmail(value string) (*mail.Address, error) { + address, err := mail.ParseAddress(value) + if err != nil || address.Name != "" || !strings.EqualFold(address.Address, value) { + return nil, fmt.Errorf("invalid email address") + } + at := strings.LastIndexByte(address.Address, '@') + if at < 1 || at == len(address.Address)-1 || !validUnsubscribeHostname(address.Address[at+1:]) { + return nil, fmt.Errorf("invalid email address") + } + return address, nil +} + +func containsUnsafeMailtoControl(value string) bool { + for _, r := range value { + if unicode.IsControl(r) && r != '\r' && r != '\n' && r != '\t' { + return true + } + } + return false +} + +func validateHTTPSUnsubscribeTarget(target string) (*url.URL, error) { + if len(target) == 0 || len(target) > maxSubscriptionHeaderBytes { + return nil, fmt.Errorf("invalid unsubscribe URL") + } + u, err := url.ParseRequestURI(target) + if err != nil || !u.IsAbs() || !strings.EqualFold(u.Scheme, "https") || u.User != nil || u.Hostname() == "" || u.Fragment != "" || (u.Port() != "" && u.Port() != "443") { + return nil, fmt.Errorf("invalid unsubscribe URL") + } + host := u.Hostname() + if address, parseErr := netip.ParseAddr(host); parseErr == nil { + if address.Zone() != "" || !isPublicUnsubscribeIP(address) { + return nil, fmt.Errorf("unsafe unsubscribe URL") + } + } else if !validUnsubscribeHostname(host) { + return nil, fmt.Errorf("invalid unsubscribe hostname") + } + return u, nil +} + +func validUnsubscribeHostname(host string) bool { + if len(host) > 253 || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") || isIPv4NumberHostname(host) { + return false + } + for _, label := range strings.Split(host, ".") { + if len(label) < 1 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, r := range label { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return false + } + } + return true +} + +func isIPv4NumberHostname(host string) bool { + parts := strings.Split(host, ".") + if len(parts) > 4 { + return false + } + for _, part := range parts { + digits := part + hex := strings.HasPrefix(part, "0x") || strings.HasPrefix(part, "0X") + if hex { + digits = part[2:] + } + if digits == "" { + return false + } + for _, r := range digits { + if r >= '0' && r <= '9' || hex && (r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') { + continue + } + return false + } + } + return true +} + +func isPublicUnsubscribeIP(address netip.Addr) bool { + address = address.Unmap() + if !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsMulticast() || address.IsUnspecified() { + return false + } + for _, prefix := range blockedUnsubscribePrefixes { + if prefix.Contains(address) { + return false + } + } + return true +} diff --git a/internal/cli/integration/email_list_read_test.go b/internal/cli/integration/email_list_read_test.go index 7691736..ac5f66a 100644 --- a/internal/cli/integration/email_list_read_test.go +++ b/internal/cli/integration/email_list_read_test.go @@ -4,6 +4,7 @@ package integration import ( "context" + "encoding/json" "fmt" "strings" "testing" @@ -73,6 +74,168 @@ func TestCLI_EmailList_Filters(t *testing.T) { } } +func TestCLI_EmailSubscriptionsList(t *testing.T) { + skipIfMissingCreds(t) + + stdout, stderr, err := runCLIWithRateLimit(t, "email", "subscriptions", "list", "--limit", "20", "--since", "30d", "--all-folders", "--json") + if err != nil { + t.Fatalf("email subscriptions list failed: %v\nstderr: %s", err, stderr) + } + + var subscriptions []map[string]any + if err := json.Unmarshal([]byte(stdout), &subscriptions); err != nil { + t.Fatalf("email subscriptions list returned invalid JSON: %v\nstdout: %s", err, stdout) + } + for _, subscription := range subscriptions { + email, _ := subscription["email"].(string) + listID, _ := subscription["list_id"].(string) + if email == "" && listID == "" { + t.Fatalf("subscription must have an email or list ID: %#v", subscription) + } + if messages, ok := subscription["messages"].(float64); !ok || messages < 1 { + t.Fatalf("subscription must have a positive message count: %#v", subscription) + } + switch subscription["method"] { + case "Web", "Email", "Unsupported": + default: + t.Fatalf("subscription has invalid method: %#v", subscription) + } + lastSeen, ok := subscription["last_seen"].(string) + if !ok { + t.Fatalf("subscription must have last_seen: %#v", subscription) + } + if _, err := time.Parse(time.RFC3339, lastSeen); err != nil { + t.Fatalf("subscription last_seen is not RFC3339: %q", lastSeen) + } + for key := range subscription { + if strings.Contains(strings.ToLower(key), "unsubscribe") || strings.Contains(strings.ToLower(key), "url") { + t.Fatalf("subscription output exposes an action target in field %q", key) + } + } + } +} + +func TestCLI_EmailSubscriptionsList_Table(t *testing.T) { + skipIfMissingCreds(t) + + stdout, stderr, err := runCLIWithRateLimit(t, "email", "subscriptions", "list", "--limit", "20", "--since", "30d", "--all-folders") + if err != nil { + t.Fatalf("email subscriptions table failed: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "SENDER") && !strings.Contains(stdout, "No subscriptions found") { + t.Fatalf("email subscriptions table returned unexpected output: %s", stdout) + } +} + +func TestCLI_EmailSubscriptionsList_RejectsInvalidInput(t *testing.T) { + skipIfMissingCreds(t) + + for _, args := range [][]string{ + {"email", "subscriptions", "list", "--limit", "0"}, + {"email", "subscriptions", "list", "--since", "0d"}, + {"email", "subscriptions", "list", testGrantID}, + } { + if _, _, err := runCLI(args...); err == nil { + t.Fatalf("email subscriptions list unexpectedly accepted args: %v", args) + } + } +} + +func TestCLI_EmailSubscriptionsUnsubscribe_DryRun(t *testing.T) { + skipIfMissingCreds(t) + + stdout, stderr, err := runCLIWithRateLimit(t, "email", "subscriptions", "list", "--limit", "200", "--since", "90d", "--all-folders", "--json") + if err != nil { + t.Fatalf("email subscriptions list for dry run failed: %v\nstderr: %s", err, stderr) + } + var subscriptions []struct { + Email string `json:"email"` + ListID string `json:"list_id"` + Method string `json:"method"` + } + if err := json.Unmarshal([]byte(stdout), &subscriptions); err != nil { + t.Fatalf("email subscriptions list returned invalid JSON: %v", err) + } + + selector := "" + for _, subscription := range subscriptions { + if subscription.Method == "Unsupported" { + continue + } + selector = subscription.Email + if selector == "" { + selector = subscription.ListID + } + if selector != "" { + break + } + } + if selector == "" { + t.Skip("no actionable subscriptions found in the integration account") + } + + stdout, stderr, err = runCLIWithRateLimit(t, "email", "subscriptions", "unsubscribe", selector, "--limit", "200", "--since", "90d", "--all-folders", "--dry-run", "--json") + if err != nil { + t.Fatalf("email subscriptions unsubscribe dry run failed: %v\nstderr: %s", err, stderr) + } + var results []map[string]any + if err := json.Unmarshal([]byte(stdout), &results); err != nil { + t.Fatalf("email subscriptions unsubscribe dry run returned invalid JSON: %v\nstdout: %s", err, stdout) + } + if len(results) == 0 { + t.Fatal("email subscriptions unsubscribe dry run returned no results") + } + foundPlan := false + for _, result := range results { + if status, _ := result["status"].(string); strings.Contains(status, "Would") || strings.Contains(status, "would") { + foundPlan = true + } + for key := range result { + if strings.Contains(strings.ToLower(key), "unsubscribe") || strings.Contains(strings.ToLower(key), "url") { + t.Fatalf("unsubscribe dry-run output exposes an action target in field %q", key) + } + } + } + if !foundPlan { + t.Fatalf("unsubscribe dry run did not describe a planned action: %#v", results) + } + + stdout, stderr, err = runCLIWithRateLimit(t, "email", "subscriptions", "cleanup", selector, "--limit", "200", "--since", "90d", "--all-folders", "--permanent", "--dry-run", "--json") + if err != nil { + t.Fatalf("email subscriptions cleanup dry run failed: %v\nstderr: %s", err, stderr) + } + results = nil + if err := json.Unmarshal([]byte(stdout), &results); err != nil { + t.Fatalf("email subscriptions cleanup dry run returned invalid JSON: %v\nstdout: %s", err, stdout) + } + if len(results) == 0 { + t.Fatal("email subscriptions cleanup dry run returned no results") + } + if status, _ := results[0]["status"].(string); !strings.Contains(status, "Would permanently delete") { + t.Fatalf("cleanup dry run did not describe permanent deletion: %#v", results) + } +} + +func TestCLI_EmailSubscriptionsUnsubscribe_RejectsInvalidInput(t *testing.T) { + skipIfMissingCreds(t) + + for _, args := range [][]string{ + {"email", "subscriptions", "unsubscribe"}, + {"email", "subscriptions", "unsubscribe", "bad selector", "--dry-run"}, + {"email", "subscriptions", "unsubscribe", "news@example.com", "--limit", "0", "--dry-run"}, + {"email", "subscriptions", "unsubscribe", "news@example.com", "--permanent", "--dry-run"}, + {"email", "subscriptions", "unsubscribe", "news@example.com", "--json"}, + {"email", "subscriptions", "cleanup"}, + {"email", "subscriptions", "cleanup", "bad selector", "--dry-run"}, + {"email", "subscriptions", "cleanup", "news@example.com", "--limit", "0", "--dry-run"}, + {"email", "subscriptions", "cleanup", "news@example.com", "--json"}, + } { + if _, _, err := runCLI(args...); err == nil { + t.Fatalf("email subscriptions unsubscribe unexpectedly accepted args: %v", args) + } + } +} + // ============================================================================= // EMAIL READ COMMAND TESTS // ============================================================================= diff --git a/internal/cli/notetaker/list.go b/internal/cli/notetaker/list.go index 5aeb77e..526cd81 100644 --- a/internal/cli/notetaker/list.go +++ b/internal/cli/notetaker/list.go @@ -3,13 +3,12 @@ package notetaker import ( "context" "fmt" + "unicode" "github.com/nylas/cli/internal/cli/common" "github.com/nylas/cli/internal/domain" "github.com/nylas/cli/internal/ports" "github.com/spf13/cobra" - "golang.org/x/text/cases" - "golang.org/x/text/language" ) func newListCmd() *cobra.Command { @@ -68,8 +67,9 @@ func newListCmd() *cobra.Command { fmt.Printf(" Link: %s\n", common.Truncate(n.MeetingLink, 60)) } if n.MeetingInfo != nil && n.MeetingInfo.Provider != "" { - caser := cases.Title(language.English) - _, _ = common.Green.Printf(" Provider: %s\n", caser.String(n.MeetingInfo.Provider)) + provider := []rune(n.MeetingInfo.Provider) + provider[0] = unicode.ToUpper(provider[0]) + _, _ = common.Green.Printf(" Provider: %s\n", string(provider)) } if !n.JoinTime.IsZero() { _, _ = common.Yellow.Printf(" Join: %s\n", n.JoinTime.Local().Format(common.DisplayWeekdayFull)) diff --git a/internal/ports/messages.go b/internal/ports/messages.go index 838335a..28203dd 100644 --- a/internal/ports/messages.go +++ b/internal/ports/messages.go @@ -55,6 +55,9 @@ type MessageClient interface { // DeleteMessage deletes a message. DeleteMessage(ctx context.Context, grantID, messageID string) error + // DeleteMessagePermanently irreversibly deletes a message. + DeleteMessagePermanently(ctx context.Context, grantID, messageID string) error + // CleanMessages parses messages into clean, display-ready text, stripping // quoted reply chains, signatures, and conclusion phrases. CleanMessages(ctx context.Context, grantID string, req *domain.CleanMessagesRequest) ([]domain.CleanedMessage, error) From 23fe611247c843444b0f0e8c29c726f8cdef939d Mon Sep 17 00:00:00 2001 From: Qasim Date: Mon, 17 Aug 2026 16:43:30 -0400 Subject: [PATCH 2/3] test(rpcserver): wait for websocket readiness --- internal/adapters/rpcserver/server_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/adapters/rpcserver/server_test.go b/internal/adapters/rpcserver/server_test.go index 9f847f0..a479efc 100644 --- a/internal/adapters/rpcserver/server_test.go +++ b/internal/adapters/rpcserver/server_test.go @@ -128,6 +128,19 @@ func TestServer_ConcurrentClientWritesAndBroadcast(t *testing.T) { t.Cleanup(func() { _ = conn.Close() }) + + // Complete one request before broadcasting so the server has registered the connection. + if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"jsonrpc":"2.0","id":-1,"method":"echo","params":{}}`)); err != nil { + t.Fatalf("write readiness request: %v", err) + } + var ready struct { + ID int `json:"id"` + } + readJSON(t, conn, &ready) + if ready.ID != -1 { + t.Fatalf("readiness response ID = %d, want -1", ready.ID) + } + if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { t.Fatalf("SetReadDeadline() error = %v", err) } From d193d43f566217580e4c60087ce9231761deed44 Mon Sep 17 00:00:00 2001 From: Qasim Date: Tue, 18 Aug 2026 00:35:02 -0400 Subject: [PATCH 3/3] fix(email): harden subscription cleanup safety (TW-6481) --- internal/adapters/nylas/folders.go | 37 ++- internal/adapters/nylas/folders_test.go | 60 +++++ internal/cli/email/list.go | 25 +- internal/cli/email/subscriptions.go | 26 ++- internal/cli/email/subscriptions_cleanup.go | 1 + .../cli/email/subscriptions_cleanup_test.go | 219 ++++++++++++++++++ internal/cli/email/subscriptions_test.go | 71 +++++- .../cli/email/subscriptions_unsubscribe.go | 5 +- 8 files changed, 403 insertions(+), 41 deletions(-) create mode 100644 internal/cli/email/subscriptions_cleanup_test.go diff --git a/internal/adapters/nylas/folders.go b/internal/adapters/nylas/folders.go index f5e394f..cba00ca 100644 --- a/internal/adapters/nylas/folders.go +++ b/internal/adapters/nylas/folders.go @@ -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. diff --git a/internal/adapters/nylas/folders_test.go b/internal/adapters/nylas/folders_test.go index 8c0b36a..2bca425 100644 --- a/internal/adapters/nylas/folders_test.go +++ b/internal/adapters/nylas/folders_test.go @@ -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) diff --git a/internal/cli/email/list.go b/internal/cli/email/list.go index b4ae200..940e537 100644 --- a/internal/cli/email/list.go +++ b/internal/cli/email/list.go @@ -153,7 +153,9 @@ func fetchListMessages(ctx context.Context, cmd *cobra.Command, client ports.Nyl params.MetadataPair = opts.metadataPair } - applyListFolderFilter(ctx, cmd.ErrOrStderr(), client, grantID, params, opts.folder, opts.allFolders) + if err := applyListFolderFilter(ctx, cmd.ErrOrStderr(), client, grantID, params, opts.folder, opts.allFolders, false); err != nil { + return nil, err + } return fetchMessages(ctx, client, grantID, params, maxItems) } @@ -224,28 +226,34 @@ func resolveFolderName(ctx context.Context, client ports.NylasClient, grantID, f return "", nil } -func applyListFolderFilter(ctx context.Context, stderr io.Writer, client ports.NylasClient, grantID string, params *domain.MessageQueryParams, folder string, allFolders bool) { +func applyListFolderFilter(ctx context.Context, stderr io.Writer, client ports.NylasClient, grantID string, params *domain.MessageQueryParams, folder string, allFolders, folderRequired bool) error { if folder != "" { // Resolve folder name to ID if needed (for Microsoft accounts) resolvedFolder, err := resolveFolderName(ctx, client, grantID, folder) if err != nil { + if folderRequired { + return common.NewUserError("Could not resolve the required folder", "Verify the folder exists and try again") + } // API error - warn user but continue with literal name _, _ = fmt.Fprintf(stderr, "Warning: could not resolve folder '%s': %v\n", folder, err) params.In = []string{folder} - return + return nil } if resolvedFolder != "" { params.In = []string{resolvedFolder} - return + return nil + } + if folderRequired { + return common.NewUserError("Could not resolve the required folder", "Verify the folder exists and try again") } // Folder not found by name, use literal params.In = []string{folder} - return + return nil } if allFolders { - return + return nil } // Try to find inbox folder ID (works for both Google and Microsoft) @@ -254,15 +262,16 @@ func applyListFolderFilter(ctx context.Context, stderr io.Writer, client ports.N // API error - warn but fallback to literal INBOX _, _ = fmt.Fprintf(stderr, "Warning: could not resolve INBOX folder: %v\n", err) params.In = []string{"INBOX"} - return + return nil } if inboxID != "" { params.In = []string{inboxID} - return + return nil } // Fallback to INBOX (works for Google) params.In = []string{"INBOX"} + return nil } // runListStructured handles structured output (JSON/YAML/quiet) for the list command. diff --git a/internal/cli/email/subscriptions.go b/internal/cli/email/subscriptions.go index 057034e..e19907f 100644 --- a/internal/cli/email/subscriptions.go +++ b/internal/cli/email/subscriptions.go @@ -22,10 +22,11 @@ const ( ) type subscriptionListOptions struct { - limit int - since time.Duration - allFolders bool - folder string + limit int + since time.Duration + allFolders bool + folder string + folderRequired bool } type emailSubscription struct { @@ -154,7 +155,9 @@ func fetchEmailSubscriptions(ctx context.Context, cmd *cobra.Command, client por ReceivedAfter: now.Add(-opts.since).Unix(), Fields: "include_headers", } - applyListFolderFilter(ctx, cmd.ErrOrStderr(), client, grantID, params, opts.folder, opts.allFolders) + if err := applyListFolderFilter(ctx, cmd.ErrOrStderr(), client, grantID, params, opts.folder, opts.allFolders, opts.folderRequired); err != nil { + return nil, err + } messages, err := fetchMessages(ctx, client, grantID, params, opts.limit) if err != nil { @@ -181,10 +184,7 @@ func summarizeEmailSubscriptions(messages []domain.Message) []emailSubscription email = safeSubscriptionText(message.From[0].Email, 254) } - key := "list:" + strings.ToLower(listID) - if listID == "" { - key = "sender:" + strings.ToLower(email) - } + key := subscriptionIdentityKey(listID, email) if key == "sender:" { continue } @@ -255,6 +255,14 @@ func summarizeEmailSubscriptions(messages []domain.Message) []emailSubscription return subscriptions } +func subscriptionIdentityKey(listID, email string) string { + email = strings.ToLower(strings.TrimSpace(email)) + if listID == "" { + return "sender:" + email + } + return "list:" + strings.ToLower(strings.TrimSpace(listID)) + "\x00sender:" + email +} + func isPostableDiscussionList(headers []domain.Header) bool { listPost := messageHeader(headers, "List-Post") return listPost != "" && !strings.EqualFold(strings.TrimSpace(listPost), "NO") diff --git a/internal/cli/email/subscriptions_cleanup.go b/internal/cli/email/subscriptions_cleanup.go index f35d777..b73c970 100644 --- a/internal/cli/email/subscriptions_cleanup.go +++ b/internal/cli/email/subscriptions_cleanup.go @@ -50,6 +50,7 @@ you intend to delete matching mail everywhere. This command never unsubscribes.` } if permanent && !allFolders { opts.folder = "TRASH" + opts.folderRequired = true } selectors, err := parseSubscriptionSelectors(args) if err != nil { diff --git a/internal/cli/email/subscriptions_cleanup_test.go b/internal/cli/email/subscriptions_cleanup_test.go new file mode 100644 index 0000000..9617472 --- /dev/null +++ b/internal/cli/email/subscriptions_cleanup_test.go @@ -0,0 +1,219 @@ +package email + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSubscriptionsCleanupCommandScopesEmailSelectorToSender(t *testing.T) { + var deleted []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test": + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": "grant-test", "provider": "google"}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/folders": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"id": "trash-id", "name": "Trash"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/messages": + assert.Equal(t, "trash-id", r.URL.Query().Get("in")) + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{ + { + "id": "marketing", "date": time.Now().Unix(), + "from": []map[string]string{{"email": "marketing@example.com"}}, + "headers": []map[string]string{ + {"name": "List-ID", "value": "shared.example.com"}, + {"name": "List-Unsubscribe", "value": ""}, + }, + }, + { + "id": "alerts", "date": time.Now().Add(-time.Hour).Unix(), + "from": []map[string]string{{"email": "alerts@example.com"}}, + "headers": []map[string]string{ + {"name": "List-ID", "value": "shared.example.com"}, + {"name": "List-Unsubscribe", "value": ""}, + }, + }, + }}) + case r.Method == http.MethodDelete: + deleted = append(deleted, r.URL.Path) + assert.Equal(t, "true", r.URL.Query().Get("hard_delete")) + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer server.Close() + + stdout, stderr, err := executeSubscriptionsTestCommand(t, server.URL, + "subscriptions", "cleanup", "marketing@example.com", "--permanent", "--yes", "--json", + ) + + require.NoError(t, err, stderr) + assert.Equal(t, []string{"/v3/grants/grant-test/messages/marketing"}, deleted) + assert.Contains(t, stdout, "Permanently deleted 1/1") +} + +func TestSubscriptionsCleanupPermanentFailsClosedWhenTrashMissing(t *testing.T) { + var messageGets, deletes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test": + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": "grant-test", "provider": "microsoft"}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/folders": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/messages": + messageGets++ + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + case r.Method == http.MethodDelete: + deletes++ + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer server.Close() + + _, _, err := executeSubscriptionsTestCommand(t, server.URL, + "subscriptions", "cleanup", "news@example.com", "--permanent", "--yes", "--json", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Could not resolve the required folder") + assert.Zero(t, messageGets) + assert.Zero(t, deletes) +} + +func TestSubscriptionsCleanupDryRunDoesNotDelete(t *testing.T) { + var deletes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test": + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": "grant-test", "provider": "google"}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/folders": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"id": "inbox-id", "name": "Inbox"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/messages": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{ + "id": "m1", "date": time.Now().Unix(), + "from": []map[string]string{{"email": "news@example.com"}}, + "headers": []map[string]string{{"name": "List-Unsubscribe", "value": ""}}, + }}}) + case r.Method == http.MethodDelete: + deletes++ + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer server.Close() + + stdout, stderr, err := executeSubscriptionsTestCommand(t, server.URL, + "subscriptions", "cleanup", "news@example.com", "--dry-run", "--json", + ) + + require.NoError(t, err, stderr) + assert.Zero(t, deletes) + assert.Contains(t, stdout, "Would move 1 to Trash") +} + +func TestSubscriptionsCleanupRejectedConfirmationDoesNotDelete(t *testing.T) { + var deletes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test": + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": "grant-test", "provider": "google"}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/folders": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"id": "trash-id", "name": "Trash"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/messages": + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{ + "id": "m1", "date": time.Now().Unix(), + "from": []map[string]string{{"email": "news@example.com"}}, + "headers": []map[string]string{{"name": "List-Unsubscribe", "value": ""}}, + }}}) + case r.Method == http.MethodDelete: + deletes++ + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unexpected request", http.StatusBadRequest) + } + })) + defer server.Close() + + input, writer, err := os.Pipe() + require.NoError(t, err) + _, err = writer.WriteString("n\n") + require.NoError(t, err) + require.NoError(t, writer.Close()) + oldStdin := os.Stdin + os.Stdin = input + t.Cleanup(func() { + os.Stdin = oldStdin + _ = input.Close() + }) + + stdout, stderr, err := executeSubscriptionsTestCommand(t, server.URL, + "subscriptions", "cleanup", "news@example.com", "--permanent", + ) + + require.NoError(t, err, stderr) + assert.Zero(t, deletes) + assert.Contains(t, stdout, "Cancelled.") +} + +func TestFetchEmailSubscriptionsRequiredFolderFailsClosedOnLookupError(t *testing.T) { + client := nylas.NewMockClient() + client.GetGrantFunc = func(context.Context, string) (*domain.Grant, error) { + return &domain.Grant{Provider: domain.ProviderGoogle}, nil + } + client.GetFoldersFunc = func(context.Context, string) ([]domain.Folder, error) { + return nil, errors.New("folder lookup failed") + } + + _, err := fetchEmailSubscriptions(context.Background(), newSubscriptionsListCmd(), client, "grant-test", subscriptionListOptions{ + limit: 10, + since: 24 * time.Hour, + folder: "TRASH", + folderRequired: true, + }, time.Now()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Could not resolve the required folder") + assert.False(t, client.GetMessagesWithParamsCalled) +} + +func executeSubscriptionsTestCommand(t *testing.T, baseURL string, args ...string) (string, string, error) { + t.Helper() + common.ResetCachedClient() + t.Cleanup(common.ResetCachedClient) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("NYLAS_DISABLE_KEYRING", "true") + t.Setenv("NYLAS_API_KEY", "test-api-key") + t.Setenv("NYLAS_GRANT_ID", "grant-test") + t.Setenv("NYLAS_API_BASE_URL", baseURL) + + root := &cobra.Command{Use: "test", SilenceErrors: true, SilenceUsage: true} + common.AddOutputFlags(root) + root.AddCommand(newSubscriptionsCmd()) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + err := root.Execute() + return stdout.String(), stderr.String(), err +} diff --git a/internal/cli/email/subscriptions_test.go b/internal/cli/email/subscriptions_test.go index a4c8b7b..e50a454 100644 --- a/internal/cli/email/subscriptions_test.go +++ b/internal/cli/email/subscriptions_test.go @@ -40,7 +40,7 @@ func TestSummarizeEmailSubscriptions(t *testing.T) { { ID: "old-message", Date: older, - From: []domain.EmailParticipant{{Name: "Old sender", Email: "old@example.com"}}, + From: []domain.EmailParticipant{{Name: "Old sender", Email: "latest@example.com"}}, Headers: []domain.Header{ {Name: "List-ID", Value: "news.example.com"}, {Name: "List-Unsubscribe", Value: ""}, @@ -93,6 +93,35 @@ func TestSummarizeEmailSubscriptions(t *testing.T) { assert.NotContains(t, string(encoded), "unsubscribe@example.com") } +func TestSummarizeEmailSubscriptionsSeparatesSendersSharingListID(t *testing.T) { + got := summarizeEmailSubscriptions([]domain.Message{ + { + ID: "marketing", Date: time.Now(), + From: []domain.EmailParticipant{{Email: "marketing@example.com"}}, + Headers: []domain.Header{ + {Name: "List-ID", Value: "shared.example.com"}, + {Name: "List-Unsubscribe", Value: ""}, + }, + }, + { + ID: "alerts", Date: time.Now().Add(-time.Hour), + From: []domain.EmailParticipant{{Email: "alerts@example.com"}}, + Headers: []domain.Header{ + {Name: "List-ID", Value: "shared.example.com"}, + {Name: "List-Unsubscribe", Value: ""}, + }, + }, + }) + + require.Len(t, got, 2) + byEmail := make(map[string]emailSubscription, len(got)) + for _, subscription := range got { + byEmail[subscription.Email] = subscription + } + assert.Equal(t, []string{"marketing"}, byEmail["marketing@example.com"].messageIDs) + assert.Equal(t, []string{"alerts"}, byEmail["alerts@example.com"].messageIDs) +} + func TestIsPostableDiscussionList(t *testing.T) { assert.True(t, isPostableDiscussionList([]domain.Header{{Name: "List-Post", Value: ""}})) assert.False(t, isPostableDiscussionList([]domain.Header{{Name: "List-Post", Value: " no "}})) @@ -179,6 +208,22 @@ func TestParseAndSelectSubscriptions(t *testing.T) { assert.Len(t, selected, 3) assert.Empty(t, missing) + sharedList := []emailSubscription{ + {Email: "marketing@example.com", ListID: "shared.example.com", messageIDs: []string{"marketing"}}, + {Email: "alerts@example.com", ListID: "shared.example.com", messageIDs: []string{"alerts"}}, + } + selected, missing, err = selectEmailSubscriptions(sharedList, []subscriptionSelector{{email: "marketing@example.com"}}, true) + require.NoError(t, err) + require.Len(t, selected, 1) + assert.Equal(t, []string{"marketing"}, selected[0].messageIDs) + assert.Empty(t, missing) + + selected, missing, err = selectEmailSubscriptions(sharedList, []subscriptionSelector{{list: "shared.example.com"}}, true) + require.NoError(t, err) + require.Len(t, selected, 2) + assert.ElementsMatch(t, []string{"marketing", "alerts"}, []string{selected[0].messageIDs[0], selected[1].messageIDs[0]}) + assert.Empty(t, missing) + for _, args := range [][]string{ nil, {"bad selector"}, @@ -253,8 +298,6 @@ func TestValidateHTTPSUnsubscribeTarget(t *testing.T) { } func TestExecuteSubscriptionActions(t *testing.T) { - client := nylas.NewMockClient() - subscriptions := []emailSubscription{ {Sender: "Web", Email: "web@example.com", Method: "Web", Messages: 1, actionTarget: "https://example.com/web", messageIDs: []string{"m2"}}, {Sender: "Email", Email: "email@example.com", Method: "Email", Messages: 1, actionTarget: "mailto:leave@example.com?subject=unsubscribe", messageIDs: []string{"m3"}}, @@ -270,9 +313,6 @@ func TestExecuteSubscriptionActions(t *testing.T) { assert.Zero(t, failures) assert.Len(t, results, 2) assert.Equal(t, []string{"https://example.com/web", "mailto:leave@example.com?subject=unsubscribe"}, opened) - assert.False(t, client.SendMessageCalled) - assert.False(t, client.DeleteMessageCalled) - assert.False(t, client.DeleteMessagePermanentlyCalled) } func TestExecuteSubscriptionCleanupPermanentlyDeletes(t *testing.T) { @@ -294,7 +334,6 @@ func TestExecuteSubscriptionCleanupPermanentlyDeletes(t *testing.T) { } func TestExecuteSubscriptionActionsFailureDoesNotDelete(t *testing.T) { - client := nylas.NewMockClient() results, failures := executeSubscriptionActions([]emailSubscription{{ Email: "one@example.com", Method: "Web", actionTarget: "https://example.com/one", messageIDs: []string{"m1"}, }}, unsubscribeActions{openURL: func(string) error { return fmt.Errorf("failed") }}) @@ -302,8 +341,6 @@ func TestExecuteSubscriptionActionsFailureDoesNotDelete(t *testing.T) { assert.Equal(t, 1, failures) require.Len(t, results, 1) assert.Equal(t, "opening unsubscribe page failed", results[0].Status) - assert.False(t, client.SendMessageCalled) - assert.False(t, client.DeleteMessageCalled) } func TestExecuteSubscriptionCleanupStopsAndReportsFirstError(t *testing.T) { @@ -469,7 +506,7 @@ func TestFetchEmailSubscriptionsRejectsUnsupportedProvider(t *testing.T) { }, time.Now()) require.Error(t, err) assert.Contains(t, err.Error(), "unavailable for this provider") - assert.False(t, client.GetMessagesCalled) + assert.False(t, client.GetMessagesWithParamsCalled) } func TestSubscriptionsCommand(t *testing.T) { @@ -507,15 +544,24 @@ func TestSubscriptionsCommand(t *testing.T) { } func TestSubscriptionsCleanupCommandPermanentlyDeletesWithoutUnsubscribing(t *testing.T) { - var messageGets, deletes int + var folderGets, messageGets, deletes int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test": _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": "grant-test", "provider": "google"}}) case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/folders": + folderGets++ + if folderGets == 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": "custom-trash", "name": "Trash"}, {"id": "trash-id", "name": "Papierkorb", "attributes": []string{"\\Trash"}}, }}) case r.Method == http.MethodGet && r.URL.Path == "/v3/grants/grant-test/messages": @@ -551,6 +597,7 @@ func TestSubscriptionsCleanupCommandPermanentlyDeletesWithoutUnsubscribing(t *te root.SetArgs([]string{"subscriptions", "cleanup", "news@example.com", "missing@example.com", "--permanent", "--yes", "--json"}) require.NoError(t, root.Execute(), stderr.String()) + assert.Equal(t, 2, folderGets) assert.Equal(t, 1, messageGets) assert.Equal(t, 1, deletes) assert.Contains(t, stdout.String(), "Permanently deleted 1/1") diff --git a/internal/cli/email/subscriptions_unsubscribe.go b/internal/cli/email/subscriptions_unsubscribe.go index 89595c6..61c40dd 100644 --- a/internal/cli/email/subscriptions_unsubscribe.go +++ b/internal/cli/email/subscriptions_unsubscribe.go @@ -213,10 +213,7 @@ func selectEmailSubscriptions(subscriptions []emailSubscription, selectors []sub continue } matched = true - key := strings.ToLower(subscription.ListID) - if key == "" { - key = "sender:" + strings.ToLower(subscription.Email) - } + key := subscriptionIdentityKey(subscription.ListID, subscription.Email) if !seen[key] { selected = append(selected, subscription) seen[key] = true