diff --git a/CHANGELOG.md b/CHANGELOG.md index 745aa979..946fbb60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Calendar: reject ambiguous calendar-name selectors for `calendar events` instead of guessing. (#131) — thanks @salmonumbrella. - Gmail: `drafts update --quote` now picks a non-draft, non-self message from thread fallback (or errors clearly), avoiding self-quote loops and wrong reply headers. (#394) — thanks @salmonumbrella. - Auth: add `--gmail-scope full|readonly`, and disable `include_granted_scopes` for readonly/limited auth requests to avoid Drive/Gmail scope accumulation. (#113) — thanks @salmonumbrella. +- Gmail: `gmail archive|read|unread|trash` convenience commands now honor `--dry-run` and emit action-specific dry-run ops. (#385) — thanks @yeager. ## 0.11.0 - 2026-02-15 diff --git a/internal/cmd/gmail.go b/internal/cmd/gmail.go index 27d9afb4..b8fb1070 100644 --- a/internal/cmd/gmail.go +++ b/internal/cmd/gmail.go @@ -27,8 +27,12 @@ type GmailCmd struct { URL GmailURLCmd `cmd:"" name:"url" group:"Read" help:"Print Gmail web URLs for threads"` History GmailHistoryCmd `cmd:"" name:"history" group:"Read" help:"Gmail history"` - Labels GmailLabelsCmd `cmd:"" name:"labels" aliases:"label" group:"Organize" help:"Label operations"` - Batch GmailBatchCmd `cmd:"" name:"batch" group:"Organize" help:"Batch operations"` + Labels GmailLabelsCmd `cmd:"" name:"labels" aliases:"label" group:"Organize" help:"Label operations"` + Batch GmailBatchCmd `cmd:"" name:"batch" group:"Organize" help:"Batch operations"` + Archive GmailArchiveCmd `cmd:"" name:"archive" group:"Organize" help:"Archive messages (remove from inbox)"` + Read GmailReadCmd `cmd:"" name:"mark-read" aliases:"read-messages" group:"Organize" help:"Mark messages as read"` + Unread GmailUnreadCmd `cmd:"" name:"unread" aliases:"mark-unread" group:"Organize" help:"Mark messages as unread"` + Trash GmailTrashMsgCmd `cmd:"" name:"trash" group:"Organize" help:"Move messages to trash"` Send GmailSendCmd `cmd:"" name:"send" group:"Write" help:"Send an email"` Track GmailTrackCmd `cmd:"" name:"track" group:"Write" help:"Email open tracking"` diff --git a/internal/cmd/gmail_archive.go b/internal/cmd/gmail_archive.go new file mode 100644 index 00000000..692018c5 --- /dev/null +++ b/internal/cmd/gmail_archive.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/gmail/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// GmailArchiveCmd archives messages (removes INBOX label). +type GmailArchiveCmd struct { + MessageIDs []string `arg:"" optional:"" name:"messageId" help:"Message IDs to archive"` + Query string `name:"query" short:"q" help:"Archive all messages matching this Gmail search query"` + Max int64 `name:"max" aliases:"limit" help:"Max messages to archive (with --query)" default:"100"` +} + +func (c *GmailArchiveCmd) Run(ctx context.Context, flags *RootFlags) error { + return gmailBulkLabelOp(ctx, flags, c.MessageIDs, c.Query, c.Max, nil, []string{"INBOX"}, "archived", "gmail.archive") +} + +// GmailTrashMsgCmd moves messages to trash. +type GmailTrashMsgCmd struct { + MessageIDs []string `arg:"" optional:"" name:"messageId" help:"Message IDs to trash"` + Query string `name:"query" short:"q" help:"Trash all messages matching this Gmail search query"` + Max int64 `name:"max" aliases:"limit" help:"Max messages to trash (with --query)" default:"100"` +} + +func (c *GmailTrashMsgCmd) Run(ctx context.Context, flags *RootFlags) error { + return gmailBulkLabelOp(ctx, flags, c.MessageIDs, c.Query, c.Max, []string{"TRASH"}, []string{"INBOX"}, "trashed", "gmail.trash") +} + +// GmailReadCmd marks messages as read. +type GmailReadCmd struct { + MessageIDs []string `arg:"" optional:"" name:"messageId" help:"Message IDs to mark as read"` + Query string `name:"query" short:"q" help:"Mark all messages matching this query as read"` + Max int64 `name:"max" aliases:"limit" help:"Max messages (with --query)" default:"100"` +} + +func (c *GmailReadCmd) Run(ctx context.Context, flags *RootFlags) error { + return gmailBulkLabelOp(ctx, flags, c.MessageIDs, c.Query, c.Max, nil, []string{"UNREAD"}, "marked as read", "gmail.read") +} + +// GmailUnreadCmd marks messages as unread. +type GmailUnreadCmd struct { + MessageIDs []string `arg:"" optional:"" name:"messageId" help:"Message IDs to mark as unread"` + Query string `name:"query" short:"q" help:"Mark all messages matching this query as unread"` + Max int64 `name:"max" aliases:"limit" help:"Max messages (with --query)" default:"100"` +} + +func (c *GmailUnreadCmd) Run(ctx context.Context, flags *RootFlags) error { + return gmailBulkLabelOp(ctx, flags, c.MessageIDs, c.Query, c.Max, []string{"UNREAD"}, nil, "marked as unread", "gmail.unread") +} + +// gmailBulkLabelOp handles the common pattern: resolve IDs (from args or query), then batch modify labels. +func gmailBulkLabelOp(ctx context.Context, flags *RootFlags, messageIDs []string, query string, limit int64, addLabels, removeLabels []string, verb string, dryRunOp string) error { + u := ui.FromContext(ctx) + + idsFromArgs := make([]string, 0, len(messageIDs)) + for _, id := range messageIDs { + id = normalizeGmailMessageID(id) + if id != "" { + idsFromArgs = append(idsFromArgs, id) + } + } + + if len(idsFromArgs) == 0 && query == "" { + return usage("provide message IDs or --query") + } + + if err := dryRunExit(ctx, flags, dryRunOp, map[string]any{ + "message_ids": idsFromArgs, + "query": strings.TrimSpace(query), + "max": limit, + "added_labels": nonNilStrings(addLabels), + "removed_labels": nonNilStrings(removeLabels), + "action": verb, + }); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newGmailService(ctx, account) + if err != nil { + return err + } + + // Collect IDs: either from args or by searching + ids := make([]string, 0, len(idsFromArgs)) + if query != "" { + ids, err = searchMessageIDs(ctx, svc, query, limit) + if err != nil { + return err + } + } + ids = append(ids, idsFromArgs...) + + if len(ids) == 0 { + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "action": verb, + "count": 0, + }) + } + u.Err().Println("No messages found") + return nil + } + + // Resolve label names to IDs + idMap, err := fetchLabelNameToID(svc) + if err != nil { + return err + } + addIDs := resolveLabelIDs(addLabels, idMap) + removeIDs := resolveLabelIDs(removeLabels, idMap) + + // Batch modify in chunks of 1000 (API limit) + total := 0 + for i := 0; i < len(ids); i += 1000 { + end := i + 1000 + if end > len(ids) { + end = len(ids) + } + chunk := ids[i:end] + + req := &gmail.BatchModifyMessagesRequest{ + Ids: chunk, + } + if len(addIDs) > 0 { + req.AddLabelIds = addIDs + } + if len(removeIDs) > 0 { + req.RemoveLabelIds = removeIDs + } + + if err := svc.Users.Messages.BatchModify("me", req).Do(); err != nil { + return fmt.Errorf("batch modify failed at offset %d: %w", i, err) + } + total += len(chunk) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "action": verb, + "count": total, + "addedLabels": addLabels, + "removedLabels": removeLabels, + }) + } + + u.Out().Printf("%s %d message%s", capitalizeFirst(verb), total, pluralS(total)) + return nil +} + +// searchMessageIDs returns message IDs matching a Gmail query. +func searchMessageIDs(ctx context.Context, svc *gmail.Service, query string, limit int64) ([]string, error) { + var ids []string + pageToken := "" + remaining := limit + + for { + batchSize := remaining + if batchSize > 500 { + batchSize = 500 + } + + call := svc.Users.Messages.List("me"). + Q(query). + MaxResults(batchSize). + Fields("messages(id),nextPageToken"). + Context(ctx) + if pageToken != "" { + call = call.PageToken(pageToken) + } + + resp, err := call.Do() + if err != nil { + return nil, err + } + + for _, m := range resp.Messages { + if m != nil && m.Id != "" { + ids = append(ids, m.Id) + } + } + + remaining -= int64(len(resp.Messages)) + if resp.NextPageToken == "" || remaining <= 0 { + break + } + pageToken = resp.NextPageToken + } + + return ids, nil +} + +func pluralS(n int) string { + if n == 1 { + return "" + } + return "s" +} + +func capitalizeFirst(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func nonNilStrings(values []string) []string { + if values == nil { + return []string{} + } + return values +} diff --git a/internal/cmd/gmail_archive_test.go b/internal/cmd/gmail_archive_test.go new file mode 100644 index 00000000..b19729ab --- /dev/null +++ b/internal/cmd/gmail_archive_test.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/steipete/gogcli/internal/outfmt" +) + +func runGmailBulkDryRun(t *testing.T, cmd any, args []string) map[string]any { + t.Helper() + + ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + err := runKong(t, cmd, args, ctx, &RootFlags{DryRun: true}) + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 0 { + t.Fatalf("expected dry-run exit code 0, got: %v", err) + } + }) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + return got +} + +func requestStringSlice(t *testing.T, req map[string]any, key string) []string { + t.Helper() + + raw, ok := req[key].([]any) + if !ok { + t.Fatalf("expected request.%s array, got %T", key, req[key]) + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + s, ok := v.(string) + if !ok { + t.Fatalf("expected string in request.%s, got %T", key, v) + } + out = append(out, s) + } + return out +} + +func requireRequestMap(t *testing.T, got map[string]any) map[string]any { + t.Helper() + + req, ok := got["request"].(map[string]any) + if !ok { + t.Fatalf("expected request object, got %T", got["request"]) + } + return req +} + +func TestGmailBulkOps_DryRun_UsesSpecificOpsAndLabels(t *testing.T) { + tests := []struct { + name string + cmd any + args []string + wantOp string + wantAdd []string + wantRemove []string + }{ + { + name: "archive", + cmd: &GmailArchiveCmd{}, + args: []string{"msg1"}, + wantOp: "gmail.archive", + wantAdd: []string{}, + wantRemove: []string{"INBOX"}, + }, + { + name: "read", + cmd: &GmailReadCmd{}, + args: []string{"msg1"}, + wantOp: "gmail.read", + wantAdd: []string{}, + wantRemove: []string{"UNREAD"}, + }, + { + name: "unread", + cmd: &GmailUnreadCmd{}, + args: []string{"msg1"}, + wantOp: "gmail.unread", + wantAdd: []string{"UNREAD"}, + wantRemove: []string{}, + }, + { + name: "trash", + cmd: &GmailTrashMsgCmd{}, + args: []string{"msg1"}, + wantOp: "gmail.trash", + wantAdd: []string{"TRASH"}, + wantRemove: []string{"INBOX"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := runGmailBulkDryRun(t, tt.cmd, tt.args) + + op, ok := got["op"].(string) + if !ok || op != tt.wantOp { + t.Fatalf("expected op=%q, got=%v", tt.wantOp, got["op"]) + } + + req := requireRequestMap(t, got) + messageIDs := requestStringSlice(t, req, "message_ids") + if len(messageIDs) != 1 || messageIDs[0] != "msg1" { + t.Fatalf("unexpected request.message_ids: %v", messageIDs) + } + + added := requestStringSlice(t, req, "added_labels") + if len(added) != len(tt.wantAdd) { + t.Fatalf("unexpected request.added_labels len: got=%v want=%v", added, tt.wantAdd) + } + for i := range tt.wantAdd { + if added[i] != tt.wantAdd[i] { + t.Fatalf("unexpected request.added_labels: got=%v want=%v", added, tt.wantAdd) + } + } + + removed := requestStringSlice(t, req, "removed_labels") + if len(removed) != len(tt.wantRemove) { + t.Fatalf("unexpected request.removed_labels len: got=%v want=%v", removed, tt.wantRemove) + } + for i := range tt.wantRemove { + if removed[i] != tt.wantRemove[i] { + t.Fatalf("unexpected request.removed_labels: got=%v want=%v", removed, tt.wantRemove) + } + } + }) + } +} + +func TestGmailArchiveCmd_DryRun_QueryMode_NoAccountRequired(t *testing.T) { + got := runGmailBulkDryRun(t, &GmailArchiveCmd{}, []string{"--query", "is:unread", "--max", "25"}) + + if op, _ := got["op"].(string); op != "gmail.archive" { + t.Fatalf("expected op gmail.archive, got %v", got["op"]) + } + + req := requireRequestMap(t, got) + if q, _ := req["query"].(string); q != "is:unread" { + t.Fatalf("unexpected query: %v", req["query"]) + } + if limit, ok := req["max"].(float64); !ok || int(limit) != 25 { + t.Fatalf("unexpected max: %v", req["max"]) + } + if ids := requestStringSlice(t, req, "message_ids"); len(ids) != 0 { + t.Fatalf("expected empty message_ids, got %v", ids) + } +}