Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Steinberger
8c0bb10f71 docs: update changelog for thread modify
Some checks failed
ci / test (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2025-12-31 13:08:35 +01:00
Peter Steinberger
e58ccfb87b fix: refine gmail thread modify command 2025-12-31 13:08:27 +01:00
Alex Hillman
356058ac7c Add thread modify command for batch label operations
This adds a new `gmail thread modify` subcommand that uses the Gmail API's
Threads.Modify endpoint to apply label changes to all messages in a thread
at once.

Usage:
  gog gmail thread modify <threadId> --add "Label1,Label2" --remove "Label3"

Features:
- Resolves label names to IDs automatically
- Supports both adding and removing labels in a single operation
- JSON output mode for programmatic use

BREAKING CHANGE: The `gmail thread` command is now a parent command with
subcommands. Use `gmail thread get <id>` instead of `gmail thread <id>`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 03:56:56 +00:00
8 changed files with 198 additions and 11 deletions

View File

@ -2,7 +2,7 @@
## 0.4.2 - Unreleased
- (next)
- Gmail: `thread modify` subcommand + `thread get` split (#21) — thanks @alexknowshtml.
## 0.4.1 - 2025-12-28

View File

@ -174,14 +174,15 @@ gog auth tokens # Manage stored refresh tokens
```bash
# Search and read
gog gmail search 'newer_than:7d' --max 10
gog gmail thread <threadId>
gog gmail thread <threadId> --download # Download attachments to current dir
gog gmail thread <threadId> --download --out-dir ./attachments
gog gmail thread get <threadId>
gog gmail thread get <threadId> --download # Download attachments to current dir
gog gmail thread get <threadId> --download --out-dir ./attachments
gog gmail get <messageId>
gog gmail get <messageId> --format metadata
gog gmail attachment <messageId> <attachmentId>
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
gog gmail url <threadId> # Print Gmail web URL
gog gmail thread modify <threadId> --add STARRED --remove INBOX
# Send and compose
gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback"
@ -484,7 +485,7 @@ If you use `pnpm`, see the shortcut section for `pnpm -s` (silent) to keep stdou
gog gmail search 'newer_than:7d has:attachment' --max 10
# Get thread details and download attachments
gog gmail thread <threadId> --download
gog gmail thread get <threadId> --download
```
### Create a calendar event with attendees

View File

@ -155,7 +155,8 @@ Environment:
- `gog calendar freebusy <calendarIds> --from RFC3339 --to RFC3339`
- `gog calendar respond <calendarId> <eventId> --status accepted|declined|tentative [--send-updates all|none|externalOnly]`
- `gog gmail search <query> [--max N] [--page TOKEN]`
- `gog gmail thread <threadId> [--download]`
- `gog gmail thread get <threadId> [--download]`
- `gog gmail thread modify <threadId> [--add ...] [--remove ...]`
- `gog gmail get <messageId> [--format full|metadata|raw] [--headers ...]`
- `gog gmail attachment <messageId> <attachmentId> [--out PATH] [--name NAME]`
- `gog gmail url <threadIds...>`

View File

@ -135,7 +135,7 @@ func TestExecute_GmailThreadDraftsSend_JSON(t *testing.T) {
_ = captureStderr(t, func() {
out := captureStdout(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "t1", "--download"}); err != nil {
if err := Execute([]string{"--json", "--account", "a@b.com", "gmail", "thread", "get", "t1", "--download"}); err != nil {
t.Fatalf("thread: %v", err)
}
})

View File

@ -86,7 +86,7 @@ func TestExecute_GmailThread_Text_Download(t *testing.T) {
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{"--account", "a@b.com", "gmail", "thread", "t-thread-1", "--download"}); execErr != nil {
if execErr := Execute([]string{"--account", "a@b.com", "gmail", "thread", "get", "t-thread-1", "--download"}); execErr != nil {
t.Fatalf("Execute: %v", execErr)
}
})
@ -238,7 +238,7 @@ func TestExecute_GmailThread_OutDir_CreatesParents_JSON(t *testing.T) {
if execErr := Execute([]string{
"--json",
"--account", "a@b.com",
"gmail", "thread", "t-thread-1",
"gmail", "thread", "get", "t-thread-1",
"--download",
"--out-dir", outDir,
}); execErr != nil {

View File

@ -16,11 +16,21 @@ import (
)
func newGmailThreadCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "thread",
Short: "Thread operations (get, modify)",
}
cmd.AddCommand(newGmailThreadGetCmd(flags))
cmd.AddCommand(newGmailThreadModifyCmd(flags))
return cmd
}
func newGmailThreadGetCmd(flags *rootFlags) *cobra.Command {
var download bool
var outDir string
cmd := &cobra.Command{
Use: "thread <threadId>",
Use: "get <threadId>",
Short: "Get a thread with all messages (optionally download attachments)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@ -147,6 +157,77 @@ func newGmailThreadCmd(flags *rootFlags) *cobra.Command {
return cmd
}
func newGmailThreadModifyCmd(flags *rootFlags) *cobra.Command {
var add string
var remove string
cmd := &cobra.Command{
Use: "modify <threadId>",
Short: "Modify labels on all messages in a thread",
Long: `Modify labels on all messages within a thread. This applies the label changes
to every message in the conversation, which is useful for archiving or categorizing
entire email threads at once.
Examples:
gog gmail thread modify abc123 --add "Work,Important"
gog gmail thread modify abc123 --remove INBOX --add "Archive"
gog gmail thread modify abc123 --add STARRED`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
threadID := args[0]
addLabels := splitCSV(add)
removeLabels := splitCSV(remove)
if len(addLabels) == 0 && len(removeLabels) == 0 {
return usage("must specify --add and/or --remove")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
// Resolve label names to IDs
idMap, err := fetchLabelNameToID(svc)
if err != nil {
return err
}
addIDs := resolveLabelIDs(addLabels, idMap)
removeIDs := resolveLabelIDs(removeLabels, idMap)
// Use Gmail's Threads.Modify API
_, err = svc.Users.Threads.Modify("me", threadID, &gmail.ModifyThreadRequest{
AddLabelIds: addIDs,
RemoveLabelIds: removeIDs,
}).Context(cmd.Context()).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"modified": threadID,
"addedLabels": addIDs,
"removedLabels": removeIDs,
})
}
u.Out().Printf("Modified thread %s", threadID)
return nil
},
}
cmd.Flags().StringVar(&add, "add", "", "Labels to add (comma-separated, name or ID)")
cmd.Flags().StringVar(&remove, "remove", "", "Labels to remove (comma-separated, name or ID)")
return cmd
}
func newGmailURLCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "url <threadIds...>",

