Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Sheets: add `sheets create --parent` to place new spreadsheets in a Drive folder. (#424) — thanks @ManManavadaria.
- Calendar: add `calendar subscribe` (aliases `sub`, `add-calendar`) to add a shared calendar to the current account’s calendar list. (#327) — thanks @cdthompson.
- Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella.
- Gmail: add `gmail messages modify` for single-message label changes, complementing thread- and batch-level modify flows. (#281) — thanks @zerone0x.
- Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97.
- Drive: add `drive ls --all` (alias `--global`) to list across all accessible files; make `--all` and `--parent` mutually exclusive. (#107) — thanks @struong.
- Docs: update install docs to use the official Homebrew core formula (`brew install gogcli`). (#361) — thanks @zeldrisho.
Expand Down
5 changes: 1 addition & 4 deletions internal/cmd/gmail_batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,11 @@ func (c *GmailBatchModifyCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}

idMap, err := fetchLabelNameToID(svc)
addIDs, removeIDs, err := resolveModifyLabelIDs(svc, addLabels, removeLabels)
if err != nil {
return err
}

addIDs := resolveLabelIDs(addLabels, idMap)
removeIDs := resolveLabelIDs(removeLabels, idMap)

err = svc.Users.Messages.BatchModify("me", &gmail.BatchModifyMessagesRequest{
Ids: ids,
AddLabelIds: addIDs,
Expand Down
9 changes: 9 additions & 0 deletions internal/cmd/gmail_labels_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ func resolveLabelIDs(labels []string, nameToID map[string]string) []string {
return out
}

func resolveModifyLabelIDs(svc *gmail.Service, addLabels, removeLabels []string) ([]string, []string, error) {
idMap, err := fetchLabelNameToID(svc)
if err != nil {
return nil, nil, err
}

return resolveLabelIDs(addLabels, idMap), resolveLabelIDs(removeLabels, idMap), nil
}

func ensureLabelNameAvailable(svc *gmail.Service, name string) error {
idMap, err := fetchLabelNameToID(svc)
if err != nil {
Expand Down
63 changes: 63 additions & 0 deletions internal/cmd/gmail_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

type GmailMessagesCmd struct {
Search GmailMessagesSearchCmd `cmd:"" name:"search" aliases:"find,query,ls,list" group:"Read" help:"Search messages using Gmail query syntax"`
Modify GmailMessagesModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" group:"Organize" help:"Modify labels on a single message"`
}

type GmailMessagesSearchCmd struct {
Expand Down Expand Up @@ -148,6 +149,68 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro
return nil
}

type GmailMessagesModifyCmd struct {
MessageID string `arg:"" name:"messageId" help:"Message ID"`
Add string `name:"add" help:"Labels to add (comma-separated, name or ID)"`
Remove string `name:"remove" help:"Labels to remove (comma-separated, name or ID)"`
}

func (c *GmailMessagesModifyCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
messageID := normalizeGmailMessageID(strings.TrimSpace(c.MessageID))
if messageID == "" {
return usage("empty messageId")
}

addLabels := splitCSV(c.Add)
removeLabels := splitCSV(c.Remove)
if len(addLabels) == 0 && len(removeLabels) == 0 {
return usage("must specify --add and/or --remove")
}

if err := dryRunExit(ctx, flags, "gmail.messages.modify", map[string]any{
"message_id": messageID,
"add": addLabels,
"remove": removeLabels,
}); err != nil {
return err
}

account, err := requireAccount(flags)
if err != nil {
return err
}

svc, err := newGmailService(ctx, account)
if err != nil {
return err
}

addIDs, removeIDs, err := resolveModifyLabelIDs(svc, addLabels, removeLabels)
if err != nil {
return err
}

_, err = svc.Users.Messages.Modify("me", messageID, &gmail.ModifyMessageRequest{
AddLabelIds: addIDs,
RemoveLabelIds: removeIDs,
}).Context(ctx).Do()
if err != nil {
return err
}

if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"modified": messageID,
"addedLabels": addIDs,
"removedLabels": removeIDs,
})
}

u.Out().Printf("Modified message %s", messageID)
return nil
}

type messageItem struct {
ID string `json:"id"`
ThreadID string `json:"threadId,omitempty"`
Expand Down
158 changes: 158 additions & 0 deletions internal/cmd/gmail_messages_modify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cmd

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"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 TestGmailMessagesModifyCmd_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"):
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": "TRASH", "name": "TRASH", "type": "system"},
{"id": "Label_1", "name": "Custom", "type": "user"},
},
})
return
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/users/me/messages/") && 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] != "Label_1" {
http.Error(w, "bad addLabelIds", http.StatusBadRequest)
return
}
if len(body.RemoveLabelIds) != 1 || body.RemoveLabelIds[0] != "INBOX" {
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})

if err := runKong(t, &GmailMessagesModifyCmd{}, []string{
"msg1",
"--add", "Custom",
"--remove", "INBOX",
}, ctx, flags); 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 != "msg1" {
t.Fatalf("unexpected modified: %q", parsed.Modified)
}
if len(parsed.AddedLabels) != 1 || parsed.AddedLabels[0] != "Label_1" {
t.Fatalf("unexpected added labels: %#v", parsed.AddedLabels)
}
if len(parsed.RemovedLabels) != 1 || parsed.RemovedLabels[0] != "INBOX" {
t.Fatalf("unexpected removed labels: %#v", parsed.RemovedLabels)
}

plainOut := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)

if err := runKong(t, &GmailMessagesModifyCmd{}, []string{
"msg1",
"--add", "Custom",
"--remove", "INBOX",
}, ctx, flags); err != nil {
t.Fatalf("execute plain: %v", err)
}
})
if !strings.Contains(plainOut, "Modified message") {
t.Fatalf("unexpected plain output: %q", plainOut)
}
}

func TestGmailMessagesModifyCmd_ValidationErrors(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })

svc, err := gmail.NewService(context.Background(), option.WithoutAuthentication())
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"}

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)

t.Run("no labels", func(t *testing.T) {
err := runKong(t, &GmailMessagesModifyCmd{}, []string{"msg1"}, ctx, flags)
if err == nil || !strings.Contains(err.Error(), "must specify --add and/or --remove") {
t.Fatalf("expected validation error, got %v", err)
}
})

t.Run("empty message id", func(t *testing.T) {
err := runKong(t, &GmailMessagesModifyCmd{}, []string{"", "--add", "INBOX"}, ctx, flags)
if err == nil || !strings.Contains(err.Error(), "empty messageId") {
t.Fatalf("expected empty messageId error, got %v", err)
}
})
}
6 changes: 1 addition & 5 deletions internal/cmd/gmail_thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,15 +212,11 @@ func (c *GmailThreadModifyCmd) Run(ctx context.Context, flags *RootFlags) error
return err
}

// Resolve label names to IDs
idMap, err := fetchLabelNameToID(svc)
addIDs, removeIDs, err := resolveModifyLabelIDs(svc, addLabels, removeLabels)
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,
Expand Down
Loading