Compare commits
3 Commits
main
...
feature/th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c0bb10f71 | ||
|
|
e58ccfb87b | ||
|
|
356058ac7c |
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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...>`
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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...>",
|
||||
|
||||
104
internal/cmd/gmail_thread_cmd_test.go
Normal file
104
internal/cmd/gmail_thread_cmd_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user