diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fba53e7..806b0d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Gmail: keep label IDs case-sensitive during label resolution and duplicate-name checks while still matching label names case-insensitively. - Docs: update the bundled `gog` agent skill to preserve broad user OAuth scopes during reauth and rely on command guards for scoped execution. ## 0.19.0 - 2026-05-22 diff --git a/internal/cmd/gmail_labels.go b/internal/cmd/gmail_labels.go index 685ee043..98180d64 100644 --- a/internal/cmd/gmail_labels.go +++ b/internal/cmd/gmail_labels.go @@ -46,7 +46,9 @@ func (c *GmailLabelsGetCmd) Run(ctx context.Context, flags *RootFlags) error { return usage("empty label") } id := raw - if v, ok := idMap[strings.ToLower(raw)]; ok { + if v, ok := idMap[raw]; ok { + id = v + } else if v, ok := idMap[strings.ToLower(raw)]; ok { id = v } @@ -400,7 +402,7 @@ func fetchLabelNameToID(svc *gmail.Service) (map[string]string, error) { if l.Id == "" { continue } - m[strings.ToLower(l.Id)] = l.Id + m[l.Id] = l.Id if l.Name != "" { m[strings.ToLower(l.Name)] = l.Id } diff --git a/internal/cmd/gmail_labels_cmd_test.go b/internal/cmd/gmail_labels_cmd_test.go index d7fb79b7..6d6b9902 100644 --- a/internal/cmd/gmail_labels_cmd_test.go +++ b/internal/cmd/gmail_labels_cmd_test.go @@ -143,6 +143,66 @@ func TestGmailLabelsGetCmd_JSON(t *testing.T) { } } +func TestGmailLabelsGetCmd_ExactIDBeatsCaseFoldedName(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/users/me/labels") || strings.HasSuffix(r.URL.Path, "/gmail/v1/users/me/labels"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "labels": []map[string]any{ + {"id": "Label_9", "name": "Original", "type": "user"}, + {"id": "Label_10", "name": "label_9", "type": "user"}, + }, + }) + return + case strings.Contains(r.URL.Path, "/users/me/labels/") || strings.Contains(r.URL.Path, "/gmail/v1/users/me/labels/"): + id := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + if id != "Label_9" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "Label_9", + "name": "Original", + "type": "user", + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + stubGmailService(t, srv) + + out := captureStdout(t, func() { + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + cmd := &GmailLabelsGetCmd{} + if err := runKong(t, cmd, []string{"Label_9"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + var parsed struct { + Label struct { + ID string `json:"id"` + } `json:"label"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if parsed.Label.ID != "Label_9" { + t.Fatalf("exact ID was shadowed: %#v", parsed.Label) + } +} + func TestGmailLabelsListCmd_TextAndJSON(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) @@ -487,6 +547,47 @@ func TestGmailLabelsCreateCmd_DuplicateName_Preflight(t *testing.T) { } } +func TestEnsureLabelNameAvailable_DoesNotCaseFoldIDs(t *testing.T) { + srv := newLabelsServer(t, []map[string]any{ + {"id": "Label_9", "name": "Different Name", "type": "user"}, + }, nil) + 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) + } + + if err := ensureLabelNameAvailable(svc, "label_9"); err != nil { + t.Fatalf("label ID should not collide with name: %v", err) + } +} + +func TestEnsureLabelNameAvailable_BlocksExactIDCollision(t *testing.T) { + srv := newLabelsServer(t, []map[string]any{ + {"id": "Label_9", "name": "Different Name", "type": "user"}, + }, nil) + 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) + } + + err = ensureLabelNameAvailable(svc, "Label_9") + if err == nil || !strings.Contains(err.Error(), "label already exists") { + t.Fatalf("expected exact ID collision error, got: %v", err) + } +} + func TestGmailLabelsCreateCmd_DuplicateName_APIError(t *testing.T) { srv := newLabelsServer(t, []map[string]any{}, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -697,3 +798,42 @@ func TestFetchLabelIDToName(t *testing.T) { t.Fatalf("unexpected label2: %q", m["Label_2"]) } } + +func TestFetchLabelNameToID_DoesNotCaseFoldIDs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(strings.HasSuffix(r.URL.Path, "/users/me/labels") || strings.HasSuffix(r.URL.Path, "/gmail/v1/users/me/labels")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "labels": []map[string]any{ + {"id": "Label_1", "name": "Custom", "type": "user"}, + }, + }) + })) + 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) + } + + m, err := fetchLabelNameToID(svc) + if err != nil { + t.Fatalf("fetchLabelNameToID: %v", err) + } + if m["custom"] != "Label_1" { + t.Fatalf("missing case-folded name lookup: %#v", m) + } + if m["Label_1"] != "Label_1" { + t.Fatalf("missing exact ID lookup: %#v", m) + } + if _, ok := m["label_1"]; ok { + t.Fatalf("case-folded label ID should not resolve: %#v", m) + } +} diff --git a/internal/cmd/gmail_labels_test.go b/internal/cmd/gmail_labels_test.go index a0193ab3..10852fdf 100644 --- a/internal/cmd/gmail_labels_test.go +++ b/internal/cmd/gmail_labels_test.go @@ -16,6 +16,39 @@ func TestResolveLabelIDs(t *testing.T) { } } +func TestResolveLabelIDs_DoesNotCaseFoldIDs(t *testing.T) { + m := map[string]string{ + "custom": "Label_123", + } + got := resolveLabelIDs([]string{"label_123", "Custom"}, m) + if len(got) != 2 { + t.Fatalf("unexpected: %#v", got) + } + if got[0] != "label_123" { + t.Fatalf("case-folded label ID: %#v", got) + } + if got[1] != "Label_123" { + t.Fatalf("did not resolve label name: %#v", got) + } +} + +func TestResolveLabelIDs_ExactIDBeatsCaseFoldedName(t *testing.T) { + m := map[string]string{ + "Label_9": "Label_9", + "label_9": "Label_10", + } + got := resolveLabelIDs([]string{"Label_9", "label_9"}, m) + if len(got) != 2 { + t.Fatalf("unexpected: %#v", got) + } + if got[0] != "Label_9" { + t.Fatalf("exact ID resolved through name collision: %#v", got) + } + if got[1] != "Label_10" { + t.Fatalf("case-folded name did not resolve: %#v", got) + } +} + func TestFetchLabelIDToNameBehavior(t *testing.T) { // Unit tests for the actual API call live in integration; here we just ensure // the helper exists and returns a map. (Compile-time coverage.) diff --git a/internal/cmd/gmail_labels_utils.go b/internal/cmd/gmail_labels_utils.go index 1a6d29e3..c4e75a54 100644 --- a/internal/cmd/gmail_labels_utils.go +++ b/internal/cmd/gmail_labels_utils.go @@ -23,6 +23,10 @@ func resolveLabelIDs(labels []string, nameToID map[string]string) []string { continue } if nameToID != nil { + if id, ok := nameToID[trimmed]; ok { + out = append(out, id) + continue + } if id, ok := nameToID[strings.ToLower(trimmed)]; ok { out = append(out, id) continue @@ -128,7 +132,7 @@ func ensureLabelNameAvailable(svc *gmail.Service, name string) error { if label == nil { continue } - if strings.ToLower(strings.TrimSpace(label.Id)) == want { + if strings.TrimSpace(label.Id) == strings.TrimSpace(name) { return usagef("label already exists: %s", name) } labelName := strings.TrimSpace(label.Name) diff --git a/internal/cmd/gmail_watch_utils.go b/internal/cmd/gmail_watch_utils.go index 6854b40c..08fa5ec4 100644 --- a/internal/cmd/gmail_watch_utils.go +++ b/internal/cmd/gmail_watch_utils.go @@ -61,6 +61,10 @@ func resolveLabelIDsWithService(svc *gmail.Service, labels []string) ([]string, if trimmed == "" { continue } + if id, ok := nameToID[trimmed]; ok { + out = append(out, id) + continue + } if id, ok := nameToID[strings.ToLower(trimmed)]; ok { out = append(out, id) continue