From 87b2e7a20d72d78bd9421906e91814ac9ef83b58 Mon Sep 17 00:00:00 2001 From: iskkw9973 Date: Tue, 17 Mar 2026 19:47:53 +0900 Subject: [PATCH] feat(gmail): add labels style command --- README.md | 3 +- internal/cmd/gmail_labels.go | 88 ++++++ internal/cmd/gmail_labels_style_cmd_test.go | 293 ++++++++++++++++++++ 3 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/gmail_labels_style_cmd_test.go diff --git a/README.md b/README.md index 13fd0f56..4ab5b160 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli ## Features -- **Gmail** - search threads/messages, send mail, view attachments, manage labels/drafts/filters/delegation/vacation settings, modify single messages, export filters, inspect history, and run Pub/Sub watch webhooks +- **Gmail** - search threads/messages, send mail, view attachments, manage labels (create/rename/style/delete)/drafts/filters/delegation/vacation settings, modify single messages, export filters, inspect history, and run Pub/Sub watch webhooks - **Email tracking** - track opens for `gog gmail send --track` with a small Cloudflare Worker backend - **Calendar** - list/create/update/delete events, manage invitations, aliases, subscriptions, team calendars, free/busy/conflicts, propose new times, focus/OOO/working-location events, recurrence, and reminders - **Classroom** - manage courses, roster, coursework/materials, submissions, announcements, topics, invitations, guardians, profiles @@ -652,6 +652,7 @@ gog gmail labels create "My Label" gog gmail labels rename "Old Label" "New Label" gog gmail labels modify --add STARRED --remove INBOX gog gmail labels delete # Deletes user label (guards system labels; confirm) +gog gmail labels style --background-color "#16a765" --text-color "#ffffff" # Change label color # Batch operations gog gmail batch delete diff --git a/internal/cmd/gmail_labels.go b/internal/cmd/gmail_labels.go index 58470584..d4e1fa1f 100644 --- a/internal/cmd/gmail_labels.go +++ b/internal/cmd/gmail_labels.go @@ -19,6 +19,7 @@ type GmailLabelsCmd struct { Rename GmailLabelsRenameCmd `cmd:"" name:"rename" aliases:"mv" help:"Rename a label"` Modify GmailLabelsModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on threads"` Delete GmailLabelsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del" help:"Delete a label"` + Style GmailLabelsStyleCmd `cmd:"" name:"style" aliases:"color" help:"Change label styles"` } type GmailLabelsGetCmd struct { @@ -384,6 +385,93 @@ func (c *GmailLabelsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error ) } +type GmailLabelsStyleCmd struct { + Label string `arg:"" name:"labelIdOrName" help:"Label ID or name"` + BackgroundColor string `name:"background-color" help:"Background color (hex, e.g. #16a765)"` + TextColor string `name:"text-color" help:"Text color (hex, e.g. #ffffff)"` +} + +func (c *GmailLabelsStyleCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + backgroundColor := strings.TrimSpace(c.BackgroundColor) + textColor := strings.TrimSpace(c.TextColor) + + if backgroundColor == "" || textColor == "" { + return usage("both --background-color and --text-color are required") + } + + raw := strings.TrimSpace(c.Label) + if raw == "" { + return usage("label is required") + } + + svc, err := newGmailService(ctx, account) + if err != nil { + return err + } + + label, err := svc.Users.Labels.Get("me", raw).Context(ctx).Do() + if err != nil { + if !isNotFoundAPIError(err) { + return err + } + if looksLikeCustomLabelID(raw) { + return fmt.Errorf("label not found: %s", raw) + } + idMap, mapErr := fetchLabelNameOnlyToID(svc) + if mapErr != nil { + return mapErr + } + id, ok := idMap[strings.ToLower(raw)] + if !ok { + return fmt.Errorf("label not found: %s", raw) + } + label, err = svc.Users.Labels.Get("me", id).Context(ctx).Do() + if err != nil { + return err + } + } + + if label.Type == "system" { + return fmt.Errorf("cannot style system label %q", label.Name) + } + + if exit := dryRunExit(ctx, flags, "gmail.labels.style", map[string]string{ + "id": label.Id, + "name": label.Name, + "backgroundColor": backgroundColor, + "textColor": textColor, + }); exit != nil { + return exit + } + + updated, err := svc.Users.Labels.Patch("me", label.Id, &gmail.Label{ + Color: &gmail.LabelColor{ + BackgroundColor: backgroundColor, + TextColor: textColor, + }, + }).Context(ctx).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"label": updated}) + } + if updated.Color != nil { + u.Out().Printf("Styled label %q (id: %s): background=%s text=%s", + updated.Name, updated.Id, updated.Color.BackgroundColor, updated.Color.TextColor) + } else { + u.Out().Printf("Styled label %q (id: %s)", updated.Name, updated.Id) + } + return nil +} + func fetchLabelIDToName(svc *gmail.Service) (map[string]string, error) { resp, err := svc.Users.Labels.List("me").Do() if err != nil { diff --git a/internal/cmd/gmail_labels_style_cmd_test.go b/internal/cmd/gmail_labels_style_cmd_test.go new file mode 100644 index 00000000..580e4177 --- /dev/null +++ b/internal/cmd/gmail_labels_style_cmd_test.go @@ -0,0 +1,293 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/gmail/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func newLabelsStyleService(t *testing.T, handler http.HandlerFunc) { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(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) + } + + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } +} + +func newLabelsStyleContext(t *testing.T, jsonMode bool) context.Context { + t.Helper() + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + if jsonMode { + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + } + return ctx +} + +func TestGmailLabelsStyleCmd_JSON_ExactID(t *testing.T) { + patchCalled := false + + newLabelsStyleService(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && isLabelsItemPath(r.URL.Path): + if pathTail(r.URL.Path) != "Label_1" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "Label_1", "name": "MyLabel", "type": "user"}) + return + case r.Method == http.MethodPatch && isLabelsItemPath(r.URL.Path): + patchCalled = true + if pathTail(r.URL.Path) != "Label_1" { + http.Error(w, "wrong patch id", http.StatusBadRequest) + return + } + var body struct { + Color struct { + BackgroundColor string `json:"backgroundColor"` + TextColor string `json:"textColor"` + } `json:"color"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Color.BackgroundColor != "#16a765" || body.Color.TextColor != "#ffffff" { + http.Error(w, "unexpected color", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "Label_1", + "name": "MyLabel", + "type": "user", + "color": map[string]any{ + "backgroundColor": "#16a765", + "textColor": "#ffffff", + }, + }) + return + default: + http.NotFound(w, r) + } + }) + + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsStyleContext(t, true) + + out := captureStdout(t, func() { + if err := runKong(t, &GmailLabelsStyleCmd{}, []string{"Label_1", "--background-color", "#16a765", "--text-color", "#ffffff"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + if !patchCalled { + t.Fatal("expected patch call") + } + + var parsed struct { + Label struct { + ID string `json:"id"` + Name string `json:"name"` + Color struct { + BackgroundColor string `json:"backgroundColor"` + TextColor string `json:"textColor"` + } `json:"color"` + } `json:"label"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if parsed.Label.Color.BackgroundColor != "#16a765" || parsed.Label.Color.TextColor != "#ffffff" { + t.Fatalf("unexpected color output: %#v", parsed.Label.Color) + } +} + +func TestGmailLabelsStyleCmd_NameFallback(t *testing.T) { + patchCalled := false + listCalled := false + + newLabelsStyleService(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && isLabelsItemPath(r.URL.Path): + id := pathTail(r.URL.Path) + if id == "MyLabel" || id == "mylabel" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": 404, "message": "Requested entity was not found."}}) + return + } + if id == "Label_5" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "Label_5", "name": "MyLabel", "type": "user"}) + return + } + http.NotFound(w, r) + return + case r.Method == http.MethodGet && isLabelsListPath(r.URL.Path): + listCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{ + {"id": "Label_5", "name": "MyLabel", "type": "user"}, + }}) + return + case r.Method == http.MethodPatch && isLabelsItemPath(r.URL.Path): + patchCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "Label_5", + "name": "MyLabel", + "type": "user", + "color": map[string]any{ + "backgroundColor": "#16a765", + "textColor": "#ffffff", + }, + }) + return + default: + http.NotFound(w, r) + } + }) + + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsStyleContext(t, false) + + var buf strings.Builder + u, err := ui.New(ui.Options{Stdout: &buf, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx = ui.WithUI(ctx, u) + + if err := runKong(t, &GmailLabelsStyleCmd{}, []string{"MyLabel", "--background-color", "#16a765", "--text-color", "#ffffff"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + if !listCalled { + t.Fatal("expected list call for name fallback") + } + if !patchCalled { + t.Fatal("expected patch call") + } + out := buf.String() + if !strings.Contains(out, "Styled label") { + t.Fatalf("missing 'Styled label' in output: %q", out) + } +} + +func TestGmailLabelsStyleCmd_SystemLabelBlocked(t *testing.T) { + patchCalled := false + + newLabelsStyleService(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && isLabelsItemPath(r.URL.Path): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "INBOX", "name": "INBOX", "type": "system"}) + return + case r.Method == http.MethodPatch && isLabelsItemPath(r.URL.Path): + patchCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + return + default: + http.NotFound(w, r) + } + }) + + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsStyleContext(t, false) + err := runKong(t, &GmailLabelsStyleCmd{}, []string{"INBOX", "--background-color", "#16a765", "--text-color", "#ffffff"}, ctx, flags) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), `cannot style system label "INBOX"`) { + t.Fatalf("unexpected error: %v", err) + } + if patchCalled { + t.Fatal("patch should not run for system labels") + } +} + +func TestGmailLabelsStyleCmd_NotFound(t *testing.T) { + newLabelsStyleService(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && isLabelsItemPath(r.URL.Path): + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": 404, "message": "Requested entity was not found."}}) + return + case r.Method == http.MethodGet && isLabelsListPath(r.URL.Path): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{}}) + return + default: + http.NotFound(w, r) + } + }) + + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsStyleContext(t, false) + err := runKong(t, &GmailLabelsStyleCmd{}, []string{"NoSuchLabel", "--background-color", "#16a765", "--text-color", "#ffffff"}, ctx, flags) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "label not found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGmailLabelsStyleCmd_MissingBothColors(t *testing.T) { + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsStyleContext(t, false) + err := runKong(t, &GmailLabelsStyleCmd{}, []string{"Label_1"}, ctx, flags) + if err == nil { + t.Fatal("expected error for missing colors") + } + if !strings.Contains(err.Error(), "both --background-color and --text-color are required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGmailLabelsStyleCmd_MissingOneColor(t *testing.T) { + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsStyleContext(t, false) + + err := runKong(t, &GmailLabelsStyleCmd{}, []string{"Label_1", "--background-color", "#16a765"}, ctx, flags) + if err == nil { + t.Fatal("expected error for missing text-color") + } + if !strings.Contains(err.Error(), "both --background-color and --text-color are required") { + t.Fatalf("unexpected error: %v", err) + } + + err = runKong(t, &GmailLabelsStyleCmd{}, []string{"Label_1", "--text-color", "#ffffff"}, ctx, flags) + if err == nil { + t.Fatal("expected error for missing background-color") + } + if !strings.Contains(err.Error(), "both --background-color and --text-color are required") { + t.Fatalf("unexpected error: %v", err) + } +}