fix(gmail): constrain system-label searches

This commit is contained in:
Peter Steinberger 2026-04-27 14:59:02 +01:00
parent c4df6c34ea
commit 7f1ef97e15
No known key found for this signature in database
8 changed files with 193 additions and 0 deletions

View File

@ -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.

View File

@ -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"})
}

View File

@ -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"})
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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 ""
}
}

View File

@ -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()