From 23cf42f197989bcfa28b7a92faf6beaf017a85d2 Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Wed, 12 Aug 2026 23:31:36 -0400 Subject: [PATCH 1/3] feat(gmail): add --count to search and messages search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report how many results a query really has. `gmail search` and `gmail messages search` currently emit items plus nextPageToken, so a caller can tell that more results exist but not how many — and a capped page then gets read as the whole answer. Deliberately NOT Gmail's resultSizeEstimate, which is the obvious source and saturates. Measured on a live account (v0.35.0, 2026-08-12) it returned exactly 201 for every non-empty query — from:freshbooks.com (21 real matches), from:housecallpro.com (6), from:thumbtack.com newer_than:30d (3), from:honeybook.com (9) — and 0 for a query with no matches. It does not vary with maxResults either (identical at 1, 10 and 100). Surfacing it would let a caller report "3 of ~201" when the truth is 3 of 6, which is worse than reporting nothing. Instead --count asks for one maximal page of bare ids (maxResults=500, fields=/id,nextPageToken) and counts them. Exact when the set fits a page, reported as totalMatches; a lower bound when the page fills with more behind it, reported as totalMatchesAtLeast, so a saturated probe can never be mistaken for a total. Exactness holds for the narrow queries where a wrong count does the most damage. Opt-in, so no caller pays the extra round-trip without asking. The text path prints to stderr, keeping stdout parseable. Refs #983 --- internal/cmd/gmail_messages.go | 29 ++- internal/cmd/gmail_search.go | 29 ++- internal/cmd/gmail_search_count.go | 86 ++++++++ internal/cmd/gmail_search_count_test.go | 277 ++++++++++++++++++++++++ 4 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/gmail_search_count.go create mode 100644 internal/cmd/gmail_search_count_test.go diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index e5ad5122f..accac6836 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -33,6 +33,7 @@ type GmailMessagesSearchCmd struct { Page string `name:"page" aliases:"cursor" help:"Page token"` All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` + Count bool `name:"count" help:"Also report how many messages match in total (exact when the set fits one page, otherwise a lower bound)"` Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"` Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` IncludeBody bool `name:"include-body" help:"Include decoded message body (JSON is full; text output truncates only unusually large bodies)"` @@ -82,10 +83,14 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro if len(messages) == 0 { if outfmt.IsJSON(ctx) { - return writePagedJSONResult(ctx, map[string]any{ + payload := map[string]any{ "messages": []messageItem{}, "nextPageToken": nextPageToken, - }, 0, c.FailEmpty) + } + if c.Count { + payload["totalMatches"] = int64(0) + } + return writePagedJSONResult(ctx, payload, 0, c.FailEmpty) } u.Err().Println("No results") return failEmptyExit(c.FailEmpty) @@ -106,11 +111,23 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro return err } + var matchCount gmailMatchCount + if c.Count { + matchCount, err = countGmailMessageMatches(ctx, svc, query) + if err != nil { + return err + } + } + if outfmt.IsJSON(ctx) { - return writePagedJSONResult(ctx, map[string]any{ + payload := map[string]any{ "messages": items, "nextPageToken": nextPageToken, - }, len(items), c.FailEmpty) + } + if c.Count { + matchCount.apply(payload) + } + return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty) } if len(items) == 0 { @@ -126,6 +143,10 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro ); err != nil { return err } + // stderr, so the table on stdout stays parseable. + if c.Count { + printGmailMatchCount(u, len(items), matchCount) + } printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages") return nil } diff --git a/internal/cmd/gmail_search.go b/internal/cmd/gmail_search.go index 5f19e0697..59fbb2757 100644 --- a/internal/cmd/gmail_search.go +++ b/internal/cmd/gmail_search.go @@ -19,6 +19,7 @@ type GmailSearchCmd struct { Page string `name:"page" aliases:"cursor" help:"Page token"` All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` + Count bool `name:"count" help:"Also report how many threads match in total (exact when the set fits one page, otherwise a lower bound)"` Oldest bool `name:"oldest" help:"Show first message date instead of last"` Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"` Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` @@ -67,10 +68,14 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { if len(threads) == 0 { if outfmt.IsJSON(ctx) { - return writePagedJSONResult(ctx, map[string]any{ + payload := map[string]any{ "threads": []threadItem{}, "nextPageToken": nextPageToken, - }, 0, c.FailEmpty) + } + if c.Count { + payload["totalMatches"] = int64(0) + } + return writePagedJSONResult(ctx, payload, 0, c.FailEmpty) } u.Err().Println("No results") return failEmptyExit(c.FailEmpty) @@ -91,11 +96,23 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { return err } + var matchCount gmailMatchCount + if c.Count { + matchCount, err = countGmailThreadMatches(ctx, svc, query) + if err != nil { + return err + } + } + if outfmt.IsJSON(ctx) { - return writePagedJSONResult(ctx, map[string]any{ + payload := map[string]any{ "threads": items, "nextPageToken": nextPageToken, - }, len(items), c.FailEmpty) + } + if c.Count { + matchCount.apply(payload) + } + return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty) } if len(items) == 0 { @@ -106,6 +123,10 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { if err := outfmt.WriteTable(ctx, stdoutWriter(ctx), items, gmailThreadColumns()); err != nil { return err } + // stderr, so the table on stdout stays parseable. + if c.Count { + printGmailMatchCount(u, len(items), matchCount) + } printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages") return nil } diff --git a/internal/cmd/gmail_search_count.go b/internal/cmd/gmail_search_count.go new file mode 100644 index 000000000..3adcd63bb --- /dev/null +++ b/internal/cmd/gmail_search_count.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "fmt" + + "google.golang.org/api/gmail/v1" + + "github.com/openclaw/gogcli/internal/ui" +) + +// One maximal page is enough to count all but the broadest result sets, and +// 500 is the ceiling Gmail's list endpoints accept. +const gmailCountProbePageSize = 500 + +// gmailMatchCount is how large a result set really is. +// +// Exact means the probe reached the end of the set, so Value is the total. +// Otherwise the probe filled its page with more behind it and Value is a lower +// bound — reported as such rather than rounded into a total nobody can trust. +type gmailMatchCount struct { + Value int64 + Exact bool +} + +// apply writes the count into a JSON payload under the name that matches its +// certainty, so a consumer never has to guess whether a number is a total. +func (c gmailMatchCount) apply(payload map[string]any) { + if c.Exact { + payload["totalMatches"] = c.Value + return + } + payload["totalMatchesAtLeast"] = c.Value +} + +// Deliberately NOT Gmail's resultSizeEstimate, which is the obvious source and +// saturates: measured against a live mailbox it returned exactly 201 for every +// non-empty query — from:freshbooks.com (21 real matches), from:housecallpro.com +// (6), from:thumbtack.com newer_than:30d (3) — and 0 for a query with none. It +// is a has-results boolean wearing a number's clothes, and it does not vary +// with maxResults either. Emitting it would let a caller report "3 of ~201" +// when the truth is 3 of 6. See openclaw/gogcli#983. +// +// Counting bare ids costs one extra list call and returns a response of ids +// alone, and it is exact whenever the set fits a single page — the common case, +// and always the case for the narrow queries where a wrong count does the most +// damage. +func countGmailThreadMatches(ctx context.Context, svc *gmail.Service, query string) (gmailMatchCount, error) { + opts := newGmailSearchRequestOptions(query, gmailCountProbePageSize, "") + resp, err := applyGmailThreadListOptions(svc.Users.Threads.List("me"), opts). + Fields("threads/id,nextPageToken"). + Context(ctx). + Do() + if err != nil { + return gmailMatchCount{}, err + } + return gmailMatchCount{Value: int64(len(resp.Threads)), Exact: resp.NextPageToken == ""}, nil +} + +func countGmailMessageMatches(ctx context.Context, svc *gmail.Service, query string) (gmailMatchCount, error) { + opts := newGmailSearchRequestOptions(query, gmailCountProbePageSize, "") + resp, err := applyGmailMessageListOptions(svc.Users.Messages.List("me"), opts). + Fields("messages/id,nextPageToken"). + Context(ctx). + Do() + if err != nil { + return gmailMatchCount{}, err + } + return gmailMatchCount{Value: int64(len(resp.Messages)), Exact: resp.NextPageToken == ""}, nil +} + +// The human-facing form of the same fact, on stderr so the table on stdout +// stays parseable. Says outright when the page is not the whole set: a caller +// reading only the visible rows is exactly how a partial result gets reported +// as an absence. +func printGmailMatchCount(u *ui.UI, shown int, count gmailMatchCount) { + if count.Exact { + if int64(shown) < count.Value { + u.Err().Println(fmt.Sprintf("Showing %d of %d matches.", shown, count.Value)) + return + } + u.Err().Println(fmt.Sprintf("%d matches.", count.Value)) + return + } + u.Err().Println(fmt.Sprintf("Showing %d of at least %d matches.", shown, count.Value)) +} diff --git a/internal/cmd/gmail_search_count_test.go b/internal/cmd/gmail_search_count_test.go new file mode 100644 index 000000000..394b0bc5d --- /dev/null +++ b/internal/cmd/gmail_search_count_test.go @@ -0,0 +1,277 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/openclaw/gogcli/internal/ui" +) + +// countProbeCapture records what the count probe actually asked Gmail for, so a +// test can assert the probe is a cheap ids-only call and not a second full +// fetch. +type countProbeCapture struct { + fields string + maxResults string + query string + calls int +} + +// gmailCountTestHandler serves a first page of `pageIDs` plus a count probe +// returning `totalIDs` ids. The probe is told apart from the listing by its +// `fields` selector, which is the only thing the count path sets. +func gmailCountTestHandler(t *testing.T, capture *countProbeCapture, resource string, pageIDs, totalIDs []string, probeNextPage string) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + q := r.URL.Query() + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.Contains(path, "/users/me/labels"): + _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{}}) + + case strings.HasSuffix(path, "/users/me/"+resource) && strings.Contains(q.Get("fields"), "/id"): + capture.calls++ + capture.fields = q.Get("fields") + capture.maxResults = q.Get("maxResults") + capture.query = q.Get("q") + items := make([]map[string]any, 0, len(totalIDs)) + for _, id := range totalIDs { + items = append(items, map[string]any{"id": id}) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + resource: items, "nextPageToken": probeNextPage, + }) + + case strings.HasSuffix(path, "/users/me/"+resource): + items := make([]map[string]any, 0, len(pageIDs)) + for _, id := range pageIDs { + items = append(items, map[string]any{"id": id, "threadId": id}) + } + _ = json.NewEncoder(w).Encode(map[string]any{resource: items, "nextPageToken": "PAGE2"}) + + case strings.Contains(path, "/users/me/threads/"), strings.Contains(path, "/users/me/messages/"): + id := path[strings.LastIndex(path, "/")+1:] + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": id, "messages": []map[string]any{{ + "id": id, "threadId": id, "internalDate": "1760000000000", + "payload": map[string]any{"headers": []map[string]any{ + {"name": "Subject", "value": "s"}, {"name": "From", "value": "a@b.com"}, + }}, + }}, + }) + + default: + http.NotFound(w, r) + } + } +} + +func TestGmailSearch_Count_ExactWhenSetFitsOnePage(t *testing.T) { + capture := &countProbeCapture{} + srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads", + []string{"t1"}, []string{"t1", "t2", "t3"}, "")) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1", "--count"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + + var parsed struct { + TotalMatches *int64 `json:"totalMatches"` + TotalMatchesAtLeast *int64 `json:"totalMatchesAtLeast"` + } + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("decode: %v (%s)", err, result.stdout) + } + if parsed.TotalMatches == nil || *parsed.TotalMatches != 3 { + t.Fatalf("totalMatches = %v, want 3", parsed.TotalMatches) + } + if parsed.TotalMatchesAtLeast != nil { + t.Fatalf("an exact count must not also report a lower bound: %v", *parsed.TotalMatchesAtLeast) + } +} + +func TestGmailSearch_Count_LowerBoundWhenProbePageFills(t *testing.T) { + capture := &countProbeCapture{} + srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads", + []string{"t1"}, []string{"t1", "t2"}, "MORE")) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1", "--count"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + + var parsed struct { + TotalMatches *int64 `json:"totalMatches"` + TotalMatchesAtLeast *int64 `json:"totalMatchesAtLeast"` + } + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("decode: %v (%s)", err, result.stdout) + } + if parsed.TotalMatchesAtLeast == nil || *parsed.TotalMatchesAtLeast != 2 { + t.Fatalf("totalMatchesAtLeast = %v, want 2", parsed.TotalMatchesAtLeast) + } + if parsed.TotalMatches != nil { + t.Fatalf("a saturated probe must NOT be reported as an exact total: %v", *parsed.TotalMatches) + } +} + +func TestGmailSearch_Count_ProbeIsIdsOnlyAndUsesTheSameQuery(t *testing.T) { + capture := &countProbeCapture{} + srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads", + []string{"t1"}, []string{"t1"}, "")) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "search", "from:x@y.com", "--max", "1", "--count"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + if capture.calls != 1 { + t.Fatalf("count probe ran %d times, want exactly 1", capture.calls) + } + if capture.fields != "threads/id,nextPageToken" { + t.Fatalf("probe fields = %q, want ids only", capture.fields) + } + if capture.maxResults != "500" { + t.Fatalf("probe maxResults = %q, want 500", capture.maxResults) + } + if capture.query != "from:x@y.com" { + t.Fatalf("probe query = %q, want the search's own query", capture.query) + } +} + +func TestGmailSearch_Count_AbsentWithoutTheFlag(t *testing.T) { + capture := &countProbeCapture{} + srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads", + []string{"t1"}, []string{"t1", "t2"}, "")) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + if strings.Contains(result.stdout, "totalMatches") { + t.Fatalf("count must be opt-in, got: %s", result.stdout) + } + if capture.calls != 0 { + t.Fatalf("count probe must not run without --count, ran %d times", capture.calls) + } +} + +func TestGmailMessagesSearch_Count_Exact(t *testing.T) { + capture := &countProbeCapture{} + srv := httptest.NewServer(gmailCountTestHandler(t, capture, "messages", + []string{"m1"}, []string{"m1", "m2", "m3", "m4"}, "")) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "messages", "search", "invoice", "--max", "1", "--count"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + + var parsed struct { + TotalMatches *int64 `json:"totalMatches"` + } + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("decode: %v (%s)", err, result.stdout) + } + if parsed.TotalMatches == nil || *parsed.TotalMatches != 4 { + t.Fatalf("totalMatches = %v, want 4", parsed.TotalMatches) + } + if capture.fields != "messages/id,nextPageToken" { + t.Fatalf("probe fields = %q, want message ids only", capture.fields) + } +} + +func TestGmailSearch_Count_ReportsZeroForNoMatches(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(r.URL.Path, "/users/me/labels") { + _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"threads": []map[string]any{}}) + })) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "search", "zzznope", "--count"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + + var parsed struct { + TotalMatches *int64 `json:"totalMatches"` + } + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("decode: %v (%s)", err, result.stdout) + } + if parsed.TotalMatches == nil || *parsed.TotalMatches != 0 { + t.Fatalf("totalMatches = %v, want 0", parsed.TotalMatches) + } +} + +func TestGmailSearch_Count_TextOutputNamesTheWholeSetOnStderr(t *testing.T) { + capture := &countProbeCapture{} + srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads", + []string{"t1"}, []string{"t1", "t2", "t3"}, "")) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--plain", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1", "--count"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + if !strings.Contains(result.stderr, "Showing 1 of 3 matches.") { + t.Fatalf("stderr should say the page is partial, got: %q", result.stderr) + } + // stdout must stay parseable. + if strings.Contains(result.stdout, "matches.") { + t.Fatalf("count hint leaked onto stdout: %q", result.stdout) + } +} + +func TestPrintGmailMatchCount_Wording(t *testing.T) { + for _, tc := range []struct { + name string + shown int + count gmailMatchCount + want string + }{ + {"partial exact", 3, gmailMatchCount{Value: 21, Exact: true}, "Showing 3 of 21 matches."}, + {"complete exact", 6, gmailMatchCount{Value: 6, Exact: true}, "6 matches."}, + {"lower bound", 3, gmailMatchCount{Value: 500}, "Showing 3 of at least 500 matches."}, + } { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + u, err := ui.New(ui.Options{Stdout: &stdout, Stderr: &stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + printGmailMatchCount(u, tc.shown, tc.count) + if !strings.Contains(stderr.String(), tc.want) { + t.Fatalf("got %q, want it to contain %q", stderr.String(), tc.want) + } + }) + } +} From ca114661c6b903214bbd65521281e93b900a45d8 Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Wed, 12 Aug 2026 23:48:01 -0400 Subject: [PATCH 2/3] feat(gmail): don't spend a count probe that is wasted or already answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the --count review on #983, which asked for an explicit contract around --page, --all and --results-only. Two of the three were real defects: --results-only unwraps the envelope to the bare result array, so a count field cannot survive it. The probe ran anyway and its result was silently discarded — a Gmail request spent for nothing, with no way for the caller to tell. It is now skipped, and the caller is told on stderr rather than left guessing. --all has already walked every page, so the items in hand ARE the whole set. The probe was asking Google a question the walk had just finished answering. The total now comes from the walk, which also makes it exact by construction. --page needed no code change but did need saying: the count is always for the WHOLE query, never the remainder after a cursor. That is the number that stops a caller concluding an absence, and keeping it page-independent means it does not drift while paging. Now stated in the flag help. Refs #983 --- internal/cmd/gmail_messages.go | 11 ++-- internal/cmd/gmail_search.go | 11 ++-- internal/cmd/gmail_search_count.go | 38 +++++++++++ internal/cmd/gmail_search_count_test.go | 84 +++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index accac6836..ccc8c5eed 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -33,7 +33,7 @@ type GmailMessagesSearchCmd struct { Page string `name:"page" aliases:"cursor" help:"Page token"` All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` - Count bool `name:"count" help:"Also report how many messages match in total (exact when the set fits one page, otherwise a lower bound)"` + Count bool `name:"count" help:"Also report how many messages match the query in total, as totalMatches (exact) or totalMatchesAtLeast (lower bound). Always counts the WHOLE query, not the remainder after --page. Free with --all; no effect with --results-only"` Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"` Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` IncludeBody bool `name:"include-body" help:"Include decoded message body (JSON is full; text output truncates only unusually large bodies)"` @@ -112,8 +112,11 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro } var matchCount gmailMatchCount + countReported := false if c.Count { - matchCount, err = countGmailMessageMatches(ctx, svc, query) + matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, len(items), func() (gmailMatchCount, error) { + return countGmailMessageMatches(ctx, svc, query) + }) if err != nil { return err } @@ -124,7 +127,7 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro "messages": items, "nextPageToken": nextPageToken, } - if c.Count { + if countReported { matchCount.apply(payload) } return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty) @@ -144,7 +147,7 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro return err } // stderr, so the table on stdout stays parseable. - if c.Count { + if countReported { printGmailMatchCount(u, len(items), matchCount) } printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages") diff --git a/internal/cmd/gmail_search.go b/internal/cmd/gmail_search.go index 59fbb2757..535a55009 100644 --- a/internal/cmd/gmail_search.go +++ b/internal/cmd/gmail_search.go @@ -19,7 +19,7 @@ type GmailSearchCmd struct { Page string `name:"page" aliases:"cursor" help:"Page token"` All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` - Count bool `name:"count" help:"Also report how many threads match in total (exact when the set fits one page, otherwise a lower bound)"` + Count bool `name:"count" help:"Also report how many threads match the query in total, as totalMatches (exact) or totalMatchesAtLeast (lower bound). Always counts the WHOLE query, not the remainder after --page. Free with --all; no effect with --results-only"` Oldest bool `name:"oldest" help:"Show first message date instead of last"` Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"` Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` @@ -97,8 +97,11 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { } var matchCount gmailMatchCount + countReported := false if c.Count { - matchCount, err = countGmailThreadMatches(ctx, svc, query) + matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, len(items), func() (gmailMatchCount, error) { + return countGmailThreadMatches(ctx, svc, query) + }) if err != nil { return err } @@ -109,7 +112,7 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { "threads": items, "nextPageToken": nextPageToken, } - if c.Count { + if countReported { matchCount.apply(payload) } return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty) @@ -124,7 +127,7 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { return err } // stderr, so the table on stdout stays parseable. - if c.Count { + if countReported { printGmailMatchCount(u, len(items), matchCount) } printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages") diff --git a/internal/cmd/gmail_search_count.go b/internal/cmd/gmail_search_count.go index 3adcd63bb..a9aa5a92f 100644 --- a/internal/cmd/gmail_search_count.go +++ b/internal/cmd/gmail_search_count.go @@ -84,3 +84,41 @@ func printGmailMatchCount(u *ui.UI, shown int, count gmailMatchCount) { } u.Err().Println(fmt.Sprintf("Showing %d of at least %d matches.", shown, count.Value)) } + +// resolveGmailMatchCount decides how --count is answered for one search, and +// whether it can be answered at all. Three cases, and only one of them is worth +// an extra request: +// +// --results-only The count is an envelope field, and --results-only exists +// to drop the envelope and emit the bare result array — so +// the number would be computed and then thrown away. Say so +// on stderr instead of spending a request in silence. +// --all The walk already exhausted every page, so the items in hand +// ARE the whole set. Probing would ask Google a question we +// just finished answering. +// otherwise Probe. +// +// The count is always for the WHOLE query, never the remainder after a +// --page cursor: "how many match this" is the number that stops a caller +// concluding an absence, and keeping it page-independent means it does not +// drift while paging through a set. +func resolveGmailMatchCount( + u *ui.UI, + resultsOnly, all bool, + shown int, + probe func() (gmailMatchCount, error), +) (gmailMatchCount, bool, error) { + switch { + case resultsOnly: + u.Err().Println("--count has no effect with --results-only: the count is an envelope field, and --results-only emits only the result array. Skipping the extra request.") + return gmailMatchCount{}, false, nil + case all: + return gmailMatchCount{Value: int64(shown), Exact: true}, true, nil + default: + count, err := probe() + if err != nil { + return gmailMatchCount{}, false, err + } + return count, true, nil + } +} diff --git a/internal/cmd/gmail_search_count_test.go b/internal/cmd/gmail_search_count_test.go index 394b0bc5d..4702542de 100644 --- a/internal/cmd/gmail_search_count_test.go +++ b/internal/cmd/gmail_search_count_test.go @@ -72,6 +72,39 @@ func gmailCountTestHandler(t *testing.T, capture *countProbeCapture, resource st } } +// gmailAllCountTestHandler serves a single complete page (no nextPageToken) and +// records any count probe, which under --all must never fire. +func gmailAllCountTestHandler(t *testing.T, capture *countProbeCapture, ids []string) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(path, "/users/me/labels"): + _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{}}) + case strings.HasSuffix(path, "/users/me/threads") && strings.Contains(r.URL.Query().Get("fields"), "/id"): + capture.calls++ + _ = json.NewEncoder(w).Encode(map[string]any{"threads": make([]map[string]any, 99)}) + case strings.HasSuffix(path, "/users/me/threads"): + items := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + items = append(items, map[string]any{"id": id, "threadId": id}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"threads": items}) + case strings.Contains(path, "/users/me/threads/"): + id := path[strings.LastIndex(path, "/")+1:] + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": id, "messages": []map[string]any{{ + "id": id, "threadId": id, "internalDate": "1760000000000", + "payload": map[string]any{"headers": []map[string]any{{"name": "Subject", "value": "s"}}}, + }}, + }) + default: + http.NotFound(w, r) + } + } +} + func TestGmailSearch_Count_ExactWhenSetFitsOnePage(t *testing.T) { capture := &countProbeCapture{} srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads", @@ -275,3 +308,54 @@ func TestPrintGmailMatchCount_Wording(t *testing.T) { }) } } + +func TestGmailSearch_Count_SkipsProbeWithAll(t *testing.T) { + capture := &countProbeCapture{} + // The probe would report 99 if it ran; --all must instead report what the + // walk actually collected, so a probe result cannot masquerade as the total. + srv := httptest.NewServer(gmailAllCountTestHandler(t, capture, []string{"t1", "t2"})) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--all", "--count"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + + var parsed struct { + Threads []struct{} `json:"threads"` + TotalMatches *int64 `json:"totalMatches"` + } + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("decode: %v (%s)", err, result.stdout) + } + if parsed.TotalMatches == nil || *parsed.TotalMatches != 2 { + t.Fatalf("totalMatches = %v, want 2 (what --all collected)", parsed.TotalMatches) + } + if capture.calls != 0 { + t.Fatalf("--all already has the whole set; probe must not run, ran %d times", capture.calls) + } +} + +func TestGmailSearch_Count_SkipsProbeWithResultsOnly(t *testing.T) { + capture := &countProbeCapture{} + srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads", + []string{"t1"}, []string{"t1", "t2", "t3"}, "")) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--json", "--results-only", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1", "--count"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + // --results-only unwraps to the bare array, so the count could never be + // reported. Spending the request anyway is pure waste. + if capture.calls != 0 { + t.Fatalf("probe must not run under --results-only, ran %d times", capture.calls) + } + if !strings.Contains(result.stderr, "--count has no effect with --results-only") { + t.Fatalf("the caller must be told, not silently ignored; stderr=%q", result.stderr) + } +} From f4974915fac2f5b6c80c61209652f6961de152cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 03:20:17 -0700 Subject: [PATCH 3/3] fix(gmail): keep search counts whole-query --- CHANGELOG.md | 1 + docs/commands/gog-gmail-messages-search.md | 1 + docs/commands/gog-gmail-search.md | 1 + internal/cmd/gmail_messages.go | 34 +++++----- internal/cmd/gmail_search.go | 34 +++++----- internal/cmd/gmail_search_count.go | 9 +-- internal/cmd/gmail_search_count_test.go | 76 ++++++++++++++++++++++ 7 files changed, 122 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 154a810ad..ed8cec41f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.35.1 - Unreleased +- Gmail: add opt-in exact-or-lower-bound match counts to thread and message searches without treating Gmail's result estimate as exact. (#983, #984) — thanks @chrischall. - Gmail: add draft-only reply, reply-all, and forward workflows with shared send-side composition, no-send compatibility, and address-aware recipient validation. (#977) — thanks @malob. - Safety: allow custom baked profiles to lock boolean CLI flags against command-line, environment, and config overrides without echoing the locked value in override errors. (#976) — thanks @ronny-rentner. - Auth: offer one-time re-authorization for expired or revoked stored OAuth refresh tokens after interactive confirmation, while preserving non-interactive recovery guidance. (#973) — thanks @inamiy. diff --git a/docs/commands/gog-gmail-messages-search.md b/docs/commands/gog-gmail-messages-search.md index 1e8897dd8..8bbdf8851 100644 --- a/docs/commands/gog-gmail-messages-search.md +++ b/docs/commands/gog-gmail-messages-search.md @@ -24,6 +24,7 @@ gog gmail (mail,email) messages (message,msg,msgs) search (find,query,ls,list) < | `--body-format` | `string` | text | Body format preference when --include-body is set: text or html | | `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | | `--color` | `string` | auto | Color output: auto\|always\|never | +| `--count` | `bool` | | Report the whole-query count as totalMatches (exact) or totalMatchesAtLeast (lower bound); free with --all unless --page is set; unavailable with --results-only | | `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | | `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | | `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | diff --git a/docs/commands/gog-gmail-search.md b/docs/commands/gog-gmail-search.md index 9608ac800..815ec873b 100644 --- a/docs/commands/gog-gmail-search.md +++ b/docs/commands/gog-gmail-search.md @@ -23,6 +23,7 @@ gog gmail (mail,email) search (find,query,ls,list) ... [flags] | `--all`
`--all-pages`
`--allpages` | `bool` | | Fetch all pages | | `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | | `--color` | `string` | auto | Color output: auto\|always\|never | +| `--count` | `bool` | | Report the whole-query count as totalMatches (exact) or totalMatchesAtLeast (lower bound); free with --all unless --page is set; unavailable with --results-only | | `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | | `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | | `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index ccc8c5eed..4425670b5 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -33,7 +33,7 @@ type GmailMessagesSearchCmd struct { Page string `name:"page" aliases:"cursor" help:"Page token"` All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` - Count bool `name:"count" help:"Also report how many messages match the query in total, as totalMatches (exact) or totalMatchesAtLeast (lower bound). Always counts the WHOLE query, not the remainder after --page. Free with --all; no effect with --results-only"` + Count bool `name:"count" help:"Report the whole-query count as totalMatches (exact) or totalMatchesAtLeast (lower bound); free with --all unless --page is set; unavailable with --results-only"` Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"` Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` IncludeBody bool `name:"include-body" help:"Include decoded message body (JSON is full; text output truncates only unusually large bodies)"` @@ -81,18 +81,33 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro return err } + var matchCount gmailMatchCount + countReported := false + if c.Count { + matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, c.Page, len(messages), func() (gmailMatchCount, error) { + return countGmailMessageMatches(ctx, svc, query) + }) + if err != nil { + return err + } + } + if len(messages) == 0 { if outfmt.IsJSON(ctx) { payload := map[string]any{ "messages": []messageItem{}, "nextPageToken": nextPageToken, } - if c.Count { - payload["totalMatches"] = int64(0) + if countReported { + matchCount.apply(payload) } return writePagedJSONResult(ctx, payload, 0, c.FailEmpty) } - u.Err().Println("No results") + if countReported { + printGmailMatchCount(u, 0, matchCount) + } else { + u.Err().Println("No results") + } return failEmptyExit(c.FailEmpty) } @@ -111,17 +126,6 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro return err } - var matchCount gmailMatchCount - countReported := false - if c.Count { - matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, len(items), func() (gmailMatchCount, error) { - return countGmailMessageMatches(ctx, svc, query) - }) - if err != nil { - return err - } - } - if outfmt.IsJSON(ctx) { payload := map[string]any{ "messages": items, diff --git a/internal/cmd/gmail_search.go b/internal/cmd/gmail_search.go index 535a55009..46a33fd6a 100644 --- a/internal/cmd/gmail_search.go +++ b/internal/cmd/gmail_search.go @@ -19,7 +19,7 @@ type GmailSearchCmd struct { Page string `name:"page" aliases:"cursor" help:"Page token"` All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` - Count bool `name:"count" help:"Also report how many threads match the query in total, as totalMatches (exact) or totalMatchesAtLeast (lower bound). Always counts the WHOLE query, not the remainder after --page. Free with --all; no effect with --results-only"` + Count bool `name:"count" help:"Report the whole-query count as totalMatches (exact) or totalMatchesAtLeast (lower bound); free with --all unless --page is set; unavailable with --results-only"` Oldest bool `name:"oldest" help:"Show first message date instead of last"` Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"` Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` @@ -66,18 +66,33 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { return err } + var matchCount gmailMatchCount + countReported := false + if c.Count { + matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, c.Page, len(threads), func() (gmailMatchCount, error) { + return countGmailThreadMatches(ctx, svc, query) + }) + if err != nil { + return err + } + } + if len(threads) == 0 { if outfmt.IsJSON(ctx) { payload := map[string]any{ "threads": []threadItem{}, "nextPageToken": nextPageToken, } - if c.Count { - payload["totalMatches"] = int64(0) + if countReported { + matchCount.apply(payload) } return writePagedJSONResult(ctx, payload, 0, c.FailEmpty) } - u.Err().Println("No results") + if countReported { + printGmailMatchCount(u, 0, matchCount) + } else { + u.Err().Println("No results") + } return failEmptyExit(c.FailEmpty) } @@ -96,17 +111,6 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - var matchCount gmailMatchCount - countReported := false - if c.Count { - matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, len(items), func() (gmailMatchCount, error) { - return countGmailThreadMatches(ctx, svc, query) - }) - if err != nil { - return err - } - } - if outfmt.IsJSON(ctx) { payload := map[string]any{ "threads": items, diff --git a/internal/cmd/gmail_search_count.go b/internal/cmd/gmail_search_count.go index a9aa5a92f..cb56e95b5 100644 --- a/internal/cmd/gmail_search_count.go +++ b/internal/cmd/gmail_search_count.go @@ -93,9 +93,9 @@ func printGmailMatchCount(u *ui.UI, shown int, count gmailMatchCount) { // to drop the envelope and emit the bare result array — so // the number would be computed and then thrown away. Say so // on stderr instead of spending a request in silence. -// --all The walk already exhausted every page, so the items in hand -// ARE the whole set. Probing would ask Google a question we -// just finished answering. +// --all When the walk started at the beginning, the items in hand +// ARE the whole set. A walk started with --page contains only +// the suffix, so it still needs a whole-query probe. // otherwise Probe. // // The count is always for the WHOLE query, never the remainder after a @@ -105,6 +105,7 @@ func printGmailMatchCount(u *ui.UI, shown int, count gmailMatchCount) { func resolveGmailMatchCount( u *ui.UI, resultsOnly, all bool, + page string, shown int, probe func() (gmailMatchCount, error), ) (gmailMatchCount, bool, error) { @@ -112,7 +113,7 @@ func resolveGmailMatchCount( case resultsOnly: u.Err().Println("--count has no effect with --results-only: the count is an envelope field, and --results-only emits only the result array. Skipping the extra request.") return gmailMatchCount{}, false, nil - case all: + case all && page == "": return gmailMatchCount{Value: int64(shown), Exact: true}, true, nil default: count, err := probe() diff --git a/internal/cmd/gmail_search_count_test.go b/internal/cmd/gmail_search_count_test.go index 4702542de..0b5ea9b96 100644 --- a/internal/cmd/gmail_search_count_test.go +++ b/internal/cmd/gmail_search_count_test.go @@ -263,6 +263,50 @@ func TestGmailSearch_Count_ReportsZeroForNoMatches(t *testing.T) { } } +func TestGmailSearch_Count_EmptyPageStillCountsWholeQuery(t *testing.T) { + for _, tc := range []struct { + name string + resource string + args []string + }{ + { + name: "threads", + resource: "threads", + args: []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--page", "PAGE2", "--count"}, + }, + { + name: "messages", + resource: "messages", + args: []string{"--json", "--account", "a@b.com", "gmail", "messages", "search", "invoice", "--page", "PAGE2", "--count"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + capture := &countProbeCapture{} + srv := httptest.NewServer(gmailCountTestHandler(t, capture, tc.resource, + nil, []string{"m1", "m2", "m3"}, "")) + defer srv.Close() + + result := executeWithGmailTestService(t, tc.args, newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + + var parsed struct { + TotalMatches *int64 `json:"totalMatches"` + } + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("decode: %v (%s)", err, result.stdout) + } + if parsed.TotalMatches == nil || *parsed.TotalMatches != 3 { + t.Fatalf("totalMatches = %v, want whole-query count 3", parsed.TotalMatches) + } + if capture.calls != 1 { + t.Fatalf("empty displayed page still needs one whole-query probe; got %d", capture.calls) + } + }) + } +} + func TestGmailSearch_Count_TextOutputNamesTheWholeSetOnStderr(t *testing.T) { capture := &countProbeCapture{} srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads", @@ -338,6 +382,38 @@ func TestGmailSearch_Count_SkipsProbeWithAll(t *testing.T) { } } +func TestGmailSearch_Count_AllFromPageStillProbesWholeQuery(t *testing.T) { + capture := &countProbeCapture{} + // The --all walk starts at PAGE2 and returns only two remaining threads. + // The separate no-cursor probe returns 99, which is the whole-query count. + srv := httptest.NewServer(gmailAllCountTestHandler(t, capture, []string{"t1", "t2"})) + defer srv.Close() + + result := executeWithGmailTestService(t, + []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--all", "--page", "PAGE2", "--count"}, + newGmailServiceFromServer(t, srv)) + if result.err != nil { + t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr) + } + + var parsed struct { + Threads []struct{} `json:"threads"` + TotalMatches *int64 `json:"totalMatches"` + } + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("decode: %v (%s)", err, result.stdout) + } + if len(parsed.Threads) != 2 { + t.Fatalf("threads = %d, want remaining page suffix of 2", len(parsed.Threads)) + } + if parsed.TotalMatches == nil || *parsed.TotalMatches != 99 { + t.Fatalf("totalMatches = %v, want whole-query count 99", parsed.TotalMatches) + } + if capture.calls != 1 { + t.Fatalf("--all from a cursor still needs one whole-query probe; got %d", capture.calls) + } +} + func TestGmailSearch_Count_SkipsProbeWithResultsOnly(t *testing.T) { capture := &countProbeCapture{} srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads",