Skip to content

Commit 32ad20a

Browse files
authored
[codex] keep Gmail label IDs case-sensitive (#653)
* fix(gmail): keep label IDs case-sensitive * fix(gmail): prefer exact label IDs * fix(gmail): block exact label ID name collisions
1 parent 929e26b commit 32ad20a

6 files changed

Lines changed: 187 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixed
66

7+
- Gmail: keep label IDs case-sensitive during label resolution and duplicate-name checks while still matching label names case-insensitively.
78
- Docs: update the bundled `gog` agent skill to preserve broad user OAuth scopes during reauth and rely on command guards for scoped execution.
89

910
## 0.19.0 - 2026-05-22

internal/cmd/gmail_labels.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ func (c *GmailLabelsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
4646
return usage("empty label")
4747
}
4848
id := raw
49-
if v, ok := idMap[strings.ToLower(raw)]; ok {
49+
if v, ok := idMap[raw]; ok {
50+
id = v
51+
} else if v, ok := idMap[strings.ToLower(raw)]; ok {
5052
id = v
5153
}
5254

@@ -400,7 +402,7 @@ func fetchLabelNameToID(svc *gmail.Service) (map[string]string, error) {
400402
if l.Id == "" {
401403
continue
402404
}
403-
m[strings.ToLower(l.Id)] = l.Id
405+
m[l.Id] = l.Id
404406
if l.Name != "" {
405407
m[strings.ToLower(l.Name)] = l.Id
406408
}

internal/cmd/gmail_labels_cmd_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,66 @@ func TestGmailLabelsGetCmd_JSON(t *testing.T) {
143143
}
144144
}
145145

146+
func TestGmailLabelsGetCmd_ExactIDBeatsCaseFoldedName(t *testing.T) {
147+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
148+
switch {
149+
case strings.HasSuffix(r.URL.Path, "/users/me/labels") || strings.HasSuffix(r.URL.Path, "/gmail/v1/users/me/labels"):
150+
w.Header().Set("Content-Type", "application/json")
151+
_ = json.NewEncoder(w).Encode(map[string]any{
152+
"labels": []map[string]any{
153+
{"id": "Label_9", "name": "Original", "type": "user"},
154+
{"id": "Label_10", "name": "label_9", "type": "user"},
155+
},
156+
})
157+
return
158+
case strings.Contains(r.URL.Path, "/users/me/labels/") || strings.Contains(r.URL.Path, "/gmail/v1/users/me/labels/"):
159+
id := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
160+
if id != "Label_9" {
161+
http.NotFound(w, r)
162+
return
163+
}
164+
w.Header().Set("Content-Type", "application/json")
165+
_ = json.NewEncoder(w).Encode(map[string]any{
166+
"id": "Label_9",
167+
"name": "Original",
168+
"type": "user",
169+
})
170+
return
171+
default:
172+
http.NotFound(w, r)
173+
return
174+
}
175+
}))
176+
defer srv.Close()
177+
stubGmailService(t, srv)
178+
179+
out := captureStdout(t, func() {
180+
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
181+
if uiErr != nil {
182+
t.Fatalf("ui.New: %v", uiErr)
183+
}
184+
ctx := ui.WithUI(context.Background(), u)
185+
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
186+
187+
cmd := &GmailLabelsGetCmd{}
188+
if err := runKong(t, cmd, []string{"Label_9"}, ctx, &RootFlags{Account: "[email protected]"}); err != nil {
189+
t.Fatalf("execute: %v", err)
190+
}
191+
})
192+
193+
var parsed struct {
194+
Label struct {
195+
ID string `json:"id"`
196+
} `json:"label"`
197+
}
198+
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
199+
t.Fatalf("json parse: %v\nout=%q", err, out)
200+
}
201+
if parsed.Label.ID != "Label_9" {
202+
t.Fatalf("exact ID was shadowed: %#v", parsed.Label)
203+
}
204+
}
205+
146206
func TestGmailLabelsListCmd_TextAndJSON(t *testing.T) {
147207
origNew := newGmailService
148208
t.Cleanup(func() { newGmailService = origNew })
@@ -487,6 +547,47 @@ func TestGmailLabelsCreateCmd_DuplicateName_Preflight(t *testing.T) {
487547
}
488548
}
489549

550+
func TestEnsureLabelNameAvailable_DoesNotCaseFoldIDs(t *testing.T) {
551+
srv := newLabelsServer(t, []map[string]any{
552+
{"id": "Label_9", "name": "Different Name", "type": "user"},
553+
}, nil)
554+
defer srv.Close()
555+
556+
svc, err := gmail.NewService(context.Background(),
557+
option.WithoutAuthentication(),
558+
option.WithHTTPClient(srv.Client()),
559+
option.WithEndpoint(srv.URL+"/"),
560+
)
561+
if err != nil {
562+
t.Fatalf("NewService: %v", err)
563+
}
564+
565+
if err := ensureLabelNameAvailable(svc, "label_9"); err != nil {
566+
t.Fatalf("label ID should not collide with name: %v", err)
567+
}
568+
}
569+
570+
func TestEnsureLabelNameAvailable_BlocksExactIDCollision(t *testing.T) {
571+
srv := newLabelsServer(t, []map[string]any{
572+
{"id": "Label_9", "name": "Different Name", "type": "user"},
573+
}, nil)
574+
defer srv.Close()
575+
576+
svc, err := gmail.NewService(context.Background(),
577+
option.WithoutAuthentication(),
578+
option.WithHTTPClient(srv.Client()),
579+
option.WithEndpoint(srv.URL+"/"),
580+
)
581+
if err != nil {
582+
t.Fatalf("NewService: %v", err)
583+
}
584+
585+
err = ensureLabelNameAvailable(svc, "Label_9")
586+
if err == nil || !strings.Contains(err.Error(), "label already exists") {
587+
t.Fatalf("expected exact ID collision error, got: %v", err)
588+
}
589+
}
590+
490591
func TestGmailLabelsCreateCmd_DuplicateName_APIError(t *testing.T) {
491592
srv := newLabelsServer(t, []map[string]any{}, func(w http.ResponseWriter, r *http.Request) {
492593
w.Header().Set("Content-Type", "application/json")
@@ -697,3 +798,42 @@ func TestFetchLabelIDToName(t *testing.T) {
697798
t.Fatalf("unexpected label2: %q", m["Label_2"])
698799
}
699800
}
801+
802+
func TestFetchLabelNameToID_DoesNotCaseFoldIDs(t *testing.T) {
803+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
804+
if !(strings.HasSuffix(r.URL.Path, "/users/me/labels") || strings.HasSuffix(r.URL.Path, "/gmail/v1/users/me/labels")) {
805+
http.NotFound(w, r)
806+
return
807+
}
808+
w.Header().Set("Content-Type", "application/json")
809+
_ = json.NewEncoder(w).Encode(map[string]any{
810+
"labels": []map[string]any{
811+
{"id": "Label_1", "name": "Custom", "type": "user"},
812+
},
813+
})
814+
}))
815+
defer srv.Close()
816+
817+
svc, err := gmail.NewService(context.Background(),
818+
option.WithoutAuthentication(),
819+
option.WithHTTPClient(srv.Client()),
820+
option.WithEndpoint(srv.URL+"/"),
821+
)
822+
if err != nil {
823+
t.Fatalf("NewService: %v", err)
824+
}
825+
826+
m, err := fetchLabelNameToID(svc)
827+
if err != nil {
828+
t.Fatalf("fetchLabelNameToID: %v", err)
829+
}
830+
if m["custom"] != "Label_1" {
831+
t.Fatalf("missing case-folded name lookup: %#v", m)
832+
}
833+
if m["Label_1"] != "Label_1" {
834+
t.Fatalf("missing exact ID lookup: %#v", m)
835+
}
836+
if _, ok := m["label_1"]; ok {
837+
t.Fatalf("case-folded label ID should not resolve: %#v", m)
838+
}
839+
}

internal/cmd/gmail_labels_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,39 @@ func TestResolveLabelIDs(t *testing.T) {
1616
}
1717
}
1818

19+
func TestResolveLabelIDs_DoesNotCaseFoldIDs(t *testing.T) {
20+
m := map[string]string{
21+
"custom": "Label_123",
22+
}
23+
got := resolveLabelIDs([]string{"label_123", "Custom"}, m)
24+
if len(got) != 2 {
25+
t.Fatalf("unexpected: %#v", got)
26+
}
27+
if got[0] != "label_123" {
28+
t.Fatalf("case-folded label ID: %#v", got)
29+
}
30+
if got[1] != "Label_123" {
31+
t.Fatalf("did not resolve label name: %#v", got)
32+
}
33+
}
34+
35+
func TestResolveLabelIDs_ExactIDBeatsCaseFoldedName(t *testing.T) {
36+
m := map[string]string{
37+
"Label_9": "Label_9",
38+
"label_9": "Label_10",
39+
}
40+
got := resolveLabelIDs([]string{"Label_9", "label_9"}, m)
41+
if len(got) != 2 {
42+
t.Fatalf("unexpected: %#v", got)
43+
}
44+
if got[0] != "Label_9" {
45+
t.Fatalf("exact ID resolved through name collision: %#v", got)
46+
}
47+
if got[1] != "Label_10" {
48+
t.Fatalf("case-folded name did not resolve: %#v", got)
49+
}
50+
}
51+
1952
func TestFetchLabelIDToNameBehavior(t *testing.T) {
2053
// Unit tests for the actual API call live in integration; here we just ensure
2154
// the helper exists and returns a map. (Compile-time coverage.)

internal/cmd/gmail_labels_utils.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ func resolveLabelIDs(labels []string, nameToID map[string]string) []string {
2323
continue
2424
}
2525
if nameToID != nil {
26+
if id, ok := nameToID[trimmed]; ok {
27+
out = append(out, id)
28+
continue
29+
}
2630
if id, ok := nameToID[strings.ToLower(trimmed)]; ok {
2731
out = append(out, id)
2832
continue
@@ -128,7 +132,7 @@ func ensureLabelNameAvailable(svc *gmail.Service, name string) error {
128132
if label == nil {
129133
continue
130134
}
131-
if strings.ToLower(strings.TrimSpace(label.Id)) == want {
135+
if strings.TrimSpace(label.Id) == strings.TrimSpace(name) {
132136
return usagef("label already exists: %s", name)
133137
}
134138
labelName := strings.TrimSpace(label.Name)

internal/cmd/gmail_watch_utils.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ func resolveLabelIDsWithService(svc *gmail.Service, labels []string) ([]string,
6161
if trimmed == "" {
6262
continue
6363
}
64+
if id, ok := nameToID[trimmed]; ok {
65+
out = append(out, id)
66+
continue
67+
}
6468
if id, ok := nameToID[strings.ToLower(trimmed)]; ok {
6569
out = append(out, id)
6670
continue

0 commit comments

Comments
 (0)