View File

@ -0,0 +1,104 @@
package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
func TestGmailThreadModifyCmd_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && (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": "INBOX", "name": "INBOX", "type": "system"},
{"id": "Label_1", "name": "Custom", "type": "user"},
},
})
return
case r.Method == http.MethodPost && (strings.Contains(r.URL.Path, "/users/me/threads/") || strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/")) && strings.HasSuffix(r.URL.Path, "/modify"):
var body struct {
AddLabelIds []string `json:"addLabelIds"`
RemoveLabelIds []string `json:"removeLabelIds"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if len(body.AddLabelIds) != 1 || body.AddLabelIds[0] != "INBOX" {
http.Error(w, "bad addLabelIds", http.StatusBadRequest)
return
}
if len(body.RemoveLabelIds) != 1 || body.RemoveLabelIds[0] != "Label_1" {
http.Error(w, "bad removeLabelIds", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(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 }
flags := &rootFlags{Account: "a@b.com"}
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 := newGmailThreadModifyCmd(flags)
cmd.SetContext(ctx)
cmd.SetArgs([]string{"t1"})
cmd.Flags().Set("add", "INBOX")
cmd.Flags().Set("remove", "Custom")
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
var parsed struct {
Modified string `json:"modified"`
AddedLabels []string `json:"addedLabels"`
RemovedLabels []string `json:"removedLabels"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Modified != "t1" {
t.Fatalf("unexpected modified: %q", parsed.Modified)
}
if len(parsed.AddedLabels) != 1 || parsed.AddedLabels[0] != "INBOX" {
t.Fatalf("unexpected added labels: %#v", parsed.AddedLabels)
}
if len(parsed.RemovedLabels) != 1 || parsed.RemovedLabels[0] != "Label_1" {
t.Fatalf("unexpected removed labels: %#v", parsed.RemovedLabels)
}
}

View File

@ -56,7 +56,7 @@ func Execute(args []string) error {
# Gmail
gog gmail search 'newer_than:7d' --max 10
gog gmail thread <threadId>
gog gmail thread get <threadId>
gog gmail get <messageId> --format metadata
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
gog gmail labels get INBOX --json