diff --git a/CHANGELOG.md b/CHANGELOG.md index eabb5bc..a3cf5ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed - Calendar: display `calendar events` times and JSON local fields in the calendar timezone instead of preserving arbitrary event offsets. (#493) +- Gmail: apply Gmail system-label filters for searches like `in:spam is:unread` so thread, message, and batch message searches do not return read spam. (#449) - Gmail: build outbound `Date` headers with the configured timezone so replies do not inherit a wrong host-local offset. (#514, #472) — thanks @dinakars777. - Gmail: preserve renewed watch expiration fields when a long-running `gmail watch serve` process records push delivery state after `gmail watch renew` runs separately. (#526) - Gmail: auto-fill draft reply subjects from the original message when `gmail drafts create --reply-to-message-id` omits `--subject`. (#488) — thanks @jbowerbir. diff --git a/internal/cmd/execute_gmail_messages_search_text_test.go b/internal/cmd/execute_gmail_messages_search_text_test.go index 163d936..102f823 100644 --- a/internal/cmd/execute_gmail_messages_search_text_test.go +++ b/internal/cmd/execute_gmail_messages_search_text_test.go @@ -175,3 +175,49 @@ func TestExecute_GmailMessagesSearch_JSON_IncludeBody(t *testing.T) { t.Fatalf("expected decoded body, got: %q", out) } } + +func TestExecute_GmailMessagesSearch_AppliesSystemLabelFilters(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + var gotQuery string + var gotLabels []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case strings.Contains(path, "/users/me/messages") && !strings.Contains(path, "/users/me/messages/"): + gotQuery = r.URL.Query().Get("q") + gotLabels = r.URL.Query()["labelIds"] + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "messages": []map[string]any{}, + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@b.com", "gmail", "messages", "search", "in:spam is:unread", "--max", "1000"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + + if gotQuery != "in:spam is:unread" { + t.Fatalf("unexpected query: %q", gotQuery) + } + assertSameStrings(t, gotLabels, []string{"SPAM", "UNREAD"}) +} diff --git a/internal/cmd/execute_gmail_search_text_test.go b/internal/cmd/execute_gmail_search_text_test.go index 6f4e1c7..dc4a237 100644 --- a/internal/cmd/execute_gmail_search_text_test.go +++ b/internal/cmd/execute_gmail_search_text_test.go @@ -127,3 +127,49 @@ func TestExecute_GmailSearch_Text_NoResults(t *testing.T) { t.Fatalf("unexpected stderr: %q", errOut) } } + +func TestExecute_GmailSearch_AppliesSystemLabelFilters(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + var gotQuery string + var gotLabels []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case strings.Contains(path, "/users/me/threads") && !strings.Contains(path, "/users/me/threads/"): + gotQuery = r.URL.Query().Get("q") + gotLabels = r.URL.Query()["labelIds"] + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "threads": []map[string]any{}, + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@b.com", "gmail", "search", "in:spam is:unread", "--max", "1000"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + + if gotQuery != "in:spam is:unread" { + t.Fatalf("unexpected query: %q", gotQuery) + } + assertSameStrings(t, gotLabels, []string{"SPAM", "UNREAD"}) +} diff --git a/internal/cmd/gmail_archive.go b/internal/cmd/gmail_archive.go index 692018c..1a9d2ba 100644 --- a/internal/cmd/gmail_archive.go +++ b/internal/cmd/gmail_archive.go @@ -177,6 +177,9 @@ func searchMessageIDs(ctx context.Context, svc *gmail.Service, query string, lim MaxResults(batchSize). Fields("messages(id),nextPageToken"). Context(ctx) + if labelIDs := gmailQuerySystemLabelIDs(query); len(labelIDs) > 0 { + call = call.LabelIds(labelIDs...) + } if pageToken != "" { call = call.PageToken(pageToken) } diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index 18c16cc..0298dd0 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -56,6 +56,9 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro MaxResults(c.Max). Fields("messages(id,threadId),nextPageToken"). Context(ctx) + if labelIDs := gmailQuerySystemLabelIDs(query); len(labelIDs) > 0 { + call = call.LabelIds(labelIDs...) + } if strings.TrimSpace(pageToken) != "" { call = call.PageToken(pageToken) } diff --git a/internal/cmd/gmail_search.go b/internal/cmd/gmail_search.go index 47c5a78..b09a319 100644 --- a/internal/cmd/gmail_search.go +++ b/internal/cmd/gmail_search.go @@ -44,6 +44,9 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { Q(query). MaxResults(c.Max). Context(ctx) + if labelIDs := gmailQuerySystemLabelIDs(query); len(labelIDs) > 0 { + call = call.LabelIds(labelIDs...) + } if strings.TrimSpace(pageToken) != "" { call = call.PageToken(pageToken) } diff --git a/internal/cmd/gmail_search_labels.go b/internal/cmd/gmail_search_labels.go new file mode 100644 index 0000000..75e5486 --- /dev/null +++ b/internal/cmd/gmail_search_labels.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "strings" +) + +const ( + gmailSystemLabelUnread = "UNREAD" + gmailSystemLabelStarred = "STARRED" + gmailSystemLabelImportant = "IMPORTANT" + gmailSystemLabelInbox = "INBOX" + gmailSystemLabelSent = "SENT" + gmailSystemLabelDraft = "DRAFT" + gmailSystemLabelSpam = "SPAM" + gmailSystemLabelTrash = "TRASH" +) + +func gmailQuerySystemLabelIDs(query string) []string { + fields := strings.Fields(query) + for _, field := range fields { + token := strings.ToLower(strings.TrimSpace(field)) + if token == "or" || strings.ContainsAny(token, "{}") { + return nil + } + } + + var labels []string + seen := map[string]bool{} + for _, field := range fields { + token := strings.ToLower(strings.Trim(field, `"'(),[]`)) + if token == "" || strings.HasPrefix(token, "-") { + continue + } + if labelID := gmailQuerySystemLabelID(token); labelID != "" && !seen[labelID] { + seen[labelID] = true + labels = append(labels, labelID) + } + } + return labels +} + +func gmailQuerySystemLabelID(token string) string { + switch token { + case "is:unread", "label:unread": + return gmailSystemLabelUnread + case "is:starred", "label:starred": + return gmailSystemLabelStarred + case "is:important", "label:important": + return gmailSystemLabelImportant + case "in:inbox", "label:inbox": + return gmailSystemLabelInbox + case "in:sent", "label:sent": + return gmailSystemLabelSent + case "in:draft", "in:drafts", "label:draft", "label:drafts": + return gmailSystemLabelDraft + case "in:spam", "is:spam", "label:spam": + return gmailSystemLabelSpam + case "in:trash", "label:trash": + return gmailSystemLabelTrash + case "category:primary": + return "CATEGORY_PERSONAL" + case "category:social": + return "CATEGORY_SOCIAL" + case "category:promotions": + return "CATEGORY_PROMOTIONS" + case "category:updates": + return "CATEGORY_UPDATES" + case "category:forums": + return "CATEGORY_FORUMS" + default: + return "" + } +} diff --git a/internal/cmd/testutil_test.go b/internal/cmd/testutil_test.go index ef0a533..cf51e4f 100644 --- a/internal/cmd/testutil_test.go +++ b/internal/cmd/testutil_test.go @@ -123,6 +123,24 @@ func captureStderr(t *testing.T, fn func()) string { return buf.String() } +func assertSameStrings(t *testing.T, got, want []string) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + seen := make(map[string]int, len(got)) + for _, v := range got { + seen[v]++ + } + for _, v := range want { + seen[v]-- + if seen[v] < 0 { + t.Fatalf("got %v, want %v", got, want) + } + } +} + func withStdin(t *testing.T, input string, fn func()) { t.Helper()