Skip to content

feat(gmail): replace +triage with +search for full-metadata Gmail search#676

Open
malob wants to merge 1 commit intogoogleworkspace:mainfrom
malob:feat/gmail-search
Open

feat(gmail): replace +triage with +search for full-metadata Gmail search#676
malob wants to merge 1 commit intogoogleworkspace:mainfrom
malob:feat/gmail-search

Conversation

@malob
Copy link
Copy Markdown
Contributor

@malob malob commented Apr 6, 2026

Note: Replaces #665 and #634, both auto-closed by the stale bot (see #675 for a fix).

Note: Replaces #634 which was auto-closed by the stale bot. Same branch, rebased on current main.

Description

Replaces +triage with +search, a general-purpose Gmail search helper with full metadata output, label resolution, and pagination.

Why replace +triage?

Three reasons:

  1. The name is wrong. +triage accepted arbitrary --query strings and was used as a general-purpose search, but the name implied inbox triage. +search reflects what it actually does.

  2. --labels was an anti-pattern. The flag toggled whether label IDs appeared in output — an output-filtering flag, which the helper guidelines explicitly prohibit (cf. PR feat(gmail): add --thread-id, --delivered-to, and --sent-last flags to +triage #597). Labels should always be present, and resolved to human-readable names rather than raw IDs.

  3. The improvements needed are all breaking. Structured address types instead of raw header strings, pagination support, JSON as default format, required --query — every change that makes the output useful for downstream scripts and pipes also breaks the existing schema. You can't evolve +triage into +search without breaking every consumer, so a clean replacement is more honest than a silent schema change behind the same name.

What it does

+search orchestrates three API calls:

  1. messages.list — fetch message IDs matching the query
  2. labels.list — build an account-wide label ID→name map
  3. Concurrent messages.get (format=metadata) — fan-out with buffered(10), preserving Gmail's relevance-ranked order

Output is a JSON envelope with structured types:

{
  "messages": [
    {
      "id": "18f1a2b3c4d",
      "threadId": "18f1a2b3c4d",
      "from": { "name": "Alice Smith", "email": "alice@example.com" },
      "to": [{ "name": null, "email": "bob@example.com" }],
      "cc": [],
      "subject": "Project update",
      "date": "Thu, 26 Mar 2026 21:58:12 -0400",
      "snippet": "Here's the latest on...",
      "labels": [
        { "id": "INBOX", "name": "INBOX" },
        { "id": "Label_3066", "name": "Media/Incoming" }
      ]
    }
  ],
  "resultSizeEstimate": 42,
  "query": "from:alice",
  "nextPageToken": "..."
}

Addresses are parsed into { name, email } objects so downstream consumers (scripts, pipes, jq, agents) don't need to parse RFC 5322 header strings. Labels are resolved via the labels.list API — each label includes both the raw ID and the resolved display name, so user-created labels show Media/Incoming alongside Label_3066.

Pagination: JSON output includes nextPageToken in the envelope. For non-JSON formats (table, CSV, YAML), the shared formatter strips envelope fields when it finds the messages array, so the token is printed as a stderr hint instead.

Architecture decisions

SearchResult is separate from OriginalMessage. Both parse Gmail message metadata, but SearchResult is for display (needs snippet, labels; uses format=metadata) while OriginalMessage is for reply/forward composition (needs body, parts, Message-ID, references; uses format=full). A shared base struct was explored but rejected — the API boundary between the two formats doesn't map cleanly to a struct hierarchy. Shared infrastructure lives at the parsing layer (parse_message_headers(), Mailbox), not the struct layer.

Three-tier error policy. The fan-out pattern (list IDs, then fetch each one) creates a window where a message can be deleted between the list call and the get call — a 404/410 on a per-message fetch is not an infrastructure failure, it's a race condition that should be skipped with a warning. But a 401/403 on a per-message fetch means auth broke mid-batch, which is infrastructure. The policy:

  • Infrastructure errors (auth, rate-limit, network, messages.list, labels.list) → abort
  • Per-message errors (404/410, validation failures) → skip with warning
  • Auth errors from per-message fetches (401/403) → abort (infrastructure)

Classification lives in is_per_message_error() with tests for each code.

--max validates 1..=500. Gmail API caps maxResults at 500. Clap rejects out-of-range values with a clear error instead of silently returning fewer results.

Other changes in this PR

OriginalMessage camelCase serialization. Added #[serde(rename_all = "camelCase")] to OriginalMessage. This changes +read --format json field names from snake_case to camelCase (thread_idthreadId, body_textbodyText, etc.) — a breaking change for +read JSON consumers. Bundled here rather than in a separate release so all Gmail JSON output follows the project's camelCase convention (established by the Model Armor helper), and consumers only need to update once alongside the +triage+search migration.

ParsedMessageHeaders and parse_message_headers() widened to pub(super). Allows search.rs to reuse the shared header parsing infrastructure that was previously private to mod.rs.

Live testing

Tested against a real Gmail account:

# Test Result
1 +search --query 'is:unread' (JSON) Structured output with resolved labels
2 +search --query 'from:...' --max 5 Correct result count
3 +search --query '...' --format table Table renders, pagination hint on stderr
4 +search --query '...' --format yaml YAML output
5 Pagination: page 1 + page 2 Same order as single larger page
6 Query with no results Empty JSON envelope ("messages": [])
7 +search --query '...' --max 0 Clap error: not in 1..=500

Test coverage

720 total tests (24 new, net +15 after removing +triage tests). New tests cover:

  • Argument parsing (10): required query, max default/override/rejection/range, page-token, format default/override
  • parse_search_result (9): happy path with multi-address To, missing id/threadId/headers/From, empty headers, label resolution (unknown label fallback, empty labels, missing labelIds field)
  • Serialization (3): SearchResult camelCase, SearchResponse camelCase, null page token omission
  • Error classification (7): 401, 403, 404, 410, 429, 500, Validation, transport errors
  • Command registration (1): +search in subcommand list

Dry Run Output: N/A — +search is read-only.

Checklist:

  • My code follows the AGENTS.md guidelines (no generated google-* crates).
  • I have run cargo fmt --all to format the code perfectly.
  • I have run cargo clippy -- -D warnings and resolved all warnings.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have provided a Changeset file (e.g. via pnpx changeset) to document my changes.

Replace +triage with +search: a general-purpose Gmail search helper with
structured output, label resolution, and pagination.

Breaking changes:
- +triage removed. Use `gws gmail +search --query 'is:unread'` instead.
- --query is now required (was optional, defaulted to 'is:unread').
- Default output format is JSON (was table).
- Output schema changed: structured Mailbox objects for from/to/cc,
  Label objects (id + name) always included, threadId, snippet added.
- +read --format json field names changed from snake_case to camelCase
  (e.g. thread_id → threadId) for consistency with project convention.

Design:
- SearchResult separate from OriginalMessage — different API formats
  (metadata vs full), different consumers (display vs composition),
  shared parsing at the function layer (parse_message_headers, Mailbox).
- Three-tier error policy: infrastructure errors abort, per-message
  errors (404/410 race between list and get, malformed metadata) skip
  with warning, auth errors from per-message fetches treated as
  infrastructure.
- buffered(10) preserves Gmail's relevance-ranked result order.
- --max validated to 1..=500 (Gmail API maximum).
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 6, 2026

🦋 Changeset detected

Latest commit: f6028cc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@googleworkspace/cli Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@googleworkspace-bot googleworkspace-bot added area: skills area: docs area: core Core CLI parsing, commands, error handling, utilities labels Apr 6, 2026
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request replaces the +triage Gmail helper with a more capable +search command. The change addresses naming inconsistencies, removes anti-pattern output-filtering flags, and introduces necessary breaking changes to support structured metadata, pagination, and JSON-first output. These improvements enable better integration with downstream scripts and pipes while aligning with the project's established conventions.

Highlights

  • Replacement of +triage with +search: Replaced the +triage helper with +search, which provides a more accurate general-purpose Gmail search with full metadata, label resolution, and pagination support.
  • Breaking API Changes: Updated +read --format json to use camelCase field names for consistency with the new +search output and project standards.
  • Improved Architecture: Implemented a robust three-tier error policy for concurrent API calls and separated SearchResult from OriginalMessage to better handle different API format requirements.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Generative AI Prohibited Use Policy, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request replaces the +triage Gmail helper with a new +search command that provides full metadata, label resolution, and pagination support. It also introduces a breaking change by converting Gmail JSON output fields from snake_case to camelCase for consistency across the project. Feedback was provided regarding the concurrency level used when fetching message metadata, noting a potential risk of hitting Gmail API rate limits during high-volume searches.

let token = &token;
let label_map = &label_map;
async move { fetch_search_result(client, token, &msg_id, label_map).await }
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using .buffered(10) with a potentially large number of messages (up to 500) could lead to hitting Gmail API rate limits if many concurrent requests are made in a short window. While send_with_retry is used, consider if a lower concurrency limit or a more robust rate-limiting strategy is needed for high-volume searches.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core Core CLI parsing, commands, error handling, utilities area: docs area: skills

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants