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 @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-gmail-messages-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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) |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-gmail-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ gog gmail (mail,email) search (find,query,ls,list) <query> ... [flags]
| `--all`<br>`--all-pages`<br>`--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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) |
Expand Down
38 changes: 33 additions & 5 deletions internal/cmd/gmail_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"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)"`
Expand Down Expand Up @@ -80,14 +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) {
return writePagedJSONResult(ctx, map[string]any{
payload := map[string]any{
"messages": []messageItem{},
"nextPageToken": nextPageToken,
}, 0, c.FailEmpty)
}
if countReported {
matchCount.apply(payload)
}
return writePagedJSONResult(ctx, payload, 0, c.FailEmpty)
}
if countReported {
printGmailMatchCount(u, 0, matchCount)
} else {
u.Err().Println("No results")
}
u.Err().Println("No results")
return failEmptyExit(c.FailEmpty)
}

Expand All @@ -107,10 +127,14 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro
}

if outfmt.IsJSON(ctx) {
return writePagedJSONResult(ctx, map[string]any{
payload := map[string]any{
"messages": items,
"nextPageToken": nextPageToken,
}, len(items), c.FailEmpty)
}
if countReported {
matchCount.apply(payload)
}
return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty)
}

if len(items) == 0 {
Expand All @@ -126,6 +150,10 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro
); err != nil {
return err
}
// stderr, so the table on stdout stays parseable.
if countReported {
printGmailMatchCount(u, len(items), matchCount)
}
printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages")
return nil
}
Expand Down
38 changes: 33 additions & 5 deletions internal/cmd/gmail_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"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)"`
Expand Down Expand Up @@ -65,14 +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) {
return writePagedJSONResult(ctx, map[string]any{
payload := map[string]any{
"threads": []threadItem{},
"nextPageToken": nextPageToken,
}, 0, c.FailEmpty)
}
if countReported {
matchCount.apply(payload)
}
return writePagedJSONResult(ctx, payload, 0, c.FailEmpty)
}
if countReported {
printGmailMatchCount(u, 0, matchCount)
} else {
u.Err().Println("No results")
}
u.Err().Println("No results")
return failEmptyExit(c.FailEmpty)
}

Expand All @@ -92,10 +112,14 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
}

if outfmt.IsJSON(ctx) {
return writePagedJSONResult(ctx, map[string]any{
payload := map[string]any{
"threads": items,
"nextPageToken": nextPageToken,
}, len(items), c.FailEmpty)
}
if countReported {
matchCount.apply(payload)
}
return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty)
}

if len(items) == 0 {
Expand All @@ -106,6 +130,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 countReported {
printGmailMatchCount(u, len(items), matchCount)
}
printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages")
return nil
}
Expand Down
125 changes: 125 additions & 0 deletions internal/cmd/gmail_search_count.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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))
}

// 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 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
// --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,
page string,
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 && page == "":
return gmailMatchCount{Value: int64(shown), Exact: true}, true, nil
default:
count, err := probe()
if err != nil {
return gmailMatchCount{}, false, err
}
return count, true, nil
}
}
Loading