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 @@ -10,6 +10,7 @@
- CLI: add Git-style `gog help <command>`, make explicit output flags override environment defaults, validate color and JSON-only transforms before command execution, report early usage errors on stderr, and reject contradictory schema plain output.
- Docs: prevent multi-paragraph Markdown range replacements from inheriting the matched paragraph's heading or list style. (#756) — thanks @sebsnyk.
- Docs: preserve nested Markdown list levels as native bullets inside imported and updated table cells. (#749) — thanks @sebsnyk.
- Gmail: add explicit `gmail archive --thread` semantics so IDs from thread search can archive every message in each thread. (#752) — thanks @sebsnyk.

## 0.24.0 - 2026-06-11

Expand Down
2 changes: 1 addition & 1 deletion docs/commands.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ Generated from `gog schema --json`.
- [`gog forms (form) watch (watches) list (ls) <formId>`](commands/gog-forms-watch-list.md) - List active watches
- [`gog forms (form) watch (watches) renew (refresh) <formId> <watchId>`](commands/gog-forms-watch-renew.md) - Renew a watch (extends 7 days)
- [`gog gmail (mail,email) <command> [flags]`](commands/gog-gmail.md) - Gmail
- [`gog gmail (mail,email) archive [<messageId> ...] [flags]`](commands/gog-gmail-archive.md) - Archive messages (remove from inbox)
- [`gog gmail (mail,email) archive [<messageId> ...] [flags]`](commands/gog-gmail-archive.md) - Archive messages or explicit threads (remove from inbox)
- [`gog gmail (mail,email) attachment <messageId> <attachmentId> [flags]`](commands/gog-gmail-attachment.md) - Download a single attachment
- [`gog gmail (mail,email) autoreply <query> ... [flags]`](commands/gog-gmail-autoreply.md) - Reply once to matching messages
- [`gog gmail (mail,email) batch <command>`](commands/gog-gmail-batch.md) - Batch operations (permanent delete requires broader Gmail scope; use gmail trash for normal trashing)
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ Generated pages: 629.
- [gog forms watch list](gog-forms-watch-list.md) - List active watches
- [gog forms watch renew](gog-forms-watch-renew.md) - Renew a watch (extends 7 days)
- [gog gmail](gog-gmail.md) - Gmail
- [gog gmail archive](gog-gmail-archive.md) - Archive messages (remove from inbox)
- [gog gmail archive](gog-gmail-archive.md) - Archive messages or explicit threads (remove from inbox)
- [gog gmail attachment](gog-gmail-attachment.md) - Download a single attachment
- [gog gmail autoreply](gog-gmail-autoreply.md) - Reply once to matching messages
- [gog gmail batch](gog-gmail-batch.md) - Batch operations (permanent delete requires broader Gmail scope; use gmail trash for normal trashing)
Expand Down
3 changes: 2 additions & 1 deletion docs/commands/gog-gmail-archive.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.

Archive messages (remove from inbox)
Archive messages or explicit threads (remove from inbox)

## Usage

Expand Down Expand Up @@ -37,6 +37,7 @@ gog gmail (mail,email) archive [<messageId> ...] [flags]
| `-q`<br>`--query` | `string` | | Archive all messages matching this Gmail search query |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--thread` | `bool` | | Treat positional IDs as thread IDs and archive every message in each thread |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/gog-gmail.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ gog gmail (mail,email) <command> [flags]

## Subcommands

- [gog gmail archive](gog-gmail-archive.md) - Archive messages (remove from inbox)
- [gog gmail archive](gog-gmail-archive.md) - Archive messages or explicit threads (remove from inbox)
- [gog gmail attachment](gog-gmail-attachment.md) - Download a single attachment
- [gog gmail autoreply](gog-gmail-autoreply.md) - Reply once to matching messages
- [gog gmail batch](gog-gmail-batch.md) - Batch operations (permanent delete requires broader Gmail scope; use gmail trash for normal trashing)
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/gmail.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type GmailCmd struct {

Labels GmailLabelsCmd `cmd:"" name:"labels" aliases:"label" group:"Organize" help:"Label operations"`
Batch GmailBatchCmd `cmd:"" name:"batch" group:"Organize" help:"Batch operations (permanent delete requires broader Gmail scope; use gmail trash for normal trashing)"`
Archive GmailArchiveCmd `cmd:"" name:"archive" group:"Organize" help:"Archive messages (remove from inbox)"`
Archive GmailArchiveCmd `cmd:"" name:"archive" group:"Organize" help:"Archive messages or explicit threads (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"`
Expand Down
91 changes: 90 additions & 1 deletion internal/cmd/gmail_archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,104 @@ import (

// GmailArchiveCmd archives messages (removes INBOX label).
type GmailArchiveCmd struct {
MessageIDs []string `arg:"" optional:"" name:"messageId" help:"Message IDs to archive"`
MessageIDs []string `arg:"" optional:"" name:"messageId" help:"Message IDs to archive, or thread IDs with --thread"`
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"`
Thread bool `name:"thread" help:"Treat positional IDs as thread IDs and archive every message in each thread"`
}

func (c *GmailArchiveCmd) Run(ctx context.Context, flags *RootFlags) error {
if c.Thread {
return gmailArchiveThreads(ctx, flags, c.MessageIDs, c.Query)
}
return gmailBulkLabelOp(ctx, flags, c.MessageIDs, c.Query, c.Max, nil, []string{"INBOX"}, "archived", "gmail.archive")
}

func gmailArchiveThreads(ctx context.Context, flags *RootFlags, rawIDs []string, query string) error {
u := ui.FromContext(ctx)
if strings.TrimSpace(query) != "" {
return usage("--thread cannot be used with --query; provide thread IDs as positional arguments")
}

threadIDs := make([]string, 0, len(rawIDs))
for _, id := range rawIDs {
if id = normalizeGmailThreadID(id); id != "" {
threadIDs = append(threadIDs, id)
}
}
if len(threadIDs) == 0 {
return usage("provide thread IDs with --thread")
}

if err := dryRunExit(ctx, flags, "gmail.archive", map[string]any{
"thread_ids": threadIDs,
"removed_labels": []string{"INBOX"},
"action": "archived",
"resource": "thread",
}); err != nil {
return err
}

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

type archiveResult struct {
ThreadID string `json:"threadId"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
results := make([]archiveResult, 0, len(threadIDs))
succeeded := 0
failed := 0
for _, threadID := range threadIDs {
_, err := svc.Users.Threads.Modify("me", threadID, &gmail.ModifyThreadRequest{
RemoveLabelIds: []string{"INBOX"},
}).Context(ctx).Do()
if err != nil {
results = append(results, archiveResult{ThreadID: threadID, Error: err.Error()})
failed++
if !outfmt.IsJSON(ctx) {
u.Err().Errorf("%s: %s", threadID, err.Error())
}
continue
}
results = append(results, archiveResult{ThreadID: threadID, Success: true})
succeeded++
}

switch {
case outfmt.IsJSON(ctx):
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"action": "archived",
"count": succeeded,
"failed": failed,
"resource": "thread",
"removedLabels": []string{"INBOX"},
"results": results,
}); err != nil {
return err
}
case failed == 0:
u.Out().Linef("Archived %d thread%s", succeeded, pluralS(succeeded))
default:
for _, result := range results {
if result.Success {
u.Out().Linef("%s\tarchived", result.ThreadID)
}
}
}
if failed > 0 {
return fmt.Errorf("archived %d of %d threads; %d failed", succeeded, len(threadIDs), failed)
}
return nil
}

// GmailTrashMsgCmd moves messages to trash.
type GmailTrashMsgCmd struct {
MessageIDs []string `arg:"" optional:"" name:"messageId" help:"Message IDs to trash"`
Expand Down
129 changes: 129 additions & 0 deletions internal/cmd/gmail_archive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"

"google.golang.org/api/gmail/v1"

"github.com/steipete/gogcli/internal/outfmt"
)

Expand Down Expand Up @@ -158,6 +161,132 @@ func TestGmailArchiveCmd_DryRun_QueryMode_NoAccountRequired(t *testing.T) {
}
}

func TestGmailArchiveCmd_DryRun_ThreadMode(t *testing.T) {
got := runGmailBulkDryRun(t, &GmailArchiveCmd{}, []string{
"--thread",
"https://mail.google.com/mail/u/0/#inbox/18abc123def45678",
"18def456abc12345",
})

if op, _ := got["op"].(string); op != "gmail.archive" {
t.Fatalf("expected op gmail.archive, got %v", got["op"])
}
req := requireRequestMap(t, got)
threadIDs := requestStringSlice(t, req, "thread_ids")
if len(threadIDs) != 2 || threadIDs[0] != "18abc123def45678" || threadIDs[1] != "18def456abc12345" {
t.Fatalf("unexpected request.thread_ids: %v", threadIDs)
}
if resource, _ := req["resource"].(string); resource != "thread" {
t.Fatalf("unexpected resource: %v", req["resource"])
}
}

func TestGmailArchiveCmd_ThreadModeRejectsQuery(t *testing.T) {
err := runKong(t, &GmailArchiveCmd{}, []string{"--thread", "--query", "in:inbox"}, context.Background(), &RootFlags{DryRun: true})
if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--thread cannot be used with --query") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestGmailArchiveCmd_ArchivesWholeThreads(t *testing.T) {
var modified []string
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
if r.Method != http.MethodPost || !strings.HasSuffix(path, "/modify") {
http.NotFound(w, r)
return
}

var req gmail.ModifyThreadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode modify request: %v", err)
}
if len(req.RemoveLabelIds) != 1 || req.RemoveLabelIds[0] != "INBOX" {
t.Fatalf("unexpected remove labels: %v", req.RemoveLabelIds)
}
parts := strings.Split(path, "/")
modified = append(modified, parts[len(parts)-2])
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"ok"}`))
})
defer cleanup()
stubGmailServiceForTest(t, svc)

ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
out := captureStdout(t, func() {
if err := runKong(t, &GmailArchiveCmd{}, []string{"--thread", "thread1", "thread2"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("archive threads: %v", err)
}
})
if strings.Join(modified, ",") != "thread1,thread2" {
t.Fatalf("modified threads = %v", modified)
}

var got map[string]any
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("decode output: %v", err)
}
if count, _ := got["count"].(float64); count != 2 {
t.Fatalf("count = %v, want 2", got["count"])
}
if resource, _ := got["resource"].(string); resource != "thread" {
t.Fatalf("resource = %v, want thread", got["resource"])
}
results, ok := got["results"].([]any)
if !ok || len(results) != 2 {
t.Fatalf("results = %#v, want two entries", got["results"])
}
}

