Compare commits
3 Commits
main
...
feature/th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c0bb10f71 | ||
|
|
e58ccfb87b | ||
|
|
356058ac7c |
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 0.4.2 - Unreleased
|
## 0.4.2 - Unreleased
|
||||||
|
|
||||||
- (next)
|
- Gmail: `thread modify` subcommand + `thread get` split (#21) — thanks @alexknowshtml.
|
||||||
|
|
||||||
## 0.4.1 - 2025-12-28
|
## 0.4.1 - 2025-12-28
|
||||||
|
|
||||||
|
|||||||
@ -174,14 +174,15 @@ gog auth tokens # Manage stored refresh tokens
|
|||||||
```bash
|
```bash
|
||||||
# Search and read
|
# Search and read
|
||||||
gog gmail search 'newer_than:7d' --max 10
|
gog gmail search 'newer_than:7d' --max 10
|
||||||
gog gmail thread <threadId>
|
gog gmail thread get <threadId>
|
||||||
gog gmail thread <threadId> --download # Download attachments to current dir
|
gog gmail thread get <threadId> --download # Download attachments to current dir
|
||||||
gog gmail thread <threadId> --download --out-dir ./attachments
|
gog gmail thread get <threadId> --download --out-dir ./attachments
|
||||||
gog gmail get <messageId>
|
gog gmail get <messageId>
|
||||||
gog gmail get <messageId> --format metadata
|
gog gmail get <messageId> --format metadata
|
||||||
gog gmail attachment <messageId> <attachmentId>
|
gog gmail attachment <messageId> <attachmentId>
|
||||||
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
|
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
|
||||||
gog gmail url <threadId> # Print Gmail web URL
|
gog gmail url <threadId> # Print Gmail web URL
|
||||||
|
gog gmail thread modify <threadId> --add STARRED --remove INBOX
|
||||||
|
|
||||||
# Send and compose
|
# Send and compose
|
||||||
gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback"
|
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
|
gog gmail search 'newer_than:7d has:attachment' --max 10
|
||||||
|
|
||||||
# Get thread details and download attachments
|
# Get thread details and download attachments
|
||||||
gog gmail thread <threadId> --download
|
gog gmail thread get <threadId> --download
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create a calendar event with attendees
|
### Create a calendar event with attendees
|
||||||
|
|||||||
@ -155,7 +155,8 @@ Environment:
|
|||||||
- `gog calendar freebusy <calendarIds> --from RFC3339 --to RFC3339`
|
- `gog calendar freebusy <calendarIds> --from RFC3339 --to RFC3339`
|
||||||
- `gog calendar respond <calendarId> <eventId> --status accepted|declined|tentative [--send-updates all|none|externalOnly]`
|
- `gog calendar respond <calendarId> <eventId> --status accepted|declined|tentative [--send-updates all|none|externalOnly]`
|
||||||
- `gog gmail search <query> [--max N] [--page TOKEN]`
|
- `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 get <messageId> [--format full|metadata|raw] [--headers ...]`
|
||||||
- `gog gmail attachment <messageId> <attachmentId> [--out PATH] [--name NAME]`
|
- `gog gmail attachment <messageId> <attachmentId> [--out PATH] [--name NAME]`
|
||||||
- `gog gmail url <threadIds...>`
|
- `gog gmail url <threadIds...>`
|
||||||
|
|||||||
@ -135,7 +135,7 @@ func TestExecute_GmailThreadDraftsSend_JSON(t *testing.T) {
|
|||||||
|
|
||||||
_ = captureStderr(t, func() {
|
_ = captureStderr(t, func() {
|
||||||
out := captureStdout(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)
|
t.Fatalf("thread: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -86,7 +86,7 @@ func TestExecute_GmailThread_Text_Download(t *testing.T) {
|
|||||||
|
|
||||||
out := captureStdout(t, func() {
|
out := captureStdout(t, func() {
|
||||||
_ = captureStderr(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)
|
t.Fatalf("Execute: %v", execErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -238,7 +238,7 @@ func TestExecute_GmailThread_OutDir_CreatesParents_JSON(t *testing.T) {
|
|||||||
if execErr := Execute([]string{
|
if execErr := Execute([]string{
|
||||||
"--json",
|
"--json",
|
||||||
"--account", "a@b.com",
|
"--account", "a@b.com",
|
||||||
"gmail", "thread", "t-thread-1",
|
"gmail", "thread", "get", "t-thread-1",
|
||||||
"--download",
|
"--download",
|
||||||
"--out-dir", outDir,
|
"--out-dir", outDir,
|
||||||
}); execErr != nil {
|
}); execErr != nil {
|
||||||
|
|||||||
@ -16,11 +16,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newGmailThreadCmd(flags *rootFlags) *cobra.Command {
|
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 download bool
|
||||||
var outDir string
|
var outDir string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "thread <threadId>",
|
Use: "get <threadId>",
|
||||||
Short: "Get a thread with all messages (optionally download attachments)",
|
Short: "Get a thread with all messages (optionally download attachments)",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@ -147,6 +157,77 @@ func newGmailThreadCmd(flags *rootFlags) *cobra.Command {
|
|||||||
return cmd
|
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 {
|
func newGmailURLCmd(flags *rootFlags) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "url <threadIds...>",
|
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
|
# Gmail
|
||||||
gog gmail search 'newer_than:7d' --max 10
|
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 get <messageId> --format metadata
|
||||||
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
|
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
|
||||||
gog gmail labels get INBOX --json
|
gog gmail labels get INBOX --json
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user