func TestGmailArchiveCmd_ReportsPartialThreadFailures(t *testing.T) {
var modified []string
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
parts := strings.Split(path, "/")
threadID := parts[len(parts)-2]
modified = append(modified, threadID)
if threadID == "thread2" {
http.Error(w, `{"error":{"code":404,"message":"not found"}}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"ok"}`))
})
defer cleanup()
stubGmailServiceForTest(t, svc)

ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
var runErr error
out := captureStdout(t, func() {
runErr = runKong(t, &GmailArchiveCmd{}, []string{"--thread", "thread1", "thread2", "thread3"}, ctx, &RootFlags{Account: "a@b.com"})
})
if runErr == nil || !strings.Contains(runErr.Error(), "archived 2 of 3 threads; 1 failed") {
t.Fatalf("unexpected error: %v", runErr)
}
if strings.Join(modified, ",") != "thread1,thread2,thread3" {
t.Fatalf("modified threads = %v", modified)
}

var got struct {
Count int `json:"count"`
Failed int `json:"failed"`
Results []struct {
ThreadID string `json:"threadId"`
Success bool `json:"success"`
Error string `json:"error"`
} `json:"results"`
}
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("decode output: %v", err)
}
if got.Count != 2 || got.Failed != 1 || len(got.Results) != 3 {
t.Fatalf("unexpected partial result: %#v", got)
}
if got.Results[1].ThreadID != "thread2" || got.Results[1].Success || got.Results[1].Error == "" {
t.Fatalf("missing thread2 failure: %#v", got.Results[1])
}
}

func TestGmailBulkOps_QueryInvalidMaxFailsBeforeDryRun(t *testing.T) {
testCases := []struct {
name string
Expand Down
Loading